Files
Apex_AI_MT5_EA_Optimizer/data/store.py
T
LEGSTECH Optimizer 7a3e13a734 Initial commit: MT5 EA Optimizer v1.0
Full optimization system for LEGSTECH_EA_V2:
- Flask + SocketIO live dashboard (dark premium UI)
- MT5 process control (auto-kill, clean launch, retry)
- HTML report parser (UTF-16 LE, 597 trades, metrics)
- Pre-run validation and actionable error messages
- Analysis engines: Reversal, TimePerfomance, EntryExit, EquityCurve
- Composite scoring (Calmar-primary)
- Mutation engine with knowledge_base.yaml
- Validation gate: IS + Walk-Forward
- Reports folder with HTML/CSV per run
- Double-click launcher batch file
2026-04-13 02:28:09 +00:00

327 lines
13 KiB
Python

"""
data/store.py
SQLite (metadata) + Parquet (per-run trade data) storage layer.
"""
from __future__ import annotations
import json
import sqlite3
import shutil
from pathlib import Path
from typing import Any, Optional
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
from loguru import logger
from data.models import (
Run, RunMetrics, Finding, Hypothesis, Candidate, Trade
)
# ── Schema DDL ────────────────────────────────────────────────────────────────
SCHEMA_SQL = """
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY,
run_ts TEXT NOT NULL,
ea_name TEXT NOT NULL,
symbol TEXT NOT NULL,
timeframe TEXT NOT NULL,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
params_json TEXT NOT NULL,
phase TEXT NOT NULL,
hypothesis_id TEXT,
tester_model INTEGER DEFAULT 0,
ini_snapshot TEXT,
report_path TEXT,
log_csv_path TEXT
);
CREATE TABLE IF NOT EXISTS run_metrics (
run_id TEXT PRIMARY KEY REFERENCES runs(run_id),
net_profit REAL,
profit_factor REAL,
max_drawdown_abs REAL,
max_drawdown_pct REAL,
calmar_ratio REAL,
sharpe_ratio REAL,
total_trades INTEGER,
win_rate REAL,
avg_win REAL,
avg_loss REAL,
recovery_factor REAL,
largest_loss REAL,
expected_payoff REAL,
avg_mfe_capture REAL,
avg_mfe_pips REAL,
avg_mae_pips REAL,
reversal_rate REAL,
composite_score REAL
);
CREATE TABLE IF NOT EXISTS findings (
finding_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES runs(run_id),
analyzer TEXT NOT NULL,
description TEXT NOT NULL,
severity TEXT NOT NULL,
confidence REAL NOT NULL,
impact_estimate_pnl REAL DEFAULT 0,
suggested_params TEXT,
evidence TEXT
);
CREATE TABLE IF NOT EXISTS hypotheses (
hypothesis_id TEXT PRIMARY KEY,
parent_run_id TEXT NOT NULL REFERENCES runs(run_id),
finding_ids TEXT NOT NULL,
description TEXT NOT NULL,
param_delta TEXT NOT NULL,
strategy TEXT NOT NULL,
kb_rule_id TEXT,
status TEXT NOT NULL DEFAULT 'pending',
tested_run_id TEXT
);
CREATE TABLE IF NOT EXISTS candidates (
candidate_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES runs(run_id),
promoted_ts TEXT NOT NULL,
composite_score REAL NOT NULL,
oos_score REAL,
params_json TEXT NOT NULL,
lineage_json TEXT
);
"""
# ── DataStore ─────────────────────────────────────────────────────────────────
class DataStore:
"""
Central storage interface.
- SQLite for all structured metadata (runs, metrics, findings, hypotheses, candidates)
- Parquet for per-run trade arrays (cheap columnar access for analysis)
"""
def __init__(self, db_path: str | Path, runs_dir: str | Path):
self.db_path = Path(db_path)
self.runs_dir = Path(runs_dir)
self.runs_dir.mkdir(parents=True, exist_ok=True)
self._init_db()
# ── Init ──────────────────────────────────────────────────────────────────
def _init_db(self) -> None:
with self._conn() as conn:
conn.executescript(SCHEMA_SQL)
logger.debug(f"Database initialised at {self.db_path}")
def _conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
# ── Runs ──────────────────────────────────────────────────────────────────
def save_run(self, run: Run) -> None:
with self._conn() as conn:
conn.execute("""
INSERT OR REPLACE INTO runs
(run_id, run_ts, ea_name, symbol, timeframe, period_start, period_end,
params_json, phase, hypothesis_id, tester_model, ini_snapshot,
report_path, log_csv_path)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
run.run_id,
run.run_ts.isoformat(),
run.ea_name, run.symbol, run.timeframe,
run.period_start, run.period_end,
json.dumps(run.params),
run.phase, run.hypothesis_id, run.tester_model,
run.ini_snapshot, run.report_path, run.log_csv_path,
))
logger.debug(f"Saved run {run.run_id}")
def get_run(self, run_id: str) -> Optional[Run]:
with self._conn() as conn:
row = conn.execute(
"SELECT * FROM runs WHERE run_id=?", (run_id,)
).fetchone()
if not row:
return None
d = dict(row)
d["params"] = json.loads(d.pop("params_json"))
return Run(**d)
def list_runs(self, phase: Optional[str] = None, n: int = 100) -> list[dict]:
q = "SELECT run_id, run_ts, phase, hypothesis_id FROM runs"
args: list[Any] = []
if phase:
q += " WHERE phase=?"
args.append(phase)
q += " ORDER BY run_ts DESC LIMIT ?"
args.append(n)
with self._conn() as conn:
return [dict(r) for r in conn.execute(q, args).fetchall()]
# ── Run Metrics ───────────────────────────────────────────────────────────
def save_metrics(self, m: RunMetrics) -> None:
with self._conn() as conn:
conn.execute("""
INSERT OR REPLACE INTO run_metrics
(run_id, net_profit, profit_factor, max_drawdown_abs, max_drawdown_pct,
calmar_ratio, sharpe_ratio, total_trades, win_rate, avg_win, avg_loss,
recovery_factor, largest_loss, expected_payoff,
avg_mfe_capture, avg_mfe_pips, avg_mae_pips, reversal_rate,
composite_score)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
m.run_id, m.net_profit, m.profit_factor,
m.max_drawdown_abs, m.max_drawdown_pct,
m.calmar_ratio, m.sharpe_ratio,
m.total_trades, m.win_rate, m.avg_win, m.avg_loss,
m.recovery_factor, m.largest_loss, m.expected_payoff,
m.avg_mfe_capture, m.avg_mfe_pips, m.avg_mae_pips,
m.reversal_rate, m.composite_score,
))
def get_metrics(self, run_id: str) -> Optional[RunMetrics]:
with self._conn() as conn:
row = conn.execute(
"SELECT * FROM run_metrics WHERE run_id=?", (run_id,)
).fetchone()
return RunMetrics(**dict(row)) if row else None
def best_candidate_score(self) -> float:
"""Return the highest composite_score ever achieved by a promoted candidate."""
with self._conn() as conn:
row = conn.execute(
"SELECT MAX(composite_score) FROM candidates"
).fetchone()
return float(row[0]) if row and row[0] is not None else 0.0
# ── Findings ──────────────────────────────────────────────────────────────
def save_findings(self, findings: list[Finding]) -> None:
with self._conn() as conn:
for f in findings:
conn.execute("""
INSERT OR REPLACE INTO findings
(finding_id, run_id, analyzer, description, severity,
confidence, impact_estimate_pnl, suggested_params, evidence)
VALUES (?,?,?,?,?,?,?,?,?)
""", (
f.finding_id, f.run_id, f.analyzer, f.description,
f.severity, f.confidence, f.impact_estimate_pnl,
json.dumps(f.suggested_params), json.dumps(f.evidence),
))
def get_findings(self, run_id: str) -> list[Finding]:
with self._conn() as conn:
rows = conn.execute(
"SELECT * FROM findings WHERE run_id=? ORDER BY confidence DESC",
(run_id,)
).fetchall()
out = []
for r in rows:
d = dict(r)
d["suggested_params"] = json.loads(d["suggested_params"] or "{}")
d["evidence"] = json.loads(d["evidence"] or "{}")
out.append(Finding(**d))
return out
# ── Hypotheses ────────────────────────────────────────────────────────────
def save_hypothesis(self, h: Hypothesis) -> None:
with self._conn() as conn:
conn.execute("""
INSERT OR REPLACE INTO hypotheses
(hypothesis_id, parent_run_id, finding_ids, description,
param_delta, strategy, kb_rule_id, status, tested_run_id)
VALUES (?,?,?,?,?,?,?,?,?)
""", (
h.hypothesis_id, h.parent_run_id,
json.dumps(h.finding_ids), h.description,
json.dumps(h.param_delta), h.strategy,
h.kb_rule_id, h.status, h.tested_run_id,
))
def update_hypothesis_status(
self, hypothesis_id: str,
status: str,
tested_run_id: Optional[str] = None
) -> None:
with self._conn() as conn:
conn.execute(
"UPDATE hypotheses SET status=?, tested_run_id=? WHERE hypothesis_id=?",
(status, tested_run_id, hypothesis_id)
)
def get_recent_param_deltas(self, n: int = 10) -> list[dict]:
"""Return param_delta dicts from the last N tested hypotheses (for dedup)."""
with self._conn() as conn:
rows = conn.execute("""
SELECT param_delta FROM hypotheses
WHERE status IN ('tested','validated','rejected')
ORDER BY rowid DESC LIMIT ?
""", (n,)).fetchall()
return [json.loads(r["param_delta"]) for r in rows]
# ── Candidates ────────────────────────────────────────────────────────────
def save_candidate(self, c: Candidate) -> None:
with self._conn() as conn:
conn.execute("""
INSERT OR REPLACE INTO candidates
(candidate_id, run_id, promoted_ts, composite_score,
oos_score, params_json, lineage_json)
VALUES (?,?,?,?,?,?,?)
""", (
c.candidate_id, c.run_id,
c.promoted_ts.isoformat(),
c.composite_score, c.oos_score,
json.dumps(c.params),
json.dumps(c.lineage),
))
logger.info(f"Promoted candidate {c.candidate_id} (score={c.composite_score:.4f})")
def list_candidates(self) -> list[dict]:
with self._conn() as conn:
rows = conn.execute(
"SELECT * FROM candidates ORDER BY composite_score DESC"
).fetchall()
return [dict(r) for r in rows]
# ── Trade Data (Parquet) ──────────────────────────────────────────────────
def save_trades(self, run_id: str, trades: list[Trade]) -> Path:
"""Serialise Trade objects to Parquet. Returns path to written file."""
parquet_path = self.runs_dir / run_id / "trades.parquet"
parquet_path.parent.mkdir(parents=True, exist_ok=True)
rows = [t.model_dump() for t in trades]
df = pd.DataFrame(rows)
# Ensure datetime columns are proper dtype
for col in ["open_time", "close_time"]:
if col in df.columns:
df[col] = pd.to_datetime(df[col], utc=True)
df.to_parquet(parquet_path, index=False, engine="pyarrow")
logger.debug(f"Saved {len(trades)} trades for run {run_id}")
return parquet_path
def load_trades(self, run_id: str) -> pd.DataFrame:
"""Load trade Parquet for a given run. Returns empty DataFrame if not found."""
parquet_path = self.runs_dir / run_id / "trades.parquet"
if not parquet_path.exists():
logger.warning(f"No trade parquet found for run {run_id}")
return pd.DataFrame()
return pd.read_parquet(parquet_path, engine="pyarrow")