feat: FRED bootstrap + Calendar page complete rebuild
- backend/services/fred_bootstrap.py: fetch 11 FRED series (PAYEMS, UNRATE, CPI, PCE, FEDFUNDS, ICSA, GDP, HY spread, T10Y2Y, T10Y3M) from public CSV endpoint — no API key needed; computes rolling z-scores and upserts into economic_events table - backend/routers/eco.py: new /api/eco router with bootstrap (POST + status GET), events list with full filtering (date range, category, series, min z-score, direction, sort/pagination), series catalog, and db status endpoints - backend/main.py: register eco router - frontend/src/pages/CalendarPage.tsx: complete rewrite — real data table from /api/eco/events, Bootstrap FRED button with live polling, filter bar (date range, category, series chips, |z| threshold, direction), sort by date/z-score/series, pagination, z-score badges with color coding, sidebar with series inventory + geo alerts + z-score guide Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,208 +1,623 @@
|
||||
import { useCalendar, useGeoNews } from '../hooks/useApi'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useGeoNews } from '../hooks/useApi'
|
||||
import clsx from 'clsx'
|
||||
import { Calendar, Clock, Globe, AlertTriangle } from 'lucide-react'
|
||||
import type { EconomicEvent, AssetClass } from '../types'
|
||||
import { format, parseISO, isAfter, isBefore, addDays } from 'date-fns'
|
||||
import {
|
||||
Calendar, RefreshCw, AlertTriangle, TrendingUp, TrendingDown,
|
||||
Minus, ChevronUp, ChevronDown, Download,
|
||||
} from 'lucide-react'
|
||||
|
||||
const ASSET_EMOJIS: Record<string, string> = {
|
||||
energy: '⛽', metals: '🥇', agriculture: '🌾', equities: '📈',
|
||||
indices: '📊', forex: '💱', rates: '🏦',
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface EcoEvent {
|
||||
id: number
|
||||
event_name: string
|
||||
series_id: string
|
||||
event_date: string
|
||||
actual_value: number | null
|
||||
actual_unit: string
|
||||
forecast_value: number | null
|
||||
previous_value: number | null
|
||||
surprise_pct: number | null
|
||||
surprise_zscore: number | null
|
||||
surprise_direction: string | null
|
||||
assets_impacted: string[]
|
||||
source: string
|
||||
fetched_at: string
|
||||
}
|
||||
|
||||
const COUNTRY_FLAGS: Record<string, string> = {
|
||||
US: '🇺🇸', EU: '🇪🇺', CN: '🇨🇳', JP: '🇯🇵', GB: '🇬🇧',
|
||||
DE: '🇩🇪', FR: '🇫🇷', Global: '🌍',
|
||||
interface EcoStatus {
|
||||
total: number
|
||||
earliest: string | null
|
||||
latest: string | null
|
||||
by_series: { series_id: string; cnt: number; latest: string }[]
|
||||
}
|
||||
|
||||
const IMPORTANCE_CONFIG: Record<string, { color: string; label: string; dots: number }> = {
|
||||
high: { color: 'text-red-400 border-red-700/40', label: 'Major', dots: 3 },
|
||||
medium: { color: 'text-yellow-400 border-yellow-700/40', label: 'Moderate', dots: 2 },
|
||||
low: { color: 'text-slate-400 border-slate-700/40', label: 'Minor', dots: 1 },
|
||||
interface SeriesMeta {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
freq: string
|
||||
unit: string
|
||||
assets: string[]
|
||||
}
|
||||
|
||||
function EventCard({ ev }: { ev: EconomicEvent }) {
|
||||
const cfg = IMPORTANCE_CONFIG[ev.importance]
|
||||
const isPast = ev.actual !== undefined && ev.actual !== null
|
||||
const today = new Date()
|
||||
const evDate = parseISO(ev.date)
|
||||
const isToday = format(evDate, 'yyyy-MM-dd') === format(today, 'yyyy-MM-dd')
|
||||
const isSoon = !isToday && isAfter(evDate, today) && isBefore(evDate, addDays(today, 3))
|
||||
interface BootstrapStatus {
|
||||
running: boolean
|
||||
last_result: Record<string, { status: string; inserted: number; skipped: number }> | { error: string } | null
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'employment', label: 'Emploi' },
|
||||
{ id: 'inflation', label: 'Inflation' },
|
||||
{ id: 'growth', label: 'Croissance' },
|
||||
{ id: 'monetary', label: 'Monétaire' },
|
||||
{ id: 'credit', label: 'Crédit' },
|
||||
{ id: 'rates', label: 'Taux' },
|
||||
]
|
||||
|
||||
const DIRECTIONS = [
|
||||
{ id: '', label: 'Tous' },
|
||||
{ id: 'bullish', label: 'Bullish' },
|
||||
{ id: 'bearish', label: 'Bearish' },
|
||||
{ id: 'neutral', label: 'Neutre' },
|
||||
]
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ id: 'date', label: 'Date' },
|
||||
{ id: 'zscore', label: '|Z-Score|' },
|
||||
{ id: 'series', label: 'Série' },
|
||||
]
|
||||
|
||||
const API_BASE = 'http://localhost:8000'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(v: number | null, decimals = 2, unit = ''): string {
|
||||
if (v === null || v === undefined) return '—'
|
||||
const s = Math.abs(v) < 100 ? v.toFixed(decimals) : v.toLocaleString('fr-FR', { maximumFractionDigits: 0 })
|
||||
return unit ? `${s} ${unit}` : s
|
||||
}
|
||||
|
||||
function ZBadge({ z, dir }: { z: number | null; dir: string | null }) {
|
||||
if (z === null || z === undefined) return <span className="text-slate-600">—</span>
|
||||
const abs = Math.abs(z)
|
||||
const sign = z > 0 ? '+' : ''
|
||||
const isBull = dir === 'bullish'
|
||||
const isBear = dir === 'bearish'
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-bold border',
|
||||
isBull ? 'text-emerald-400 border-emerald-700/40 bg-emerald-900/10'
|
||||
: isBear ? 'text-red-400 border-red-700/40 bg-red-900/10'
|
||||
: 'text-slate-400 border-slate-700/30',
|
||||
)}>
|
||||
{sign}{z.toFixed(2)}{abs >= 1.5 ? ' ⚡' : abs >= 2.5 ? ' 🔥' : ''}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DirBadge({ dir }: { dir: string | null }) {
|
||||
if (!dir || dir === 'neutral') return <span className="text-slate-600 text-xs">—</span>
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-0.5 text-xs font-semibold',
|
||||
dir === 'bullish' ? 'text-emerald-400' : 'text-red-400',
|
||||
)}>
|
||||
{dir === 'bullish' ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||||
{dir === 'bullish' ? 'Bull' : 'Bear'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SurprisePct({ v }: { v: number | null }) {
|
||||
if (v === null || v === undefined) return <span className="text-slate-600">—</span>
|
||||
const sign = v > 0 ? '+' : ''
|
||||
return (
|
||||
<span className={clsx('text-xs font-mono', v > 0 ? 'text-emerald-400' : v < 0 ? 'text-red-400' : 'text-slate-400')}>
|
||||
{sign}{v.toFixed(1)}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Bootstrap modal ───────────────────────────────────────────────────────────
|
||||
|
||||
function BootstrapPanel({ onDone }: { onDone: () => void }) {
|
||||
const [fromDate, setFromDate] = useState('2020-01-01')
|
||||
const [force, setForce] = useState(false)
|
||||
const [bsStatus, setBsStatus] = useState<BootstrapStatus>({ running: false, last_result: null })
|
||||
const [polling, setPolling] = useState(false)
|
||||
const onDoneRef = useRef(onDone)
|
||||
useEffect(() => { onDoneRef.current = onDone }, [onDone])
|
||||
|
||||
const startBootstrap = async () => {
|
||||
const url = `${API_BASE}/api/eco/bootstrap?from_date=${fromDate}&force=${force}`
|
||||
await fetch(url, { method: 'POST' })
|
||||
setPolling(true)
|
||||
setBsStatus(s => ({ ...s, running: true }))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!polling) return
|
||||
const iv = setInterval(async () => {
|
||||
const r = await fetch(`${API_BASE}/api/eco/bootstrap/status`)
|
||||
const data: BootstrapStatus = await r.json()
|
||||
setBsStatus(data)
|
||||
if (!data.running) {
|
||||
setPolling(false)
|
||||
onDoneRef.current()
|
||||
}
|
||||
}, 2000)
|
||||
return () => clearInterval(iv)
|
||||
}, [polling])
|
||||
|
||||
const result = bsStatus.last_result
|
||||
const hasError = result && 'error' in result
|
||||
|
||||
return (
|
||||
<div className={clsx('card hover:border-slate-600/50 transition-colors', {
|
||||
'border-yellow-500/40 bg-yellow-900/5': isToday,
|
||||
'border-blue-500/30': isSoon && !isToday,
|
||||
'opacity-60': isPast,
|
||||
})}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-base">{COUNTRY_FLAGS[ev.country] ?? '🌍'}</span>
|
||||
<span className={clsx('text-xs font-bold uppercase tracking-wider', cfg.color.split(' ')[0])}>
|
||||
{'●'.repeat(cfg.dots)}
|
||||
</span>
|
||||
{isToday && <span className="badge badge-yellow text-xs">TODAY</span>}
|
||||
{isSoon && !isToday && <span className="badge badge-blue text-xs">SOON</span>}
|
||||
</div>
|
||||
<div className="text-sm text-white font-semibold">{ev.title}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{format(evDate, "EEEE, MMM d yyyy")} · {ev.country}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0 space-y-0.5">
|
||||
<div className={clsx('text-xs font-semibold', cfg.color.split(' ')[0])}>{cfg.label}</div>
|
||||
{ev.previous && <div className="text-xs text-slate-600">Prev: {ev.previous}</div>}
|
||||
{ev.forecast && <div className="text-xs text-slate-500">Fcst: {ev.forecast}</div>}
|
||||
{ev.actual && (
|
||||
<div className="text-xs text-emerald-400 font-bold">Actual: {ev.actual}</div>
|
||||
)}
|
||||
{ev.surprise_zscore !== undefined && Math.abs(ev.surprise_zscore) >= 0.8 && (
|
||||
<div className={clsx(
|
||||
'text-xs font-bold px-1.5 py-0.5 rounded border',
|
||||
ev.surprise_direction === 'bullish'
|
||||
? 'text-emerald-400 border-emerald-700/40 bg-emerald-900/20'
|
||||
: ev.surprise_direction === 'bearish'
|
||||
? 'text-red-400 border-red-700/40 bg-red-900/20'
|
||||
: 'text-slate-400 border-slate-700/40'
|
||||
)}>
|
||||
z={ev.surprise_zscore > 0 ? '+' : ''}{ev.surprise_zscore?.toFixed(1)} {
|
||||
Math.abs(ev.surprise_zscore) >= 1.5 ? '⚡' : ''
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card border-blue-500/30 p-4 space-y-3">
|
||||
<div className="text-xs font-semibold text-blue-400 flex items-center gap-1">
|
||||
<Download className="w-3 h-3" /> Bootstrap FRED
|
||||
</div>
|
||||
{ev.asset_impact && ev.asset_impact.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{ev.asset_impact.map(cls => (
|
||||
<span key={cls} className="text-xs bg-dark-700 text-slate-400 px-1.5 py-0.5 rounded border border-slate-700/40">
|
||||
{ASSET_EMOJIS[cls] ?? ''} {cls}
|
||||
</span>
|
||||
))}
|
||||
<div className="text-xs text-slate-400">
|
||||
Télécharge les données FRED (endpoint CSV public, sans clé API) depuis la date choisie.
|
||||
Séries : NFP, Chômage, CPI, Core CPI, Core PCE, FOMC, Jobless Claims, GDP, HY Spread, T10Y2Y, T10Y3M.
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 mb-0.5">Depuis</div>
|
||||
<input
|
||||
type="date" value={fromDate} onChange={e => setFromDate(e.target.value)}
|
||||
className="text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer mt-4">
|
||||
<input type="checkbox" checked={force} onChange={e => setForce(e.target.checked)}
|
||||
className="w-3 h-3 accent-blue-500" />
|
||||
Écraser existants
|
||||
</label>
|
||||
<button
|
||||
onClick={startBootstrap}
|
||||
disabled={bsStatus.running}
|
||||
className={clsx(
|
||||
'mt-4 px-3 py-1.5 rounded text-xs font-semibold transition-colors',
|
||||
bsStatus.running
|
||||
? 'bg-blue-900/40 text-blue-400 cursor-wait'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-white',
|
||||
)}
|
||||
>
|
||||
{bsStatus.running ? '⏳ En cours...' : 'Lancer'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bsStatus.running && (
|
||||
<div className="text-xs text-blue-300 animate-pulse">
|
||||
Téléchargement en cours (peut prendre 30–60s)…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !bsStatus.running && (
|
||||
hasError ? (
|
||||
<div className="text-xs text-red-400">Erreur : {'error' in result ? result.error : ''}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
{Object.entries(result as Record<string, { status: string; inserted: number; skipped: number; name: string }>).map(([sid, r]) => (
|
||||
<div key={sid} className={clsx(
|
||||
'text-xs px-2 py-1 rounded border',
|
||||
r.status === 'ok' ? 'border-emerald-700/30 bg-emerald-900/5 text-emerald-300'
|
||||
: 'border-red-700/30 text-red-400',
|
||||
)}>
|
||||
<span className="font-mono text-slate-400">{sid}</span>{' '}
|
||||
{r.status === 'ok' ? `+${r.inserted} / skip ${r.skipped}` : 'failed'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CalendarPage() {
|
||||
const { data: calendar, isLoading } = useCalendar()
|
||||
const { data: news } = useGeoNews()
|
||||
const { data: geoNews } = useGeoNews()
|
||||
|
||||
const today = new Date()
|
||||
const upcoming = calendar?.filter(ev => isAfter(parseISO(ev.date), today)) ?? []
|
||||
const past = calendar?.filter(ev => isBefore(parseISO(ev.date), today)) ?? []
|
||||
// ── Filters state ──
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const twoYearsAgo = new Date(Date.now() - 730 * 86400_000).toISOString().slice(0, 10)
|
||||
|
||||
const highImpactNews = news?.filter(n => n.impact_score > 0.4).slice(0, 5) ?? []
|
||||
const [dateFrom, setDateFrom] = useState(twoYearsAgo)
|
||||
const [dateTo, setDateTo] = useState(today)
|
||||
const [category, setCategory] = useState('')
|
||||
const [selectedSeries, setSelectedSeries] = useState<string[]>([])
|
||||
const [minZ, setMinZ] = useState(0)
|
||||
const [direction, setDirection] = useState('')
|
||||
const [sortBy, setSortBy] = useState('date')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||
const [limit, setLimit] = useState(200)
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
// ── Data ──
|
||||
const [events, setEvents] = useState<EcoEvent[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [ecoStatus, setEcoStatus] = useState<EcoStatus | null>(null)
|
||||
const [seriesMeta, setSeriesMeta] = useState<SeriesMeta[]>([])
|
||||
const [showBootstrap, setShowBootstrap] = useState(false)
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/eco/status`)
|
||||
setEcoStatus(await r.json())
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
const fetchSeriesMeta = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/eco/series`)
|
||||
setSeriesMeta(await r.json())
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
const fetchEvents = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (dateFrom) params.set('date_from', dateFrom)
|
||||
if (dateTo) params.set('date_to', dateTo)
|
||||
if (category) params.set('category', category)
|
||||
if (selectedSeries.length) params.set('series', selectedSeries.join(','))
|
||||
if (minZ > 0) params.set('min_zscore', String(minZ))
|
||||
if (direction) params.set('direction', direction)
|
||||
params.set('sort_by', sortBy)
|
||||
params.set('sort_dir', sortDir)
|
||||
params.set('limit', String(limit))
|
||||
params.set('offset', String(offset))
|
||||
|
||||
const r = await fetch(`${API_BASE}/api/eco/events?${params}`)
|
||||
const data = await r.json()
|
||||
setEvents(data.events ?? [])
|
||||
setTotal(data.total ?? 0)
|
||||
} catch (e) {
|
||||
setEvents([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [dateFrom, dateTo, category, selectedSeries, minZ, direction, sortBy, sortDir, limit, offset])
|
||||
|
||||
useEffect(() => { fetchSeriesMeta(); fetchStatus() }, [])
|
||||
useEffect(() => { setOffset(0) }, [dateFrom, dateTo, category, selectedSeries, minZ, direction, sortBy, sortDir])
|
||||
useEffect(() => { fetchEvents() }, [fetchEvents])
|
||||
|
||||
const toggleSort = (col: string) => {
|
||||
if (sortBy === col) setSortDir(d => d === 'desc' ? 'asc' : 'desc')
|
||||
else { setSortBy(col); setSortDir('desc') }
|
||||
}
|
||||
|
||||
const SortIcon = ({ col }: { col: string }) =>
|
||||
sortBy !== col ? null
|
||||
: sortDir === 'desc' ? <ChevronDown className="w-3 h-3 inline ml-0.5" />
|
||||
: <ChevronUp className="w-3 h-3 inline ml-0.5" />
|
||||
|
||||
const highGeoNews = geoNews?.filter(n => n.impact_score > 0.4).slice(0, 6) ?? []
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-blue-400" /> Economic & Geopolitical Calendar
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Macro events, geopolitical catalysts, key dates
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 text-xs text-slate-500">
|
||||
<div className="flex items-center gap-1"><span className="text-red-400">●●●</span> Major (high volatility expected)</div>
|
||||
<div className="flex items-center gap-1"><span className="text-yellow-400">●●</span> Moderate</div>
|
||||
<div className="flex items-center gap-1"><span className="text-slate-400">●</span> Minor</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-5">
|
||||
{/* Economic calendar */}
|
||||
<div className="col-span-2 space-y-3">
|
||||
<div className="section-title flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> Upcoming events ({upcoming.length})
|
||||
</div>
|
||||
{isLoading ? (
|
||||
[1,2,3].map(i => <div key={i} className="card animate-pulse h-20 bg-dark-700"></div>)
|
||||
) : upcoming.length > 0 ? (
|
||||
upcoming.map((ev, i) => <EventCard key={i} ev={ev} />)
|
||||
) : (
|
||||
<div className="card text-center py-8 text-slate-500 text-sm">
|
||||
Start the backend to load the calendar
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-blue-400" /> Calendrier Économique — FRED
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Données historiques FRED depuis 2020 · NFP, CPI, FOMC, GDP, Spreads…
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{ecoStatus && (
|
||||
<div className="text-xs text-slate-400 bg-dark-700 border border-slate-700/40 rounded px-2 py-1">
|
||||
<span className="text-white font-semibold">{ecoStatus.total.toLocaleString()}</span> events
|
||||
{ecoStatus.earliest && (
|
||||
<> · {ecoStatus.earliest?.slice(0, 7)} → {ecoStatus.latest?.slice(0, 7)}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowBootstrap(s => !s) }}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 text-xs px-3 py-1.5 rounded font-semibold border transition-colors',
|
||||
showBootstrap
|
||||
? 'bg-blue-900/30 border-blue-500/40 text-blue-300'
|
||||
: 'bg-dark-700 border-slate-700 text-slate-300 hover:border-blue-500/40',
|
||||
)}
|
||||
>
|
||||
<Download className="w-3 h-3" /> Bootstrap FRED
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchEvents}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1.5 rounded bg-dark-700 border border-slate-700 text-slate-400 hover:text-white"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3 h-3', loading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{past.length > 0 && (
|
||||
<>
|
||||
<div className="section-title mt-6 flex items-center gap-1 opacity-60">
|
||||
Past events ({past.length})
|
||||
{/* Bootstrap panel */}
|
||||
{showBootstrap && (
|
||||
<BootstrapPanel onDone={() => { fetchStatus(); fetchEvents(); setShowBootstrap(false) }} />
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{/* ── Filters + Table ── */}
|
||||
<div className="col-span-3 space-y-3">
|
||||
{/* Filter bar */}
|
||||
<div className="card p-3 space-y-2">
|
||||
{/* Row 1: dates + direction + z-score */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 mb-0.5">Du</div>
|
||||
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
|
||||
className="text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white w-32" />
|
||||
</div>
|
||||
{past.map((ev, i) => <EventCard key={i} ev={ev} />)}
|
||||
</>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 mb-0.5">Au</div>
|
||||
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
|
||||
className="text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white w-32" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 mb-0.5">|Z| min</div>
|
||||
<select value={minZ} onChange={e => setMinZ(Number(e.target.value))}
|
||||
className="text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white">
|
||||
{[0, 0.5, 1, 1.5, 2, 2.5].map(v => (
|
||||
<option key={v} value={v}>{v === 0 ? 'Tous' : `≥ ${v}σ`}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 mb-0.5">Direction</div>
|
||||
<div className="flex gap-1">
|
||||
{DIRECTIONS.map(d => (
|
||||
<button key={d.id}
|
||||
onClick={() => setDirection(d.id)}
|
||||
className={clsx(
|
||||
'text-xs px-2 py-0.5 rounded border transition-colors',
|
||||
direction === d.id
|
||||
? d.id === 'bullish' ? 'bg-emerald-900/30 border-emerald-500/40 text-emerald-300'
|
||||
: d.id === 'bearish' ? 'bg-red-900/30 border-red-500/40 text-red-300'
|
||||
: 'bg-blue-900/30 border-blue-500/40 text-blue-300'
|
||||
: 'bg-dark-700 border-slate-700 text-slate-400 hover:text-white',
|
||||
)}
|
||||
>{d.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 mb-0.5">Limite</div>
|
||||
<select value={limit} onChange={e => setLimit(Number(e.target.value))}
|
||||
className="text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white">
|
||||
{[50, 100, 200, 500].map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: category chips */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button onClick={() => setCategory('')}
|
||||
className={clsx('text-xs px-2 py-0.5 rounded border transition-colors',
|
||||
!category ? 'bg-slate-700 border-slate-500 text-white' : 'bg-dark-700 border-slate-700 text-slate-400 hover:text-white')}>
|
||||
Toutes catégories
|
||||
</button>
|
||||
{CATEGORIES.map(c => (
|
||||
<button key={c.id} onClick={() => setCategory(category === c.id ? '' : c.id)}
|
||||
className={clsx('text-xs px-2 py-0.5 rounded border transition-colors',
|
||||
category === c.id ? 'bg-blue-900/30 border-blue-500/40 text-blue-300'
|
||||
: 'bg-dark-700 border-slate-700 text-slate-400 hover:text-white')}>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Row 3: series chips */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{seriesMeta.map(s => (
|
||||
<button key={s.id}
|
||||
onClick={() => setSelectedSeries(prev =>
|
||||
prev.includes(s.id) ? prev.filter(x => x !== s.id) : [...prev, s.id]
|
||||
)}
|
||||
className={clsx('text-xs px-2 py-0.5 rounded border transition-colors font-mono',
|
||||
selectedSeries.includes(s.id)
|
||||
? 'bg-violet-900/30 border-violet-500/40 text-violet-300'
|
||||
: 'bg-dark-700 border-slate-700/50 text-slate-500 hover:text-white hover:border-slate-600',
|
||||
)}
|
||||
title={s.name}
|
||||
>
|
||||
{s.id}
|
||||
</button>
|
||||
))}
|
||||
{selectedSeries.length > 0 && (
|
||||
<button onClick={() => setSelectedSeries([])}
|
||||
className="text-xs px-2 py-0.5 rounded border border-slate-700 text-slate-500 hover:text-red-400">
|
||||
✕ Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results summary */}
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>
|
||||
{total.toLocaleString()} événements
|
||||
{total > limit && <> · page {Math.floor(offset / limit) + 1}/{Math.ceil(total / limit)}</>}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{offset > 0 && (
|
||||
<button onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||
className="text-blue-400 hover:text-blue-300">← Précédent</button>
|
||||
)}
|
||||
{offset + limit < total && (
|
||||
<button onClick={() => setOffset(offset + limit)}
|
||||
className="text-blue-400 hover:text-blue-300">Suivant →</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{ecoStatus?.total === 0 ? (
|
||||
<div className="card text-center py-12 space-y-3">
|
||||
<div className="text-slate-400 text-sm">Aucune donnée économique en base</div>
|
||||
<div className="text-slate-500 text-xs">Lance le Bootstrap FRED pour importer les données depuis 2020</div>
|
||||
<button onClick={() => setShowBootstrap(true)}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded font-semibold">
|
||||
Ouvrir Bootstrap
|
||||
</button>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-8 bg-dark-700 animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="card text-center py-8 text-slate-500 text-sm">
|
||||
Aucun événement pour ces filtres
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-slate-500 border-b border-slate-700/50">
|
||||
<th className="text-left py-1.5 pr-3 cursor-pointer hover:text-white" onClick={() => toggleSort('date')}>
|
||||
Date <SortIcon col="date" />
|
||||
</th>
|
||||
<th className="text-left py-1.5 pr-3 cursor-pointer hover:text-white" onClick={() => toggleSort('name')}>
|
||||
Série <SortIcon col="name" />
|
||||
</th>
|
||||
<th className="text-right py-1.5 pr-3">Actuel</th>
|
||||
<th className="text-right py-1.5 pr-3">Précédent</th>
|
||||
<th className="text-right py-1.5 pr-3">Δ%</th>
|
||||
<th className="text-right py-1.5 pr-3 cursor-pointer hover:text-white" onClick={() => toggleSort('zscore')}>
|
||||
Z-Score <SortIcon col="zscore" />
|
||||
</th>
|
||||
<th className="text-center py-1.5 pr-3">Signal</th>
|
||||
<th className="text-left py-1.5">Assets</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/20">
|
||||
{events.map(ev => {
|
||||
const z = ev.surprise_zscore
|
||||
const isStrong = z !== null && Math.abs(z) >= 1.5
|
||||
return (
|
||||
<tr key={ev.id} className={clsx(
|
||||
'hover:bg-dark-600/30 transition-colors',
|
||||
isStrong && ev.surprise_direction === 'bullish' && 'bg-emerald-900/5',
|
||||
isStrong && ev.surprise_direction === 'bearish' && 'bg-red-900/5',
|
||||
)}>
|
||||
<td className="py-1.5 pr-3 font-mono text-slate-400 whitespace-nowrap">
|
||||
{ev.event_date}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<div className="text-white font-medium">{ev.event_name}</div>
|
||||
<div className="text-slate-600 font-mono text-xs">{ev.series_id}</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-white">
|
||||
{fmt(ev.actual_value)} <span className="text-slate-600">{ev.actual_unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right font-mono text-slate-500">
|
||||
{fmt(ev.previous_value)}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right">
|
||||
<SurprisePct v={ev.surprise_pct} />
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right">
|
||||
<ZBadge z={ev.surprise_zscore} dir={ev.surprise_direction} />
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-center">
|
||||
<DirBadge dir={ev.surprise_direction} />
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{ev.assets_impacted.slice(0, 3).map(a => (
|
||||
<span key={a} className="text-xs px-1 rounded bg-dark-600 text-slate-500 border border-slate-700/30">
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: geo alerts + timeline */}
|
||||
{/* ── Right sidebar ── */}
|
||||
<div className="col-span-1 space-y-4">
|
||||
<div className="card">
|
||||
<div className="section-title flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3 text-orange-400" /> Geopolitical alerts
|
||||
</div>
|
||||
{highImpactNews.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{highImpactNews.map((n, i) => (
|
||||
<div key={i} className="card-sm">
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<div className="text-xs text-white line-clamp-2">{n.title}</div>
|
||||
<span className="text-xs text-orange-400 font-bold shrink-0 ml-1">
|
||||
{Math.round(n.impact_score * 100)}
|
||||
</span>
|
||||
{/* Séries disponibles */}
|
||||
{ecoStatus && ecoStatus.total > 0 && (
|
||||
<div className="card">
|
||||
<div className="text-xs font-semibold text-slate-300 mb-2 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3 text-blue-400" /> En base
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ecoStatus.by_series.map(s => {
|
||||
const meta = seriesMeta.find(m => m.id === s.series_id)
|
||||
return (
|
||||
<div key={s.series_id} className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-xs font-mono text-slate-400">{s.series_id}</div>
|
||||
<div className="text-xs text-slate-600">{meta?.name}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-white">{s.cnt}</div>
|
||||
<div className="text-xs text-slate-600">{s.latest?.slice(0, 7)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Geo alerts */}
|
||||
<div className="card">
|
||||
<div className="text-xs font-semibold text-slate-300 mb-2 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3 text-orange-400" /> Alertes géopolitiques
|
||||
</div>
|
||||
{highGeoNews.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{highGeoNews.map((n, i) => (
|
||||
<div key={i} className="border-b border-slate-700/20 pb-1.5 last:border-0 last:pb-0">
|
||||
<div className="text-xs text-white line-clamp-2">{n.title}</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<span className="text-xs text-slate-600">{n.source}</span>
|
||||
<span className="text-xs text-orange-400 font-bold">{Math.round(n.impact_score * 100)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-600 mt-1">{n.source}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-600 text-center py-4">Load geopolitical news</div>
|
||||
<div className="text-xs text-slate-600 py-4 text-center">Aucune alerte active</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trade opportunity windows */}
|
||||
{/* Légende z-score */}
|
||||
<div className="card">
|
||||
<div className="section-title">Opportunity windows</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="text-xs font-semibold text-slate-400 mb-2">Guide Z-Score</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
{[
|
||||
{ window: 'Pre-FOMC (-3d)', strategy: 'Straddle on SPY', rationale: 'IV rises ahead of decision' },
|
||||
{ window: 'Pre-NFP (-2d)', strategy: 'Straddle on indices', rationale: 'Directional uncertainty' },
|
||||
{ window: 'Post-OPEC', strategy: 'Bull Call Spread USO', rationale: 'Cut → oil spike likely' },
|
||||
{ window: 'Pre-USDA Crop', strategy: 'Long Call WEAT', rationale: 'Supply news catalyst' },
|
||||
{ window: 'US Election approaching', strategy: 'Long VIX Call', rationale: 'Vol expansion guaranteed' },
|
||||
].map((op, i) => (
|
||||
<div key={i} className="card-sm">
|
||||
<div className="font-semibold text-blue-400">{op.window}</div>
|
||||
<div className="text-white mt-0.5">{op.strategy}</div>
|
||||
<div className="text-slate-500">{op.rationale}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Geo-event impact guide */}
|
||||
<div className="card">
|
||||
<div className="section-title flex items-center gap-1">
|
||||
<Globe className="w-3 h-3" /> Impact guide
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
{[
|
||||
{ event: 'Middle East conflict', impact: 'Oil +10-20%', cls: 'energy' },
|
||||
{ event: 'Russia sanctions', impact: 'Gas +15-40%', cls: 'energy' },
|
||||
{ event: 'US-China tariffs', impact: 'Soy -8%', cls: 'agriculture' },
|
||||
{ event: 'Health crisis', impact: 'Gold +7-12%', cls: 'metals' },
|
||||
{ event: 'Fed hikes', impact: 'USD +2-4%', cls: 'forex' },
|
||||
{ event: 'Ukraine war', impact: 'Wheat +15-50%', cls: 'agriculture' },
|
||||
{ label: '|z| < 0.5', desc: 'Neutre / dans la norme', cls: 'text-slate-500' },
|
||||
{ label: '0.5–1.0σ', desc: 'Légère surprise', cls: 'text-slate-400' },
|
||||
{ label: '1.0–1.5σ', desc: 'Surprise modérée', cls: 'text-yellow-400' },
|
||||
{ label: '≥ 1.5σ ⚡', desc: 'Forte surprise (rare)', cls: 'text-orange-400' },
|
||||
{ label: '≥ 2.5σ 🔥', desc: 'Choc extrême', cls: 'text-red-400' },
|
||||
].map((g, i) => (
|
||||
<div key={i} className="flex justify-between items-center py-1 border-b border-slate-700/20 last:border-0">
|
||||
<span className="text-slate-400">{g.event}</span>
|
||||
<span className={clsx('font-bold', g.impact.includes('+') ? 'positive' : 'negative')}>
|
||||
{g.impact}
|
||||
</span>
|
||||
<div key={i} className="flex justify-between">
|
||||
<span className={clsx('font-mono', g.cls)}>{g.label}</span>
|
||||
<span className="text-slate-600">{g.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user