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:
@@ -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("/")
|
||||
|
||||
55
backend/routers/analytics.py
Normal file
55
backend/routers/analytics.py
Normal 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)}
|
||||
@@ -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")
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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é"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import JournalDeBord from './pages/JournalDeBord'
|
||||
import RapportIA from './pages/RapportIA'
|
||||
import SuperContexte from './pages/SuperContexte'
|
||||
import Config from './pages/Config'
|
||||
import Analytics from './pages/Analytics'
|
||||
import { useCycleWatcher } from './hooks/useApi'
|
||||
|
||||
function GlobalWatcher() {
|
||||
@@ -41,6 +42,7 @@ export default function App() {
|
||||
<Route path="/rapport" element={<RapportIA />} />
|
||||
<Route path="/super-contexte" element={<SuperContexte />} />
|
||||
<Route path="/config" element={<Config />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ const nav = [
|
||||
{ to: '/journal', icon: BookOpen, label: 'Journal de Bord' },
|
||||
{ to: '/rapport', icon: FileBarChart, label: 'Rapport IA' },
|
||||
{ to: '/super-contexte', icon: Brain, label: 'Super Contexte' },
|
||||
{ to: '/analytics', icon: FlaskConical, label: 'Analytics' },
|
||||
{ to: '/backtest', icon: History, label: 'Backtest' },
|
||||
{ to: '/calendar', icon: Calendar, label: 'Calendrier' },
|
||||
{ to: '/config', icon: Settings, label: 'Configuration' },
|
||||
|
||||
@@ -613,3 +613,19 @@ export const useIvForTrade = (underlying: string) =>
|
||||
enabled: !!underlying,
|
||||
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,
|
||||
})
|
||||
|
||||
245
frontend/src/pages/Analytics.tsx
Normal file
245
frontend/src/pages/Analytics.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { 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 {
|
||||
if (!a.length && !b.length) return 0
|
||||
@@ -26,6 +26,7 @@ const EMPTY_PATTERN = {
|
||||
name: '', description: '', triggers: [] as string[], keywords: [] as string[],
|
||||
historical_instances: [] as any[], suggested_trades: [] as any[],
|
||||
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 }) {
|
||||
@@ -34,10 +35,24 @@ function QualityBadge({ score }: { score: number }) {
|
||||
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
|
||||
similarTo?: Array<{ name: string; similarity: number }>
|
||||
aiScore?: number | null
|
||||
reliability?: any
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
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>}
|
||||
{!isActive && <span className="badge badge-red text-xs">Désactivé</span>}
|
||||
{p.ai_quality_score && <QualityBadge score={p.ai_quality_score} />}
|
||||
<ReliabilityBadge rel={reliability} />
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">{p.description}</div>
|
||||
</div>
|
||||
@@ -142,6 +158,26 @@ function PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore }: {
|
||||
))}
|
||||
</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 && (
|
||||
<div className="card-sm border-blue-700/30">
|
||||
<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>
|
||||
</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 && (
|
||||
<div className="space-y-1">
|
||||
{p.suggested_trades.map((t: any, ti: number) => (
|
||||
@@ -477,6 +523,33 @@ function PatternForm({ initial, onSave, onCancel }: { initial?: any; onSave: (p:
|
||||
</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 & 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 */}
|
||||
{aiResult && (
|
||||
<div className="card border-blue-700/40">
|
||||
@@ -557,6 +630,7 @@ export default function PatternEditor() {
|
||||
const { data: aiStatus } = useAiStatus()
|
||||
const { data: simData } = usePatternSimilarity()
|
||||
const { data: lastScoresData } = useLastScores()
|
||||
const { data: reliabilityData } = usePatternReliability()
|
||||
const [editing, setEditing] = useState<any>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [showAiSuggest, setShowAiSuggest] = useState(false)
|
||||
@@ -576,6 +650,15 @@ export default function PatternEditor() {
|
||||
return map
|
||||
}, [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
|
||||
const similarityMap = useMemo(() => {
|
||||
const pairs: any[] = (simData as any)?.pairs ?? []
|
||||
@@ -674,6 +757,7 @@ export default function PatternEditor() {
|
||||
onToggle={() => togglePattern(p.id)}
|
||||
similarTo={similarityMap[p.id]}
|
||||
aiScore={aiScoreMap[p.id] ?? null}
|
||||
reliability={reliabilityMap[p.id]}
|
||||
/>
|
||||
))}
|
||||
{displayPatterns.length === 0 && (
|
||||
|
||||
Reference in New Issue
Block a user