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:
@@ -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é"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user