From 91f12e177f9bca8922034de0d9f035085c387615 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Tue, 23 Jun 2026 12:38:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20pattern=20calibration=20=E2=80=94=20pro?= =?UTF-8?q?gressive=20AI=E2=86=92observed=20expected=5Fmove=20blending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB (database.py): - 3 new columns on custom_patterns: calibrated_expected_move, calibration_weight, observed_avg_win_pct - update_bayesian_posteriors() now also computes credibility blend w=n/(n+5): calibrated = (1-w)*ai_estimate + w*observed_avg_win_pct (only when wins exist) - log_trade_entries() prefers calibrated_expected_move when w>10% - get_calibration_summary() returns per-pattern state (source: pure_ai/early/mixed/data_driven) Backend (patterns.py, auto_cycle.py): - GET /api/patterns/calibration endpoint - calibration_report block in cycle report: counts by source, avg weight, per-pattern detail Frontend (PatternExplorer.tsx, RapportIA.tsx, useApi.ts): - MaturityBadge on each PatternCard: blend bar (AI→observed), win rate, AI estimate vs calibrated - usePatternCalibration hook - Cycle report: calibration section with global bar + per-pattern table (weight%, n_trades, WR, AI→calibrated) Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/patterns.py | 10 ++- backend/services/auto_cycle.py | 48 +++++++++++ backend/services/database.py | 112 ++++++++++++++++++++++--- frontend/src/hooks/useApi.ts | 21 +++++ frontend/src/pages/PatternExplorer.tsx | 90 +++++++++++++++++++- frontend/src/pages/RapportIA.tsx | 63 +++++++++++++- 6 files changed, 327 insertions(+), 17 deletions(-) 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 ( +
+
+ + {sourceLabel} + + + {hasData ? `${n} trade${n > 1 ? 's' : ''} matures` : 'Aucun trade mature'} + +
+ + {/* Blend bar */} +
+
+ IA pure + {w.toFixed(0)}% observé + Observé +
+
+
+
+
+ + {hasData && ( +
+
+
{(cal.bayes_win_rate * 100).toFixed(0)}%
+
Win rate
+
+
+
+ {cal.ai_estimate != null ? `${cal.ai_estimate.toFixed(0)}%` : '—'} +
+
IA estim.
+
+
+
+ {cal.calibrated_expected_move != null ? `${cal.calibrated_expected_move.toFixed(0)}%` : '—'} +
+
Calibré
+
+
+ )} +
+ ) +} + // ── Pattern Card ────────────────────────────────────────────────────────────── function PatternCard({ pattern, highlightTrades, + calibration, }: { pattern: Pattern highlightTrades?: string[] + calibration?: PatternCalibration }) { const [expanded, setExpanded] = useState(false) const [confirmDel, setConfirmDel] = useState(false) @@ -373,6 +448,9 @@ function PatternCard({
)} + {/* Maturity & calibration */} + {calibration && } + {/* Similarity result panel */} {simResult && (
= { neutral: 'bg-amber-900/40 border-amber-600/50 text-amber-300', } -function FacetedSearch({ patterns }: { patterns: Pattern[] }) { +function FacetedSearch({ patterns, calibrationMap = {} }: { patterns: Pattern[]; calibrationMap?: Record }) { const [query, setQuery] = useState('') const [direction, setDirection] = useState('') const [assetClass, setAssetClass] = useState('') @@ -658,7 +736,7 @@ function FacetedSearch({ patterns }: { patterns: Pattern[] }) {
) : (
- {filtered.map(p => )} + {filtered.map(p => )}
)} @@ -1025,6 +1103,12 @@ export default function PatternExplorer() { const [view, setView] = useState('search') const { data: patternsData, isLoading } = useAllPatterns() const { data: _scores } = useLastScores() + const { data: calibrationData } = usePatternCalibration() + + const calibrationMap = useMemo>(() => { + if (!calibrationData) return {} + return Object.fromEntries(calibrationData.map(c => [c.pattern_id, c])) + }, [calibrationData]) const patterns = useMemo(() => { if (!Array.isArray(patternsData)) return [] @@ -1101,7 +1185,7 @@ export default function PatternExplorer() { ) : ( <> - {view === 'search' && } + {view === 'search' && } {view === 'instrument' && } {view === 'regime' && } diff --git a/frontend/src/pages/RapportIA.tsx b/frontend/src/pages/RapportIA.tsx index 42d0bf0..82a89b9 100644 --- a/frontend/src/pages/RapportIA.tsx +++ b/frontend/src/pages/RapportIA.tsx @@ -4,7 +4,7 @@ import clsx from 'clsx' import { Brain, TrendingUp, TrendingDown, RefreshCw, ShieldAlert, GitCompare, Layers, Zap, BookOpen, Clock, ChevronRight, - CheckCircle2, AlertTriangle, XCircle, BarChart2, + CheckCircle2, AlertTriangle, XCircle, BarChart2, FlaskConical, } from 'lucide-react' const SCENARIO_META: Record = { @@ -561,6 +561,67 @@ export default function RapportCycle() { )} + {/* Calibration report */} + {report.calibration_report && (report.calibration_report as any).total_patterns > 0 && (() => { + const cr = report.calibration_report as any + const pct = cr.avg_calibration_weight_pct ?? 0 + return ( +
} title="Calibration expected_move — IA vs observé"> + {/* Global bar */} +
+
+ IA pure ({cr.pure_ai_count} patterns) + {pct.toFixed(1)}% poids observé moyen + Data-driven ({cr.data_driven_count}) +
+
+
+
+
+ {[ + { label: 'Pure AI', count: cr.pure_ai_count, cls: 'text-slate-400' }, + { label: 'Early', count: cr.early_count, cls: 'text-sky-400' }, + { label: 'Mixed', count: cr.mixed_count, cls: 'text-violet-400' }, + { label: 'Data', count: cr.data_driven_count, cls: 'text-emerald-400' }, + ].map(({ label, count, cls }) => ( +
+
{count}
+
{label}
+
+ ))} +
+
+ {/* Per-pattern detail */} + {(cr.detail ?? []).length > 0 && ( +
+
Patterns avec données ({cr.patterns_with_data})
+ {(cr.detail as any[]).map((d: any, i: number) => ( +
+ + {d.weight_pct?.toFixed(0)}% + + {d.name} + {d.n_trades}t · {d.win_rate_pct}% WR + AI:{d.ai_estimate?.toFixed(0)}% + {d.calibrated != null && ( + →{d.calibrated?.toFixed(0)}% + )} +
+ ))} +
+ )} +
+ ) + })()} + {/* Risk / portfolio monitor */} {report.portfolio_monitor && (
} title="Risk & Portfolio alerts">