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:
OpenSquared
2026-06-23 12:38:16 +02:00
parent a630cdc708
commit 91f12e177f
6 changed files with 327 additions and 17 deletions

View File

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

View File

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

View File

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