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

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

View File

@@ -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' },

View File

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

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 { 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 &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 */}
{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 && (