diff --git a/frontend/src/pages/ExternalSnapshot.tsx b/frontend/src/pages/ExternalSnapshot.tsx index 72016a3..f02422b 100644 --- a/frontend/src/pages/ExternalSnapshot.tsx +++ b/frontend/src/pages/ExternalSnapshot.tsx @@ -4,14 +4,14 @@ import axios from 'axios' import clsx from 'clsx' import { Globe, BarChart2, Activity, Calendar, - ArrowRight, Clock, TrendingUp, TrendingDown, Minus, ExternalLink, + ArrowRight, Clock, TrendingUp, TrendingDown, Minus, } from 'lucide-react' const api = axios.create({ baseURL: '/api' }) function todayStr() { return new Date().toISOString().split('T')[0] } -function fmtDate(d: string) { - return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' }) +function fmtShort(d: string) { + return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' }) } function daysSince(ref: string, start: string) { return Math.floor((new Date(ref).getTime() - new Date(start).getTime()) / 86_400_000) @@ -25,7 +25,8 @@ function offsetDate(d: string, days: number) { interface MarketEvent { id: number; name: string; start_date: string; end_date: string | null - level: string; category: string; description: string; impact_score: number + level: string; category: string; description: string; market_impact: string + affected_assets: string; impact_score: number } interface DayContext { @@ -36,285 +37,346 @@ interface DayContext { } interface Quote { symbol: string; price: number; change_pct: number; name?: string } + interface MacroRegime { scenarios: { dominant: string; scores: Record } gauges: Record } + interface CalEvent { title: string; date: string; importance: number; currency?: string } -// ── Sub-components ───────────────────────────────────────────────────────────── +// ── Config ───────────────────────────────────────────────────────────────────── -const LEVEL_CFG = { - long: { label: 'L', color: 'text-violet-400', bg: 'bg-violet-900/30', border: 'border-violet-700/40' }, - medium: { label: 'M', color: 'text-blue-400', bg: 'bg-blue-900/30', border: 'border-blue-700/40' }, - short: { label: 'C', color: 'text-emerald-400', bg: 'bg-emerald-900/30', border: 'border-emerald-700/40' }, +const LEVEL = { + long: { + label: 'Long Terme', badge: 'L', + ring: 'border-violet-700/50', bg: 'bg-violet-950/20', + header: 'bg-violet-950/40 border-violet-700/40', + accent: 'text-violet-400', dot: 'bg-violet-500', + badgeCls: 'bg-violet-800 text-violet-200', + }, + medium: { + label: 'Moyen Terme', badge: 'M', + ring: 'border-blue-700/50', bg: 'bg-blue-950/20', + header: 'bg-blue-950/40 border-blue-700/40', + accent: 'text-blue-400', dot: 'bg-blue-500', + badgeCls: 'bg-blue-800 text-blue-200', + }, + short: { + label: 'Court Terme', badge: 'C', + ring: 'border-emerald-700/50', bg: 'bg-emerald-950/20', + header: 'bg-emerald-950/40 border-emerald-700/40', + accent: 'text-emerald-400', dot: 'bg-emerald-500', + badgeCls: 'bg-emerald-800 text-emerald-200', + }, +} as const + +const REGIME_LABELS: Record = { + risk_on: 'Risk On', risk_off: 'Risk Off', stagflation: 'Stagflation', + goldilocks: 'Goldilocks', recession: 'Récession', reflation: 'Reflation', + soft_landing: 'Soft Landing', desinflation: 'Désinflation', incertain: 'Incertain', } -function EventBadge({ ev, level, refDate }: { ev: MarketEvent | null; level: 'long' | 'medium' | 'short'; refDate: string }) { - const cfg = LEVEL_CFG[level] +// Assets relevant to each temporal level — fallback to standard set +const LEVEL_ASSETS: Record = { + long: ['SPY', 'GLD', 'USO', 'TLT'], + medium: ['SPY', 'USO', 'GLD', 'UUP'], + short: ['QQQ', 'TLT', 'UUP', 'GLD'], +} + +// Try to map affected_assets field to quote symbols we have +function relevantAssets(ev: MarketEvent | null, level: string): string[] { + if (!ev) return LEVEL_ASSETS[level] ?? [] + try { + const raw: string[] = JSON.parse(ev.affected_assets || '[]') + const MAP: Record = { + SPX: 'SPY', SPY: 'SPY', QQQ: 'QQQ', NVDA: 'NVDA', + GC: 'GLD', Gold: 'GLD', GLD: 'GLD', + CL: 'USO', Oil: 'USO', USO: 'USO', + NG: 'USO', TLT: 'TLT', USD: 'UUP', UUP: 'UUP', + VIX: 'VIX', HYG: 'HYG', + } + const mapped = raw.map(a => MAP[a]).filter(Boolean) as string[] + const unique = [...new Set(mapped)].slice(0, 4) + return unique.length >= 2 ? unique : LEVEL_ASSETS[level] ?? [] + } catch { return LEVEL_ASSETS[level] ?? [] } +} + +// ── Cells ────────────────────────────────────────────────────────────────────── + +function CellGeo({ + ev, level, refDate, +}: { + ev: MarketEvent | null; level: keyof typeof LEVEL; refDate: string +}) { + const cfg = LEVEL[level] if (!ev) return ( -
- {cfg.label} - +
+ Aucun événement {cfg.label.toLowerCase()} actif
) const days = daysSince(refDate, ev.start_date) return ( -
- {cfg.label} - {ev.name} - J+{days} +
+
{ev.name}
+
+ + {ev.start_date} + J+{days} + {!ev.end_date && en cours} +
+

{ev.description}

+ {ev.market_impact && ( +

{ev.market_impact}

+ )}
) } -// Pillar 1 — Géopolitique / Temporal Context -function PillarGeo({ ctx, refDate }: { ctx: DayContext | null; refDate: string }) { +function CellMarkets({ + ev, level, quotes, +}: { + ev: MarketEvent | null; level: string; quotes: Record | null +}) { + const assets = relevantAssets(ev, level) return ( -
-
-
- - Géopolitique -
- - Timeline - -
- -
- {ctx ? ( - <> - - - - - ) : ( -
- {(['long', 'medium', 'short'] as const).map(l => ( -
- ))} +
+ {assets.map(sym => { + const q = quotes?.[sym] + if (!q) return ( +
+ {sym} +
+ ) + const up = q.change_pct > 0.1 + const dn = q.change_pct < -0.1 + return ( +
+ {q.name || sym} +
+ {q.price?.toFixed(q.price > 100 ? 1 : 2)} + + {up ? : dn ? : } + {q.change_pct > 0 ? '+' : ''}{q.change_pct?.toFixed(2)}% + +
+
+ ) + })} +
+ ) +} + +function CellMacro({ + level, macro, +}: { + level: keyof typeof LEVEL; macro: MacroRegime | null +}) { + const cfg = LEVEL[level] + const dominant = macro?.scenarios?.dominant ?? 'incertain' + const dominantLabel = REGIME_LABELS[dominant] ?? dominant + + const scores = macro?.scenarios?.scores ?? {} + const maxScore = Math.max(...Object.values(scores).map(Number), 1) + const top3 = Object.entries(scores) + .sort((a, b) => Number(b[1]) - Number(a[1])) + .slice(0, 3) + + // Key gauge per level + const gaugeKeys = { + long: ['vix', 'us10y'], + medium: ['dxy', 'credit_spread'], + short: ['vix', 'dxy'], + }[level] ?? ['vix', 'dxy'] + + return ( +
+
{dominantLabel}
+
+ {top3.map(([name, score]) => { + const pct = Math.min(100, Math.round((Number(score) / maxScore) * 100)) + const label = REGIME_LABELS[name] ?? name + return ( +
+ {label} +
+
+
+ {pct}% +
+ ) + })} +
+
+ {gaugeKeys.map(k => { + const g = macro?.gauges?.[k] + if (!g?.value) return null + const up = (g.change_pct ?? 0) > 0 + return ( +
+ {g.label} + + {g.value?.toFixed(g.value > 10 ? 1 : 2)} + +
+ ) + })} +
+
+ ) +} + +// Calendar events filtered by temporal horizon relative to refDate +function calForLevel(events: CalEvent[], level: string, refDate: string): CalEvent[] { + const horizons: Record = { long: 90, medium: 30, short: 7 } + const horizon = horizons[level] ?? 30 + return events + .filter(e => e.date >= refDate && e.date <= offsetDate(refDate, horizon)) + .sort((a, b) => a.date.localeCompare(b.date)) + .slice(0, 4) +} + +function CellCalendar({ + level, events, refDate, +}: { + level: keyof typeof LEVEL; events: CalEvent[] | null; refDate: string +}) { + const cfg = LEVEL[level] + const horizon = { long: '3 mois', medium: '1 mois', short: '7 jours' }[level] + const items = calForLevel(events ?? [], level, refDate) + + return ( +
+
Horizon {horizon}
+ {items.length === 0 && ( +
Aucun événement
+ )} + {items.map((ev, i) => { + const imp = ev.importance ?? 1 + const impColor = imp >= 3 ? 'text-red-400' : imp >= 2 ? 'text-yellow-400' : 'text-slate-500' + return ( +
+ {fmtShort(ev.date)} + + {ev.currency && [{ev.currency}]} + {ev.title} + +
+ {[1, 2, 3].map(n => ( +
= 3 ? 'bg-red-500' : imp >= 2 ? 'bg-yellow-500' : 'bg-slate-500') + : 'bg-slate-700' + )} /> + ))} +
+
+ ) + })} +
+ ) +} + +// ── Temporal Row ─────────────────────────────────────────────────────────────── + +function TemporalRow({ + level, ev, quotes, macro, calEvents, refDate, +}: { + level: keyof typeof LEVEL + ev: MarketEvent | null + quotes: Record | null + macro: MacroRegime | null + calEvents: CalEvent[] | null + refDate: string +}) { + const cfg = LEVEL[level] + + return ( +
+ {/* Row header */} +
+ + {cfg.badge} + + {cfg.label} + {ev && ( + <> + · + {ev.name} + + + J+{daysSince(refDate, ev.start_date)} depuis {ev.start_date} + + + )} + {!ev && ( + Aucun événement actif )}
- {ctx?.has_commentary && ( -
- Commentaires IA disponibles → voir Timeline -
- )} -
- ) -} - -// Pillar 2 — Prix & Marchés -const KEY_SYMBOLS = ['SPY', 'QQQ', 'GLD', 'USO', 'TLT', 'UUP'] - -function QuoteRow({ symbol, quote }: { symbol: string; quote?: Quote }) { - if (!quote) return ( -
- {symbol} - -
- ) - const up = quote.change_pct > 0.1 - const dn = quote.change_pct < -0.1 - return ( -
- {quote.name || symbol} -
- {quote.price?.toFixed(2)} - - {up ? : dn ? : } - {quote.change_pct > 0 ? '+' : ''}{quote.change_pct?.toFixed(2)}% - -
-
- ) -} - -function PillarMarkets({ quotes }: { quotes: Record | null }) { - return ( -
-
-
- - Prix & Marchés -
- - Marchés - -
- -
- {KEY_SYMBOLS.map(sym => ( - - ))} -
- - - - COT & Forward Curves → Specialist Desks - -
- ) -} - -// Pillar 3 — Régimes Macro -const REGIME_LABELS: Record = { - risk_on: { label: 'Risk On', color: 'text-emerald-400' }, - risk_off: { label: 'Risk Off', color: 'text-red-400' }, - stagflation: { label: 'Stagflation', color: 'text-orange-400' }, - goldilocks: { label: 'Goldilocks', color: 'text-emerald-300' }, - recession: { label: 'Récession', color: 'text-red-500' }, - reflation: { label: 'Reflation', color: 'text-yellow-400' }, - incertain: { label: 'Incertain', color: 'text-slate-400' }, -} - -function PillarMacro({ macro }: { macro: MacroRegime | null }) { - const dominant = macro?.scenarios?.dominant ?? 'incertain' - const cfg = REGIME_LABELS[dominant] ?? { label: dominant, color: 'text-slate-400' } - const scores = macro?.scenarios?.scores ?? {} - const top3 = Object.entries(scores).sort((a, b) => b[1] - a[1]).slice(0, 4) - - const KEY_GAUGES = ['vix', 'dxy', 'us10y', 'credit_spread'] - - return ( -
-
-
- - Régimes Macro -
- - Macro - -
- - {/* Dominant regime */} -
-
Régime dominant
-
{cfg.label}
-
- - {/* Top scenarios */} -
- {top3.map(([name, score]) => { - const r = REGIME_LABELS[name] ?? { label: name, color: 'text-slate-400' } - const pct = Math.round((score as number) * 100) - return ( -
- {r.label} -
-
60 ? 'bg-amber-500' : pct > 30 ? 'bg-amber-700' : 'bg-slate-600')} - style={{ width: `${pct}%` }} /> -
- {pct}% -
- ) - })} -
- - {/* Key gauges */} -
- {KEY_GAUGES.map(k => { - const g = macro?.gauges?.[k] - if (!g) return null - const up = (g.change_pct ?? 0) > 0 - const dn = (g.change_pct ?? 0) < 0 - return ( -
- {g.label} - - {g.value?.toFixed(g.value > 10 ? 1 : 2) ?? '—'} - -
- ) - })} -
-
- ) -} - -// Pillar 4 — Calendrier Économique -function PillarCalendar({ events }: { events: CalEvent[] | null }) { - const upcoming = (events ?? []) - .filter(e => e.date >= todayStr()) - .sort((a, b) => a.date.localeCompare(b.date)) - .slice(0, 5) - - const recent = (events ?? []) - .filter(e => e.date < todayStr()) - .sort((a, b) => b.date.localeCompare(a.date)) - .slice(0, 3) - - const importanceColor = (n: number) => - n >= 3 ? 'text-red-400' : n >= 2 ? 'text-yellow-400' : 'text-slate-500' - - return ( -
-
-
- - Calendrier Économique -
-
- - Rapports - - - Calendrier - -
-
- - {/* Upcoming */} -
-
À venir
-
- {upcoming.length === 0 &&
Aucun événement
} - {upcoming.map((ev, i) => ( -
-
- - {fmtDate(ev.date)} -
- - {ev.currency && [{ev.currency}]} - {ev.title} - -
- {[1, 2, 3].map(n => ( -
- ))} -
-
- ))} -
-
- - {/* Recent */} - {recent.length > 0 && ( -
-
Récents
-
- {recent.map((ev, i) => ( -
- {fmtDate(ev.date)} - - {ev.currency && [{ev.currency}]} - {ev.title} - -
- ))} + {/* Row body: 4 columns */} +
+ {/* Pilier 1: Géopolitique */} +
+
+ + Géopolitique + + +
+
- )} + + {/* Pilier 2: Prix & Marchés */} +
+
+ + Prix & Marchés + + + +
+ +
+ + {/* Pilier 3: Régimes Macro */} +
+
+ + Régimes Macro + + + +
+ +
+ + {/* Pilier 4: Calendrier */} +
+
+ + Calendrier + + + +
+ +
+
) } -// ── Main component ───────────────────────────────────────────────────────────── +// ── Main ─────────────────────────────────────────────────────────────────────── export default function ExternalSnapshot() { const [refDate, setRefDate] = useState(todayStr()) @@ -322,25 +384,18 @@ export default function ExternalSnapshot() { const [quotes, setQuotes] = useState | null>(null) const [macro, setMacro] = useState(null) const [calEvents, setCalEvents] = useState(null) - const [loadingCtx, setLoadingCtx] = useState(false) - // Fetch temporal context when date changes useEffect(() => { - setLoadingCtx(true) api.get(`/timeline/day/${refDate}`) .then(r => setCtx(r.data)) - .catch(() => {}) - .finally(() => setLoadingCtx(false)) + .catch(() => setCtx(null)) }, [refDate]) - // Fetch live data once (always current) useEffect(() => { api.get('/market/quotes').then(r => { - // Flatten: quotes is Record → flatten to Record const flat: Record = {} - for (const arr of Object.values(r.data as Record)) { + for (const arr of Object.values(r.data as Record)) for (const q of arr) flat[q.symbol] = q - } setQuotes(flat) }).catch(() => {}) @@ -353,70 +408,66 @@ export default function ExternalSnapshot() { if (next <= todayStr()) setRefDate(next) } - return ( -
- {/* Header */} -
-

- - Snapshot Externe -

-

- Vue d'ensemble des 4 piliers du contexte externe — {fmtDate(refDate)} -

-
+ const evLong = ctx?.events?.long ?? null + const evMedium = ctx?.events?.medium ?? null + const evShort = ctx?.events?.short ?? null - {/* Date navigator */} -
-
-
- - - -
-
- setRefDate(e.target.value)} - className="bg-dark-700 border border-slate-700/40 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-slate-500" - /> - -
-
- {refDate < todayStr() && ( - Piliers 2-3-4 en données live - )} -
+ return ( +
+ {/* Header */} +
+
+

+ + Snapshot Externe +

+

+ 3 temporalités × 4 piliers — vision situationnelle complète +

+
+
+ + + + setRefDate(e.target.value)} + className="bg-dark-800 border border-slate-700/40 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-slate-500" + /> + +
- {/* 4 pillar grid */} -
- - - - + {/* Column headers */} +
+
Géopolitique
+
Prix & Marchés
+
Régimes Macro
+
Calendrier
- {/* Links bar */} -
- Navigation rapide : + {/* 3 temporal rows */} + + + + + {/* Footer links */} +
+ Voir aussi : {[ - { to: '/timeline', label: 'Timeline' }, - { to: '/geo', label: 'Geo Radar' }, - { to: '/markets', label: 'Marchés' }, - { to: '/macro', label: 'Macro' }, - { to: '/calendar', label: 'Calendrier' }, - { to: '/institutional', label: 'Rapports' }, - { to: '/specialist-desks', label: 'Desks' }, - ].map(({ to, label }) => ( - - {label} + ['/timeline', 'Timeline'], + ['/specialist-desks','COT & Curves'], + ['/geo', 'Geo Radar'], + ['/institutional', 'Rapports'], + ].map(([to, label]) => ( + + {label} ))}