feat: Phase 2 — Pattern Reliability, Contre-thèses & Calibration probabiliste

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 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-17 16:50:53 +02:00
parent 9a6b6f70b1
commit f09c5b8ee7
11 changed files with 718 additions and 14 deletions

View File

@@ -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("/")

View File

@@ -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)}

View File

@@ -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")

View File

@@ -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": <float, RENDEMENT OPTION MOYEN en % pour ce pattern, levier inclus. Typiquement 50-300%.>,
"probability": <float 0-1>,
"horizon_days": <int>,
"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": <float 0-1, probabilité que ce trigger d'invalidation se réalise dans l'horizon>,
"suggested_trades": [
{{
"strategy": "<Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle>",

View File

@@ -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}")

View File

@@ -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é"
),
}