Frise chronologique:
- Sub-lane stacking (assignSubLanes) — overlapping events se décalent verticalement
- Zone d'overlap semi-transparente sur la période commune entre 2 événements
- Hauteur dynamique selon nb de sub-lanes par niveau
- Événements en cours avec flèche ▶ à droite, gradient de fin
- Tri par start_date pour placement greedy
Event Manager (composant EventManager.tsx):
- Tableau filtrable par niveau (Long/Moyen/Court)
- Edit modal complet : tous les champs + absorption_pct éditable
- Bouton "IA — Enrichir" par événement → POST /api/timeline/events/{id}/ai-enrich
→ GPT-4o-mini suggère absorption_pct + indicateurs pertinents par niveau temporel
- Delete avec confirmation double-clic
- Expand row pour voir description + indicateurs
- Intégré Timeline page via bouton "Gérer événements"
Backend:
- Nouvelles colonnes market_events: absorption_pct + relevant_indicators (ALTER idempotent)
- DELETE /api/timeline/events/{id}
- POST /api/timeline/events/{id}/ai-enrich
Snapshot Externe:
- AbsorptionBar par événement dans cellule Géopolitique
- MA indicators : fetch 200j history, compute MA10/MA20/MA100 per level (short/med/long)
- Affichage prix vs MA + % écart dans CellMarkets
- Si relevant_indicators configurés sur l'event → utilise ces symbols au lieu des défauts
- Calendar : horizons exclusifs (short 0-7j, medium 8-30j, long 31-90j) — bug corrigé
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
584 lines
24 KiB
TypeScript
584 lines
24 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import axios from 'axios'
|
||
import clsx from 'clsx'
|
||
import {
|
||
Globe, BarChart2, Activity, Calendar,
|
||
ArrowRight, Clock, TrendingUp, TrendingDown, Minus,
|
||
} from 'lucide-react'
|
||
|
||
const api = axios.create({ baseURL: '/api' })
|
||
|
||
function todayStr() { return new Date().toISOString().split('T')[0] }
|
||
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)
|
||
}
|
||
function offsetDate(d: string, days: number) {
|
||
const dt = new Date(d); dt.setDate(dt.getDate() + days)
|
||
return dt.toISOString().split('T')[0]
|
||
}
|
||
|
||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||
|
||
interface MarketEvent {
|
||
id: number; name: string; start_date: string; end_date: string | null
|
||
level: string; category: string; description: string; market_impact: string
|
||
affected_assets: string; impact_score: number
|
||
}
|
||
|
||
interface DayContext {
|
||
ref_date: string
|
||
events: { long: MarketEvent | null; medium: MarketEvent | null; short: MarketEvent | null }
|
||
has_commentary: boolean
|
||
long_commentary: string; medium_commentary: string; short_commentary: string
|
||
}
|
||
|
||
interface Quote { symbol: string; price: number; change_pct: number; name?: string }
|
||
|
||
interface MacroRegime {
|
||
scenarios: { dominant: string; scores: Record<string, number> }
|
||
gauges: Record<string, { value: number | null; label: string; change_pct?: number | null }>
|
||
}
|
||
|
||
interface CalEvent { title: string; date: string; importance: number; currency?: string }
|
||
|
||
// ── Config ─────────────────────────────────────────────────────────────────────
|
||
|
||
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<string, string> = {
|
||
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',
|
||
}
|
||
|
||
// Assets relevant to each temporal level — fallback to standard set
|
||
const LEVEL_ASSETS: Record<string, string[]> = {
|
||
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<string, string> = {
|
||
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 AbsorptionBar({ pct }: { pct: number | null | undefined }) {
|
||
if (pct === null || pct === undefined) return null
|
||
const color = pct >= 80 ? 'bg-red-500' : pct >= 40 ? 'bg-yellow-500' : 'bg-emerald-500'
|
||
const label = pct >= 80 ? 'Fully priced' : pct >= 40 ? 'Partial' : 'Not priced'
|
||
return (
|
||
<div className="mt-2">
|
||
<div className="flex items-center justify-between text-xs mb-0.5">
|
||
<span className="text-slate-600">Absorption marché</span>
|
||
<span className={clsx('font-medium', pct >= 80 ? 'text-red-400' : pct >= 40 ? 'text-yellow-400' : 'text-emerald-400')}>
|
||
{pct.toFixed(0)}% — {label}
|
||
</span>
|
||
</div>
|
||
<div className="h-1 bg-dark-700 rounded-full overflow-hidden">
|
||
<div className={clsx('h-1 rounded-full transition-all', color)} style={{ width: `${Math.min(100, pct)}%` }} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CellGeo({
|
||
ev, level, refDate,
|
||
}: {
|
||
ev: MarketEvent | null; level: keyof typeof LEVEL; refDate: string
|
||
}) {
|
||
const cfg = LEVEL[level]
|
||
if (!ev) return (
|
||
<div className="flex items-center justify-center h-full text-xs text-slate-600 italic p-3">
|
||
Aucun événement {cfg.label.toLowerCase()} actif
|
||
</div>
|
||
)
|
||
const days = daysSince(refDate, ev.start_date)
|
||
const absorption = (ev as any).absorption_pct as number | null
|
||
return (
|
||
<div className="p-3 space-y-2 h-full">
|
||
<div className={clsx('text-sm font-bold leading-tight', cfg.accent)}>{ev.name}</div>
|
||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||
<Clock className="w-3 h-3 shrink-0" />
|
||
<span>{ev.start_date}</span>
|
||
<span className={clsx('font-semibold', cfg.accent)}>J+{days}</span>
|
||
{!ev.end_date && <span className="text-amber-500/70">en cours</span>}
|
||
</div>
|
||
<p className="text-xs text-slate-400 leading-relaxed line-clamp-3">{ev.description}</p>
|
||
{ev.market_impact && (
|
||
<p className="text-xs text-slate-600 italic line-clamp-2">{ev.market_impact}</p>
|
||
)}
|
||
<AbsorptionBar pct={absorption} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// MA period per temporal level
|
||
const MA_PERIOD: Record<string, number> = { long: 100, medium: 20, short: 10 }
|
||
|
||
function CellMarkets({
|
||
ev, level, quotes, maData,
|
||
}: {
|
||
ev: MarketEvent | null; level: string; quotes: Record<string, Quote> | null
|
||
maData: Record<string, number | null>
|
||
}) {
|
||
// Use event's relevant_indicators if configured
|
||
let configuredInds: { symbol: string; indicator: string; label: string }[] = []
|
||
if (ev) {
|
||
try { configuredInds = JSON.parse((ev as any).relevant_indicators || '[]') } catch { /**/ }
|
||
}
|
||
|
||
const assets = configuredInds.length >= 2
|
||
? configuredInds.map(i => i.symbol).slice(0, 4)
|
||
: relevantAssets(ev, level)
|
||
|
||
const maPeriod = MA_PERIOD[level] ?? 20
|
||
const maLabel = `MA${maPeriod}`
|
||
|
||
return (
|
||
<div className="p-3 space-y-1.5 h-full">
|
||
{/* MA context label */}
|
||
<div className="text-xs text-slate-600 mb-1">
|
||
{configuredInds.length >= 2 ? 'Indicateurs configurés' : `Prix spot + ${maLabel}`}
|
||
</div>
|
||
{assets.map(sym => {
|
||
const q = quotes?.[sym]
|
||
if (!q) return (
|
||
<div key={sym} className="flex items-center justify-between py-0.5">
|
||
<span className="text-xs text-slate-600">{sym}</span>
|
||
<span className="text-xs text-slate-700">—</span>
|
||
</div>
|
||
)
|
||
const up = q.change_pct > 0.1
|
||
const dn = q.change_pct < -0.1
|
||
const ma = maData[sym]
|
||
const maVsPct = ma && q.price ? ((q.price - ma) / ma * 100) : null
|
||
const maUp = (maVsPct ?? 0) > 0
|
||
return (
|
||
<div key={sym} className="py-0.5">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-slate-400 truncate max-w-[7rem]">{q.name || sym}</span>
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
<span className="text-xs text-slate-300 tabular-nums">{q.price?.toFixed(q.price > 100 ? 1 : 2)}</span>
|
||
<span className={clsx(
|
||
'text-xs flex items-center gap-0.5 tabular-nums',
|
||
up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-500'
|
||
)}>
|
||
{up ? <TrendingUp className="w-2.5 h-2.5" /> : dn ? <TrendingDown className="w-2.5 h-2.5" /> : <Minus className="w-2.5 h-2.5" />}
|
||
{q.change_pct > 0 ? '+' : ''}{q.change_pct?.toFixed(2)}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{ma !== undefined && ma !== null && (
|
||
<div className="flex items-center justify-between mt-0.5">
|
||
<span className="text-xs text-slate-600">{maLabel} {ma.toFixed(ma > 100 ? 1 : 2)}</span>
|
||
<span className={clsx('text-xs tabular-nums', maUp ? 'text-emerald-500/70' : 'text-red-500/70')}>
|
||
{maVsPct !== null ? `${maUp ? '+' : ''}${maVsPct.toFixed(1)}%` : ''}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="p-3 space-y-2.5 h-full">
|
||
<div className={clsx('text-sm font-bold', cfg.accent)}>{dominantLabel}</div>
|
||
<div className="space-y-1.5">
|
||
{top3.map(([name, score]) => {
|
||
const pct = Math.min(100, Math.round((Number(score) / maxScore) * 100))
|
||
const label = REGIME_LABELS[name] ?? name
|
||
return (
|
||
<div key={name} className="flex items-center gap-2">
|
||
<span className="text-xs text-slate-500 w-20 truncate">{label}</span>
|
||
<div className="flex-1 bg-dark-700 rounded-full h-1.5 overflow-hidden">
|
||
<div
|
||
className={clsx('h-1.5 rounded-full transition-all', cfg.dot)}
|
||
style={{ width: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-xs text-slate-600 w-7 text-right tabular-nums">{pct}%</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
<div className="flex gap-2 flex-wrap pt-1 border-t border-slate-700/20">
|
||
{gaugeKeys.map(k => {
|
||
const g = macro?.gauges?.[k]
|
||
if (!g?.value) return null
|
||
const up = (g.change_pct ?? 0) > 0
|
||
return (
|
||
<div key={k} className="flex items-center gap-1 bg-dark-900/40 rounded px-2 py-1">
|
||
<span className="text-xs text-slate-500">{g.label}</span>
|
||
<span className={clsx('text-xs font-medium tabular-nums', up ? 'text-emerald-400' : 'text-red-400')}>
|
||
{g.value?.toFixed(g.value > 10 ? 1 : 2)}
|
||
</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Calendar events filtered by EXCLUSIVE temporal horizon ranges
|
||
function calForLevel(events: CalEvent[], level: string, refDate: string): CalEvent[] {
|
||
// Exclusive ranges so each level shows different events:
|
||
// short = 0-7 days ahead
|
||
// medium = 8-30 days ahead
|
||
// long = 31-90 days ahead
|
||
const ranges: Record<string, [number, number]> = {
|
||
short: [0, 7],
|
||
medium: [8, 30],
|
||
long: [31, 90],
|
||
}
|
||
const [from, to] = ranges[level] ?? [0, 30]
|
||
return events
|
||
.filter(e => {
|
||
const d = e.date
|
||
return d >= offsetDate(refDate, from) && d <= offsetDate(refDate, to)
|
||
})
|
||
.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 (
|
||
<div className="p-3 space-y-1.5 h-full">
|
||
<div className="text-xs text-slate-600 mb-2">Horizon {horizon}</div>
|
||
{items.length === 0 && (
|
||
<div className="text-xs text-slate-700 italic">Aucun événement</div>
|
||
)}
|
||
{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 (
|
||
<div key={i} className="flex items-start gap-2">
|
||
<span className="text-xs text-slate-600 shrink-0 w-14">{fmtShort(ev.date)}</span>
|
||
<span className={clsx('text-xs truncate flex-1', impColor)}>
|
||
{ev.currency && <span className="text-slate-600 mr-1">[{ev.currency}]</span>}
|
||
{ev.title}
|
||
</span>
|
||
<div className="flex gap-0.5 shrink-0 mt-0.5">
|
||
{[1, 2, 3].map(n => (
|
||
<div key={n} className={clsx(
|
||
'w-1 h-1 rounded-full',
|
||
n <= imp
|
||
? (imp >= 3 ? 'bg-red-500' : imp >= 2 ? 'bg-yellow-500' : 'bg-slate-500')
|
||
: 'bg-slate-700'
|
||
)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Temporal Row ───────────────────────────────────────────────────────────────
|
||
|
||
function TemporalRow({
|
||
level, ev, quotes, macro, calEvents, refDate, maData,
|
||
}: {
|
||
level: keyof typeof LEVEL
|
||
ev: MarketEvent | null
|
||
quotes: Record<string, Quote> | null
|
||
macro: MacroRegime | null
|
||
calEvents: CalEvent[] | null
|
||
refDate: string
|
||
maData: Record<string, number | null>
|
||
}) {
|
||
const cfg = LEVEL[level]
|
||
|
||
return (
|
||
<div className={clsx('rounded-xl border overflow-hidden', cfg.ring)}>
|
||
{/* Row header */}
|
||
<div className={clsx('flex items-center gap-3 px-4 py-2.5 border-b', cfg.header)}>
|
||
<span className={clsx('text-xs font-bold px-2 py-0.5 rounded', cfg.badgeCls)}>
|
||
{cfg.badge}
|
||
</span>
|
||
<span className={clsx('text-sm font-semibold', cfg.accent)}>{cfg.label}</span>
|
||
{ev && (
|
||
<>
|
||
<span className="text-slate-600">·</span>
|
||
<span className="text-sm text-slate-300 font-medium">{ev.name}</span>
|
||
<span className="text-xs text-slate-500 ml-auto flex items-center gap-1">
|
||
<Clock className="w-3 h-3" />
|
||
J+{daysSince(refDate, ev.start_date)} depuis {ev.start_date}
|
||
</span>
|
||
</>
|
||
)}
|
||
{!ev && (
|
||
<span className="text-xs text-slate-600 italic ml-2">Aucun événement actif</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Row body: 4 columns */}
|
||
<div className={clsx('grid grid-cols-4 divide-x divide-slate-700/30', cfg.bg)}>
|
||
{/* Pilier 1: Géopolitique */}
|
||
<div className="flex flex-col">
|
||
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-slate-700/20">
|
||
<Globe className="w-3 h-3 text-slate-500" />
|
||
<span className="text-xs text-slate-500 font-medium">Géopolitique</span>
|
||
<Link to={`/timeline?date=${refDate}`} className={clsx('ml-auto text-xs flex items-center gap-0.5 hover:opacity-100 opacity-50', cfg.accent)}>
|
||
<ArrowRight className="w-3 h-3" />
|
||
</Link>
|
||
</div>
|
||
<CellGeo ev={ev} level={level} refDate={refDate} />
|
||
</div>
|
||
|
||
{/* Pilier 2: Prix & Marchés */}
|
||
<div className="flex flex-col">
|
||
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-slate-700/20">
|
||
<BarChart2 className="w-3 h-3 text-slate-500" />
|
||
<span className="text-xs text-slate-500 font-medium">Prix & Marchés</span>
|
||
<Link to="/markets" className="ml-auto text-xs text-slate-600 hover:text-slate-400 flex items-center gap-0.5">
|
||
<ArrowRight className="w-3 h-3" />
|
||
</Link>
|
||
</div>
|
||
<CellMarkets ev={ev} level={level} quotes={quotes} maData={maData} />
|
||
</div>
|
||
|
||
{/* Pilier 3: Régimes Macro */}
|
||
<div className="flex flex-col">
|
||
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-slate-700/20">
|
||
<Activity className="w-3 h-3 text-slate-500" />
|
||
<span className="text-xs text-slate-500 font-medium">Régimes Macro</span>
|
||
<Link to="/macro" className="ml-auto text-xs text-slate-600 hover:text-slate-400 flex items-center gap-0.5">
|
||
<ArrowRight className="w-3 h-3" />
|
||
</Link>
|
||
</div>
|
||
<CellMacro level={level} macro={macro} />
|
||
</div>
|
||
|
||
{/* Pilier 4: Calendrier */}
|
||
<div className="flex flex-col">
|
||
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-slate-700/20">
|
||
<Calendar className="w-3 h-3 text-slate-500" />
|
||
<span className="text-xs text-slate-500 font-medium">Calendrier</span>
|
||
<Link to="/calendar" className="ml-auto text-xs text-slate-600 hover:text-slate-400 flex items-center gap-0.5">
|
||
<ArrowRight className="w-3 h-3" />
|
||
</Link>
|
||
</div>
|
||
<CellCalendar level={level} events={calEvents} refDate={refDate} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Main ───────────────────────────────────────────────────────────────────────
|
||
|
||
// Compute simple moving average from array of closes (most recent last)
|
||
function computeMA(closes: number[], period: number): number | null {
|
||
if (closes.length < period) return null
|
||
const slice = closes.slice(-period)
|
||
return slice.reduce((a, b) => a + b, 0) / period
|
||
}
|
||
|
||
export default function ExternalSnapshot() {
|
||
const [refDate, setRefDate] = useState(todayStr())
|
||
const [ctx, setCtx] = useState<DayContext | null>(null)
|
||
const [quotes, setQuotes] = useState<Record<string, Quote> | null>(null)
|
||
const [macro, setMacro] = useState<MacroRegime | null>(null)
|
||
const [calEvents, setCalEvents] = useState<CalEvent[] | null>(null)
|
||
// maData[level][symbol] = MA value
|
||
const [maData, setMaData] = useState<Record<string, Record<string, number | null>>>({
|
||
long: {}, medium: {}, short: {},
|
||
})
|
||
|
||
useEffect(() => {
|
||
api.get(`/timeline/day/${refDate}`)
|
||
.then(r => setCtx(r.data))
|
||
.catch(() => setCtx(null))
|
||
}, [refDate])
|
||
|
||
useEffect(() => {
|
||
api.get('/market/quotes').then(r => {
|
||
const flat: Record<string, Quote> = {}
|
||
for (const arr of Object.values(r.data as Record<string, Quote[]>))
|
||
for (const q of arr) flat[q.symbol] = q
|
||
setQuotes(flat)
|
||
}).catch(() => {})
|
||
|
||
api.get('/market/macro-regime').then(r => setMacro(r.data)).catch(() => {})
|
||
api.get('/geo/calendar').then(r => setCalEvents(r.data)).catch(() => {})
|
||
|
||
// Fetch MA data for key symbols (200 days history)
|
||
const KEY_SYMS = ['SPY', 'GLD', 'USO', 'TLT', 'UUP', 'QQQ']
|
||
const MA_PERIODS = { long: 100, medium: 20, short: 10 }
|
||
Promise.allSettled(
|
||
KEY_SYMS.map(sym =>
|
||
api.get(`/market/history/${sym}`, { params: { days: 200 } })
|
||
.then(r => {
|
||
const candles: { close: number }[] = Array.isArray(r.data) ? r.data : []
|
||
const closes = candles.map(c => c.close).filter(Boolean)
|
||
const result: Record<string, Record<string, number | null>> = {
|
||
long: {}, medium: {}, short: {},
|
||
}
|
||
for (const [level, period] of Object.entries(MA_PERIODS)) {
|
||
result[level][sym] = computeMA(closes, period as number)
|
||
}
|
||
return result
|
||
})
|
||
)
|
||
).then(results => {
|
||
const merged: Record<string, Record<string, number | null>> = { long: {}, medium: {}, short: {} }
|
||
for (const res of results) {
|
||
if (res.status === 'fulfilled') {
|
||
for (const level of ['long', 'medium', 'short']) {
|
||
Object.assign(merged[level], res.value[level])
|
||
}
|
||
}
|
||
}
|
||
setMaData(merged)
|
||
})
|
||
}, [])
|
||
|
||
const navigate = (days: number) => {
|
||
const next = offsetDate(refDate, days)
|
||
if (next <= todayStr()) setRefDate(next)
|
||
}
|
||
|
||
const evLong = ctx?.events?.long ?? null
|
||
const evMedium = ctx?.events?.medium ?? null
|
||
const evShort = ctx?.events?.short ?? null
|
||
|
||
return (
|
||
<div className="p-6 space-y-4 max-w-7xl mx-auto">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||
<Globe className="w-5 h-5 text-slate-400" />
|
||
Snapshot Externe
|
||
</h1>
|
||
<p className="text-sm text-slate-500 mt-0.5">
|
||
3 temporalités × 4 piliers — vision situationnelle complète
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button onClick={() => navigate(-30)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-1M</button>
|
||
<button onClick={() => navigate(-7)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-7J</button>
|
||
<button onClick={() => navigate(-1)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-1J</button>
|
||
<input
|
||
type="date"
|
||
value={refDate}
|
||
min="2020-01-01"
|
||
max={todayStr()}
|
||
onChange={e => 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"
|
||
/>
|
||
<button onClick={() => setRefDate(todayStr())} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">
|
||
Aujourd'hui
|
||
</button>
|
||
<button onClick={() => navigate(1)} disabled={refDate >= todayStr()} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded disabled:opacity-20">+1J</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Column headers */}
|
||
<div className="grid grid-cols-4 gap-0 text-xs text-slate-600 uppercase tracking-wider px-1">
|
||
<div className="flex items-center gap-1.5"><Globe className="w-3 h-3" /> Géopolitique</div>
|
||
<div className="flex items-center gap-1.5"><BarChart2 className="w-3 h-3" /> Prix & Marchés</div>
|
||
<div className="flex items-center gap-1.5"><Activity className="w-3 h-3" /> Régimes Macro</div>
|
||
<div className="flex items-center gap-1.5"><Calendar className="w-3 h-3" /> Calendrier</div>
|
||
</div>
|
||
|
||
{/* 3 temporal rows */}
|
||
<TemporalRow level="long" ev={evLong} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} maData={maData.long} />
|
||
<TemporalRow level="medium" ev={evMedium} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} maData={maData.medium} />
|
||
<TemporalRow level="short" ev={evShort} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} maData={maData.short} />
|
||
|
||
{/* Footer links */}
|
||
<div className="flex items-center gap-4 text-xs text-slate-600 pt-2">
|
||
<span>Voir aussi :</span>
|
||
{[
|
||
['/timeline', 'Timeline'],
|
||
['/specialist-desks','COT & Curves'],
|
||
['/geo', 'Geo Radar'],
|
||
['/institutional', 'Rapports'],
|
||
].map(([to, label]) => (
|
||
<Link key={to} to={to} className="hover:text-slate-300 flex items-center gap-1">
|
||
{label} <ArrowRight className="w-3 h-3" />
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|