From f09c5b8ee7c728aaccd5aaaef7f776c60c11dd2b Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Wed, 17 Jun 2026 16:50:53 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20Pattern=20Relia?= =?UTF-8?q?bility,=20Contre-th=C3=A8ses=20&=20Calibration=20probabiliste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 2.1 — Pattern Reliability Score - database.py: get_pattern_reliability() — win_rate × log(n+1) sur trades matures (≥35% horizon) - database.py: get_all_pattern_reliability_map() pour injection rapide dans les prompts - ai_analyzer.py: inject reliability_map dans suggest_patterns (patterns fiables mis en avant) - auto_cycle.py: charge reliability_map avant suggestion et le passe au suggéreur - routers/analytics.py: GET /api/analytics/reliability - PatternEditor.tsx: ReliabilityBadge sur chaque card + usePatternReliability hook - useApi.ts: usePatternReliability, useCalibration hooks Sprint 2.2 — Contre-thèses & Invalidation Triggers - database.py: migration ALTER TABLE — counter_thesis, invalidation_trigger, invalidation_probability - database.py: save_custom_pattern() persiste les 3 nouveaux champs - ai_analyzer.py: counter_thesis + invalidation_trigger + invalidation_probability dans le JSON schema - auto_cycle.py: détection automatique des triggers d'invalidation contre les news (keyword match) - routers/analytics.py: GET /api/analytics/invalidation-alerts - PatternEditor.tsx: affichage contre-thèse dans les cards + champs dans le formulaire - PatternEditor.tsx: affichage dans AiSuggestModal (suggestions IA) - routers/patterns.py: PatternRequest inclut les 3 nouveaux champs Sprint 2.3 — Calibration probabiliste & Demi-vie KB - database.py: migration — predicted_probability sur pattern_score_history - database.py: save_pattern_scores() stocke probability du pattern à chaque scoring run - database.py: get_calibration_data() — Brier score + buckets de calibration par décile - database.py: expires_at + confidence_decay_days sur knowledge_base - database.py: decay_kb_confidence() — decay automatique + archivage à 0 - auto_cycle.py: decay_kb_confidence() appelé au début de chaque cycle (non-bloquant) - routers/analytics.py: GET /api/analytics/calibration + POST /api/analytics/kb/decay - frontend/src/pages/Analytics.tsx: nouvelle page — tableau fiabilité + calibration Brier - App.tsx + Sidebar.tsx: route /analytics + entrée menu Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 3 +- backend/routers/analytics.py | 55 +++++ backend/routers/patterns.py | 3 + backend/services/ai_analyzer.py | 28 ++- backend/services/auto_cycle.py | 49 +++++ backend/services/database.py | 240 +++++++++++++++++++- frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 1 + frontend/src/hooks/useApi.ts | 16 ++ frontend/src/pages/Analytics.tsx | 245 +++++++++++++++++++++ frontend/src/pages/PatternEditor.tsx | 90 +++++++- 11 files changed, 718 insertions(+), 14 deletions(-) create mode 100644 backend/routers/analytics.py create mode 100644 frontend/src/pages/Analytics.tsx diff --git a/backend/main.py b/backend/main.py index 5b5da35..03ffa81 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router, options_vol as options_vol_router +from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router, options_vol as options_vol_router, analytics as analytics_router from services.database import init_db, get_config, cleanup_stale_running_cycles import os import uvicorn @@ -72,6 +72,7 @@ app.include_router(profiles_router.router) app.include_router(reasoning_router.router) app.include_router(knowledge_router.router) app.include_router(options_vol_router.router) +app.include_router(analytics_router.router) @app.get("/") diff --git a/backend/routers/analytics.py b/backend/routers/analytics.py new file mode 100644 index 0000000..81cc578 --- /dev/null +++ b/backend/routers/analytics.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Query +from services.database import ( + get_pattern_reliability, + get_all_pattern_reliability_map, + get_calibration_data, + decay_kb_confidence, + get_custom_patterns, +) + +router = APIRouter(prefix="/api/analytics", tags=["analytics"]) + + +@router.get("/reliability") +def reliability_all(): + """Pattern reliability scores — all patterns with mature trade history.""" + return {"reliability": get_pattern_reliability()} + + +@router.get("/reliability/{pattern_id}") +def reliability_one(pattern_id: str): + result = get_pattern_reliability(pattern_id=pattern_id) + if not result: + return {"reliability": None} + return {"reliability": result[0]} + + +@router.get("/calibration") +def calibration(days: int = Query(default=365, ge=30, le=1000)): + """Predicted probability vs realized outcomes (Brier score + buckets).""" + return get_calibration_data(days=days) + + +@router.post("/kb/decay") +def run_kb_decay(): + """Manually trigger KB confidence decay (also runs at cycle start).""" + updated = decay_kb_confidence() + return {"updated": updated, "message": f"{updated} entrée(s) KB mise(s) à jour"} + + +@router.get("/invalidation-alerts") +def invalidation_alerts(): + """List all active patterns that have an invalidation trigger defined.""" + patterns = get_custom_patterns() + alerts = [ + { + "pattern_id": p["id"], + "pattern_name": p["name"], + "invalidation_trigger": p.get("invalidation_trigger"), + "invalidation_probability": p.get("invalidation_probability"), + "counter_thesis": p.get("counter_thesis"), + } + for p in patterns + if p.get("invalidation_trigger") + ] + return {"alerts": alerts, "count": len(alerts)} diff --git a/backend/routers/patterns.py b/backend/routers/patterns.py index 5953762..d85d1fe 100644 --- a/backend/routers/patterns.py +++ b/backend/routers/patterns.py @@ -23,6 +23,9 @@ class PatternRequest(BaseModel): ai_quality_score: Optional[int] = None ai_evaluation: Optional[Dict[str, Any]] = None source: Optional[str] = "custom" + counter_thesis: Optional[str] = None + invalidation_trigger: Optional[str] = None + invalidation_probability: Optional[float] = None @router.get("/all") diff --git a/backend/services/ai_analyzer.py b/backend/services/ai_analyzer.py index f82e2e2..c6eef9b 100644 --- a/backend/services/ai_analyzer.py +++ b/backend/services/ai_analyzer.py @@ -727,6 +727,7 @@ def suggest_patterns_from_market_context( macro_regime: Optional[Dict] = None, geo_score: Optional[Dict] = None, portfolio_lessons: Optional[Dict] = None, + reliability_map: Optional[Dict] = None, ) -> List[Dict]: """Ask GPT-4o to propose new patterns based on current geo/market + macro regime context.""" top_news = sorted(news, key=lambda x: x.get("impact_score", 0), reverse=True)[:12] @@ -810,8 +811,30 @@ Leçons clés : Évite les erreurs identifiées dans les pertes. Privilégie les types de thèses qui ont fonctionné. """ + reliability_block = "" + if reliability_map: + top_reliable = sorted(reliability_map.values(), key=lambda r: -r["reliability_score"])[:5] + bottom_reliable = [r for r in sorted(reliability_map.values(), key=lambda r: r["reliability_score"]) if r["trade_count"] >= 3][:3] + lines = [] + for r in top_reliable: + lines.append( + f" ✅ {r['pattern_name']}: WR={r['win_rate_pct']}% | {r['trade_count']} trades | " + f"avgPnL={r['avg_pnl_pct']:+.1f}% | fiabilité={r['reliability_score']:.2f}" + ) + for r in bottom_reliable: + lines.append( + f" ❌ {r['pattern_name']}: WR={r['win_rate_pct']}% | {r['trade_count']} trades | " + f"avgPnL={r['avg_pnl_pct']:+.1f}% → À ÉVITER ou reformuler" + ) + if lines: + reliability_block = ( + "\n## 📊 FIABILITÉ HISTORIQUE DES PATTERNS (trades matures uniquement)\n" + + "\n".join(lines) + + "\n⚠️ Inspire-toi des patterns fiables. Évite de reproduire les patterns en bas de liste.\n" + ) + user = f"""Tu es un stratège géopolitique et financier senior. -{macro_block}{geo_block}{lessons_block} +{macro_block}{geo_block}{lessons_block}{reliability_block} ## Actualités géopolitiques du moment (triées par impact) {news_block} @@ -847,6 +870,9 @@ Retourne UNIQUEMENT ce JSON: "expected_move_pct": , "probability": , "horizon_days": , + "counter_thesis": "<1-2 phrases: principal scénario adverse qui invaliderait ce pattern — sois spécifique (ex: accord de paix inattendu, données CPI sous 3%, etc.)>", + "invalidation_trigger": "<événement précis et mesurable à surveiller — ex: 'prix pétrole < 70$/b 3j consécutifs', 'FOMC hawkish surprise', 'cessez-le-feu Russie-Ukraine'>", + "invalidation_probability": , "suggested_trades": [ {{ "strategy": "", diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index 335c2ae..de0e3fc 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -87,6 +87,15 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: ai_score_news_batch, _chat, DEFAULT_ANALYSIS_TEMPLATE, ) + # KB confidence decay (non-blocking) + try: + from services.database import decay_kb_confidence + _decayed = decay_kb_confidence() + if _decayed: + logger.info(f"[Cycle {run_id[:16]}] KB decay: {_decayed} entrée(s) mise(s) à jour") + except Exception as _e: + logger.warning(f"[Cycle] KB decay failed (non-blocking): {_e}") + # Check AI key ai_key = get_config("openai_api_key") or "" if not ai_key: @@ -157,6 +166,37 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: geo_score_val = int(geo_score_obj.get("score") or 0) summary["geo_score"] = geo_score_val + # ── Invalidation trigger detection ──────────────────────────────────── + try: + from services.database import get_custom_patterns as _gcp + _active_patterns = _gcp() + _news_headlines = " ".join( + (n.get("title", "") + " " + (n.get("summary", "") or "")).lower() + for n in news[:15] + ) + _triggered = [] + for _pat in _active_patterns: + _trigger = (_pat.get("invalidation_trigger") or "").lower().strip() + if not _trigger: + continue + # Simple keyword matching: split trigger into words and check majority match + _words = [w for w in _trigger.split() if len(w) > 3] + if _words and sum(1 for w in _words if w in _news_headlines) >= max(1, len(_words) // 2): + _triggered.append({ + "pattern_id": _pat["id"], + "pattern_name": _pat["name"], + "trigger": _pat["invalidation_trigger"], + "probability": _pat.get("invalidation_probability"), + }) + if _triggered: + logger.warning( + f"[Cycle {run_id[:16]}] ⚠ INVALIDATION TRIGGERS FIRED for " + f"{len(_triggered)} pattern(s): {[t['pattern_name'] for t in _triggered]}" + ) + summary["invalidation_alerts"] = _triggered + except Exception as _ie: + logger.debug(f"[Cycle] Invalidation check failed (non-blocking): {_ie}") + quotes = get_all_quotes() gauges = get_macro_gauges() @@ -167,12 +207,21 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: # ── Step 2: Suggest new patterns ────────────────────────────────────── logger.info(f"[Cycle {run_id[:16]}] Step 2: suggesting patterns") + _reliability_map = {} + try: + from services.database import get_all_pattern_reliability_map + _reliability_map = get_all_pattern_reliability_map() + if _reliability_map: + logger.info(f"[Cycle {run_id[:16]}] Reliability map: {len(_reliability_map)} patterns") + except Exception as _re: + logger.warning(f"[Cycle] Reliability map failed (non-blocking): {_re}") try: from services.data_fetcher import get_economic_calendar calendar = get_economic_calendar() suggestions = suggest_patterns_from_market_context( news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj, portfolio_lessons=portfolio_lessons, + reliability_map=_reliability_map or None, ) except Exception as e: logger.warning(f"[Cycle] Suggestion step failed: {e}") diff --git a/backend/services/database.py b/backend/services/database.py index 9cdf0ec..00a2b8a 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -63,11 +63,20 @@ def init_db(): 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 + # Migrations: add columns if not present + for _sql in [ + "ALTER TABLE custom_patterns ADD COLUMN source TEXT DEFAULT 'custom'", + "ALTER TABLE custom_patterns ADD COLUMN counter_thesis TEXT", + "ALTER TABLE custom_patterns ADD COLUMN invalidation_trigger TEXT", + "ALTER TABLE custom_patterns ADD COLUMN invalidation_probability REAL", + "ALTER TABLE pattern_score_history ADD COLUMN predicted_probability REAL", + "ALTER TABLE knowledge_base ADD COLUMN expires_at TEXT", + "ALTER TABLE knowledge_base ADD COLUMN confidence_decay_days INTEGER DEFAULT 90", + ]: + try: + c.execute(_sql) + except Exception: + pass c.execute("""CREATE TABLE IF NOT EXISTS config ( key TEXT PRIMARY KEY, @@ -358,9 +367,12 @@ def save_pattern_scores(scores: List[Dict[str, Any]], meta: Dict[str, Any] = Non for sp in scores: pid = sp.get("pattern_id", "") if pid: + # Look up predicted_probability from custom_patterns + row = conn.execute("SELECT probability FROM custom_patterns WHERE id=?", (pid,)).fetchone() + predicted_prob = float(row["probability"]) if row and row["probability"] is not None else None 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), + "INSERT INTO pattern_score_history (run_id, pattern_id, score, confidence, summary, scored_at, predicted_probability) VALUES (?,?,?,?,?,?,?)", + (run_id, pid, sp.get("score"), sp.get("confidence"), sp.get("summary", ""), run_id, predicted_prob), ) # Keep only the last 30 runs conn.execute("""DELETE FROM pattern_score_history WHERE run_id NOT IN ( @@ -575,8 +587,10 @@ def save_custom_pattern(pattern: Dict[str, Any]) -> str: 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'))""", ( + horizon_days, ai_quality_score, ai_evaluation, source, + counter_thesis, invalidation_trigger, invalidation_probability, + is_active, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,datetime('now'))""", ( pat_id, pattern.get("name", ""), pattern.get("description", ""), @@ -591,6 +605,9 @@ def save_custom_pattern(pattern: Dict[str, Any]) -> str: pattern.get("ai_quality_score"), json.dumps(pattern.get("ai_evaluation", {})), source, + pattern.get("counter_thesis"), + pattern.get("invalidation_trigger"), + pattern.get("invalidation_probability"), )) conn.commit() conn.close() @@ -1573,3 +1590,208 @@ def get_iv_history(ticker: str, days: int = 90) -> List[Dict]: conn.close() return [dict(r) for r in rows] + +# ── Knowledge Base Decay ────────────────────────────────────────────────────── + +def decay_kb_confidence() -> int: + """ + Decrease confidence on KB entries past their expires_at or older than + confidence_decay_days since last_confirmed_at. Archives entries at 0. + Returns number of entries updated. + """ + conn = get_conn() + c = conn.cursor() + today_str = datetime.utcnow().date().isoformat() + + # Entries past expires_at → archive + c.execute(""" + UPDATE knowledge_base + SET status = 'archived', confidence = 0 + WHERE expires_at IS NOT NULL AND expires_at <= ? AND status = 'active' + """, (today_str,)) + expired = c.rowcount + + # Entries where days_since_confirmation > confidence_decay_days + # Reduce confidence by 10 per overdue period + rows = c.execute(""" + SELECT id, confidence, last_confirmed_at, confidence_decay_days + FROM knowledge_base + WHERE status = 'active' AND last_confirmed_at IS NOT NULL + """).fetchall() + + decayed = 0 + for row in rows: + r = dict(row) + try: + from datetime import date as _d + last = _d.fromisoformat(r["last_confirmed_at"][:10]) + days_since = (_d.today() - last).days + decay_period = r["confidence_decay_days"] or 90 + if days_since > decay_period: + periods_overdue = days_since // decay_period + new_conf = max(0, r["confidence"] - periods_overdue * 10) + if new_conf != r["confidence"]: + c.execute( + "UPDATE knowledge_base SET confidence=? WHERE id=?", + (new_conf, r["id"]) + ) + decayed += 1 + if new_conf == 0: + c.execute( + "UPDATE knowledge_base SET status='archived' WHERE id=?", + (r["id"],) + ) + except Exception: + pass + + conn.commit() + conn.close() + return expired + decayed + + +# ── Pattern Reliability ─────────────────────────────────────────────────────── + +def get_pattern_reliability(pattern_id: str = None) -> List[Dict]: + """ + Compute win_rate, avg_pnl, trade_count, reliability_score per pattern. + Uses MATURE trades only (days_held >= 35% of horizon_days). + If pattern_id is provided, returns single-item list for that pattern. + """ + import math + from datetime import date as _date + + conn = get_conn() + q = "SELECT * FROM trade_entry_prices WHERE pnl_pct IS NOT NULL" + args: list = [] + if pattern_id: + q += " AND pattern_id = ?" + args.append(pattern_id) + rows = conn.execute(q, args).fetchall() + conn.close() + + today = _date.today() + by_pattern: Dict[str, list] = {} + for row in rows: + r = dict(row) + try: + entry = _date.fromisoformat(r["entry_date"]) + days_held = (today - entry).days + except Exception: + days_held = 0 + horizon = r.get("horizon_days") or 30 + ratio = days_held / horizon if horizon else 0 + # Only mature trades (≥35% of horizon elapsed) + if ratio < 0.35: + continue + by_pattern.setdefault(r["pattern_id"], []).append(r) + + result = [] + for pid, trades in by_pattern.items(): + pnls = [t["pnl_pct"] for t in trades if t.get("pnl_pct") is not None] + if not pnls: + continue + wins = sum(1 for p in pnls if p > 0) + win_rate = wins / len(pnls) + avg_pnl = sum(pnls) / len(pnls) + # Composite score: win_rate × log(n+1) — penalises small samples + reliability = round(win_rate * math.log(len(pnls) + 1), 3) + + result.append({ + "pattern_id": pid, + "pattern_name": trades[0].get("pattern_name", pid), + "trade_count": len(pnls), + "win_rate": round(win_rate, 3), + "win_rate_pct": round(win_rate * 100, 1), + "avg_pnl_pct": round(avg_pnl, 2), + "max_pnl_pct": round(max(pnls), 2), + "max_loss_pct": round(min(pnls), 2), + "reliability_score": reliability, + }) + + result.sort(key=lambda x: -x["reliability_score"]) + return result + + +def get_all_pattern_reliability_map() -> Dict[str, Dict]: + """Returns {pattern_id: reliability_dict} for fast lookup.""" + return {r["pattern_id"]: r for r in get_pattern_reliability()} + + +# ── Calibration ─────────────────────────────────────────────────────────────── + +def get_calibration_data(days: int = 365) -> Dict: + """ + Compare predicted probability (stored at score time) vs realized outcome + (pnl_pct > 0 at maturity) to compute Brier score and calibration buckets. + """ + import math + from datetime import date as _date + + conn = get_conn() + # Join pattern_scores (has predicted probability) with trade_entry_prices (has realized P&L) + rows = conn.execute(""" + SELECT + psh.pattern_id, + psh.score, + tep.pnl_pct, + tep.entry_date, + tep.horizon_days, + cp.probability as predicted_prob + FROM pattern_score_history psh + JOIN trade_entry_prices tep ON tep.pattern_id = psh.pattern_id + LEFT JOIN custom_patterns cp ON cp.id = psh.pattern_id + WHERE tep.pnl_pct IS NOT NULL + AND cp.probability IS NOT NULL + AND tep.entry_date >= date('now', ?) + """, (f"-{days} days",)).fetchall() + conn.close() + + today = _date.today() + pairs = [] + for row in rows: + r = dict(row) + try: + entry = _date.fromisoformat(r["entry_date"]) + dh = (today - entry).days + except Exception: + dh = 0 + horizon = r.get("horizon_days") or 30 + if dh / horizon < 0.35: + continue # only mature + pred = float(r["predicted_prob"] or 0) + realized = 1.0 if (r["pnl_pct"] or 0) > 0 else 0.0 + pairs.append({"predicted": pred, "realized": realized}) + + if not pairs: + return {"pairs": [], "brier_score": None, "buckets": [], "sample_size": 0} + + # Brier score + brier = sum((p["predicted"] - p["realized"]) ** 2 for p in pairs) / len(pairs) + + # Calibration buckets (deciles) + buckets = [] + for low in [i / 10 for i in range(0, 10)]: + high = low + 0.1 + bucket_pairs = [p for p in pairs if low <= p["predicted"] < high] + if bucket_pairs: + actual_rate = sum(p["realized"] for p in bucket_pairs) / len(bucket_pairs) + buckets.append({ + "predicted_range": f"{int(low*100)}-{int(high*100)}%", + "predicted_mid": round((low + high) / 2, 2), + "actual_rate": round(actual_rate, 3), + "count": len(bucket_pairs), + "bias": round(actual_rate - (low + high) / 2, 3), + }) + + return { + "pairs": pairs, + "brier_score": round(brier, 4), + "buckets": buckets, + "sample_size": len(pairs), + "interpretation": ( + "Bien calibré" if brier < 0.15 + else "Modérément calibré" if brier < 0.25 + else "Surconfiant ou mal calibré" + ), + } + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 01cb28b..a34f074 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import JournalDeBord from './pages/JournalDeBord' import RapportIA from './pages/RapportIA' import SuperContexte from './pages/SuperContexte' import Config from './pages/Config' +import Analytics from './pages/Analytics' import { useCycleWatcher } from './hooks/useApi' function GlobalWatcher() { @@ -41,6 +42,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index c608c76..ec44369 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -17,6 +17,7 @@ const nav = [ { to: '/journal', icon: BookOpen, label: 'Journal de Bord' }, { to: '/rapport', icon: FileBarChart, label: 'Rapport IA' }, { to: '/super-contexte', icon: Brain, label: 'Super Contexte' }, + { to: '/analytics', icon: FlaskConical, label: 'Analytics' }, { to: '/backtest', icon: History, label: 'Backtest' }, { to: '/calendar', icon: Calendar, label: 'Calendrier' }, { to: '/config', icon: Settings, label: 'Configuration' }, diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 1b132ee..506c784 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -613,3 +613,19 @@ export const useIvForTrade = (underlying: string) => enabled: !!underlying, staleTime: 60 * 60_000, }) + +// ── Analytics (Phase 2) ─────────────────────────────────────────────────────── + +export const usePatternReliability = () => + useQuery({ + queryKey: ['pattern-reliability'], + queryFn: () => api.get('/analytics/reliability').then(r => r.data), + staleTime: 10 * 60_000, + }) + +export const useCalibration = (days = 365) => + useQuery({ + queryKey: ['calibration', days], + queryFn: () => api.get('/analytics/calibration', { params: { days } }).then(r => r.data), + staleTime: 10 * 60_000, + }) diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx new file mode 100644 index 0000000..7094520 --- /dev/null +++ b/frontend/src/pages/Analytics.tsx @@ -0,0 +1,245 @@ +import { useState } from 'react' +import { usePatternReliability, useCalibration } from '../hooks/useApi' +import clsx from 'clsx' +import { BarChart2, Target, TrendingUp, AlertTriangle } from 'lucide-react' + +function ReliabilityTable({ data }: { data: any[] }) { + if (!data || data.length === 0) { + return ( +
+ +
Aucune donnée de fiabilité
+
Les données apparaissent après 3+ trades matures (≥35% de l'horizon écoulé) par pattern
+
+ ) + } + + return ( +
+ + + + + + + + + + + + + + {data.map((r: any) => { + const wr = r.win_rate_pct + const wrColor = wr >= 60 ? 'text-emerald-400' : wr >= 40 ? 'text-amber-400' : 'text-red-400' + const rel = r.reliability_score + const relColor = rel >= 1.5 ? 'text-emerald-400' : rel >= 0.8 ? 'text-amber-400' : 'text-red-400' + return ( + + + + + + + + + + ) + })} + +
PatternTradesWin RatePnL moyenMax gainMax perteScore fiabilité
+
{r.pattern_name}
+
{r.pattern_id}
+
{r.trade_count}{wr}%= 0 ? 'text-emerald-400' : 'text-red-400')}> + {r.avg_pnl_pct > 0 ? '+' : ''}{r.avg_pnl_pct}% + +{r.max_pnl_pct}%{r.max_loss_pct}% + {rel.toFixed(2)} +
WR × log(n+1)
+
+
+ ) +} + +function CalibrationSection({ data }: { data: any }) { + if (!data) return null + + const { brier_score, interpretation, buckets, sample_size } = data + + if (!brier_score && sample_size === 0) { + return ( +
+ +
Pas encore de données de calibration
+
Nécessite des trades matures avec probabilité stockée vs résultat réalisé
+
+ ) + } + + const brierColor = brier_score < 0.15 ? 'text-emerald-400' : brier_score < 0.25 ? 'text-amber-400' : 'text-red-400' + + return ( +
+ {/* Score summary */} +
+
+
{brier_score?.toFixed(3) ?? '—'}
+
Brier Score
+
(0 = parfait, 1 = nul)
+
+
+
{sample_size}
+
Trades analysés
+
+
+
{interpretation ?? '—'}
+
Interprétation
+
+
+ + {/* Calibration buckets */} + {buckets && buckets.length > 0 && ( +
+
Calibration par décile (prédit vs réalisé)
+
+ + + + + + + + + + + + {buckets.map((b: any) => { + const bias = b.bias + const biasColor = Math.abs(bias) < 0.05 ? 'text-emerald-400' : Math.abs(bias) < 0.15 ? 'text-amber-400' : 'text-red-400' + const actualPct = Math.round(b.actual_rate * 100) + const predPct = Math.round(b.predicted_mid * 100) + return ( + + + + + + + + ) + })} + +
Prob. préditeTaux réelBiaisTradesBarre
{b.predicted_range}{actualPct}% + {bias > 0 ? '+' : ''}{(bias * 100).toFixed(1)}% + {b.count} +
+ {/* Predicted (grey) vs actual (colored) */} +
+
+
= predPct ? 'bg-emerald-500' : 'bg-red-500')} + style={{ width: `${actualPct}%` }} /> +
+
+
+
+
+ Barre grise = prob. prédite · Barre colorée = taux réalisé · Vert si réel ≥ prédit, rouge sinon +
+
+ )} +
+ ) +} + +export default function Analytics() { + const { data: reliabilityData, isLoading: loadingR } = usePatternReliability() + const [calDays, setCalDays] = useState(365) + const { data: calData, isLoading: loadingC } = useCalibration(calDays) + + const reliability: any[] = (reliabilityData as any)?.reliability ?? [] + const topPatterns = reliability.slice(0, 5) + const bottomPatterns = reliability.filter((r: any) => r.trade_count >= 3).slice(-3) + + return ( +
+
+

+ Analytics & Calibration +

+

+ Fiabilité historique des patterns · Calibration probabiliste · Brier score +

+
+ + {/* Summary KPIs */} + {reliability.length > 0 && ( +
+
+
{reliability.length}
+
Patterns avec historique
+
+
+
+ {reliability.reduce((s: number, r: any) => s + r.trade_count, 0)} +
+
Trades matures analysés
+
+ {topPatterns[0] && ( +
+
+ Meilleur pattern +
+
{topPatterns[0].pattern_name}
+
{topPatterns[0].win_rate_pct}% WR
+
+ )} + {bottomPatterns[0] && ( +
+
+ À éviter +
+
{bottomPatterns[0].pattern_name}
+
{bottomPatterns[0].win_rate_pct}% WR
+
+ )} +
+ )} + + {/* Reliability table */} +
+
+ Fiabilité par pattern + (trades ≥35% de l'horizon uniquement) +
+ {loadingR ? ( +
{[1,2,3].map(i =>
)}
+ ) : ( + + )} +
+ + {/* Calibration */} +
+
+
+ Calibration probabiliste +
+ +
+ {loadingC ? ( +
{[1,2,3].map(i =>
)}
+ ) : ( + + )} +
+
+ ) +} diff --git a/frontend/src/pages/PatternEditor.tsx b/frontend/src/pages/PatternEditor.tsx index 91ec58a..6e4ce9f 100644 --- a/frontend/src/pages/PatternEditor.tsx +++ b/frontend/src/pages/PatternEditor.tsx @@ -1,7 +1,7 @@ import { useState, useMemo } from 'react' -import { useAllPatterns, useSavePattern, useDeletePattern, useEvaluatePattern, useSuggestPattern, useAiStatus, useSuggestNewPatterns, useTogglePattern, usePatternSimilarity, useLastScores } from '../hooks/useApi' +import { useAllPatterns, useSavePattern, useDeletePattern, useEvaluatePattern, useSuggestPattern, useAiStatus, useSuggestNewPatterns, useTogglePattern, usePatternSimilarity, useLastScores, usePatternReliability } from '../hooks/useApi' import clsx from 'clsx' -import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff } from 'lucide-react' +import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff, ShieldAlert } from 'lucide-react' function jaccard(a: string[], b: string[]): number { if (!a.length && !b.length) return 0 @@ -26,6 +26,7 @@ const EMPTY_PATTERN = { name: '', description: '', triggers: [] as string[], keywords: [] as string[], historical_instances: [] as any[], suggested_trades: [] as any[], asset_class: 'energy', expected_move_pct: 10, probability: 0.6, horizon_days: 30, + counter_thesis: '', invalidation_trigger: '', invalidation_probability: undefined as number | undefined, } function QualityBadge({ score }: { score: number }) { @@ -34,10 +35,24 @@ function QualityBadge({ score }: { score: number }) { return {score}/100 — {label} } -function PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore }: { +function ReliabilityBadge({ rel }: { rel: any }) { + if (!rel || rel.trade_count < 3) return null + const wr = rel.win_rate_pct + const color = wr >= 60 ? 'text-emerald-400 border-emerald-700/40 bg-emerald-900/20' + : wr >= 40 ? 'text-amber-400 border-amber-700/40 bg-amber-900/20' + : 'text-red-400 border-red-700/40 bg-red-900/20' + return ( + + 📊 {wr}% WR ({rel.trade_count}t · ⌀{rel.avg_pnl_pct > 0 ? '+' : ''}{rel.avg_pnl_pct}%) + + ) +} + +function PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore, reliability }: { p: any; onEdit: () => void; onDelete: () => void; onToggle: () => void similarTo?: Array<{ name: string; similarity: number }> aiScore?: number | null + reliability?: any }) { const [expanded, setExpanded] = useState(false) const isCustom = p.source === 'custom' @@ -55,6 +70,7 @@ function PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore }: { {isCustom && Custom} {!isActive && Désactivé} {p.ai_quality_score && } +
{p.description}
@@ -142,6 +158,26 @@ function PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore }: { ))} )} + {(p.counter_thesis || p.invalidation_trigger) && ( +
+
+ Contre-thèse & Invalidation +
+ {p.counter_thesis && ( +
+ Contre-thèse : {p.counter_thesis} +
+ )} + {p.invalidation_trigger && ( +
+ Trigger : {p.invalidation_trigger} + {p.invalidation_probability != null && ( + ({Math.round(p.invalidation_probability * 100)}% prob.) + )} +
+ )} +
+ )} {p.ai_evaluation && Object.keys(p.ai_evaluation).length > 0 && (
Évaluation IA
@@ -269,6 +305,16 @@ function AiSuggestModal({ onClose, onSaveAll, allPatterns }: { onClose: () => vo {p.macro_fit}
)} + {(p.counter_thesis || p.invalidation_trigger) && ( +
+ {p.counter_thesis && ( +
⚠ Contre-thèse : {p.counter_thesis}
+ )} + {p.invalidation_trigger && ( +
Trigger : {p.invalidation_trigger}{p.invalidation_probability != null ? ` (${Math.round(p.invalidation_probability * 100)}%)` : ''}
+ )} +
+ )} {p.suggested_trades?.length > 0 && (
{p.suggested_trades.map((t: any, ti: number) => ( @@ -477,6 +523,33 @@ function PatternForm({ initial, onSave, onCancel }: { initial?: any; onSave: (p:
+ {/* Contre-thèse & Invalidation */} +
+
+ +