Files

718 lines
28 KiB
Python
Raw Permalink Normal View History

2026-06-16 12:27:37 +01:00
"""
APEX Layer 1 — Database Layer
Manages all SQLite database operations:
- Auto-create schema on first run
- Insert/update/select rates (from FRED API)
- Insert/update monthly CPI and PMI data
- Calculate and store scores
- Log trading signals
- Query history by month
All operations use parameterized queries to prevent SQL injection.
"""
import sqlite3
2026-06-17 11:26:57 +01:00
import threading
from datetime import datetime, timezone
2026-06-16 12:27:37 +01:00
from pathlib import Path
from typing import Optional, Dict, List, Tuple
import config
class Database:
"""SQLite database manager for APEX Layer 1."""
def __init__(self, db_path: str = None):
"""
Initialize database connection.
Args:
db_path: Path to SQLite database file. If None, uses config.DB_PATH.
Raises:
RuntimeError: If database cannot be created or connected.
"""
self.db_path = db_path or config.DB_PATH
# Ensure parent directory exists
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
try:
2026-06-17 11:26:57 +01:00
self._lock = threading.Lock()
2026-06-16 12:27:37 +01:00
self.conn = sqlite3.connect(
self.db_path,
timeout=config.DB_TIMEOUT,
check_same_thread=False # Allow access from multiple threads
)
self.conn.row_factory = sqlite3.Row # Return rows as dicts
2026-06-17 11:26:57 +01:00
# Performance PRAGMAs (Task 2.2 — WAL + synchronous=NORMAL)
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA synchronous=NORMAL")
2026-06-16 12:27:37 +01:00
self.conn.execute("PRAGMA foreign_keys = ON")
if config.DB_AUTO_CREATE:
self._create_schema()
except sqlite3.Error as e:
raise RuntimeError(f"Failed to initialize database at {self.db_path}: {e}")
def _create_schema(self):
"""Create database schema if it doesn't exist."""
try:
2026-06-17 11:26:57 +01:00
with self._lock:
cursor = self.conn.cursor()
2026-06-16 12:27:37 +01:00
2026-06-17 11:26:57 +01:00
# Table 1: Interest rates (updated via FRED API)
cursor.execute("""
CREATE TABLE IF NOT EXISTS rates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
currency TEXT NOT NULL UNIQUE,
rate REAL NOT NULL,
updated_at TEXT NOT NULL,
source TEXT DEFAULT 'FRED',
CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD'))
);
""")
2026-06-16 12:27:37 +01:00
2026-06-17 11:26:57 +01:00
# Table 2: Monthly manual entries (CPI + PMI)
cursor.execute("""
CREATE TABLE IF NOT EXISTS monthly_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
month TEXT NOT NULL,
currency TEXT NOT NULL,
cpi_actual REAL,
pmi_actual REAL,
entered_at TEXT NOT NULL,
UNIQUE(month, currency),
CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD')),
CONSTRAINT valid_month CHECK (month LIKE '____-__')
);
""")
# Table 3: Calculated scores (generated after each data entry)
cursor.execute("""
CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
month TEXT NOT NULL,
currency TEXT NOT NULL,
score_rate REAL,
score_cpi REAL,
score_pmi REAL,
total_score REAL NOT NULL,
rank INTEGER NOT NULL,
calculated_at TEXT NOT NULL,
UNIQUE(month, currency),
CONSTRAINT valid_currency CHECK (currency IN ('USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD')),
CONSTRAINT valid_month CHECK (month LIKE '____-__')
);
""")
# Table 4: M1/M5 Interval Bar Cache (Task 2.2 — optimized for bar storage)
cursor.execute("""
CREATE TABLE IF NOT EXISTS bar_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pair TEXT NOT NULL,
timeframe TEXT NOT NULL CHECK (timeframe IN ('M1', 'M5')),
bar_time TEXT NOT NULL,
open REAL,
high REAL,
low REAL,
close REAL NOT NULL,
volume INTEGER DEFAULT 0,
UNIQUE(pair, timeframe, bar_time)
);
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_bar_cache_lookup
ON bar_cache(pair, timeframe, bar_time);
""")
# Table 5: Signal log (one per month)
cursor.execute("""
CREATE TABLE IF NOT EXISTS signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
generated_at TEXT NOT NULL,
month TEXT NOT NULL UNIQUE,
strongest TEXT NOT NULL,
weakest TEXT NOT NULL,
gap REAL NOT NULL,
signal TEXT NOT NULL,
status TEXT NOT NULL,
CONSTRAINT valid_status CHECK (status IN ('ACTIVE', 'NO_TRADE', 'CLOSED')),
CONSTRAINT valid_month CHECK (month LIKE '____-__')
);
""")
2026-06-18 09:35:03 +01:00
# Table 6: Confluence signal audit log (real-time triggers)
cursor.execute("""
CREATE TABLE IF NOT EXISTS confluence_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
triggered_at TEXT NOT NULL,
pair TEXT NOT NULL,
signal_type TEXT NOT NULL,
confidence REAL NOT NULL,
z_score REAL,
gap REAL,
reason TEXT,
layer1_active INTEGER DEFAULT 0,
status TEXT DEFAULT 'PENDING'
);
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_confluence_log_time
ON confluence_log(triggered_at DESC);
""")
2026-06-17 11:26:57 +01:00
self.conn.commit()
if config.DEBUG:
print("[DB] Schema created successfully")
2026-06-16 12:27:37 +01:00
except sqlite3.Error as e:
self.conn.rollback()
raise RuntimeError(f"Failed to create schema: {e}")
# ========================================================================
# RATES Table Operations
# ========================================================================
def upsert_rate(self, currency: str, rate: float, source: str = "FRED") -> None:
"""
Insert or update an interest rate.
Args:
currency: Currency code (USD, EUR, etc.)
rate: Interest rate as percentage (e.g., 5.25)
source: Data source (default "FRED")
"""
if currency not in config.CURRENCIES:
raise ValueError(f"Invalid currency: {currency}")
try:
2026-06-17 11:26:57 +01:00
with self._lock:
cursor = self.conn.cursor()
cursor.execute("""
INSERT INTO rates (currency, rate, updated_at, source)
VALUES (?, ?, ?, ?)
ON CONFLICT(currency) DO UPDATE SET
rate = excluded.rate,
updated_at = excluded.updated_at,
source = excluded.source
""", (currency, rate, datetime.now(timezone.utc).isoformat(), source))
self.conn.commit()
2026-06-16 12:27:37 +01:00
2026-06-17 11:26:57 +01:00
if config.DEBUG:
print(f"[DB] Rate updated: {currency} = {rate}% (from {source})")
2026-06-16 12:27:37 +01:00
except sqlite3.Error as e:
self.conn.rollback()
raise RuntimeError(f"Failed to upsert rate for {currency}: {e}")
def get_rate(self, currency: str) -> Optional[float]:
"""
Get the latest interest rate for a currency.
Args:
currency: Currency code
Returns:
Rate as float, or None if not found
"""
cursor = self.conn.cursor()
try:
cursor.execute("SELECT rate FROM rates WHERE currency = ?", (currency,))
row = cursor.fetchone()
return row["rate"] if row else None
except sqlite3.Error as e:
raise RuntimeError(f"Failed to fetch rate for {currency}: {e}")
def get_all_rates(self) -> Dict[str, Optional[float]]:
"""
Get all interest rates as a dict.
Returns:
Dict mapping currency code to rate (or None if missing)
"""
cursor = self.conn.cursor()
try:
cursor.execute("SELECT currency, rate FROM rates")
rows = cursor.fetchall()
rates = {row["currency"]: row["rate"] for row in rows}
# Fill missing currencies with None
for currency in config.CURRENCIES:
if currency not in rates:
rates[currency] = None
return rates
except sqlite3.Error as e:
raise RuntimeError(f"Failed to fetch all rates: {e}")
# ========================================================================
# MONTHLY_DATA Table Operations
# ========================================================================
def update_monthly_cpi(self, month: str, currency: str, cpi: float) -> None:
"""
Insert or update CPI data for a currency in a given month.
Args:
month: Month in format "YYYY-MM"
currency: Currency code
cpi: CPI value as percentage (e.g., 3.2)
"""
if currency not in config.CURRENCIES:
raise ValueError(f"Invalid currency: {currency}")
try:
2026-06-17 11:26:57 +01:00
with self._lock:
cursor = self.conn.cursor()
# First, get existing PMI if any
cursor.execute(
"SELECT pmi_actual FROM monthly_data WHERE month = ? AND currency = ?",
(month, currency)
)
row = cursor.fetchone()
pmi = row["pmi_actual"] if row else None
2026-06-16 12:27:37 +01:00
2026-06-17 11:26:57 +01:00
# Upsert with CPI
cursor.execute("""
INSERT INTO monthly_data (month, currency, cpi_actual, pmi_actual, entered_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(month, currency) DO UPDATE SET
cpi_actual = excluded.cpi_actual,
entered_at = excluded.entered_at
""", (month, currency, cpi, pmi, datetime.now(timezone.utc).isoformat()))
self.conn.commit()
if config.DEBUG:
print(f"[DB] CPI saved: {month} {currency} = {cpi}%")
2026-06-16 12:27:37 +01:00
except sqlite3.Error as e:
self.conn.rollback()
raise RuntimeError(f"Failed to update CPI for {currency} in {month}: {e}")
def update_monthly_pmi(self, month: str, currency: str, pmi: float) -> None:
"""
Insert or update PMI data for a currency in a given month.
Args:
month: Month in format "YYYY-MM"
currency: Currency code
pmi: PMI value (e.g., 51.4)
"""
if currency not in config.CURRENCIES:
raise ValueError(f"Invalid currency: {currency}")
try:
2026-06-17 11:26:57 +01:00
with self._lock:
cursor = self.conn.cursor()
# First, get existing CPI if any
cursor.execute(
"SELECT cpi_actual FROM monthly_data WHERE month = ? AND currency = ?",
(month, currency)
)
row = cursor.fetchone()
cpi = row["cpi_actual"] if row else None
# Upsert with PMI
cursor.execute("""
INSERT INTO monthly_data (month, currency, cpi_actual, pmi_actual, entered_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(month, currency) DO UPDATE SET
pmi_actual = excluded.pmi_actual,
entered_at = excluded.entered_at
""", (month, currency, cpi, pmi, datetime.now(timezone.utc).isoformat()))
2026-06-16 12:27:37 +01:00
2026-06-17 11:26:57 +01:00
self.conn.commit()
if config.DEBUG:
print(f"[DB] PMI saved: {month} {currency} = {pmi}")
2026-06-16 12:27:37 +01:00
except sqlite3.Error as e:
self.conn.rollback()
raise RuntimeError(f"Failed to update PMI for {currency} in {month}: {e}")
def get_monthly_data(self, month: str) -> Dict[str, Dict]:
"""
Get all CPI and PMI data for a given month.
Args:
month: Month in format "YYYY-MM"
Returns:
Dict mapping currency to {cpi_actual, pmi_actual, entered_at}
"""
cursor = self.conn.cursor()
try:
cursor.execute(
"SELECT currency, cpi_actual, pmi_actual, entered_at FROM monthly_data WHERE month = ?",
(month,)
)
rows = cursor.fetchall()
data = {}
for row in rows:
data[row["currency"]] = {
"cpi_actual": row["cpi_actual"],
"pmi_actual": row["pmi_actual"],
"entered_at": row["entered_at"]
}
return data
except sqlite3.Error as e:
raise RuntimeError(f"Failed to fetch monthly data for {month}: {e}")
def get_month_completeness(self, month: str) -> Tuple[int, int]:
"""
Check how many of the 16 required fields (8 CPI + 8 PMI) are filled.
Args:
month: Month in format "YYYY-MM"
Returns:
Tuple of (filled_count, total_required_16)
"""
cursor = self.conn.cursor()
try:
cursor.execute("""
SELECT COUNT(CASE WHEN cpi_actual IS NOT NULL THEN 1 END) as cpi_filled,
COUNT(CASE WHEN pmi_actual IS NOT NULL THEN 1 END) as pmi_filled
FROM monthly_data
WHERE month = ?
""", (month,))
row = cursor.fetchone()
filled = (row["cpi_filled"] or 0) + (row["pmi_filled"] or 0)
return (filled, 16)
except sqlite3.Error as e:
raise RuntimeError(f"Failed to check month completeness for {month}: {e}")
2026-06-17 11:26:57 +01:00
# ========================================================================
# BAR_CACHE Table Operations (Task 2.2)
# ========================================================================
def upsert_bar(
self, pair: str, timeframe: str, bar_time: str,
open_p: float, high: float, low: float, close: float, volume: int = 0
) -> None:
with self._lock:
cursor = self.conn.cursor()
cursor.execute("""
INSERT INTO bar_cache (pair, timeframe, bar_time, open, high, low, close, volume)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pair, timeframe, bar_time) DO UPDATE SET
open = excluded.open,
high = excluded.high,
low = excluded.low,
close = excluded.close,
volume = excluded.volume
""", (pair, timeframe, bar_time, open_p, high, low, close, volume))
self.conn.commit()
def get_bars(
self, pair: str, timeframe: str, limit: int = 288
) -> List[Dict]:
cursor = self.conn.cursor()
try:
cursor.execute("""
SELECT bar_time, open, high, low, close, volume
FROM bar_cache
WHERE pair = ? AND timeframe = ?
ORDER BY bar_time DESC
LIMIT ?
""", (pair, timeframe, limit))
rows = cursor.fetchall()
bars = []
for r in reversed(rows):
bars.append({
"time": r["bar_time"],
"open": r["open"],
"high": r["high"],
"low": r["low"],
"close": r["close"],
"volume": r["volume"],
})
return bars
except sqlite3.Error as e:
return []
def get_latest_bar_time(self, pair: str, timeframe: str) -> Optional[str]:
cursor = self.conn.cursor()
try:
cursor.execute("""
SELECT bar_time FROM bar_cache
WHERE pair = ? AND timeframe = ?
ORDER BY bar_time DESC LIMIT 1
""", (pair, timeframe))
row = cursor.fetchone()
return row["bar_time"] if row else None
except sqlite3.Error:
return None
2026-06-16 12:27:37 +01:00
# ========================================================================
# SCORES Table Operations
# ========================================================================
def save_scores(self, month: str, scores: Dict[str, Dict]) -> None:
"""
Save calculated scores for all currencies in a month.
Args:
month: Month in format "YYYY-MM"
scores: Dict mapping currency to {score_rate, score_cpi, score_pmi, total_score, rank}
"""
try:
2026-06-17 11:26:57 +01:00
with self._lock:
cursor = self.conn.cursor()
for currency, score_data in scores.items():
cursor.execute("""
INSERT INTO scores (month, currency, score_rate, score_cpi, score_pmi, total_score, rank, calculated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(month, currency) DO UPDATE SET
score_rate = excluded.score_rate,
score_cpi = excluded.score_cpi,
score_pmi = excluded.score_pmi,
total_score = excluded.total_score,
rank = excluded.rank,
calculated_at = excluded.calculated_at
""", (
month,
currency,
score_data.get("score_rate"),
score_data.get("score_cpi"),
score_data.get("score_pmi"),
score_data["total_score"],
score_data["rank"],
datetime.now(timezone.utc).isoformat()
))
self.conn.commit()
2026-06-16 12:27:37 +01:00
2026-06-17 11:26:57 +01:00
if config.DEBUG:
print(f"[DB] {len(scores)} scores saved for {month}")
2026-06-16 12:27:37 +01:00
except sqlite3.Error as e:
self.conn.rollback()
raise RuntimeError(f"Failed to save scores for {month}: {e}")
def get_month_scores(self, month: str) -> Dict[str, Dict]:
"""
Get all scores for a given month, ranked by total_score descending.
Args:
month: Month in format "YYYY-MM"
Returns:
Dict mapping currency to score data, ordered by rank
"""
cursor = self.conn.cursor()
try:
cursor.execute("""
SELECT currency, score_rate, score_cpi, score_pmi, total_score, rank
FROM scores
WHERE month = ?
ORDER BY rank ASC
""", (month,))
rows = cursor.fetchall()
scores = {}
for row in rows:
scores[row["currency"]] = {
"score_rate": row["score_rate"],
"score_cpi": row["score_cpi"],
"score_pmi": row["score_pmi"],
"total_score": row["total_score"],
"rank": row["rank"]
}
return scores
except sqlite3.Error as e:
raise RuntimeError(f"Failed to fetch scores for {month}: {e}")
# ========================================================================
# SIGNALS Table Operations
# ========================================================================
def save_signal(self, month: str, strongest: str, weakest: str, gap: float,
signal: str, status: str) -> None:
"""
Save a trading signal for a month.
Args:
month: Month in format "YYYY-MM"
strongest: Currency code with highest score
weakest: Currency code with lowest score
gap: Score difference (strongest - weakest)
signal: Signal string (e.g., "SHORT AUD/JPY" or "NO TRADE")
status: "ACTIVE", "NO_TRADE", or "CLOSED"
"""
if status not in ("ACTIVE", "NO_TRADE", "CLOSED"):
raise ValueError(f"Invalid status: {status}")
try:
2026-06-17 11:26:57 +01:00
with self._lock:
cursor = self.conn.cursor()
cursor.execute("""
INSERT INTO signals (generated_at, month, strongest, weakest, gap, signal, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(month) DO UPDATE SET
generated_at = excluded.generated_at,
strongest = excluded.strongest,
weakest = excluded.weakest,
gap = excluded.gap,
signal = excluded.signal,
status = excluded.status
""", (datetime.now(timezone.utc).isoformat(), month, strongest, weakest, gap, signal, status))
self.conn.commit()
2026-06-16 12:27:37 +01:00
2026-06-17 11:26:57 +01:00
if config.DEBUG:
print(f"[DB] Signal saved for {month}: {signal} (status={status})")
2026-06-16 12:27:37 +01:00
except sqlite3.Error as e:
self.conn.rollback()
raise RuntimeError(f"Failed to save signal for {month}: {e}")
def get_signal(self, month: str) -> Optional[Dict]:
"""
Get the signal for a given month.
Args:
month: Month in format "YYYY-MM"
Returns:
Dict with signal data, or None if not found
"""
cursor = self.conn.cursor()
try:
cursor.execute(
"SELECT strongest, weakest, gap, signal, status FROM signals WHERE month = ?",
(month,)
)
row = cursor.fetchone()
if row:
return {
"strongest": row["strongest"],
"weakest": row["weakest"],
"gap": row["gap"],
"signal": row["signal"],
"status": row["status"]
}
return None
except sqlite3.Error as e:
raise RuntimeError(f"Failed to fetch signal for {month}: {e}")
def get_all_signals(self, limit: int = 24) -> List[Dict]:
"""
Get most recent signals (for history tab).
Args:
limit: Maximum number of signals to return (default 24 months)
Returns:
List of signal dicts, ordered by month descending
"""
cursor = self.conn.cursor()
try:
cursor.execute("""
SELECT month, strongest, weakest, gap, signal, status
FROM signals
ORDER BY month DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
signals = []
for row in rows:
signals.append({
"month": row["month"],
"strongest": row["strongest"],
"weakest": row["weakest"],
"gap": row["gap"],
"signal": row["signal"],
"status": row["status"]
})
return signals
except sqlite3.Error as e:
raise RuntimeError(f"Failed to fetch signals: {e}")
2026-06-18 09:35:03 +01:00
# ========================================================================
# CONFLUENCE_LOG Table Operations (Phase 3 audit trail)
# ========================================================================
def save_confluence_signal(self, pair: str, signal_type: str, confidence: float,
z_score: float = None, gap: float = None,
reason: str = None, layer1_active: bool = False) -> None:
"""Persist a real-time confluence trigger to the audit log.
Unlike save_signal() (monthly Layer 1 signals), this logs every
live/backtested trigger with sub-second granularity.
"""
try:
with self._lock:
cursor = self.conn.cursor()
cursor.execute("""
INSERT INTO confluence_log
(triggered_at, pair, signal_type, confidence, z_score, gap, reason, layer1_active, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING')
""", (
datetime.now(timezone.utc).isoformat(),
pair, signal_type, confidence, z_score, gap, reason,
1 if layer1_active else 0
))
self.conn.commit()
except sqlite3.Error as e:
self.conn.rollback()
def get_confluence_log(self, limit: int = 100) -> List[Dict]:
"""Get the most recent confluence triggers."""
cursor = self.conn.cursor()
try:
cursor.execute("""
SELECT triggered_at, pair, signal_type, confidence, z_score, gap, reason, status
FROM confluence_log
ORDER BY triggered_at DESC LIMIT ?
""", (limit,))
rows = cursor.fetchall()
return [dict(r) for r in rows]
except sqlite3.Error:
return []
2026-06-16 12:27:37 +01:00
# ========================================================================
# Utility Methods
# ========================================================================
def close(self):
"""Close database connection."""
if self.conn:
self.conn.close()
if config.DEBUG:
print("[DB] Connection closed")
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.close()
# Singleton instance (optional convenience)
_db_instance = None
def get_database() -> Database:
"""Get or create the global database instance."""
global _db_instance
if _db_instance is None:
_db_instance = Database()
return _db_instance