feat: macro regime + 30 historical events + regime confidence fix

- Add macro_regime (goldilocks/stagflation/recession/etc.) to every instrument snapshot via get_macro_gauges() + score_macro_scenarios()
- RegimeCard now shows global macro cycle section (emoji + label + top-3 scenarios) above technical signals
- Fix _detect_regime() confidence: capped at 85% max; add late-bull (dist_MA200 > 20%) and correction-in-bull (MA50 > MA200 but momentum < -3%) detection so regime no longer locks at 100%
- Add macro_events_bootstrap.py with 30 curated historical events (FOMC 2022-2025, CPI surprises, Ukraine/Hamas/Iran geopolitics, BOJ pivots, Bitcoin ETF, Liberation Day tariffs, SVB crisis, etc.)
- POST /api/timeline/bootstrap-macro endpoint (idempotent, deduplicates by name)
- Fix event date filter in _get_relevant_events(): overlap logic instead of start-only filter — events extending into the chart window are now included
- EventTimelineStrip: add "Signaux Techniques" fallback row for events not matched by any driver keyword (MA crossovers are now always visible)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-24 23:24:24 +02:00
parent aa81598278
commit aec9ced74f
4 changed files with 495 additions and 17 deletions

View File

@@ -44,11 +44,22 @@ interface TrendMetrics {
current_price: number; high_52w: number; low_52w: number
}
interface MacroRegime {
dominant: string
label: string
color: string
emoji: string
scores: Record<string, number>
ranked: string[]
asset_bias: Record<string, any>
}
interface Snapshot {
instrument: InstrumentConfig
price_data: PriceCandle[]
indicators: Record<string, LinePoint[]>
regime: { current: string; confidence: number; scores: Record<string, number>; signals: RegimeSignals }
macro_regime: MacroRegime
trend: TrendMetrics
events: SnapshotEvent[]
current_price: number; change_pct: number; change_abs: number; period: string
@@ -103,12 +114,31 @@ function pctN(a: number | undefined, b: number | undefined): number {
return ((a - b) / b) * 100
}
// ── Macro regime colour mapping ───────────────────────────────────────────────
const MACRO_COLOR_MAP: Record<string, string> = {
goldilocks: 'emerald',
desinflation: 'cyan',
soft_landing: 'blue',
reflation: 'amber',
stagflation: 'orange',
inflation_shock: 'red',
recession: 'red',
crise_liquidite: 'red',
incertain: 'slate',
}
function macroRegimeColor(dominant: string): string {
return MACRO_COLOR_MAP[dominant] ?? 'slate'
}
// ── RegimeCard (métriques de signaux) ─────────────────────────────────────────
function RegimeCard({
regime, signalsAt, dateLabel,
regime, macroRegime, signalsAt, dateLabel,
}: {
regime: Snapshot['regime']
macroRegime: MacroRegime | null
signalsAt: RegimeSignals | null
dateLabel: string
}) {
@@ -121,6 +151,8 @@ function RegimeCard({
orange: 'border-orange-700/40 bg-orange-950/30',
slate: 'border-slate-700/40 bg-slate-900/30',
blue: 'border-blue-700/40 bg-blue-950/30',
cyan: 'border-cyan-700/40 bg-cyan-950/30',
amber: 'border-amber-700/40 bg-amber-950/30',
}
// 6 signal metrics displayed as a compact grid
@@ -163,20 +195,55 @@ function RegimeCard({
},
]
// Macro regime display
const macroCol = macroRegime ? macroRegimeColor(macroRegime.dominant) : 'slate'
const top3Macro = macroRegime?.ranked?.slice(0, 3) ?? []
return (
<div className={clsx('rounded-xl border p-4 space-y-3', borderMap[col])}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart2 className={clsx('w-4 h-4', `text-${col}-400`)} />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime Détecté</span>
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime</span>
</div>
<span className="text-xs text-slate-600">{dateLabel}</span>
</div>
{/* Regime label + confidence */}
{/* Macro regime section (global cycle) */}
{macroRegime && macroRegime.dominant !== 'incertain' && (
<div className="rounded-lg border border-slate-700/30 bg-dark-700/40 px-3 py-2 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 uppercase tracking-wide">Cycle macro global</span>
<span className="text-xs text-slate-600">Fed cycle</span>
</div>
<div className="flex items-center gap-2">
<span className="text-base">{macroRegime.emoji || '🌍'}</span>
<span className={clsx('text-sm font-bold', `text-${macroCol}-300`)}>{macroRegime.label || macroRegime.dominant}</span>
</div>
{top3Macro.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5">
{top3Macro.map((key, i) => {
const score = macroRegime.scores?.[key]
const c = macroRegimeColor(key)
return (
<span key={key} className={clsx(
'text-xs px-1.5 py-0.5 rounded border',
i === 0 ? `text-${c}-300 bg-${c}-900/30 border-${c}-700/40` : 'text-slate-500 bg-dark-800/40 border-slate-700/30'
)}>
{key} {score !== undefined ? Math.round(score) + '%' : ''}
</span>
)
})}
</div>
)}
</div>
)}
{/* Technical regime label + confidence */}
<div className={clsx('rounded-lg px-3 py-2', `bg-${col}-900/20`)}>
<div className={clsx('text-base font-bold leading-tight', `text-${col}-300`)}>{regime.current}</div>
<div className="text-xs text-slate-500 mb-0.5">Régime technique</div>
<div className={clsx('text-sm font-bold leading-tight', `text-${col}-300`)}>{regime.current}</div>
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', `bg-${col}-500`)}
@@ -409,6 +476,13 @@ function EventTimelineStrip({
// Top 4 drivers by weight
const topDrivers = [...drivers].sort((a, b) => b.weight - a.weight).slice(0, 4)
// Events matched by ANY driver (for fallback row)
const allKeywords = topDrivers.flatMap(d => d.keywords ?? [])
const matchedAnyIdxs = new Set(
events.map((ev, i) => eventMatchesDriver(ev, allKeywords) ? i : -1).filter(i => i >= 0)
)
const unmatchedEvs = events.filter((_ev, i) => !matchedAnyIdxs.has(i))
return (
<div className="bg-dark-900/40 rounded-xl border border-slate-700/40 px-4 py-3">
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2.5">Événements par driver</div>
@@ -453,6 +527,34 @@ function EventTimelineStrip({
</div>
)
})}
{/* Fallback row: events not matched by any driver keyword */}
{unmatchedEvs.length > 0 && (
<div className="flex items-center gap-2 h-6">
<span className="text-xs font-semibold shrink-0 truncate w-28 text-slate-500"
style={{ fontSize: 10 }}>Signaux tech.</span>
<div className="relative flex-1 h-full" style={{ paddingRight: 62 }}>
{unmatchedEvs.map((ev, i) => {
const left = leftPct(ev.date)
const width = widthPct(ev.date, ev.end_date)
return (
<div key={i}
title={`${ev.title}\n${ev.date}${ev.end_date ? ' → ' + ev.end_date : ''}\n${ev.description ?? ''}`}
className="absolute top-0 h-full rounded border overflow-hidden bg-slate-700/40 border-slate-600/40"
style={{ left: `${left}%`, width: `${width}%` }}
>
{width > 3 && (
<span className="absolute inset-0 flex items-center px-1 truncate pointer-events-none text-slate-400"
style={{ fontSize: 9 }}>
{ev.title}
</span>
)}
</div>
)
})}
</div>
</div>
)}
</div>
</div>
)
@@ -834,6 +936,7 @@ export default function InstrumentDashboard() {
<div className="grid grid-cols-3 gap-4">
<RegimeCard
regime={snapshot.regime}
macroRegime={snapshot.macro_regime ?? null}
signalsAt={dateSignals}
dateLabel={dateLabel}
/>