""" SQLite persistence layer for portfolio positions, custom patterns, and config. """ import sqlite3 import json import os from datetime import datetime from typing import List, Dict, Any, Optional DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "geooptions.db") def get_conn() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def init_db(): os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) conn = get_conn() c = conn.cursor() c.execute("""CREATE TABLE IF NOT EXISTS portfolio ( id TEXT PRIMARY KEY, title TEXT NOT NULL, underlying TEXT NOT NULL, strategy TEXT NOT NULL, asset_class TEXT, entry_date TEXT NOT NULL, expiry_date TEXT, expiry_days INTEGER, legs TEXT NOT NULL, capital_invested REAL NOT NULL, entry_underlying_price REAL, geo_trigger TEXT, rationale TEXT, status TEXT DEFAULT 'open', close_date TEXT, close_value REAL, notes TEXT, ib_fees_entry REAL DEFAULT 0, ib_fees_exit REAL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) )""") c.execute("""CREATE TABLE IF NOT EXISTS custom_patterns ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, triggers TEXT, keywords TEXT, historical_instances TEXT, suggested_trades TEXT, asset_class TEXT, expected_move_pct REAL, probability REAL, horizon_days INTEGER, ai_quality_score INTEGER, ai_evaluation TEXT, source TEXT DEFAULT 'custom', is_active INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) )""") # Migration: add source column if not present try: c.execute("ALTER TABLE custom_patterns ADD COLUMN source TEXT DEFAULT 'custom'") except Exception: pass c.execute("""CREATE TABLE IF NOT EXISTS config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) )""") c.execute("""CREATE TABLE IF NOT EXISTS pattern_score_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, pattern_id TEXT NOT NULL, score INTEGER, confidence INTEGER, summary TEXT, scored_at TEXT NOT NULL )""") try: c.execute("CREATE INDEX IF NOT EXISTS idx_psh_pattern ON pattern_score_history(pattern_id, scored_at DESC)") except Exception: pass c.execute("""CREATE TABLE IF NOT EXISTS cycle_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL UNIQUE, started_at TEXT NOT NULL, completed_at TEXT, trigger TEXT DEFAULT 'auto', patterns_suggested INTEGER DEFAULT 0, patterns_added INTEGER DEFAULT 0, patterns_scored INTEGER DEFAULT 0, geo_score INTEGER, dominant_regime TEXT, commentary TEXT, status TEXT DEFAULT 'running' )""") try: c.execute("CREATE INDEX IF NOT EXISTS idx_cr_started ON cycle_runs(started_at DESC)") except Exception: pass c.execute("""CREATE TABLE IF NOT EXISTS macro_regime_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL, dominant TEXT NOT NULL, scores_json TEXT NOT NULL, reasons_json TEXT NOT NULL, gauges_summary_json TEXT NOT NULL DEFAULT '{}' )""") try: c.execute("CREATE INDEX IF NOT EXISTS idx_mrh_ts ON macro_regime_history(timestamp DESC)") except Exception: pass c.execute("""CREATE TABLE IF NOT EXISTS geo_alert_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL, run_id TEXT NOT NULL, geo_score INTEGER NOT NULL, top_patterns_json TEXT NOT NULL DEFAULT '[]', news_count INTEGER DEFAULT 0 )""") try: c.execute("CREATE INDEX IF NOT EXISTS idx_gah_ts ON geo_alert_history(timestamp DESC)") except Exception: pass c.execute("""CREATE TABLE IF NOT EXISTS trade_entry_prices ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, pattern_id TEXT NOT NULL, pattern_name TEXT, underlying TEXT NOT NULL, strategy TEXT, entry_price REAL, entry_date TEXT NOT NULL, score_at_entry INTEGER DEFAULT 0, latest_score INTEGER, expected_move_pct REAL, horizon_days INTEGER DEFAULT 30, ev_at_entry REAL, ev_net REAL, trade_score REAL, matched_profile TEXT, last_seen_at TEXT )""") for col, definition in [ ("latest_score", "INTEGER"), ("ev_at_entry", "REAL"), ("ev_net", "REAL"), ("trade_score", "REAL"), ("matched_profile", "TEXT"), ("last_seen_at", "TEXT"), ]: try: c.execute(f"ALTER TABLE trade_entry_prices ADD COLUMN {col} {definition}") except Exception: pass try: c.execute("CREATE INDEX IF NOT EXISTS idx_tep_date ON trade_entry_prices(entry_date DESC)") except Exception: pass c.execute("""CREATE TABLE IF NOT EXISTS risk_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, min_score INTEGER NOT NULL DEFAULT 0, min_gain_pct REAL NOT NULL DEFAULT 0, color TEXT DEFAULT '#3b82f6', enabled INTEGER DEFAULT 1, sort_order INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) )""") # Default risk profiles (seed only if table is empty) existing_profiles = c.execute("SELECT COUNT(*) FROM risk_profiles").fetchone()[0] if existing_profiles == 0: default_profiles = [ ("Conservateur", 50, 100.0, "#22c55e", 1, 0), ("Équilibré", 30, 250.0, "#3b82f6", 1, 1), ("Agressif", 15, 600.0, "#ef4444", 1, 2), ] c.executemany( "INSERT INTO risk_profiles (name, min_score, min_gain_pct, color, enabled, sort_order) VALUES (?,?,?,?,?,?)", default_profiles ) # Default config defaults = { "openai_api_key": "", "newsapi_key": "", "eia_api_key": "", "fred_api_key": "", "sources": json.dumps({ "reuters_world": {"enabled": True, "url": "https://feeds.reuters.com/reuters/worldNews", "name": "Reuters World"}, "reuters_business": {"enabled": True, "url": "https://feeds.reuters.com/reuters/businessNews", "name": "Reuters Business"}, "reuters_energy": {"enabled": True, "url": "https://feeds.reuters.com/reuters/USenergyNews", "name": "Reuters Commodities"}, "ap_top": {"enabled": True, "url": "https://feeds.apnews.com/rss/apf-topnews", "name": "AP Top News"}, "aljazeera": {"enabled": True, "url": "https://www.aljazeera.com/xml/rss/all.xml", "name": "Al Jazeera"}, "ft": {"enabled": False, "url": "https://www.ft.com/rss/home", "name": "Financial Times"}, "bloomberg": {"enabled": False, "url": "https://feeds.bloomberg.com/markets/news.rss", "name": "Bloomberg Markets"}, "newsapi": {"enabled": False, "url": "", "name": "NewsAPI (clé requise)", "requires_key": "newsapi_key"}, "gdelt": {"enabled": False, "url": "https://api.gdeltproject.org/api/v2/doc/doc?query=geopolitics&mode=artlist&format=json", "name": "GDELT Project (gratuit)"}, "eia": {"enabled": False, "url": "", "name": "EIA Energy (clé requise)", "requires_key": "eia_api_key"}, "fred": {"enabled": False, "url": "", "name": "FRED Macro Fed (clé requise)", "requires_key": "fred_api_key"}, "usda": {"enabled": False, "url": "https://apps.fas.usda.gov/psdonline/api/psd/crops", "name": "USDA Agriculture (gratuit)"}, "who": {"enabled": False, "url": "https://www.who.int/rss-feeds/news-english.xml", "name": "WHO Santé (gratuit)"}, "emdat": {"enabled": False, "url": "", "name": "EM-DAT Catastrophes (inscription requise)"}, "twitter_trump": {"enabled": False, "url": "", "name": "X/Twitter Trump (API payante)"}, }), "ai_enabled": "false", "ai_auto_rescore": "false", "auto_cycle_enabled": "false", "auto_cycle_hours": "3", "auto_cycle_similarity_threshold": "0.30", "min_ev_threshold": "0.0", "min_score_threshold": "0", } for k, v in defaults.items(): c.execute("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)", (k, v)) c.execute("""CREATE TABLE IF NOT EXISTS ai_reasoning_traces ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, trace_type TEXT NOT NULL, pattern_id TEXT, input_context_json TEXT DEFAULT '{}', output_json TEXT DEFAULT '{}', reasoning_summary TEXT, geo_score INTEGER, macro_dominant TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) )""") try: c.execute("CREATE INDEX IF NOT EXISTS idx_art_run ON ai_reasoning_traces(run_id)") c.execute("CREATE INDEX IF NOT EXISTS idx_art_pattern ON ai_reasoning_traces(pattern_id, trace_type)") except Exception: pass c.execute("""CREATE TABLE IF NOT EXISTS ai_reports ( id INTEGER PRIMARY KEY AUTOINCREMENT, report_type TEXT NOT NULL DEFAULT 'portfolio', days INTEGER NOT NULL DEFAULT 30, stats_json TEXT DEFAULT '{}', winners_json TEXT DEFAULT '[]', losers_json TEXT DEFAULT '[]', report_json TEXT DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (datetime('now')) )""") try: c.execute("CREATE INDEX IF NOT EXISTS idx_reports_type_date ON ai_reports(report_type, created_at)") except Exception: pass c.execute("""CREATE TABLE IF NOT EXISTS knowledge_base ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, confidence INTEGER DEFAULT 50, confirmation_count INTEGER DEFAULT 1, status TEXT DEFAULT 'active', tags TEXT DEFAULT '', first_seen_at TEXT NOT NULL DEFAULT (datetime('now')), last_confirmed_at TEXT NOT NULL DEFAULT (datetime('now')) )""") c.execute("""CREATE TABLE IF NOT EXISTS reasoning_state ( id INTEGER PRIMARY KEY AUTOINCREMENT, version INTEGER NOT NULL DEFAULT 1, narrative TEXT NOT NULL, synthesis_json TEXT DEFAULT '{}', sources_count INTEGER DEFAULT 0, reports_used INTEGER DEFAULT 0, trades_analyzed INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) )""") try: c.execute("CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category, status)") c.execute("CREATE INDEX IF NOT EXISTS idx_rs_version ON reasoning_state(version DESC)") except Exception: pass conn.commit() conn.close() # ── Config ──────────────────────────────────────────────────────────────────── def get_config(key: str) -> Optional[str]: conn = get_conn() row = conn.execute("SELECT value FROM config WHERE key=?", (key,)).fetchone() conn.close() return row["value"] if row else None def set_config(key: str, value: str): conn = get_conn() conn.execute( "INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, datetime('now'))", (key, value) ) conn.commit() conn.close() if key == "openai_api_key": os.environ["OPENAI_API_KEY"] = value def get_all_config() -> Dict[str, str]: conn = get_conn() rows = conn.execute("SELECT key, value FROM config").fetchall() conn.close() result = {r["key"]: r["value"] for r in rows} if "openai_api_key" in result and result["openai_api_key"]: result["openai_api_key_set"] = True result["openai_api_key"] = "***" return result def get_sources() -> Dict[str, Any]: raw = get_config("sources") return json.loads(raw) if raw else {} def update_sources(sources: Dict[str, Any]): set_config("sources", json.dumps(sources)) def save_pattern_scores(scores: List[Dict[str, Any]], meta: Dict[str, Any] = None) -> str: """Persist last AI scores and append a history snapshot. Returns run_id.""" from datetime import datetime as _dt run_id = _dt.utcnow().isoformat() data = {"scores": scores, "meta": meta or {}, "scored_at": run_id, "run_id": run_id} set_config("last_pattern_scores", json.dumps(data)) conn = get_conn() for sp in scores: pid = sp.get("pattern_id", "") if pid: conn.execute( "INSERT INTO pattern_score_history (run_id, pattern_id, score, confidence, summary, scored_at) VALUES (?,?,?,?,?,?)", (run_id, pid, sp.get("score"), sp.get("confidence"), sp.get("summary", ""), run_id), ) # Keep only the last 30 runs conn.execute("""DELETE FROM pattern_score_history WHERE run_id NOT IN ( SELECT DISTINCT run_id FROM pattern_score_history ORDER BY scored_at DESC LIMIT 30 )""") conn.commit() conn.close() return run_id def get_pattern_scores() -> Dict[str, Any]: """Return last persisted AI scores.""" raw = get_config("last_pattern_scores") if raw: try: return json.loads(raw) except Exception: pass return {"scores": [], "meta": {}, "scored_at": None} def get_score_deltas() -> Dict[str, int]: """Compute score change per pattern between the two most recent scoring runs.""" conn = get_conn() runs = conn.execute( "SELECT DISTINCT run_id FROM pattern_score_history ORDER BY scored_at DESC LIMIT 2" ).fetchall() if len(runs) < 2: conn.close() return {} latest_run, prev_run = runs[0]["run_id"], runs[1]["run_id"] latest = {r["pattern_id"]: r["score"] for r in conn.execute("SELECT pattern_id, score FROM pattern_score_history WHERE run_id=?", (latest_run,)).fetchall()} prev = {r["pattern_id"]: r["score"] for r in conn.execute("SELECT pattern_id, score FROM pattern_score_history WHERE run_id=?", (prev_run,)).fetchall()} conn.close() return { pid: score - prev[pid] for pid, score in latest.items() if pid in prev and score is not None and prev[pid] is not None } def get_score_history(pattern_id: str, limit: int = 10) -> List[Dict[str, Any]]: """Return the last N score snapshots for a given pattern.""" conn = get_conn() rows = conn.execute( "SELECT score, confidence, summary, scored_at FROM pattern_score_history WHERE pattern_id=? ORDER BY scored_at DESC LIMIT ?", (pattern_id, limit), ).fetchall() conn.close() return [dict(r) for r in rows] def compute_pattern_similarity(patterns: List[Dict[str, Any]], threshold: float = 0.25) -> List[Dict[str, Any]]: """Return pairs of patterns with Jaccard keyword similarity above threshold.""" results = [] for i, p1 in enumerate(patterns): kw1 = set(kw.lower() for kw in (p1.get("keywords") or [])) for j, p2 in enumerate(patterns): if i >= j: continue kw2 = set(kw.lower() for kw in (p2.get("keywords") or [])) if not kw1 or not kw2: continue common = kw1 & kw2 union = kw1 | kw2 sim = len(common) / len(union) if union else 0.0 if sim >= threshold: results.append({ "id_a": p1.get("id"), "name_a": p1.get("name"), "id_b": p2.get("id"), "name_b": p2.get("name"), "similarity": round(sim, 2), "common_keywords": sorted(common)[:8], }) return sorted(results, key=lambda x: -x["similarity"]) def get_analysis_config() -> Dict[str, Any]: """Return the AI analysis config: template, top_n, category_filter.""" raw = get_config("analysis_config") if raw: try: return json.loads(raw) except Exception: pass return {"top_n": 10, "category_filter": "all", "template": None} def save_analysis_config(cfg: Dict[str, Any]): set_config("analysis_config", json.dumps(cfg)) # ── Portfolio ───────────────────────────────────────────────────────────────── IB_OPTIONS_FEE_PER_CONTRACT = 0.65 IB_MIN_FEE = 1.0 def compute_ib_fees(num_contracts: int) -> float: return max(IB_MIN_FEE, num_contracts * IB_OPTIONS_FEE_PER_CONTRACT) def add_position(pos: Dict[str, Any]) -> str: import uuid pos_id = pos.get("id") or f"POS-{uuid.uuid4().hex[:8].upper()}" legs = pos.get("legs", []) num_contracts = sum(abs(leg.get("quantity", 1)) for leg in legs) ib_fees = compute_ib_fees(num_contracts) conn = get_conn() conn.execute("""INSERT INTO portfolio ( id, title, underlying, strategy, asset_class, entry_date, expiry_date, expiry_days, legs, capital_invested, entry_underlying_price, geo_trigger, rationale, status, notes, ib_fees_entry ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,'open',?,?)""", ( pos_id, pos.get("title", pos.get("underlying", "")), pos.get("underlying", ""), pos.get("strategy", ""), pos.get("asset_class", ""), pos.get("entry_date", datetime.utcnow().isoformat()[:10]), pos.get("expiry_date", ""), pos.get("expiry_days", 90), json.dumps(legs), pos.get("capital_invested", 1000.0), pos.get("entry_underlying_price"), pos.get("geo_trigger", ""), pos.get("rationale", ""), pos.get("notes", ""), ib_fees, )) conn.commit() conn.close() return pos_id def get_positions(status: str = "open") -> List[Dict[str, Any]]: conn = get_conn() rows = conn.execute( "SELECT * FROM portfolio WHERE status=? ORDER BY created_at DESC", (status,) ).fetchall() conn.close() result = [] for r in rows: d = dict(r) d["legs"] = json.loads(d.get("legs", "[]")) result.append(d) return result def close_position(pos_id: str, close_value: float) -> Dict[str, Any]: conn = get_conn() pos = conn.execute("SELECT * FROM portfolio WHERE id=?", (pos_id,)).fetchone() if not pos: conn.close() return {"error": "Position non trouvée"} legs = json.loads(pos["legs"]) num_contracts = sum(abs(leg.get("quantity", 1)) for leg in legs) ib_exit = compute_ib_fees(num_contracts) conn.execute("""UPDATE portfolio SET status='closed', close_date=?, close_value=?, ib_fees_exit=? WHERE id=?""", (datetime.utcnow().isoformat()[:10], close_value, ib_exit, pos_id)) conn.commit() pnl = close_value - pos["capital_invested"] - pos["ib_fees_entry"] - ib_exit conn.close() return {"id": pos_id, "close_value": close_value, "ib_fees_exit": ib_exit, "pnl": pnl} def update_position_notes(pos_id: str, notes: str): conn = get_conn() conn.execute("UPDATE portfolio SET notes=? WHERE id=?", (notes, pos_id)) conn.commit() conn.close() # ── Custom Patterns ──────────────────────────────────────────────────────────── def seed_builtin_patterns(builtin_patterns: List[Dict[str, Any]]): """Seed built-in patterns into DB (idempotent — skips existing IDs).""" conn = get_conn() existing = {r[0] for r in conn.execute("SELECT id FROM custom_patterns").fetchall()} for p in builtin_patterns: if p["id"] not in existing: conn.execute("""INSERT INTO custom_patterns ( id, name, description, triggers, keywords, historical_instances, suggested_trades, asset_class, expected_move_pct, probability, horizon_days, source, is_active, updated_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?,'builtin',1,datetime('now'))""", ( p["id"], p.get("name", ""), p.get("description", ""), json.dumps(p.get("triggers", [])), json.dumps(p.get("keywords", [])), json.dumps(p.get("historical_instances", [])), json.dumps(p.get("suggested_trades", [])), p.get("asset_class", "indices"), p.get("expected_move_pct", 0), p.get("probability", 0.5), p.get("horizon_days", 30), )) conn.commit() conn.close() def save_custom_pattern(pattern: Dict[str, Any]) -> str: import uuid pat_id = pattern.get("id") or f"P_USER_{uuid.uuid4().hex[:6].upper()}" source = pattern.get("source", "custom") conn = get_conn() conn.execute("""INSERT OR REPLACE INTO custom_patterns ( id, name, description, triggers, keywords, historical_instances, suggested_trades, asset_class, expected_move_pct, probability, horizon_days, ai_quality_score, ai_evaluation, source, is_active, updated_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,datetime('now'))""", ( pat_id, pattern.get("name", ""), pattern.get("description", ""), json.dumps(pattern.get("triggers", [])), json.dumps(pattern.get("keywords", [])), json.dumps(pattern.get("historical_instances", [])), json.dumps(pattern.get("suggested_trades", [])), pattern.get("asset_class", "indices"), pattern.get("expected_move_pct", 0), pattern.get("probability", 0.5), pattern.get("horizon_days", 30), pattern.get("ai_quality_score"), json.dumps(pattern.get("ai_evaluation", {})), source, )) conn.commit() conn.close() return pat_id def get_custom_patterns() -> List[Dict[str, Any]]: conn = get_conn() rows = conn.execute( "SELECT * FROM custom_patterns WHERE is_active=1 ORDER BY source DESC, created_at DESC" ).fetchall() conn.close() result = [] for r in rows: d = dict(r) for f in ["triggers", "keywords", "historical_instances", "suggested_trades", "ai_evaluation"]: d[f] = json.loads(d.get(f) or "[]") result.append(d) return result def toggle_pattern_active(pat_id: str) -> bool: """Toggle is_active for a pattern. Returns the new state.""" conn = get_conn() row = conn.execute("SELECT is_active FROM custom_patterns WHERE id=?", (pat_id,)).fetchone() if not row: conn.close() return False new_state = 0 if row["is_active"] else 1 conn.execute("UPDATE custom_patterns SET is_active=? WHERE id=?", (new_state, pat_id)) conn.commit() conn.close() return bool(new_state) def delete_custom_pattern(pat_id: str): conn = get_conn() conn.execute("UPDATE custom_patterns SET is_active=0 WHERE id=?", (pat_id,)) conn.commit() conn.close() # ── Risk Profiles ───────────────────────────────────────────────────────────── def get_risk_profiles(enabled_only: bool = False) -> List[Dict[str, Any]]: conn = get_conn() q = "SELECT * FROM risk_profiles" if enabled_only: q += " WHERE enabled=1" q += " ORDER BY sort_order ASC, id ASC" rows = conn.execute(q).fetchall() conn.close() return [dict(r) for r in rows] def upsert_risk_profile(profile: Dict[str, Any]) -> int: conn = get_conn() pid = profile.get("id") if pid: conn.execute("""UPDATE risk_profiles SET name=?, min_score=?, min_gain_pct=?, color=?, enabled=?, sort_order=? WHERE id=?""", ( profile["name"], int(profile["min_score"]), float(profile["min_gain_pct"]), profile.get("color", "#3b82f6"), 1 if profile.get("enabled", True) else 0, int(profile.get("sort_order", 0)), pid, )) else: cur = conn.execute("""INSERT INTO risk_profiles (name, min_score, min_gain_pct, color, enabled, sort_order) VALUES (?,?,?,?,?,?)""", ( profile["name"], int(profile["min_score"]), float(profile["min_gain_pct"]), profile.get("color", "#3b82f6"), 1 if profile.get("enabled", True) else 0, int(profile.get("sort_order", 0)), )) pid = cur.lastrowid conn.commit() conn.close() return pid def delete_risk_profile(profile_id: int): conn = get_conn() conn.execute("DELETE FROM risk_profiles WHERE id=?", (profile_id,)) conn.commit() conn.close() def _compute_trade_score(score: int, gain_pct: float) -> tuple[float, float, float]: """ Returns (ev_gross, ev_net, trade_score) for a (score, gain_pct) pair. - ev_gross = p × G (raw expected multiple) - ev_net = p × G - (1-p) (net EV assuming total loss if wrong) - trade_score = p×G / (p×G + (1-p)) × 100 (normalized 0-100) """ p = max(0.0, min(1.0, score / 100)) G = abs(gain_pct) / 100 ev_gross = round(p * G, 4) ev_net = round(p * G - (1 - p), 4) denom = p * G + (1 - p) trade_score = round((p * G / denom * 100) if denom > 0 else 0.0, 1) return ev_gross, ev_net, trade_score def _matches_profile(score: int, gain_pct: float, profiles: List[Dict[str, Any]]) -> Optional[str]: """Return the name of the first enabled profile this trade satisfies, or None.""" for prof in profiles: if not prof.get("enabled", True): continue if score >= prof["min_score"] and gain_pct >= prof["min_gain_pct"]: return prof["name"] return None # ── Journal de Bord ──────────────────────────────────────────────────────────── def log_macro_regime(dominant: str, scores: Dict[str, Any], reasons: Dict[str, Any], gauges_summary: Dict[str, Any]): """Append a macro regime snapshot. Keeps last 90 days.""" conn = get_conn() conn.execute("""INSERT INTO macro_regime_history (timestamp, dominant, scores_json, reasons_json, gauges_summary_json) VALUES (datetime('now'), ?, ?, ?, ?)""", (dominant, json.dumps(scores), json.dumps(reasons), json.dumps(gauges_summary))) conn.execute("""DELETE FROM macro_regime_history WHERE timestamp < datetime('now', '-90 days')""") conn.commit() conn.close() def get_macro_regime_history(days: int = 15) -> List[Dict[str, Any]]: conn = get_conn() rows = conn.execute( "SELECT * FROM macro_regime_history WHERE timestamp >= datetime('now', ?) ORDER BY timestamp DESC", (f"-{days} days",) ).fetchall() conn.close() result = [] for r in rows: d = dict(r) d["scores"] = json.loads(d.pop("scores_json", "{}")) d["reasons"] = json.loads(d.pop("reasons_json", "{}")) d["gauges_summary"] = json.loads(d.pop("gauges_summary_json", "{}")) result.append(d) return result def log_geo_alert(geo_score: int, top_patterns: List[Dict[str, Any]], news_count: int, run_id: str): """Append a geo alert snapshot tied to a scoring run.""" conn = get_conn() conn.execute("""INSERT INTO geo_alert_history (timestamp, run_id, geo_score, top_patterns_json, news_count) VALUES (datetime('now'), ?, ?, ?, ?)""", (run_id, geo_score, json.dumps(top_patterns[:10]), news_count)) conn.execute("DELETE FROM geo_alert_history WHERE timestamp < datetime('now', '-90 days')") conn.commit() conn.close() def get_geo_alert_history(days: int = 30) -> List[Dict[str, Any]]: conn = get_conn() rows = conn.execute( "SELECT * FROM geo_alert_history WHERE timestamp >= datetime('now', ?) ORDER BY timestamp DESC", (f"-{days} days",) ).fetchall() conn.close() result = [] for r in rows: d = dict(r) d["top_patterns"] = json.loads(d.pop("top_patterns_json", "[]")) result.append(d) return result def _normalize_yf_ticker(ticker: str) -> str: """Normalize ticker for yfinance. - USD/KRW → USDKRW=X (slash-format forex pairs from GPT-4o) - USDKRW → USDKRW=X (bare 6-char alphabetic forex pairs) - CL=F, SPY, etc. → unchanged """ t = ticker.upper().strip() if '/' in t: parts = t.split('/') if len(parts) == 2 and all(p.isalpha() and len(p) >= 2 for p in parts): return parts[0] + parts[1] + '=X' return t if len(t) == 6 and t.isalpha(): return t + "=X" return t def log_trade_entries(run_id: str, scored_patterns: List[Dict[str, Any]], quotes: Dict[str, Any]): """ For each scored pattern's trade_rankings, record entry price if the trade passes at least one enabled risk profile (min_score + min_gain_pct pair). Deduplicates: one row per (pattern_id, underlying, strategy). Falls back to yfinance for tickers not found in the quotes snapshot. """ import logging as _logging _log = _logging.getLogger(__name__) profiles = get_risk_profiles(enabled_only=True) _log.info(f"[TradeLog] run_id={run_id} scored_patterns={len(scored_patterns)} profiles={len(profiles)}") # Load original patterns as fallback for expected_move_pct # (GPT-4o scored output doesn't include this field) _orig_patterns = {p.get("id", ""): p for p in get_custom_patterns()} # Build price map from pre-fetched quotes price_map: Dict[str, float] = {} for asset_class, items in quotes.items(): if isinstance(items, list): for item in items: if item.get("ticker") and item.get("price") is not None: price_map[item["ticker"].upper()] = float(item["price"]) elif isinstance(items, dict): for ticker, item in items.items(): if isinstance(item, dict) and item.get("price") is not None: price_map[ticker.upper()] = float(item["price"]) # Collect tickers NOT already in price_map for yfinance fallback tickers_to_fetch: set = set() for sp in scored_patterns: for trade in sp.get("trade_rankings") or sp.get("suggested_trades", []): t = (trade.get("underlying") or trade.get("ticker", "")).upper() if t and t not in price_map: tickers_to_fetch.add(t) if tickers_to_fetch: _log.info(f"[TradeLog] yfinance fallback for {len(tickers_to_fetch)} tickers: {sorted(tickers_to_fetch)}") try: import yfinance as yf import pandas as pd from concurrent.futures import ThreadPoolExecutor, as_completed def _fetch(ticker: str): normalized = _normalize_yf_ticker(ticker) try: # Use yf.download() — fresh HTTP request, no in-process Ticker cache for kwargs in [ {"period": "1d", "interval": "5m"}, {"period": "5d", "interval": "1d"}, ]: df = yf.download(normalized, progress=False, auto_adjust=True, **kwargs) if df.empty: continue if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.get_level_values(0) if "Close" not in df.columns: continue close = df["Close"].dropna() if close.empty: continue price = float(close.iloc[-1]) if price > 0: _log.debug(f"[TradeLog] {ticker} → {normalized} = {price}") return ticker, price except Exception as e: _log.warning(f"[TradeLog] Failed to fetch '{ticker}' (normalized='{normalized}'): {e}") return ticker, None with ThreadPoolExecutor(max_workers=min(len(tickers_to_fetch), 10)) as ex: for fut in as_completed({ex.submit(_fetch, t): t for t in tickers_to_fetch}, timeout=20): try: tk, price = fut.result() if price is not None: price_map[tk] = price except Exception: pass except Exception as e: _log.error(f"[TradeLog] yfinance fallback failed: {e}") conn = get_conn() today = datetime.utcnow().isoformat()[:10] now_ts = datetime.utcnow().isoformat() inserted_count = 0 updated_count = 0 skipped_no_profile = 0 for sp in scored_patterns: pid = sp.get("pattern_id", "") pattern_name = sp.get("geo_trigger") or sp.get("pattern_name") or pid base_score = int(sp.get("score") or 0) _orig = _orig_patterns.get(pid, {}) for trade in sp.get("trade_rankings") or sp.get("suggested_trades", []): underlying = trade.get("underlying") or trade.get("ticker", "") if not underlying: continue strategy = trade.get("strategy") or trade.get("trade_type", "") delta = int(trade.get("score_delta") or 0) eff_score = max(0, min(100, base_score + delta)) # Fallback chain: trade field → scored sp field → auto_cycle enrichment → original DB pattern exp_move = abs(float( trade.get("expected_move_pct") or sp.get("expected_move_pct") or _orig.get("expected_move_pct") or 0 )) if exp_move == 0: _log.warning(f"[TradeLog] Pattern '{pattern_name}' trade {underlying} has expected_move_pct=0 — all profiles with min_gain_pct>0 will fail") # Check if this trade passes any enabled risk profile matched = _matches_profile(eff_score, exp_move, profiles) if matched is None: skipped_no_profile += 1 _log.debug(f"[TradeLog] SKIP {underlying} score={eff_score} gain={exp_move:.0f}% — no profile match") continue ev_gross, ev_net, trade_score = _compute_trade_score(eff_score, exp_move) ticker_key = underlying.upper() entry_price = price_map.get(ticker_key) horizon = int( trade.get("horizon_days") or sp.get("horizon_days") or sp.get("recommended_trade", {}).get("expiry_days") or _orig.get("horizon_days") or 90 ) existing_row = conn.execute( "SELECT id FROM trade_entry_prices WHERE pattern_id=? AND underlying=? AND strategy=?", (pid, ticker_key, strategy) ).fetchone() if existing_row: conn.execute( "UPDATE trade_entry_prices SET latest_score=?, trade_score=?, last_seen_at=? WHERE id=?", (eff_score, trade_score, now_ts, existing_row["id"]) ) updated_count += 1 else: conn.execute("""INSERT INTO trade_entry_prices (run_id, pattern_id, pattern_name, underlying, strategy, entry_price, entry_date, score_at_entry, latest_score, expected_move_pct, horizon_days, ev_at_entry, ev_net, trade_score, matched_profile, last_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( run_id, pid, pattern_name, ticker_key, strategy, entry_price, today, eff_score, eff_score, exp_move, horizon, ev_gross, ev_net, trade_score, matched, now_ts, )) inserted_count += 1 _log.info(f"[TradeLog] NEW trade: pattern='{pattern_name}' {underlying} {strategy} score={eff_score} gain={exp_move:.0f}% profile='{matched}' price={entry_price}") _log.info(f"[TradeLog] Done — inserted={inserted_count} updated={updated_count} skipped_no_profile={skipped_no_profile}") conn.execute("DELETE FROM trade_entry_prices WHERE entry_date < date('now', '-90 days')") conn.commit() conn.close() def reset_journal_history(): """Truncate all journal history tables for a clean slate.""" conn = get_conn() conn.execute("DELETE FROM trade_entry_prices") conn.execute("DELETE FROM macro_regime_history") conn.execute("DELETE FROM geo_alert_history") conn.execute("DELETE FROM cycle_runs") conn.commit() conn.close() def add_cycle_run(run_id: str, trigger: str = "auto") -> None: conn = get_conn() conn.execute( "INSERT OR IGNORE INTO cycle_runs (run_id, started_at, trigger, status) VALUES (?, datetime('now'), ?, 'running')", (run_id, trigger) ) conn.commit() conn.close() def update_cycle_run(run_id: str, **fields) -> None: if not fields: return allowed = {"completed_at", "patterns_suggested", "patterns_added", "patterns_scored", "geo_score", "dominant_regime", "commentary", "status"} sets = ", ".join(f"{k}=?" for k in fields if k in allowed) vals = [v for k, v in fields.items() if k in allowed] if not sets: return conn = get_conn() conn.execute(f"UPDATE cycle_runs SET {sets} WHERE run_id=?", vals + [run_id]) conn.commit() conn.close() def get_cycle_runs(limit: int = 30) -> List[Dict[str, Any]]: conn = get_conn() rows = conn.execute( "SELECT * FROM cycle_runs ORDER BY started_at DESC LIMIT ?", (limit,) ).fetchall() conn.close() return [dict(r) for r in rows] def get_cycle_run(run_id: str) -> Optional[Dict[str, Any]]: conn = get_conn() row = conn.execute("SELECT * FROM cycle_runs WHERE run_id=?", (run_id,)).fetchone() conn.close() return dict(row) if row else None def _trade_maturity(days_held: int, horizon_days: int) -> Dict[str, Any]: """ Classify a trade's maturity based on elapsed time vs planned horizon. Returns status, label, weight (0-1 for lesson extraction), and color hint. Thresholds (percentage of horizon elapsed): < 10% → trop_tot : P&L is pure noise, never evaluate 10-35% → debut : early signal, very low weight 35-75% → mature : reliable signal, full weight > 75% → fin_horizon : approaching expiry, full weight + watch flag """ h = max(horizon_days or 90, 1) d = max(days_held or 0, 0) ratio = d / h pct = round(ratio * 100, 1) if ratio < 0.10: return { "status": "trop_tot", "label": "Trop tôt", "emoji": "🕐", "weight": 0.0, "color": "slate", "ratio_pct": pct, "readable": f"{d}j / {h}j ({pct}% écoulé — bruit statistique)", } elif ratio < 0.35: return { "status": "debut", "label": "Début", "emoji": "📊", "weight": 0.25, "color": "yellow", "ratio_pct": pct, "readable": f"{d}j / {h}j ({pct}% écoulé — signal précoce)", } elif ratio < 0.75: return { "status": "mature", "label": "Signal fiable", "emoji": "✅", "weight": 1.0, "color": "emerald", "ratio_pct": pct, "readable": f"{d}j / {h}j ({pct}% écoulé — signal fiable)", } else: return { "status": "fin_horizon", "label": "Fin d'horizon", "emoji": "⏰", "weight": 1.0, "color": "orange", "ratio_pct": pct, "readable": f"{d}j / {h}j ({pct}% écoulé — surveiller de près)", } def get_trade_entry_prices(days: int = 30) -> List[Dict[str, Any]]: conn = get_conn() rows = conn.execute( """SELECT * FROM trade_entry_prices WHERE entry_date >= date('now', ?) ORDER BY entry_date DESC, score_at_entry DESC""", (f"-{days} days",) ).fetchall() conn.close() return [dict(r) for r in rows] def get_trade_entry_by_id(trade_id: int) -> Optional[Dict[str, Any]]: conn = get_conn() row = conn.execute("SELECT * FROM trade_entry_prices WHERE id=?", (trade_id,)).fetchone() conn.close() return dict(row) if row else None # ── AI Reasoning Traces ─────────────────────────────────────────────────────── def save_reasoning_trace( run_id: str, trace_type: str, pattern_id: str = None, input_context: Dict[str, Any] = None, output: Dict[str, Any] = None, reasoning_summary: str = None, geo_score: int = None, macro_dominant: str = None, ) -> int: """Persist one AI reasoning step. Returns the new row id.""" conn = get_conn() cur = conn.execute( """INSERT INTO ai_reasoning_traces (run_id, trace_type, pattern_id, input_context_json, output_json, reasoning_summary, geo_score, macro_dominant) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", ( run_id, trace_type, pattern_id, json.dumps(input_context or {}, ensure_ascii=False, default=str), json.dumps(output or {}, ensure_ascii=False, default=str), reasoning_summary, geo_score, macro_dominant, ), ) row_id = cur.lastrowid conn.commit() conn.close() return row_id def _parse_trace(row) -> Dict[str, Any]: """Deserialize a reasoning trace row.""" d = dict(row) for field in ("input_context_json", "output_json"): try: d[field.replace("_json", "")] = json.loads(d.get(field) or "{}") except Exception: d[field.replace("_json", "")] = {} return d def get_scoring_trace(run_id: str, pattern_id: str) -> Optional[Dict[str, Any]]: """Return the scoring trace for a given (run_id, pattern_id) pair.""" conn = get_conn() row = conn.execute( "SELECT * FROM ai_reasoning_traces WHERE run_id=? AND pattern_id=? AND trace_type='scoring'", (run_id, pattern_id), ).fetchone() conn.close() return _parse_trace(row) if row else None def get_suggestion_trace(pattern_id: str) -> Optional[Dict[str, Any]]: """Return the original suggestion trace for a pattern (first one ever).""" conn = get_conn() row = conn.execute( """SELECT * FROM ai_reasoning_traces WHERE pattern_id=? AND trace_type='suggestion' ORDER BY created_at ASC LIMIT 1""", (pattern_id,), ).fetchone() conn.close() return _parse_trace(row) if row else None def get_pattern_scoring_history(pattern_id: str, limit: int = 10) -> List[Dict[str, Any]]: """All scoring traces for a pattern across cycles — for trend analysis.""" conn = get_conn() rows = conn.execute( """SELECT * FROM ai_reasoning_traces WHERE pattern_id=? AND trace_type='scoring' ORDER BY created_at DESC LIMIT ?""", (pattern_id, limit), ).fetchall() conn.close() return [_parse_trace(r) for r in rows] _BEARISH_KEYWORDS = {"bear", "put", "short", "sell", "vente", "baissier"} def _fetch_live_prices(tickers: List[str], timeout: int = 20) -> Dict[str, Optional[float]]: """ Fetch current prices for a list of tickers using yf.download(). Shared by journal MTM and portfolio report so both get consistent live data. """ result: Dict[str, Optional[float]] = {t: None for t in tickers} if not tickers: return result try: import yfinance as yf import pandas as pd from concurrent.futures import ThreadPoolExecutor, as_completed def _one(ticker: str) -> tuple: for kwargs in [ {"period": "1d", "interval": "5m"}, {"period": "5d", "interval": "1d"}, ]: try: df = yf.download(ticker, progress=False, auto_adjust=True, **kwargs) if df.empty: continue if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.get_level_values(0) if "Close" not in df.columns: continue close = df["Close"].dropna() if close.empty: continue price = float(close.iloc[-1]) if price > 0: return ticker, price except Exception: continue return ticker, None with ThreadPoolExecutor(max_workers=min(len(tickers), 10)) as ex: futs = {ex.submit(_one, t): t for t in tickers} from concurrent.futures import as_completed as _ac for fut in _ac(futs, timeout=timeout): try: tk, price = fut.result() result[tk] = price except Exception: pass except Exception: pass return result def get_mtm_trades_with_traces(days: int = 90, limit_movers: int = 5) -> Dict[str, Any]: """ Return all MTM trades with live prices and reasoning traces for the top winners and losers (by pnl_pct). Used by the AI portfolio report. """ from datetime import timedelta cutoff_date = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d") conn = get_conn() rows = conn.execute( """SELECT * FROM trade_entry_prices WHERE entry_date >= ? ORDER BY entry_date DESC""", (cutoff_date,), ).fetchall() conn.close() all_trades = [dict(r) for r in rows] # Fetch live prices for all unique tickers tickers = list({(t.get("underlying") or "").upper() for t in all_trades if t.get("underlying")}) live_prices = _fetch_live_prices(tickers, timeout=25) # Enrich trades with live price + pnl_pct def _with_pnl(trade: Dict) -> Dict: ticker = (trade.get("underlying") or "").upper() entry = trade.get("entry_price") current = live_prices.get(ticker) pnl_pct = None if entry and current and entry > 0: raw = (current - entry) / entry * 100 strategy = trade.get("strategy", "").lower() bearish = any(k in strategy for k in _BEARISH_KEYWORDS) pnl_pct = round(-raw if bearish else raw, 2) return {**trade, "current_price": current, "pnl_pct": pnl_pct} enriched = [_with_pnl(t) for t in all_trades] priced = [t for t in enriched if t.get("pnl_pct") is not None] winners = sorted(priced, key=lambda t: t.get("pnl_pct", 0), reverse=True)[:limit_movers] losers = sorted(priced, key=lambda t: t.get("pnl_pct", 0))[:limit_movers] def _with_traces(trade: Dict) -> Dict: pid = trade.get("pattern_id", "") run_id = trade.get("run_id", "") sc = get_scoring_trace(run_id, pid) if run_id and pid else None sg = get_suggestion_trace(pid) if pid else None history = get_pattern_scoring_history(pid, limit=5) if pid else [] return { **trade, "scoring_context": sc, "suggestion_context": sg, "score_history_count": len(history), "score_trend": [h["output"].get("score") for h in reversed(history)] if history else [], } return { "total_trades": len(all_trades), "priced_count": len(priced), "avg_pnl_pct": (sum(t.get("pnl_pct", 0) for t in priced) / len(priced)) if priced else None, "winners": [_with_traces(t) for t in winners], "losers": [_with_traces(t) for t in losers], "all_trades": enriched, } def save_ai_report( days: int, stats: Dict[str, Any], winners: List[Dict], losers: List[Dict], report: Dict[str, Any], report_type: str = "portfolio", ) -> int: conn = get_conn() cur = conn.execute( """INSERT INTO ai_reports (report_type, days, stats_json, winners_json, losers_json, report_json) VALUES (?, ?, ?, ?, ?, ?)""", ( report_type, days, json.dumps(stats, ensure_ascii=False, default=str), json.dumps(winners, ensure_ascii=False, default=str), json.dumps(losers, ensure_ascii=False, default=str), json.dumps(report, ensure_ascii=False, default=str), ), ) row_id = cur.lastrowid conn.commit() conn.close() return row_id def _parse_report(row) -> Dict[str, Any]: d = dict(row) for field in ("stats_json", "winners_json", "losers_json", "report_json"): key = field.replace("_json", "") try: d[key] = json.loads(d.get(field) or "{}") except Exception: d[key] = {} return d def get_latest_portfolio_lessons() -> Optional[Dict[str, Any]]: """ Return the key lessons from the most recent portfolio report. Used by auto_cycle to inject feedback into the next AI cycle's prompts. Returns None if no report exists yet. """ conn = get_conn() row = conn.execute( """SELECT report_json, stats_json, created_at, days FROM ai_reports WHERE report_type='portfolio' ORDER BY created_at DESC LIMIT 1""" ).fetchone() conn.close() if not row: return None try: report = json.loads(row["report_json"] or "{}") stats = json.loads(row["stats_json"] or "{}") except Exception: return None if not report: return None return { "created_at": row["created_at"], "days": row["days"], "stats": stats, "headline": report.get("headline", ""), "winners_analysis": report.get("winners_analysis", ""), "losers_analysis": report.get("losers_analysis", ""), "key_lessons": report.get("key_lessons", []), "blind_spots": report.get("blind_spots", ""), "next_cycle_priorities": report.get("next_cycle_priorities", ""), "risk_watch": report.get("risk_watch", ""), } def list_ai_reports(report_type: str = "portfolio", limit: int = 20) -> List[Dict[str, Any]]: conn = get_conn() rows = conn.execute( """SELECT id, report_type, days, stats_json, report_json, created_at FROM ai_reports WHERE report_type=? ORDER BY created_at DESC LIMIT ?""", (report_type, limit), ).fetchall() conn.close() result = [] for row in rows: d = dict(row) for field in ("stats_json", "report_json"): key = field.replace("_json", "") try: d[key] = json.loads(d.get(field) or "{}") except Exception: d[key] = {} result.append(d) return result def get_ai_report(report_id: int) -> Optional[Dict[str, Any]]: conn = get_conn() row = conn.execute("SELECT * FROM ai_reports WHERE id=?", (report_id,)).fetchone() conn.close() return _parse_report(row) if row else None # ── Knowledge Base ───────────────────────────────────────────────────────────── def save_kb_entry(category: str, title: str, content: str, confidence: int = 50, tags: str = "", existing_id: Optional[int] = None) -> int: conn = get_conn() now = datetime.utcnow().isoformat() if existing_id: conn.execute("""UPDATE knowledge_base SET content=?, confidence=?, tags=?, last_confirmed_at=?, confirmation_count=confirmation_count+1 WHERE id=?""", (content, confidence, tags, now, existing_id)) conn.commit() conn.close() return existing_id cur = conn.execute("""INSERT INTO knowledge_base (category, title, content, confidence, confirmation_count, status, tags, first_seen_at, last_confirmed_at) VALUES (?,?,?,?,1,'active',?,?,?)""", (category, title, content, confidence, tags, now, now)) new_id = cur.lastrowid conn.commit() conn.close() return new_id def get_kb_entries(status: str = "active") -> List[Dict[str, Any]]: conn = get_conn() rows = conn.execute( "SELECT * FROM knowledge_base WHERE status=? ORDER BY confidence DESC, last_confirmed_at DESC", (status,) ).fetchall() conn.close() return [dict(r) for r in rows] def get_all_kb_entries() -> List[Dict[str, Any]]: conn = get_conn() rows = conn.execute( "SELECT * FROM knowledge_base ORDER BY confidence DESC, last_confirmed_at DESC" ).fetchall() conn.close() return [dict(r) for r in rows] def update_kb_entry_status(entry_id: int, status: str): conn = get_conn() conn.execute("UPDATE knowledge_base SET status=? WHERE id=?", (status, entry_id)) conn.commit() conn.close() # ── Reasoning State ──────────────────────────────────────────────────────────── def save_reasoning_state(narrative: str, synthesis: Dict[str, Any], sources_count: int = 0, reports_used: int = 0, trades_analyzed: int = 0) -> int: conn = get_conn() cur_row = conn.execute( "SELECT COALESCE(MAX(version), 0) as v FROM reasoning_state" ).fetchone() next_version = (cur_row["v"] if cur_row else 0) + 1 cur = conn.execute("""INSERT INTO reasoning_state (version, narrative, synthesis_json, sources_count, reports_used, trades_analyzed, created_at) VALUES (?,?,?,?,?,?,datetime('now'))""", (next_version, narrative, json.dumps(synthesis), sources_count, reports_used, trades_analyzed)) new_id = cur.lastrowid conn.commit() conn.close() return new_id def get_latest_reasoning_state() -> Optional[Dict[str, Any]]: conn = get_conn() row = conn.execute( "SELECT * FROM reasoning_state ORDER BY version DESC LIMIT 1" ).fetchone() conn.close() if not row: return None d = dict(row) try: d["synthesis"] = json.loads(d.get("synthesis_json") or "{}") except Exception: d["synthesis"] = {} return d def get_reasoning_history(limit: int = 10) -> List[Dict[str, Any]]: conn = get_conn() rows = conn.execute( "SELECT id, version, sources_count, reports_used, trades_analyzed, created_at FROM reasoning_state ORDER BY version DESC LIMIT ?", (limit,) ).fetchall() conn.close() return [dict(r) for r in rows] def get_reasoning_state_by_id(state_id: int) -> Optional[Dict[str, Any]]: conn = get_conn() row = conn.execute("SELECT * FROM reasoning_state WHERE id=?", (state_id,)).fetchone() conn.close() if not row: return None d = dict(row) try: d["synthesis"] = json.loads(d.get("synthesis_json") or "{}") except Exception: d["synthesis"] = {} return d def delete_ai_report(report_id: int) -> bool: conn = get_conn() cur = conn.execute("DELETE FROM ai_reports WHERE id=?", (report_id,)) conn.commit() conn.close() return cur.rowcount > 0 def delete_reasoning_state(state_id: int) -> bool: conn = get_conn() cur = conn.execute("DELETE FROM reasoning_state WHERE id=?", (state_id,)) conn.commit() conn.close() return cur.rowcount > 0 def delete_kb_entry(entry_id: int) -> bool: conn = get_conn() cur = conn.execute("DELETE FROM knowledge_base WHERE id=?", (entry_id,)) conn.commit() conn.close() return cur.rowcount > 0