diff --git a/backend/routers/patterns.py b/backend/routers/patterns.py index 25b284d..1a2ef63 100644 --- a/backend/routers/patterns.py +++ b/backend/routers/patterns.py @@ -5,7 +5,7 @@ from pydantic import BaseModel from typing import Optional, List, Dict, Any from services.database import ( save_custom_pattern, get_custom_patterns, delete_custom_pattern, toggle_pattern_active, - get_config, + get_config, get_calibration_summary, ) from services.geo_analyzer import PATTERN_TAXONOMY @@ -141,6 +141,14 @@ def get_by_instrument(ticker: str = Query(..., description="Ticker / underlying return result +# ── Calibration summary ─────────────────────────────────────────────────────── + +@router.get("/calibration") +def get_calibration(): + """Per-pattern calibration state: AI estimate vs observed blend.""" + return get_calibration_summary() + + # ── Find Similar ────────────────────────────────────────────────────────────── class FindSimilarRequest(BaseModel): diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index ae8beea..24181e5 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -1506,6 +1506,52 @@ Réponds en JSON avec ce schéma EXACT: except Exception: commentary_parsed = {"commentary": str(commentary)} + # ── Calibration report ─────────────────────────────────────────────────── + calibration_report: Dict = {} + try: + from services.database import get_calibration_summary as _get_calib + calib_rows = _get_calib() + if calib_rows: + pure_ai = [r for r in calib_rows if r["source"] == "pure_ai"] + early = [r for r in calib_rows if r["source"] == "early"] + mixed = [r for r in calib_rows if r["source"] == "mixed"] + driven = [r for r in calib_rows if r["source"] == "data_driven"] + with_data = [r for r in calib_rows if (r["n_mature_trades"] or 0) > 0] + avg_w = ( + round(sum(r["calibration_weight"] for r in with_data) / len(with_data), 3) + if with_data else 0.0 + ) + calibration_report = { + "total_patterns": len(calib_rows), + "pure_ai_count": len(pure_ai), + "early_count": len(early), + "mixed_count": len(mixed), + "data_driven_count": len(driven), + "avg_calibration_weight": avg_w, + "avg_calibration_weight_pct": round(avg_w * 100, 1), + "patterns_with_data": len(with_data), + "detail": [ + { + "name": r["pattern_name"], + "asset_class": r["asset_class"], + "source": r["source"], + "weight_pct": r["calibration_weight_pct"], + "ai_estimate": r["ai_estimate"], + "observed": r["observed_avg_win_pct"], + "calibrated": r["calibrated_expected_move"], + "n_trades": r["n_mature_trades"], + "win_rate_pct": round((r["bayes_win_rate"] or 0) * 100, 1), + } + for r in calib_rows if (r["n_mature_trades"] or 0) > 0 + ][:15], + } + logger.info( + f"[CycleReport] Calibration: {len(pure_ai)} pure-AI, " + f"{len(mixed)+len(driven)} with data, avg_weight={avg_w:.1%}" + ) + except Exception as _ce: + logger.warning(f"[CycleReport] Calibration report failed: {_ce}") + # ── Assemble full report ────────────────────────────────────────────────── report = { "run_id": run_id, @@ -1553,6 +1599,8 @@ Réponds en JSON avec ce schéma EXACT: "n_alert": (options_assessment or {}).get("n_alert", 0), "assessments": (options_assessment or {}).get("assessments", []), } if options_assessment is not None else None, + # Calibration: AI vs observed expected_move blend + "calibration_report": calibration_report, } return report diff --git a/backend/services/database.py b/backend/services/database.py index b19dbe3..8b5a085 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -86,6 +86,10 @@ def init_db(): # Pattern Lab — backtest reliability tracking "ALTER TABLE custom_patterns ADD COLUMN backtest_hits INTEGER DEFAULT 0", "ALTER TABLE custom_patterns ADD COLUMN backtest_runs_count INTEGER DEFAULT 0", + # Calibration — observed vs AI blending + "ALTER TABLE custom_patterns ADD COLUMN calibrated_expected_move REAL", + "ALTER TABLE custom_patterns ADD COLUMN calibration_weight REAL DEFAULT 0.0", + "ALTER TABLE custom_patterns ADD COLUMN observed_avg_win_pct REAL", # Remove all built-in patterns (no proof of legitimacy) "DELETE FROM custom_patterns WHERE source = 'builtin'", # Regime / counter-scenario architecture @@ -1506,11 +1510,16 @@ def log_trade_entries(run_id: str, scored_patterns: List[Dict[str, Any]], quotes 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 + # Fallback chain: trade field → scored sp field → calibrated DB (observed blend) → AI estimate DB + _calib = _orig.get("calibrated_expected_move") + _calib_w = float(_orig.get("calibration_weight") or 0) + _ai_est = _orig.get("expected_move_pct") + # Use calibrated value when credibility weight > 10% (at least ~1 mature trade) + _db_move = _calib if (_calib and _calib_w > 0.1) else _ai_est exp_move = abs(float( trade.get("expected_move_pct") or sp.get("expected_move_pct") or - _orig.get("expected_move_pct") or + _db_move or 0 )) if exp_move == 0: @@ -3328,18 +3337,31 @@ def get_risk_dashboard() -> Dict: def update_bayesian_posteriors() -> int: """ - Met à jour les posteriors Beta(α,β) de chaque pattern selon ses trades matures. - Prior faible : α₀=1, β₀=1 (Laplace smoothing). - Posterior : α = 1 + wins, β = 1 + losses → win_rate bayésien = α/(α+β) - Retourne le nombre de patterns mis à jour. + Met à jour les posteriors Beta(α,β) et la calibration de l'expected_move. + + Bayesian: α = 1 + wins, β = 1 + losses → bayesian_win_rate = α/(α+β) + + Calibration (credibility blending): + k = 5 → w = n / (n + 5) (50% credibility at 5 trades, 80% at 20) + observed_avg_win_pct = mean(pnl_pct for wins) + calibrated_expected_move = (1-w) × ai_estimate + w × observed_avg_win_pct + (only blends when at least 1 win exists; pure AI until then) """ import math as _math from datetime import date as _date + _CREDIBILITY_K = 5 # trades needed to reach 50% observed weight + conn = get_conn() rows = conn.execute( "SELECT pattern_id, pnl_pct, entry_date, horizon_days FROM trade_entry_prices WHERE pnl_pct IS NOT NULL" ).fetchall() + # Also load ai estimates for blending + ai_estimates = { + r["id"]: float(r["expected_move_pct"] or 0) + for r in conn.execute("SELECT id, expected_move_pct FROM custom_patterns").fetchall() + if r["expected_move_pct"] + } conn.close() today = _date.today() @@ -3353,7 +3375,7 @@ def update_bayesian_posteriors() -> int: continue horizon = r.get("horizon_days") or 30 if days_held / max(horizon, 1) < 0.35: - continue # trades immatures exclus + continue by_pattern.setdefault(r["pattern_id"], []).append(float(r["pnl_pct"] or 0)) if not by_pattern: @@ -3365,17 +3387,36 @@ def update_bayesian_posteriors() -> int: now_iso = datetime.utcnow().isoformat() for pid, pnls in by_pattern.items(): n = len(pnls) - wins = sum(1 for p in pnls if p > 0) + wins_pnl = [p for p in pnls if p > 0] + wins = len(wins_pnl) losses = n - wins - alpha = 1.0 + wins # posterior alpha - beta = 1.0 + losses # posterior beta + + # Bayesian posteriors + alpha = 1.0 + wins + beta = 1.0 + losses bayes_wr = alpha / (alpha + beta) + + # Credibility blending + w = n / (n + _CREDIBILITY_K) + ai_est = ai_estimates.get(pid) + observed_avg_win = round(sum(wins_pnl) / len(wins_pnl), 2) if wins_pnl else None + if ai_est and observed_avg_win is not None: + calibrated = round((1 - w) * ai_est + w * observed_avg_win, 2) + else: + calibrated = None # not enough data — keep pure AI in log_trade_entries + c.execute(""" UPDATE custom_patterns SET bayesian_alpha=?, bayesian_beta=?, bayesian_win_rate=?, - bayesian_updated_at=?, bayesian_sample_size=? + bayesian_updated_at=?, bayesian_sample_size=?, + calibration_weight=?, observed_avg_win_pct=?, calibrated_expected_move=? WHERE id=? - """, (round(alpha, 1), round(beta, 1), round(bayes_wr, 4), now_iso, n, pid)) + """, ( + round(alpha, 1), round(beta, 1), round(bayes_wr, 4), + now_iso, n, + round(w, 4), observed_avg_win, calibrated, + pid, + )) if c.rowcount: updated += 1 conn.commit() @@ -3383,6 +3424,53 @@ def update_bayesian_posteriors() -> int: return updated +def get_calibration_summary() -> List[Dict]: + """ + Returns per-pattern calibration state for the cycle report and UI. + Includes: ai_estimate, observed_avg_win_pct, calibration_weight, + calibrated_expected_move, n_mature_trades, win_rate. + """ + conn = get_conn() + rows = conn.execute(""" + SELECT cp.id, cp.name, cp.asset_class, + cp.expected_move_pct AS ai_estimate, + cp.calibrated_expected_move AS calibrated, + cp.calibration_weight AS weight, + cp.observed_avg_win_pct AS observed_win, + cp.bayesian_sample_size AS n_trades, + cp.bayesian_win_rate AS bayes_wr + FROM custom_patterns cp + WHERE cp.is_active = 1 + ORDER BY cp.calibration_weight DESC NULLS LAST, cp.bayesian_sample_size DESC + """).fetchall() + conn.close() + + result = [] + for r in rows: + d = dict(r) + w = d.get("weight") or 0.0 + n = d.get("n_trades") or 0 + result.append({ + "pattern_id": d["id"], + "pattern_name": d["name"], + "asset_class": d.get("asset_class", ""), + "ai_estimate": d.get("ai_estimate"), + "observed_avg_win_pct": d.get("observed_win"), + "calibrated_expected_move": d.get("calibrated"), + "calibration_weight": round(w, 4), + "calibration_weight_pct": round(w * 100, 1), + "n_mature_trades": n, + "bayes_win_rate": round((d.get("bayes_wr") or 0), 3), + "source": ( + "pure_ai" if w < 0.1 else + "early" if w < 0.4 else + "mixed" if w < 0.75 else + "data_driven" + ), + }) + return result + + def get_bayesian_posteriors() -> List[Dict]: """ Retourne tous les patterns avec leurs posteriors bayésiens + intervalle de crédibilité 95%. diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index cc9b029..21bbfa9 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -363,6 +363,27 @@ export const useTogglePattern = () => { }) } +export interface PatternCalibration { + pattern_id: string + pattern_name: string + asset_class: string + ai_estimate: number | null + observed_avg_win_pct: number | null + calibrated_expected_move: number | null + calibration_weight: number + calibration_weight_pct: number + n_mature_trades: number + bayes_win_rate: number + source: 'pure_ai' | 'early' | 'mixed' | 'data_driven' +} + +export const usePatternCalibration = () => + useQuery({ + queryKey: ['pattern-calibration'], + queryFn: () => api.get('/patterns/calibration').then(r => r.data as PatternCalibration[]), + staleTime: 60_000, + }) + export interface SimilarityResult { recommendation: 'merge_as_instance' | 'counter_scenario' | 'new_pattern' match_id: string | null diff --git a/frontend/src/pages/PatternExplorer.tsx b/frontend/src/pages/PatternExplorer.tsx index 79bb26e..de70260 100644 --- a/frontend/src/pages/PatternExplorer.tsx +++ b/frontend/src/pages/PatternExplorer.tsx @@ -14,8 +14,10 @@ import { usePatchPattern, useFindSimilarPattern, useMergePatterns, + usePatternCalibration, validateTicker, type SimilarityResult, + type PatternCalibration, } from '../hooks/useApi' import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments' @@ -135,14 +137,87 @@ function CategoryBadge({ category }: { category: string }) { ) } +// ── Maturity / Calibration Badge ───────────────────────────────────────────── + +function MaturityBadge({ cal }: { cal: PatternCalibration }) { + const w = cal.calibration_weight_pct + const n = cal.n_mature_trades + const hasData = n > 0 + + const sourceColor = { + pure_ai: 'text-slate-500 border-slate-700/40', + early: 'text-sky-400 border-sky-700/40', + mixed: 'text-violet-400 border-violet-700/40', + data_driven: 'text-emerald-400 border-emerald-700/40', + }[cal.source] + + const sourceLabel = { + pure_ai: 'Pure AI', + early: 'Early data', + mixed: 'Mixed', + data_driven: 'Data-driven', + }[cal.source] + + return ( +