feat: pattern calibration — progressive AI→observed expected_move blending
DB (database.py): - 3 new columns on custom_patterns: calibrated_expected_move, calibration_weight, observed_avg_win_pct - update_bayesian_posteriors() now also computes credibility blend w=n/(n+5): calibrated = (1-w)*ai_estimate + w*observed_avg_win_pct (only when wins exist) - log_trade_entries() prefers calibrated_expected_move when w>10% - get_calibration_summary() returns per-pattern state (source: pure_ai/early/mixed/data_driven) Backend (patterns.py, auto_cycle.py): - GET /api/patterns/calibration endpoint - calibration_report block in cycle report: counts by source, avg weight, per-pattern detail Frontend (PatternExplorer.tsx, RapportIA.tsx, useApi.ts): - MaturityBadge on each PatternCard: blend bar (AI→observed), win rate, AI estimate vs calibrated - usePatternCalibration hook - Cycle report: calibration section with global bar + per-pattern table (weight%, n_trades, WR, AI→calibrated) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
from services.database import (
|
||||
save_custom_pattern, get_custom_patterns, delete_custom_pattern, toggle_pattern_active,
|
||||
get_config,
|
||||
get_config, get_calibration_summary,
|
||||
)
|
||||
from services.geo_analyzer import PATTERN_TAXONOMY
|
||||
|
||||
@@ -141,6 +141,14 @@ def get_by_instrument(ticker: str = Query(..., description="Ticker / underlying
|
||||
return result
|
||||
|
||||
|
||||
# ── Calibration summary ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/calibration")
|
||||
def get_calibration():
|
||||
"""Per-pattern calibration state: AI estimate vs observed blend."""
|
||||
return get_calibration_summary()
|
||||
|
||||
|
||||
# ── Find Similar ──────────────────────────────────────────────────────────────
|
||||
|
||||
class FindSimilarRequest(BaseModel):
|
||||
|
||||
@@ -1506,6 +1506,52 @@ Réponds en JSON avec ce schéma EXACT:
|
||||
except Exception:
|
||||
commentary_parsed = {"commentary": str(commentary)}
|
||||
|
||||
# ── Calibration report ───────────────────────────────────────────────────
|
||||
calibration_report: Dict = {}
|
||||
try:
|
||||
from services.database import get_calibration_summary as _get_calib
|
||||
calib_rows = _get_calib()
|
||||
if calib_rows:
|
||||
pure_ai = [r for r in calib_rows if r["source"] == "pure_ai"]
|
||||
early = [r for r in calib_rows if r["source"] == "early"]
|
||||
mixed = [r for r in calib_rows if r["source"] == "mixed"]
|
||||
driven = [r for r in calib_rows if r["source"] == "data_driven"]
|
||||
with_data = [r for r in calib_rows if (r["n_mature_trades"] or 0) > 0]
|
||||
avg_w = (
|
||||
round(sum(r["calibration_weight"] for r in with_data) / len(with_data), 3)
|
||||
if with_data else 0.0
|
||||
)
|
||||
calibration_report = {
|
||||
"total_patterns": len(calib_rows),
|
||||
"pure_ai_count": len(pure_ai),
|
||||
"early_count": len(early),
|
||||
"mixed_count": len(mixed),
|
||||
"data_driven_count": len(driven),
|
||||
"avg_calibration_weight": avg_w,
|
||||
"avg_calibration_weight_pct": round(avg_w * 100, 1),
|
||||
"patterns_with_data": len(with_data),
|
||||
"detail": [
|
||||
{
|
||||
"name": r["pattern_name"],
|
||||
"asset_class": r["asset_class"],
|
||||
"source": r["source"],
|
||||
"weight_pct": r["calibration_weight_pct"],
|
||||
"ai_estimate": r["ai_estimate"],
|
||||
"observed": r["observed_avg_win_pct"],
|
||||
"calibrated": r["calibrated_expected_move"],
|
||||
"n_trades": r["n_mature_trades"],
|
||||
"win_rate_pct": round((r["bayes_win_rate"] or 0) * 100, 1),
|
||||
}
|
||||
for r in calib_rows if (r["n_mature_trades"] or 0) > 0
|
||||
][:15],
|
||||
}
|
||||
logger.info(
|
||||
f"[CycleReport] Calibration: {len(pure_ai)} pure-AI, "
|
||||
f"{len(mixed)+len(driven)} with data, avg_weight={avg_w:.1%}"
|
||||
)
|
||||
except Exception as _ce:
|
||||
logger.warning(f"[CycleReport] Calibration report failed: {_ce}")
|
||||
|
||||
# ── Assemble full report ──────────────────────────────────────────────────
|
||||
report = {
|
||||
"run_id": run_id,
|
||||
@@ -1553,6 +1599,8 @@ Réponds en JSON avec ce schéma EXACT:
|
||||
"n_alert": (options_assessment or {}).get("n_alert", 0),
|
||||
"assessments": (options_assessment or {}).get("assessments", []),
|
||||
} if options_assessment is not None else None,
|
||||
# Calibration: AI vs observed expected_move blend
|
||||
"calibration_report": calibration_report,
|
||||
}
|
||||
return report
|
||||
|
||||
|
||||
@@ -86,6 +86,10 @@ def init_db():
|
||||
# Pattern Lab — backtest reliability tracking
|
||||
"ALTER TABLE custom_patterns ADD COLUMN backtest_hits INTEGER DEFAULT 0",
|
||||
"ALTER TABLE custom_patterns ADD COLUMN backtest_runs_count INTEGER DEFAULT 0",
|
||||
# Calibration — observed vs AI blending
|
||||
"ALTER TABLE custom_patterns ADD COLUMN calibrated_expected_move REAL",
|
||||
"ALTER TABLE custom_patterns ADD COLUMN calibration_weight REAL DEFAULT 0.0",
|
||||
"ALTER TABLE custom_patterns ADD COLUMN observed_avg_win_pct REAL",
|
||||
# Remove all built-in patterns (no proof of legitimacy)
|
||||
"DELETE FROM custom_patterns WHERE source = 'builtin'",
|
||||
# Regime / counter-scenario architecture
|
||||
@@ -1506,11 +1510,16 @@ def log_trade_entries(run_id: str, scored_patterns: List[Dict[str, Any]], quotes
|
||||
|
||||
delta = int(trade.get("score_delta") or 0)
|
||||
eff_score = max(0, min(100, base_score + delta))
|
||||
# Fallback chain: trade field → scored sp field → auto_cycle enrichment → original DB pattern
|
||||
# Fallback chain: trade field → scored sp field → calibrated DB (observed blend) → AI estimate DB
|
||||
_calib = _orig.get("calibrated_expected_move")
|
||||
_calib_w = float(_orig.get("calibration_weight") or 0)
|
||||
_ai_est = _orig.get("expected_move_pct")
|
||||
# Use calibrated value when credibility weight > 10% (at least ~1 mature trade)
|
||||
_db_move = _calib if (_calib and _calib_w > 0.1) else _ai_est
|
||||
exp_move = abs(float(
|
||||
trade.get("expected_move_pct") or
|
||||
sp.get("expected_move_pct") or
|
||||
_orig.get("expected_move_pct") or
|
||||
_db_move or
|
||||
0
|
||||
))
|
||||
if exp_move == 0:
|
||||
@@ -3328,18 +3337,31 @@ def get_risk_dashboard() -> Dict:
|
||||
|
||||
def update_bayesian_posteriors() -> int:
|
||||
"""
|
||||
Met à jour les posteriors Beta(α,β) de chaque pattern selon ses trades matures.
|
||||
Prior faible : α₀=1, β₀=1 (Laplace smoothing).
|
||||
Posterior : α = 1 + wins, β = 1 + losses → win_rate bayésien = α/(α+β)
|
||||
Retourne le nombre de patterns mis à jour.
|
||||
Met à jour les posteriors Beta(α,β) et la calibration de l'expected_move.
|
||||
|
||||
Bayesian: α = 1 + wins, β = 1 + losses → bayesian_win_rate = α/(α+β)
|
||||
|
||||
Calibration (credibility blending):
|
||||
k = 5 → w = n / (n + 5) (50% credibility at 5 trades, 80% at 20)
|
||||
observed_avg_win_pct = mean(pnl_pct for wins)
|
||||
calibrated_expected_move = (1-w) × ai_estimate + w × observed_avg_win_pct
|
||||
(only blends when at least 1 win exists; pure AI until then)
|
||||
"""
|
||||
import math as _math
|
||||
from datetime import date as _date
|
||||
|
||||
_CREDIBILITY_K = 5 # trades needed to reach 50% observed weight
|
||||
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT pattern_id, pnl_pct, entry_date, horizon_days FROM trade_entry_prices WHERE pnl_pct IS NOT NULL"
|
||||
).fetchall()
|
||||
# Also load ai estimates for blending
|
||||
ai_estimates = {
|
||||
r["id"]: float(r["expected_move_pct"] or 0)
|
||||
for r in conn.execute("SELECT id, expected_move_pct FROM custom_patterns").fetchall()
|
||||
if r["expected_move_pct"]
|
||||
}
|
||||
conn.close()
|
||||
|
||||
today = _date.today()
|
||||
@@ -3353,7 +3375,7 @@ def update_bayesian_posteriors() -> int:
|
||||
continue
|
||||
horizon = r.get("horizon_days") or 30
|
||||
if days_held / max(horizon, 1) < 0.35:
|
||||
continue # trades immatures exclus
|
||||
continue
|
||||
by_pattern.setdefault(r["pattern_id"], []).append(float(r["pnl_pct"] or 0))
|
||||
|
||||
if not by_pattern:
|
||||
@@ -3365,17 +3387,36 @@ def update_bayesian_posteriors() -> int:
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
for pid, pnls in by_pattern.items():
|
||||
n = len(pnls)
|
||||
wins = sum(1 for p in pnls if p > 0)
|
||||
wins_pnl = [p for p in pnls if p > 0]
|
||||
wins = len(wins_pnl)
|
||||
losses = n - wins
|
||||
alpha = 1.0 + wins # posterior alpha
|
||||
beta = 1.0 + losses # posterior beta
|
||||
|
||||
# Bayesian posteriors
|
||||
alpha = 1.0 + wins
|
||||
beta = 1.0 + losses
|
||||
bayes_wr = alpha / (alpha + beta)
|
||||
|
||||
# Credibility blending
|
||||
w = n / (n + _CREDIBILITY_K)
|
||||
ai_est = ai_estimates.get(pid)
|
||||
observed_avg_win = round(sum(wins_pnl) / len(wins_pnl), 2) if wins_pnl else None
|
||||
if ai_est and observed_avg_win is not None:
|
||||
calibrated = round((1 - w) * ai_est + w * observed_avg_win, 2)
|
||||
else:
|
||||
calibrated = None # not enough data — keep pure AI in log_trade_entries
|
||||
|
||||
c.execute("""
|
||||
UPDATE custom_patterns
|
||||
SET bayesian_alpha=?, bayesian_beta=?, bayesian_win_rate=?,
|
||||
bayesian_updated_at=?, bayesian_sample_size=?
|
||||
bayesian_updated_at=?, bayesian_sample_size=?,
|
||||
calibration_weight=?, observed_avg_win_pct=?, calibrated_expected_move=?
|
||||
WHERE id=?
|
||||
""", (round(alpha, 1), round(beta, 1), round(bayes_wr, 4), now_iso, n, pid))
|
||||
""", (
|
||||
round(alpha, 1), round(beta, 1), round(bayes_wr, 4),
|
||||
now_iso, n,
|
||||
round(w, 4), observed_avg_win, calibrated,
|
||||
pid,
|
||||
))
|
||||
if c.rowcount:
|
||||
updated += 1
|
||||
conn.commit()
|
||||
@@ -3383,6 +3424,53 @@ def update_bayesian_posteriors() -> int:
|
||||
return updated
|
||||
|
||||
|
||||
def get_calibration_summary() -> List[Dict]:
|
||||
"""
|
||||
Returns per-pattern calibration state for the cycle report and UI.
|
||||
Includes: ai_estimate, observed_avg_win_pct, calibration_weight,
|
||||
calibrated_expected_move, n_mature_trades, win_rate.
|
||||
"""
|
||||
conn = get_conn()
|
||||
rows = conn.execute("""
|
||||
SELECT cp.id, cp.name, cp.asset_class,
|
||||
cp.expected_move_pct AS ai_estimate,
|
||||
cp.calibrated_expected_move AS calibrated,
|
||||
cp.calibration_weight AS weight,
|
||||
cp.observed_avg_win_pct AS observed_win,
|
||||
cp.bayesian_sample_size AS n_trades,
|
||||
cp.bayesian_win_rate AS bayes_wr
|
||||
FROM custom_patterns cp
|
||||
WHERE cp.is_active = 1
|
||||
ORDER BY cp.calibration_weight DESC NULLS LAST, cp.bayesian_sample_size DESC
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
w = d.get("weight") or 0.0
|
||||
n = d.get("n_trades") or 0
|
||||
result.append({
|
||||
"pattern_id": d["id"],
|
||||
"pattern_name": d["name"],
|
||||
"asset_class": d.get("asset_class", ""),
|
||||
"ai_estimate": d.get("ai_estimate"),
|
||||
"observed_avg_win_pct": d.get("observed_win"),
|
||||
"calibrated_expected_move": d.get("calibrated"),
|
||||
"calibration_weight": round(w, 4),
|
||||
"calibration_weight_pct": round(w * 100, 1),
|
||||
"n_mature_trades": n,
|
||||
"bayes_win_rate": round((d.get("bayes_wr") or 0), 3),
|
||||
"source": (
|
||||
"pure_ai" if w < 0.1 else
|
||||
"early" if w < 0.4 else
|
||||
"mixed" if w < 0.75 else
|
||||
"data_driven"
|
||||
),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_bayesian_posteriors() -> List[Dict]:
|
||||
"""
|
||||
Retourne tous les patterns avec leurs posteriors bayésiens + intervalle de crédibilité 95%.
|
||||
|
||||
@@ -363,6 +363,27 @@ export const useTogglePattern = () => {
|
||||
})
|
||||
}
|
||||
|
||||
export interface PatternCalibration {
|
||||
pattern_id: string
|
||||
pattern_name: string
|
||||
asset_class: string
|
||||
ai_estimate: number | null
|
||||
observed_avg_win_pct: number | null
|
||||
calibrated_expected_move: number | null
|
||||
calibration_weight: number
|
||||
calibration_weight_pct: number
|
||||
n_mature_trades: number
|
||||
bayes_win_rate: number
|
||||
source: 'pure_ai' | 'early' | 'mixed' | 'data_driven'
|
||||
}
|
||||
|
||||
export const usePatternCalibration = () =>
|
||||
useQuery({
|
||||
queryKey: ['pattern-calibration'],
|
||||
queryFn: () => api.get('/patterns/calibration').then(r => r.data as PatternCalibration[]),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
export interface SimilarityResult {
|
||||
recommendation: 'merge_as_instance' | 'counter_scenario' | 'new_pattern'
|
||||
match_id: string | null
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
usePatchPattern,
|
||||
useFindSimilarPattern,
|
||||
useMergePatterns,
|
||||
usePatternCalibration,
|
||||
validateTicker,
|
||||
type SimilarityResult,
|
||||
type PatternCalibration,
|
||||
} from '../hooks/useApi'
|
||||
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
|
||||
|
||||
@@ -135,14 +137,87 @@ function CategoryBadge({ category }: { category: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Maturity / Calibration Badge ─────────────────────────────────────────────
|
||||
|
||||
function MaturityBadge({ cal }: { cal: PatternCalibration }) {
|
||||
const w = cal.calibration_weight_pct
|
||||
const n = cal.n_mature_trades
|
||||
const hasData = n > 0
|
||||
|
||||
const sourceColor = {
|
||||
pure_ai: 'text-slate-500 border-slate-700/40',
|
||||
early: 'text-sky-400 border-sky-700/40',
|
||||
mixed: 'text-violet-400 border-violet-700/40',
|
||||
data_driven: 'text-emerald-400 border-emerald-700/40',
|
||||
}[cal.source]
|
||||
|
||||
const sourceLabel = {
|
||||
pure_ai: 'Pure AI',
|
||||
early: 'Early data',
|
||||
mixed: 'Mixed',
|
||||
data_driven: 'Data-driven',
|
||||
}[cal.source]
|
||||
|
||||
return (
|
||||
<div className="border-t border-slate-700/30 pt-2 mt-1 space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className={clsx('font-semibold px-1.5 py-0.5 rounded border', sourceColor)}>
|
||||
{sourceLabel}
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
{hasData ? `${n} trade${n > 1 ? 's' : ''} matures` : 'Aucun trade mature'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Blend bar */}
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-[10px] text-slate-500">
|
||||
<span>IA pure</span>
|
||||
<span className="text-slate-400 font-mono">{w.toFixed(0)}% observé</span>
|
||||
<span>Observé</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-slate-700/60 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-slate-500 to-violet-500 transition-all duration-500"
|
||||
style={{ width: `${Math.max(w, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasData && (
|
||||
<div className="grid grid-cols-3 gap-1 text-[10px]">
|
||||
<div className="bg-dark-700/60 rounded px-1.5 py-1 text-center">
|
||||
<div className="text-slate-400 font-mono">{(cal.bayes_win_rate * 100).toFixed(0)}%</div>
|
||||
<div className="text-slate-600">Win rate</div>
|
||||
</div>
|
||||
<div className="bg-dark-700/60 rounded px-1.5 py-1 text-center">
|
||||
<div className="text-slate-400 font-mono">
|
||||
{cal.ai_estimate != null ? `${cal.ai_estimate.toFixed(0)}%` : '—'}
|
||||
</div>
|
||||
<div className="text-slate-600">IA estim.</div>
|
||||
</div>
|
||||
<div className={clsx('rounded px-1.5 py-1 text-center', cal.calibrated_expected_move ? 'bg-violet-900/30' : 'bg-dark-700/60')}>
|
||||
<div className={clsx('font-mono', cal.calibrated_expected_move ? 'text-violet-300' : 'text-slate-600')}>
|
||||
{cal.calibrated_expected_move != null ? `${cal.calibrated_expected_move.toFixed(0)}%` : '—'}
|
||||
</div>
|
||||
<div className="text-slate-600">Calibré</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pattern Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function PatternCard({
|
||||
pattern,
|
||||
highlightTrades,
|
||||
calibration,
|
||||
}: {
|
||||
pattern: Pattern
|
||||
highlightTrades?: string[]
|
||||
calibration?: PatternCalibration
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [confirmDel, setConfirmDel] = useState(false)
|
||||
@@ -373,6 +448,9 @@ function PatternCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Maturity & calibration */}
|
||||
{calibration && <MaturityBadge cal={calibration} />}
|
||||
|
||||
{/* Similarity result panel */}
|
||||
{simResult && (
|
||||
<div className={clsx(
|
||||
@@ -503,7 +581,7 @@ const DIR_COLORS: Record<string, string> = {
|
||||
neutral: 'bg-amber-900/40 border-amber-600/50 text-amber-300',
|
||||
}
|
||||
|
||||
function FacetedSearch({ patterns }: { patterns: Pattern[] }) {
|
||||
function FacetedSearch({ patterns, calibrationMap = {} }: { patterns: Pattern[]; calibrationMap?: Record<string, PatternCalibration> }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [direction, setDirection] = useState('')
|
||||
const [assetClass, setAssetClass] = useState('')
|
||||
@@ -658,7 +736,7 @@ function FacetedSearch({ patterns }: { patterns: Pattern[] }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{filtered.map(p => <PatternCard key={p.id} pattern={p} />)}
|
||||
{filtered.map(p => <PatternCard key={p.id} pattern={p} calibration={calibrationMap[p.id]} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1025,6 +1103,12 @@ export default function PatternExplorer() {
|
||||
const [view, setView] = useState<ViewMode>('search')
|
||||
const { data: patternsData, isLoading } = useAllPatterns()
|
||||
const { data: _scores } = useLastScores()
|
||||
const { data: calibrationData } = usePatternCalibration()
|
||||
|
||||
const calibrationMap = useMemo<Record<string, PatternCalibration>>(() => {
|
||||
if (!calibrationData) return {}
|
||||
return Object.fromEntries(calibrationData.map(c => [c.pattern_id, c]))
|
||||
}, [calibrationData])
|
||||
|
||||
const patterns = useMemo<Pattern[]>(() => {
|
||||
if (!Array.isArray(patternsData)) return []
|
||||
@@ -1101,7 +1185,7 @@ export default function PatternExplorer() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{view === 'search' && <FacetedSearch patterns={patterns} />}
|
||||
{view === 'search' && <FacetedSearch patterns={patterns} calibrationMap={calibrationMap} />}
|
||||
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
|
||||
{view === 'regime' && <RegimeView patterns={patterns} />}
|
||||
</>
|
||||
|
||||
@@ -4,7 +4,7 @@ import clsx from 'clsx'
|
||||
import {
|
||||
Brain, TrendingUp, TrendingDown, RefreshCw, ShieldAlert,
|
||||
GitCompare, Layers, Zap, BookOpen, Clock, ChevronRight,
|
||||
CheckCircle2, AlertTriangle, XCircle, BarChart2,
|
||||
CheckCircle2, AlertTriangle, XCircle, BarChart2, FlaskConical,
|
||||
} from 'lucide-react'
|
||||
|
||||
const SCENARIO_META: Record<string, { label: string; color: string; emoji: string }> = {
|
||||
@@ -561,6 +561,67 @@ export default function RapportCycle() {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Calibration report */}
|
||||
{report.calibration_report && (report.calibration_report as any).total_patterns > 0 && (() => {
|
||||
const cr = report.calibration_report as any
|
||||
const pct = cr.avg_calibration_weight_pct ?? 0
|
||||
return (
|
||||
<Section icon={<FlaskConical className="w-4 h-4" />} title="Calibration expected_move — IA vs observé">
|
||||
{/* Global bar */}
|
||||
<div className="space-y-1 mb-4">
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>IA pure ({cr.pure_ai_count} patterns)</span>
|
||||
<span className="font-mono text-violet-400">{pct.toFixed(1)}% poids observé moyen</span>
|
||||
<span>Data-driven ({cr.data_driven_count})</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-700/60 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-slate-500 via-violet-500 to-emerald-500 transition-all"
|
||||
style={{ width: `${Math.max(pct, 1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 mt-2">
|
||||
{[
|
||||
{ label: 'Pure AI', count: cr.pure_ai_count, cls: 'text-slate-400' },
|
||||
{ label: 'Early', count: cr.early_count, cls: 'text-sky-400' },
|
||||
{ label: 'Mixed', count: cr.mixed_count, cls: 'text-violet-400' },
|
||||
{ label: 'Data', count: cr.data_driven_count, cls: 'text-emerald-400' },
|
||||
].map(({ label, count, cls }) => (
|
||||
<div key={label} className="bg-dark-700/60 border border-slate-700/30 rounded p-2 text-center">
|
||||
<div className={clsx('text-lg font-bold', cls)}>{count}</div>
|
||||
<div className="text-[10px] text-slate-600">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Per-pattern detail */}
|
||||
{(cr.detail ?? []).length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] text-slate-600 uppercase tracking-wider mb-1">Patterns avec données ({cr.patterns_with_data})</div>
|
||||
{(cr.detail as any[]).map((d: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs bg-dark-700/40 rounded px-2 py-1.5">
|
||||
<span className={clsx('shrink-0 font-bold text-[10px] px-1.5 py-0.5 rounded border', {
|
||||
'text-slate-400 border-slate-700/40': d.source === 'pure_ai',
|
||||
'text-sky-400 border-sky-700/40': d.source === 'early',
|
||||
'text-violet-400 border-violet-700/40': d.source === 'mixed',
|
||||
'text-emerald-400 border-emerald-700/40': d.source === 'data_driven',
|
||||
})}>
|
||||
{d.weight_pct?.toFixed(0)}%
|
||||
</span>
|
||||
<span className="flex-1 text-slate-300 truncate">{d.name}</span>
|
||||
<span className="text-slate-500 shrink-0">{d.n_trades}t · {d.win_rate_pct}% WR</span>
|
||||
<span className="font-mono text-slate-500 shrink-0">AI:{d.ai_estimate?.toFixed(0)}%</span>
|
||||
{d.calibrated != null && (
|
||||
<span className="font-mono text-violet-300 shrink-0">→{d.calibrated?.toFixed(0)}%</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Risk / portfolio monitor */}
|
||||
{report.portfolio_monitor && (
|
||||
<Section icon={<ShieldAlert className="w-4 h-4" />} title="Risk & Portfolio alerts">
|
||||
|
||||
Reference in New Issue
Block a user