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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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 from services.database import init_db, get_config, cleanup_stale_running_cycles
import os import os
import uvicorn import uvicorn
@@ -72,6 +72,7 @@ app.include_router(profiles_router.router)
app.include_router(reasoning_router.router) app.include_router(reasoning_router.router)
app.include_router(knowledge_router.router) app.include_router(knowledge_router.router)
app.include_router(options_vol_router.router) app.include_router(options_vol_router.router)
app.include_router(analytics_router.router)
@app.get("/") @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_quality_score: Optional[int] = None
ai_evaluation: Optional[Dict[str, Any]] = None ai_evaluation: Optional[Dict[str, Any]] = None
source: Optional[str] = "custom" source: Optional[str] = "custom"
counter_thesis: Optional[str] = None
invalidation_trigger: Optional[str] = None
invalidation_probability: Optional[float] = None
@router.get("/all") @router.get("/all")

View File

@@ -727,6 +727,7 @@ def suggest_patterns_from_market_context(
macro_regime: Optional[Dict] = None, macro_regime: Optional[Dict] = None,
geo_score: Optional[Dict] = None, geo_score: Optional[Dict] = None,
portfolio_lessons: Optional[Dict] = None, portfolio_lessons: Optional[Dict] = None,
reliability_map: Optional[Dict] = None,
) -> List[Dict]: ) -> List[Dict]:
"""Ask GPT-4o to propose new patterns based on current geo/market + macro regime context.""" """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] 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é. É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. 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) ## Actualités géopolitiques du moment (triées par impact)
{news_block} {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%.>, "expected_move_pct": <float, RENDEMENT OPTION MOYEN en % pour ce pattern, levier inclus. Typiquement 50-300%.>,
"probability": <float 0-1>, "probability": <float 0-1>,
"horizon_days": <int>, "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": [ "suggested_trades": [
{{ {{
"strategy": "<Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle>", "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, 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 # Check AI key
ai_key = get_config("openai_api_key") or "" ai_key = get_config("openai_api_key") or ""
if not ai_key: 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) geo_score_val = int(geo_score_obj.get("score") or 0)
summary["geo_score"] = geo_score_val 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() quotes = get_all_quotes()
gauges = get_macro_gauges() gauges = get_macro_gauges()
@@ -167,12 +207,21 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
# ── Step 2: Suggest new patterns ────────────────────────────────────── # ── Step 2: Suggest new patterns ──────────────────────────────────────
logger.info(f"[Cycle {run_id[:16]}] Step 2: suggesting 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: try:
from services.data_fetcher import get_economic_calendar from services.data_fetcher import get_economic_calendar
calendar = get_economic_calendar() calendar = get_economic_calendar()
suggestions = suggest_patterns_from_market_context( suggestions = suggest_patterns_from_market_context(
news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj, news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj,
portfolio_lessons=portfolio_lessons, portfolio_lessons=portfolio_lessons,
reliability_map=_reliability_map or None,
) )
except Exception as e: except Exception as e:
logger.warning(f"[Cycle] Suggestion step failed: {e}") logger.warning(f"[Cycle] Suggestion step failed: {e}")

View File

@@ -63,11 +63,20 @@ def init_db():
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now'))
)""") )""")
# Migration: add source column if not present # Migrations: add columns if not present
try: for _sql in [
c.execute("ALTER TABLE custom_patterns ADD COLUMN source TEXT DEFAULT 'custom'") "ALTER TABLE custom_patterns ADD COLUMN source TEXT DEFAULT 'custom'",
except Exception: "ALTER TABLE custom_patterns ADD COLUMN counter_thesis TEXT",
pass "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 ( c.execute("""CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY, 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: for sp in scores:
pid = sp.get("pattern_id", "") pid = sp.get("pattern_id", "")
if pid: 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( conn.execute(
"INSERT INTO pattern_score_history (run_id, pattern_id, score, confidence, summary, scored_at) VALUES (?,?,?,?,?,?)", "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), (run_id, pid, sp.get("score"), sp.get("confidence"), sp.get("summary", ""), run_id, predicted_prob),
) )
# Keep only the last 30 runs # Keep only the last 30 runs
conn.execute("""DELETE FROM pattern_score_history WHERE run_id NOT IN ( 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 ( conn.execute("""INSERT OR REPLACE INTO custom_patterns (
id, name, description, triggers, keywords, historical_instances, id, name, description, triggers, keywords, historical_instances,
suggested_trades, asset_class, expected_move_pct, probability, suggested_trades, asset_class, expected_move_pct, probability,
horizon_days, ai_quality_score, ai_evaluation, source, is_active, updated_at horizon_days, ai_quality_score, ai_evaluation, source,
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,datetime('now'))""", ( counter_thesis, invalidation_trigger, invalidation_probability,
is_active, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,datetime('now'))""", (
pat_id, pat_id,
pattern.get("name", ""), pattern.get("name", ""),
pattern.get("description", ""), pattern.get("description", ""),
@@ -591,6 +605,9 @@ def save_custom_pattern(pattern: Dict[str, Any]) -> str:
pattern.get("ai_quality_score"), pattern.get("ai_quality_score"),
json.dumps(pattern.get("ai_evaluation", {})), json.dumps(pattern.get("ai_evaluation", {})),
source, source,
pattern.get("counter_thesis"),
pattern.get("invalidation_trigger"),
pattern.get("invalidation_probability"),
)) ))
conn.commit() conn.commit()
conn.close() conn.close()
@@ -1573,3 +1590,208 @@ def get_iv_history(ticker: str, days: int = 90) -> List[Dict]:
conn.close() conn.close()
return [dict(r) for r in rows] 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é"
),
}

View File

@@ -13,6 +13,7 @@ import JournalDeBord from './pages/JournalDeBord'
import RapportIA from './pages/RapportIA' import RapportIA from './pages/RapportIA'
import SuperContexte from './pages/SuperContexte' import SuperContexte from './pages/SuperContexte'
import Config from './pages/Config' import Config from './pages/Config'
import Analytics from './pages/Analytics'
import { useCycleWatcher } from './hooks/useApi' import { useCycleWatcher } from './hooks/useApi'
function GlobalWatcher() { function GlobalWatcher() {
@@ -41,6 +42,7 @@ export default function App() {
<Route path="/rapport" element={<RapportIA />} /> <Route path="/rapport" element={<RapportIA />} />
<Route path="/super-contexte" element={<SuperContexte />} /> <Route path="/super-contexte" element={<SuperContexte />} />
<Route path="/config" element={<Config />} /> <Route path="/config" element={<Config />} />
<Route path="/analytics" element={<Analytics />} />
</Routes> </Routes>
</main> </main>
</div> </div>

View File

@@ -17,6 +17,7 @@ const nav = [
{ to: '/journal', icon: BookOpen, label: 'Journal de Bord' }, { to: '/journal', icon: BookOpen, label: 'Journal de Bord' },
{ to: '/rapport', icon: FileBarChart, label: 'Rapport IA' }, { to: '/rapport', icon: FileBarChart, label: 'Rapport IA' },
{ to: '/super-contexte', icon: Brain, label: 'Super Contexte' }, { to: '/super-contexte', icon: Brain, label: 'Super Contexte' },
{ to: '/analytics', icon: FlaskConical, label: 'Analytics' },
{ to: '/backtest', icon: History, label: 'Backtest' }, { to: '/backtest', icon: History, label: 'Backtest' },
{ to: '/calendar', icon: Calendar, label: 'Calendrier' }, { to: '/calendar', icon: Calendar, label: 'Calendrier' },
{ to: '/config', icon: Settings, label: 'Configuration' }, { to: '/config', icon: Settings, label: 'Configuration' },

View File

@@ -613,3 +613,19 @@ export const useIvForTrade = (underlying: string) =>
enabled: !!underlying, enabled: !!underlying,
staleTime: 60 * 60_000, staleTime: 60 * 60_000,
}) })
// ── Analytics (Phase 2) ───────────────────────────────────────────────────────
export const usePatternReliability = () =>
useQuery({
queryKey: ['pattern-reliability'],
queryFn: () => api.get('/analytics/reliability').then(r => r.data),
staleTime: 10 * 60_000,
})
export const useCalibration = (days = 365) =>
useQuery({
queryKey: ['calibration', days],
queryFn: () => api.get('/analytics/calibration', { params: { days } }).then(r => r.data),
staleTime: 10 * 60_000,
})

View File

@@ -0,0 +1,245 @@
import { useState } from 'react'
import { usePatternReliability, useCalibration } from '../hooks/useApi'
import clsx from 'clsx'
import { BarChart2, Target, TrendingUp, AlertTriangle } from 'lucide-react'
function ReliabilityTable({ data }: { data: any[] }) {
if (!data || data.length === 0) {
return (
<div className="card text-center py-10 text-slate-500">
<BarChart2 className="w-8 h-8 mx-auto mb-2 opacity-20" />
<div>Aucune donnée de fiabilité</div>
<div className="text-xs mt-1">Les données apparaissent après 3+ trades matures (35% de l'horizon écoulé) par pattern</div>
</div>
)
}
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500 border-b border-slate-700/30">
<th className="text-left py-2 pr-3 font-medium">Pattern</th>
<th className="text-right py-2 px-2 font-medium">Trades</th>
<th className="text-right py-2 px-2 font-medium">Win Rate</th>
<th className="text-right py-2 px-2 font-medium">PnL moyen</th>
<th className="text-right py-2 px-2 font-medium">Max gain</th>
<th className="text-right py-2 px-2 font-medium">Max perte</th>
<th className="text-right py-2 px-2 font-medium">Score fiabilité</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{data.map((r: any) => {
const wr = r.win_rate_pct
const wrColor = wr >= 60 ? 'text-emerald-400' : wr >= 40 ? 'text-amber-400' : 'text-red-400'
const rel = r.reliability_score
const relColor = rel >= 1.5 ? 'text-emerald-400' : rel >= 0.8 ? 'text-amber-400' : 'text-red-400'
return (
<tr key={r.pattern_id} className="hover:bg-dark-700/30 transition-colors">
<td className="py-2 pr-3">
<div className="text-white font-medium truncate max-w-[200px]">{r.pattern_name}</div>
<div className="text-slate-600 font-mono text-[10px]">{r.pattern_id}</div>
</td>
<td className="text-right py-2 px-2 text-slate-300 font-mono">{r.trade_count}</td>
<td className={clsx('text-right py-2 px-2 font-mono font-bold', wrColor)}>{wr}%</td>
<td className={clsx('text-right py-2 px-2 font-mono', r.avg_pnl_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{r.avg_pnl_pct > 0 ? '+' : ''}{r.avg_pnl_pct}%
</td>
<td className="text-right py-2 px-2 font-mono text-emerald-400/70">+{r.max_pnl_pct}%</td>
<td className="text-right py-2 px-2 font-mono text-red-400/70">{r.max_loss_pct}%</td>
<td className={clsx('text-right py-2 px-2 font-mono font-bold', relColor)}>
{rel.toFixed(2)}
<div className="text-[9px] text-slate-600 font-normal">WR × log(n+1)</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
function CalibrationSection({ data }: { data: any }) {
if (!data) return null
const { brier_score, interpretation, buckets, sample_size } = data
if (!brier_score && sample_size === 0) {
return (
<div className="card text-center py-10 text-slate-500">
<Target className="w-8 h-8 mx-auto mb-2 opacity-20" />
<div>Pas encore de données de calibration</div>
<div className="text-xs mt-1">Nécessite des trades matures avec probabilité stockée vs résultat réalisé</div>
</div>
)
}
const brierColor = brier_score < 0.15 ? 'text-emerald-400' : brier_score < 0.25 ? 'text-amber-400' : 'text-red-400'
return (
<div className="space-y-4">
{/* Score summary */}
<div className="grid grid-cols-3 gap-3">
<div className="card text-center">
<div className={clsx('text-2xl font-bold font-mono', brierColor)}>{brier_score?.toFixed(3) ?? ''}</div>
<div className="text-xs text-slate-500 mt-1">Brier Score</div>
<div className="text-xs text-slate-600">(0 = parfait, 1 = nul)</div>
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-white">{sample_size}</div>
<div className="text-xs text-slate-500 mt-1">Trades analysés</div>
</div>
<div className="card text-center">
<div className={clsx('text-sm font-bold mt-1', brierColor)}>{interpretation ?? ''}</div>
<div className="text-xs text-slate-500 mt-1">Interprétation</div>
</div>
</div>
{/* Calibration buckets */}
{buckets && buckets.length > 0 && (
<div>
<div className="text-xs text-slate-500 mb-2 font-medium">Calibration par décile (prédit vs réalisé)</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500 border-b border-slate-700/30">
<th className="text-left py-1.5 pr-3 font-medium">Prob. prédite</th>
<th className="text-right py-1.5 px-2 font-medium">Taux réel</th>
<th className="text-right py-1.5 px-2 font-medium">Biais</th>
<th className="text-right py-1.5 px-2 font-medium">Trades</th>
<th className="py-1.5 pl-3 font-medium">Barre</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{buckets.map((b: any) => {
const bias = b.bias
const biasColor = Math.abs(bias) < 0.05 ? 'text-emerald-400' : Math.abs(bias) < 0.15 ? 'text-amber-400' : 'text-red-400'
const actualPct = Math.round(b.actual_rate * 100)
const predPct = Math.round(b.predicted_mid * 100)
return (
<tr key={b.predicted_range}>
<td className="py-1.5 pr-3 text-slate-400 font-mono">{b.predicted_range}</td>
<td className="text-right py-1.5 px-2 font-mono text-white">{actualPct}%</td>
<td className={clsx('text-right py-1.5 px-2 font-mono font-bold', biasColor)}>
{bias > 0 ? '+' : ''}{(bias * 100).toFixed(1)}%
</td>
<td className="text-right py-1.5 px-2 text-slate-500">{b.count}</td>
<td className="py-1.5 pl-3">
<div className="flex items-center gap-1 h-4">
{/* Predicted (grey) vs actual (colored) */}
<div className="relative w-32 h-2 bg-dark-700 rounded-full overflow-hidden">
<div className="absolute h-full bg-slate-600 rounded-full" style={{ width: `${predPct}%` }} />
<div className={clsx('absolute h-full rounded-full opacity-80', actualPct >= predPct ? 'bg-emerald-500' : 'bg-red-500')}
style={{ width: `${actualPct}%` }} />
</div>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="text-[10px] text-slate-600 mt-2">
Barre grise = prob. prédite · Barre colorée = taux réalisé · Vert si réel ≥ prédit, rouge sinon
</div>
</div>
)}
</div>
)
}
export default function Analytics() {
const { data: reliabilityData, isLoading: loadingR } = usePatternReliability()
const [calDays, setCalDays] = useState(365)
const { data: calData, isLoading: loadingC } = useCalibration(calDays)
const reliability: any[] = (reliabilityData as any)?.reliability ?? []
const topPatterns = reliability.slice(0, 5)
const bottomPatterns = reliability.filter((r: any) => r.trade_count >= 3).slice(-3)
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<BarChart2 className="w-5 h-5 text-blue-400" /> Analytics & Calibration
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Fiabilité historique des patterns · Calibration probabiliste · Brier score
</p>
</div>
{/* Summary KPIs */}
{reliability.length > 0 && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="card">
<div className="text-2xl font-bold text-white font-mono">{reliability.length}</div>
<div className="text-xs text-slate-500 mt-1">Patterns avec historique</div>
</div>
<div className="card">
<div className="text-2xl font-bold text-white font-mono">
{reliability.reduce((s: number, r: any) => s + r.trade_count, 0)}
</div>
<div className="text-xs text-slate-500 mt-1">Trades matures analysés</div>
</div>
{topPatterns[0] && (
<div className="card border-emerald-700/30">
<div className="text-xs text-slate-500 mb-1 flex items-center gap-1">
<TrendingUp className="w-3 h-3 text-emerald-400" /> Meilleur pattern
</div>
<div className="text-sm font-semibold text-emerald-400 truncate">{topPatterns[0].pattern_name}</div>
<div className="text-xs font-mono text-slate-400">{topPatterns[0].win_rate_pct}% WR</div>
</div>
)}
{bottomPatterns[0] && (
<div className="card border-red-700/30">
<div className="text-xs text-slate-500 mb-1 flex items-center gap-1">
<AlertTriangle className="w-3 h-3 text-red-400" /> À éviter
</div>
<div className="text-sm font-semibold text-red-400 truncate">{bottomPatterns[0].pattern_name}</div>
<div className="text-xs font-mono text-slate-400">{bottomPatterns[0].win_rate_pct}% WR</div>
</div>
)}
</div>
)}
{/* Reliability table */}
<div className="card">
<div className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<BarChart2 className="w-4 h-4 text-blue-400" /> Fiabilité par pattern
<span className="text-xs text-slate-500 font-normal">(trades ≥35% de l'horizon uniquement)</span>
</div>
{loadingR ? (
<div className="space-y-2">{[1,2,3].map(i => <div key={i} className="h-8 bg-dark-700 animate-pulse rounded" />)}</div>
) : (
<ReliabilityTable data={reliability} />
)}
</div>
{/* Calibration */}
<div className="card">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-white flex items-center gap-2">
<Target className="w-4 h-4 text-blue-400" /> Calibration probabiliste
</div>
<select
value={calDays}
onChange={e => setCalDays(Number(e.target.value))}
className="bg-dark-700 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
>
<option value={90}>90 jours</option>
<option value={180}>180 jours</option>
<option value={365}>1 an</option>
<option value={730}>2 ans</option>
</select>
</div>
{loadingC ? (
<div className="space-y-2">{[1,2,3].map(i => <div key={i} className="h-8 bg-dark-700 animate-pulse rounded" />)}</div>
) : (
<CalibrationSection data={calData} />
)}
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useAllPatterns, useSavePattern, useDeletePattern, useEvaluatePattern, useSuggestPattern, useAiStatus, useSuggestNewPatterns, useTogglePattern, usePatternSimilarity, useLastScores } from '../hooks/useApi' import { useAllPatterns, useSavePattern, useDeletePattern, useEvaluatePattern, useSuggestPattern, useAiStatus, useSuggestNewPatterns, useTogglePattern, usePatternSimilarity, useLastScores, usePatternReliability } from '../hooks/useApi'
import clsx from 'clsx' import clsx from 'clsx'
import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff } from 'lucide-react' import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff, ShieldAlert } from 'lucide-react'
function jaccard(a: string[], b: string[]): number { function jaccard(a: string[], b: string[]): number {
if (!a.length && !b.length) return 0 if (!a.length && !b.length) return 0
@@ -26,6 +26,7 @@ const EMPTY_PATTERN = {
name: '', description: '', triggers: [] as string[], keywords: [] as string[], name: '', description: '', triggers: [] as string[], keywords: [] as string[],
historical_instances: [] as any[], suggested_trades: [] as any[], historical_instances: [] as any[], suggested_trades: [] as any[],
asset_class: 'energy', expected_move_pct: 10, probability: 0.6, horizon_days: 30, asset_class: 'energy', expected_move_pct: 10, probability: 0.6, horizon_days: 30,
counter_thesis: '', invalidation_trigger: '', invalidation_probability: undefined as number | undefined,
} }
function QualityBadge({ score }: { score: number }) { function QualityBadge({ score }: { score: number }) {
@@ -34,10 +35,24 @@ function QualityBadge({ score }: { score: number }) {
return <span className={clsx('badge', color)}>{score}/100 {label}</span> return <span className={clsx('badge', color)}>{score}/100 {label}</span>
} }
function PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore }: { function ReliabilityBadge({ rel }: { rel: any }) {
if (!rel || rel.trade_count < 3) return null
const wr = rel.win_rate_pct
const color = wr >= 60 ? 'text-emerald-400 border-emerald-700/40 bg-emerald-900/20'
: wr >= 40 ? 'text-amber-400 border-amber-700/40 bg-amber-900/20'
: 'text-red-400 border-red-700/40 bg-red-900/20'
return (
<span className={clsx('inline-flex items-center gap-1 text-[10px] border rounded px-1.5 py-0.5 font-mono', color)}>
📊 {wr}% WR ({rel.trade_count}t · {rel.avg_pnl_pct > 0 ? '+' : ''}{rel.avg_pnl_pct}%)
</span>
)
}
function PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore, reliability }: {
p: any; onEdit: () => void; onDelete: () => void; onToggle: () => void p: any; onEdit: () => void; onDelete: () => void; onToggle: () => void
similarTo?: Array<{ name: string; similarity: number }> similarTo?: Array<{ name: string; similarity: number }>
aiScore?: number | null aiScore?: number | null
reliability?: any
}) { }) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const isCustom = p.source === 'custom' const isCustom = p.source === 'custom'
@@ -55,6 +70,7 @@ function PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore }: {
{isCustom && <span className="badge badge-purple text-xs">Custom</span>} {isCustom && <span className="badge badge-purple text-xs">Custom</span>}
{!isActive && <span className="badge badge-red text-xs">Désactivé</span>} {!isActive && <span className="badge badge-red text-xs">Désactivé</span>}
{p.ai_quality_score && <QualityBadge score={p.ai_quality_score} />} {p.ai_quality_score && <QualityBadge score={p.ai_quality_score} />}
<ReliabilityBadge rel={reliability} />
</div> </div>
<div className="text-xs text-slate-500">{p.description}</div> <div className="text-xs text-slate-500">{p.description}</div>
</div> </div>
@@ -142,6 +158,26 @@ function PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore }: {
))} ))}
</div> </div>
)} )}
{(p.counter_thesis || p.invalidation_trigger) && (
<div className="card-sm border-red-700/30 bg-red-900/10">
<div className="text-xs text-red-400 font-semibold mb-1 flex items-center gap-1">
<ShieldAlert className="w-3 h-3" /> Contre-thèse & Invalidation
</div>
{p.counter_thesis && (
<div className="text-xs text-slate-300 mb-1">
<span className="text-slate-500">Contre-thèse :</span> {p.counter_thesis}
</div>
)}
{p.invalidation_trigger && (
<div className="text-xs text-orange-300/80">
<span className="text-slate-500">Trigger :</span> {p.invalidation_trigger}
{p.invalidation_probability != null && (
<span className="ml-2 font-mono text-slate-500">({Math.round(p.invalidation_probability * 100)}% prob.)</span>
)}
</div>
)}
</div>
)}
{p.ai_evaluation && Object.keys(p.ai_evaluation).length > 0 && ( {p.ai_evaluation && Object.keys(p.ai_evaluation).length > 0 && (
<div className="card-sm border-blue-700/30"> <div className="card-sm border-blue-700/30">
<div className="text-xs text-blue-400 font-semibold mb-1">Évaluation IA</div> <div className="text-xs text-blue-400 font-semibold mb-1">Évaluation IA</div>
@@ -269,6 +305,16 @@ function AiSuggestModal({ onClose, onSaveAll, allPatterns }: { onClose: () => vo
<span>{p.macro_fit}</span> <span>{p.macro_fit}</span>
</div> </div>
)} )}
{(p.counter_thesis || p.invalidation_trigger) && (
<div className="mb-2 text-[11px] bg-red-900/10 border border-red-700/20 rounded px-2 py-1.5 space-y-0.5">
{p.counter_thesis && (
<div className="text-slate-300"><span className="text-red-400/80">⚠ Contre-thèse :</span> {p.counter_thesis}</div>
)}
{p.invalidation_trigger && (
<div className="text-orange-300/70"><span className="text-slate-500">Trigger :</span> {p.invalidation_trigger}{p.invalidation_probability != null ? ` (${Math.round(p.invalidation_probability * 100)}%)` : ''}</div>
)}
</div>
)}
{p.suggested_trades?.length > 0 && ( {p.suggested_trades?.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
{p.suggested_trades.map((t: any, ti: number) => ( {p.suggested_trades.map((t: any, ti: number) => (
@@ -477,6 +523,33 @@ function PatternForm({ initial, onSave, onCancel }: { initial?: any; onSave: (p:
</div> </div>
</div> </div>
{/* Contre-thèse & Invalidation */}
<div className="space-y-2">
<div>
<label className="text-xs text-slate-500 mb-1 block flex items-center gap-1">
<ShieldAlert className="w-3 h-3 text-red-400" /> Contre-thèse (scénario adverse principal)
</label>
<textarea value={form.counter_thesis || ''} onChange={e => set('counter_thesis', e.target.value)}
rows={2} placeholder="Ex: accord de paix inattendu, données inflation sous 3%, pivot Fed…"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-red-500/50 resize-none" />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="text-xs text-slate-500 mb-1 block">Trigger d'invalidation (événement précis &amp; mesurable)</label>
<input value={form.invalidation_trigger || ''} onChange={e => set('invalidation_trigger', e.target.value)}
placeholder="Ex: prix pétrole < 70$/b 3j consécutifs"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-red-500/50" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Prob. invalidation (0-1)</label>
<input type="number" step="0.05" min="0" max="1" value={form.invalidation_probability ?? ''}
onChange={e => set('invalidation_probability', e.target.value ? Number(e.target.value) : undefined)}
placeholder="0.20"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-red-500/50" />
</div>
</div>
</div>
{/* AI Evaluation result */} {/* AI Evaluation result */}
{aiResult && ( {aiResult && (
<div className="card border-blue-700/40"> <div className="card border-blue-700/40">
@@ -557,6 +630,7 @@ export default function PatternEditor() {
const { data: aiStatus } = useAiStatus() const { data: aiStatus } = useAiStatus()
const { data: simData } = usePatternSimilarity() const { data: simData } = usePatternSimilarity()
const { data: lastScoresData } = useLastScores() const { data: lastScoresData } = useLastScores()
const { data: reliabilityData } = usePatternReliability()
const [editing, setEditing] = useState<any>(null) const [editing, setEditing] = useState<any>(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [showAiSuggest, setShowAiSuggest] = useState(false) const [showAiSuggest, setShowAiSuggest] = useState(false)
@@ -576,6 +650,15 @@ export default function PatternEditor() {
return map return map
}, [lastScoresData]) }, [lastScoresData])
// Build reliability map: patternId → reliability record
const reliabilityMap = useMemo(() => {
const map: Record<string, any> = {}
for (const r of (reliabilityData as any)?.reliability ?? []) {
map[r.pattern_id] = r
}
return map
}, [reliabilityData])
// Build a map: patternId → [{name, similarity}] for each side of a similar pair // Build a map: patternId → [{name, similarity}] for each side of a similar pair
const similarityMap = useMemo(() => { const similarityMap = useMemo(() => {
const pairs: any[] = (simData as any)?.pairs ?? [] const pairs: any[] = (simData as any)?.pairs ?? []
@@ -674,6 +757,7 @@ export default function PatternEditor() {
onToggle={() => togglePattern(p.id)} onToggle={() => togglePattern(p.id)}
similarTo={similarityMap[p.id]} similarTo={similarityMap[p.id]}
aiScore={aiScoreMap[p.id] ?? null} aiScore={aiScoreMap[p.id] ?? null}
reliability={reliabilityMap[p.id]}
/> />
))} ))}
{displayPatterns.length === 0 && ( {displayPatterns.length === 0 && (