feat: eco calendar bootstrap + driver types + timeline cursor

Backend:
- eco_calendar_bootstrap.py: 75 historical events 2020-2026 (FOMC, NFP,
  CPI, GDP, ISM, BOJ, ECB, BOE) with expected_value/actual_value/
  surprise_pct/unit/absorption_pct fields
- database.py: 5 new columns on market_events (expected_value,
  actual_value, surprise_pct, unit, sub_type) + updated save_market_event
- timeline.py: POST /api/timeline/bootstrap-eco endpoint
- instrument_service.py: _get_relevant_events now returns eco fields
- instruments.json: type field on all 90 drivers across 20 instruments
  (event_calendar | report | geopolitical | fundamental | sentiment | technical)

Frontend (InstrumentDashboard):
- EventTimelineStrip: vertical cursor line tracking crosshair selectedDate
- EventsCard: active-event highlight at hovered date + expected/actual/
  surprise_pct display + absorption progress bar
- DriversPanel: type selector dropdown per driver
- DriverTypeBadge: colored micro-badge on driver labels in strip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-25 00:07:41 +02:00
parent aec9ced74f
commit 3470205d54
6 changed files with 878 additions and 160 deletions

View File

@@ -13,7 +13,7 @@ const api = axios.create({ baseURL: '/api' })
// ── Types ─────────────────────────────────────────────────────────────────────
interface Driver {
key: string; label: string; weight: number; keywords: string[]
key: string; label: string; weight: number; keywords: string[]; type?: string
}
interface InstrumentConfig {
@@ -29,7 +29,9 @@ interface LinePoint { time: string; value: number }
interface SnapshotEvent {
date: string; end_date: string | null; title: string; level: string
category: string; description: string; impact_score: number
category: string; sub_type?: string; description: string; impact_score: number
expected_value?: string | null; actual_value?: string | null
surprise_pct?: number | null; unit?: string | null; absorption_pct?: number | null
}
interface RegimeSignals {
@@ -114,6 +116,32 @@ function pctN(a: number | undefined, b: number | undefined): number {
return ((a - b) / b) * 100
}
// ── Driver type config ────────────────────────────────────────────────────────
const DRIVER_TYPES = ['event_calendar', 'report', 'geopolitical', 'fundamental', 'sentiment', 'technical'] as const
type DriverType = typeof DRIVER_TYPES[number]
const DRIVER_TYPE_CFG: Record<string, { short: string; tw: string }> = {
event_calendar: { short: 'Cal.', tw: 'text-amber-400 bg-amber-900/30 border-amber-700/30' },
report: { short: 'Rep.', tw: 'text-blue-400 bg-blue-900/30 border-blue-700/30' },
geopolitical: { short: 'Geo.', tw: 'text-red-400 bg-red-900/30 border-red-700/30' },
fundamental: { short: 'Fund.', tw: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/30' },
sentiment: { short: 'Sent.', tw: 'text-violet-400 bg-violet-900/30 border-violet-700/30' },
technical: { short: 'Tech.', tw: 'text-cyan-400 bg-cyan-900/30 border-cyan-700/30' },
}
function DriverTypeBadge({ type }: { type?: string }) {
if (!type) return null
const cfg = DRIVER_TYPE_CFG[type]
if (!cfg) return null
return (
<span className={clsx('text-xs px-1 py-0 rounded border leading-none', cfg.tw)}
style={{ fontSize: 8, paddingTop: 1, paddingBottom: 1 }}>
{cfg.short}
</span>
)
}
// ── Macro regime colour mapping ───────────────────────────────────────────────
const MACRO_COLOR_MAP: Record<string, string> = {
@@ -132,7 +160,7 @@ function macroRegimeColor(dominant: string): string {
return MACRO_COLOR_MAP[dominant] ?? 'slate'
}
// ── RegimeCard (métriques de signaux) ─────────────────────────────────────────
// ── RegimeCard ────────────────────────────────────────────────────────────────
function RegimeCard({
regime, macroRegime, signalsAt, dateLabel,
@@ -155,7 +183,6 @@ function RegimeCard({
amber: 'border-amber-700/40 bg-amber-950/30',
}
// 6 signal metrics displayed as a compact grid
const ma50Color = signals.ma50_above_ma200 ? 'text-emerald-400' : 'text-red-400'
const volColor = (signals.vol_ratio_pct ?? 0) > 130 ? 'text-orange-400' : (signals.vol_ratio_pct ?? 0) < 70 ? 'text-cyan-400' : 'text-slate-300'
@@ -166,21 +193,9 @@ function RegimeCard({
sub: signals.ma50_above_ma200 === true ? '↑ Golden cross' : signals.ma50_above_ma200 === false ? '↓ Death cross' : '',
color: ma50Color,
},
{
label: 'Slope MA50 (10j)',
value: fmt(signals.ma50_slope_pct) + '%',
color: pctColor(signals.ma50_slope_pct),
},
{
label: 'Slope MA200 (10j)',
value: fmt(signals.ma200_slope_pct) + '%',
color: pctColor(signals.ma200_slope_pct),
},
{
label: 'Momentum 20j',
value: fmt(signals.momentum_20d_pct) + '%',
color: pctColor(signals.momentum_20d_pct),
},
{ label: 'Slope MA50 (10j)', value: fmt(signals.ma50_slope_pct) + '%', color: pctColor(signals.ma50_slope_pct) },
{ label: 'Slope MA200 (10j)', value: fmt(signals.ma200_slope_pct) + '%', color: pctColor(signals.ma200_slope_pct) },
{ label: 'Momentum 20j', value: fmt(signals.momentum_20d_pct) + '%', color: pctColor(signals.momentum_20d_pct) },
{
label: 'Distance MA200',
value: fmt(signals.dist_ma200_pct) + '%',
@@ -195,13 +210,11 @@ 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`)} />
@@ -210,7 +223,6 @@ function RegimeCard({
<span className="text-xs text-slate-600">{dateLabel}</span>
</div>
{/* 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">
@@ -240,7 +252,6 @@ function RegimeCard({
</div>
)}
{/* Technical regime label + confidence */}
<div className={clsx('rounded-lg px-3 py-2', `bg-${col}-900/20`)}>
<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>
@@ -253,7 +264,6 @@ function RegimeCard({
</div>
</div>
{/* 6 signal metrics — 2 column grid */}
<div className="grid grid-cols-2 gap-2">
{metrics.map(m => (
<div key={m.label} className="rounded-lg bg-dark-700/50 px-2.5 py-2">
@@ -364,13 +374,32 @@ function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: strin
// ── EventsCard ────────────────────────────────────────────────────────────────
function EventsCard({ events }: { events: SnapshotEvent[] }) {
function EventsCard({ events, selectedDate }: { events: SnapshotEvent[]; selectedDate: string | null }) {
const navigate = useNavigate()
const LEVEL_C: Record<string, string> = {
long: 'text-violet-400 bg-violet-900/30 border-violet-700/30',
medium: 'text-blue-400 bg-blue-900/30 border-blue-700/30',
short: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/30',
}
function isActiveAt(ev: SnapshotEvent): boolean {
if (!selectedDate) return false
const evStart = ev.date
const evEnd = ev.end_date
if (evStart > selectedDate) return false
if (evEnd) return evEnd >= selectedDate
// point event: active if within 30 days after
const diffDays = (new Date(selectedDate).getTime() - new Date(evStart).getTime()) / 86400000
return diffDays <= 30
}
function surpriseColor(v: number | null | undefined): string {
if (v === null || v === undefined) return 'text-slate-400'
if (v > 5) return 'text-emerald-400'
if (v < -5) return 'text-red-400'
return 'text-slate-400'
}
return (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
<div className="flex items-center gap-2">
@@ -381,25 +410,75 @@ function EventsCard({ events }: { events: SnapshotEvent[] }) {
<div className="text-xs text-slate-600 italic py-2">Aucun événement lié trouvé</div>
) : (
<div className="space-y-2 max-h-[380px] overflow-y-auto pr-1">
{events.map((ev, i) => (
<div key={i}
className="rounded-lg border border-slate-700/30 bg-dark-700/40 p-2 cursor-pointer hover:bg-dark-700/70 transition-colors"
onClick={() => navigate(`/timeline?date=${ev.date}`)}
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-medium text-slate-300 leading-tight">{ev.title}</span>
<span className={clsx('shrink-0 text-xs px-1.5 py-0.5 rounded border', LEVEL_C[ev.level] ?? 'text-slate-400 bg-slate-800 border-slate-700/30')}>
{ev.level === 'long' ? 'LT' : ev.level === 'medium' ? 'MT' : 'CT'}
</span>
{events.map((ev, i) => {
const active = isActiveAt(ev)
return (
<div key={i}
className={clsx(
'rounded-lg border p-2 cursor-pointer transition-colors',
active
? 'border-amber-600/50 bg-amber-900/20 hover:bg-amber-900/30'
: 'border-slate-700/30 bg-dark-700/40 hover:bg-dark-700/70'
)}
onClick={() => navigate(`/timeline?date=${ev.date}`)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{active && <span className="shrink-0 w-1.5 h-1.5 rounded-full bg-amber-400 mt-0.5" />}
<span className="text-xs font-medium text-slate-300 leading-tight truncate">{ev.title}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{ev.sub_type && (
<span className="text-xs px-1 py-0 rounded bg-slate-700/50 text-slate-400 border border-slate-600/30"
style={{ fontSize: 8, paddingTop: 1, paddingBottom: 1 }}>
{ev.sub_type}
</span>
)}
<span className={clsx('text-xs px-1.5 py-0.5 rounded border', LEVEL_C[ev.level] ?? 'text-slate-400 bg-slate-800 border-slate-700/30')}>
{ev.level === 'long' ? 'LT' : ev.level === 'medium' ? 'MT' : 'CT'}
</span>
</div>
</div>
{/* Eco calendar fields */}
{(ev.expected_value || ev.actual_value) && (
<div className="flex items-center gap-3 mt-1.5 text-xs">
{ev.expected_value && (
<span className="text-slate-500">Att. <span className="text-slate-300">{ev.expected_value}</span></span>
)}
{ev.actual_value && (
<span className="text-slate-500">Réel <span className="font-semibold text-white">{ev.actual_value}</span></span>
)}
{ev.surprise_pct !== null && ev.surprise_pct !== undefined && (
<span className={clsx('font-semibold', surpriseColor(ev.surprise_pct))}>
{ev.surprise_pct > 0 ? '+' : ''}{ev.surprise_pct.toFixed(0)}%
{ev.surprise_pct > 5 ? ' ↑' : ev.surprise_pct < -5 ? ' ↓' : ''}
</span>
)}
{ev.unit && <span className="text-slate-600">{ev.unit}</span>}
</div>
)}
{ev.absorption_pct !== null && ev.absorption_pct !== undefined && (
<div className="mt-1.5">
<div className="flex items-center justify-between text-xs text-slate-600 mb-0.5">
<span>Absorption marché</span><span>{ev.absorption_pct}%</span>
</div>
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
<div className="h-full bg-amber-600/60 rounded-full" style={{ width: `${ev.absorption_pct}%` }} />
</div>
</div>
)}
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
<Clock className="w-3 h-3" />
{fmtDateFR(ev.date)}{ev.end_date ? `${fmtDateFR(ev.end_date)}` : ''}
{ev.impact_score > 0.7 && <AlertCircle className="w-3 h-3 text-orange-400" />}
</div>
{ev.description && <p className="text-xs text-slate-600 mt-1 line-clamp-1">{ev.description}</p>}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
<Clock className="w-3 h-3" />
{ev.date}{ev.end_date ? `${ev.end_date}` : ''}
{ev.impact_score > 0.7 && <AlertCircle className="w-3 h-3 text-orange-400" />}
</div>
{ev.description && <p className="text-xs text-slate-600 mt-1 line-clamp-1">{ev.description}</p>}
</div>
))}
)
})}
</div>
)}
</div>
@@ -455,9 +534,9 @@ function eventMatchesDriver(ev: SnapshotEvent, keywords: string[]): boolean {
}
function EventTimelineStrip({
events, priceData, drivers,
events, priceData, drivers, selectedDate,
}: {
events: SnapshotEvent[]; priceData: PriceCandle[]; drivers: Driver[]
events: SnapshotEvent[]; priceData: PriceCandle[]; drivers: Driver[]; selectedDate: string | null
}) {
if (priceData.length < 2) return null
@@ -468,21 +547,21 @@ function EventTimelineStrip({
function leftPct(d: string) { return Math.max(0, Math.min(99, ((dateToMs(d) - chartStart) / totalMs) * 100)) }
function widthPct(start: string, end: string | null): number {
// Default width = 3% of period if no end date
const endMs = end ? dateToMs(end) : dateToMs(start) + totalMs * 0.03
return Math.min(100, Math.max(0.4, ((endMs - dateToMs(start)) / totalMs) * 100))
}
// 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))
// Cursor position for the selected date
const cursorLeft = selectedDate ? leftPct(selectedDate) : null
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>
@@ -492,13 +571,13 @@ function EventTimelineStrip({
const matchedEvs = events.filter(ev => eventMatchesDriver(ev, driver.keywords ?? []))
return (
<div key={driver.key} className="flex items-center gap-2 h-6">
<span className={clsx('text-xs font-semibold shrink-0 truncate w-28', cols.label)}
style={{ fontSize: 10 }}
title={driver.label}
>
{driver.label}
</span>
{/* paddingRight aligns with chart right price scale (~60px) */}
<div className="flex items-center gap-1 shrink-0 w-28">
<span className={clsx('text-xs font-semibold truncate', cols.label)}
style={{ fontSize: 10 }} title={driver.label}>
{driver.label}
</span>
<DriverTypeBadge type={driver.type} />
</div>
<div className="relative flex-1 h-full" style={{ paddingRight: 62 }}>
{matchedEvs.map((ev, i) => {
const left = leftPct(ev.date)
@@ -523,6 +602,13 @@ function EventTimelineStrip({
<div className="w-full border-t border-dashed border-slate-700/30" />
</div>
)}
{/* Vertical cursor for selected date */}
{cursorLeft !== null && (
<div
className="absolute top-0 bottom-0 w-px bg-white/50 z-20 pointer-events-none"
style={{ left: `${cursorLeft}%` }}
/>
)}
</div>
</div>
)
@@ -552,6 +638,12 @@ function EventTimelineStrip({
</div>
)
})}
{cursorLeft !== null && (
<div
className="absolute top-0 bottom-0 w-px bg-white/50 z-20 pointer-events-none"
style={{ left: `${cursorLeft}%` }}
/>
)}
</div>
</div>
)}
@@ -582,7 +674,10 @@ function DriversPanel({ instrumentId, drivers, onSave, onClose }: {
}
function addDriver() {
setLocal(prev => [...prev, { key: `driver_${Date.now()}`, label: 'Nouveau driver', weight: 0.5, keywords: [] }])
setLocal(prev => [...prev, {
key: `driver_${Date.now()}`, label: 'Nouveau driver',
weight: 0.5, keywords: [], type: 'fundamental',
}])
}
function removeDriver(i: number) {
@@ -634,6 +729,16 @@ function DriversPanel({ instrumentId, drivers, onSave, onClose }: {
value={d.label}
onChange={e => updateDriver(i, 'label', e.target.value)}
/>
{/* Type selector */}
<select
className="text-xs bg-dark-600 border border-slate-700/40 rounded px-1.5 py-1 text-slate-300"
value={d.type ?? 'fundamental'}
onChange={e => updateDriver(i, 'type', e.target.value)}
>
{DRIVER_TYPES.map(t => (
<option key={t} value={t}>{DRIVER_TYPE_CFG[t]?.short ?? t}</option>
))}
</select>
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500">Poids</span>
<input
@@ -682,7 +787,6 @@ export default function InstrumentDashboard() {
const [selectorOpen, setSelectorOpen] = useState(false)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [editDrivers, setEditDrivers] = useState(false)
// Local drivers (updated optimistically after save)
const [localDrivers, setLocalDrivers] = useState<Driver[] | null>(null)
const instrumentId = id.toUpperCase()
@@ -720,7 +824,6 @@ export default function InstrumentDashboard() {
if (date) setSelectedDate(date)
}, [])
// ── Lookup maps ───────────────────────────────────────────────────────────
const { priceMap, indMap, sortedDates, dateIndex } = useMemo(() => {
if (!snapshot) return { priceMap: {} as Record<string, PriceCandle>, indMap: {} as Record<string, Record<string, number>>, sortedDates: [] as string[], dateIndex: {} as Record<string, number> }
const priceMap: Record<string, PriceCandle> = {}
@@ -786,7 +889,6 @@ export default function InstrumentDashboard() {
}
}, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot])
// ── UI ────────────────────────────────────────────────────────────────────
const grouped = CATEGORY_ORDER.map(cat => ({
cat, label: CATEGORY_LABELS[cat] ?? cat,
items: instruments.filter(i => i.category === cat),
@@ -797,7 +899,6 @@ export default function InstrumentDashboard() {
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
const displayPrice = dateTrend?.current_price ?? snapshot?.current_price
// Drivers to use (local override after edit, else from snapshot)
const activeDrivers = localDrivers ?? snapshot?.instrument?.drivers ?? []
return (
@@ -860,7 +961,6 @@ export default function InstrumentDashboard() {
)}
<div className="ml-auto flex items-center gap-2">
{/* Drivers edit button */}
{snapshot && (
<button onClick={() => setEditDrivers(e => !e)}
className={clsx('flex items-center gap-1.5 text-xs px-3 py-1.5 border rounded-lg transition-colors',
@@ -873,7 +973,6 @@ export default function InstrumentDashboard() {
Drivers
</button>
)}
{/* Period selector */}
<div className="flex items-center gap-1 bg-dark-800 border border-slate-700/40 rounded-xl p-1">
{PERIODS.map(p => (
<button key={p.key} onClick={() => setPeriod(p.key)}
@@ -916,6 +1015,7 @@ export default function InstrumentDashboard() {
events={snapshot.events}
priceData={snapshot.price_data}
drivers={activeDrivers}
selectedDate={effectiveDate}
/>
{/* Date badge */}
@@ -944,10 +1044,12 @@ export default function InstrumentDashboard() {
trend={dateTrend ?? snapshot.trend}
dateLabel={dateLabel}
/>
<EventsCard events={snapshot.events} />
<EventsCard
events={snapshot.events}
selectedDate={effectiveDate}
/>
</div>
{/* Drivers edit panel */}
{editDrivers && (
<DriversPanel
instrumentId={instrumentId}
@@ -957,7 +1059,6 @@ export default function InstrumentDashboard() {
/>
)}
{/* AI Narrative */}
<NarrativeCard
narrative={narrative}
loading={loadingNarr}