feat: pattern calibration — progressive AI→observed expected_move blending

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 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-23 12:38:16 +02:00
parent a630cdc708
commit 91f12e177f
6 changed files with 327 additions and 17 deletions

View File

@@ -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%.