feat: macro series

This commit is contained in:
OpenSquared
2026-07-01 15:58:47 +02:00
parent 292d2c6413
commit 776c19e1ae
3 changed files with 133 additions and 80 deletions

View File

@@ -658,13 +658,11 @@ def series_history(
ts_params,
).fetchall()
# ── FF events for this series ─────────────────────────────────────
ff_where = ["series_id = ?", "currency = 'USD'"]
# ── FF events for this series (no date ceiling — include future releases) ──
ff_where = ["series_id = ?"]
ff_params: list = [series_id]
if from_date:
ff_where.append("event_date >= ?"); ff_params.append(from_date)
if to_date:
ff_where.append("event_date <= ?"); ff_params.append(to_date)
ff_rows = conn.execute(
f"SELECT event_date, event_time, event_name, actual_value, "

View File

@@ -41,7 +41,7 @@ SUPPORTED_CURRENCIES = {"USD", "EUR", "GBP", "JPY", "AUD", "CAD", "NZD", "CHF",
FF_TO_FRED: dict[str, str] = {
# Employment
"Non-Farm Employment Change": "PAYEMS",
"ADP Non-Farm Employment Change": "PAYEMS",
"ADP Non-Farm Employment Change": "ADP_NFP",
"Unemployment Rate": "UNRATE",
"Unemployment Claims": "ICSA",
# Inflation — CPI

View File

@@ -301,52 +301,75 @@ export default function MacroSeriesPage() {
finally { setBackfilling(false) }
}
// Build chart data — merge FRED time series + log entries by event_date
// For each release date: actual from log (last entry with actual) + last forecast before actual
const chartData = (() => {
if (!history) return []
const ffByDate = new Map<string, FFEvent>()
for (const ev of history.events) ffByDate.set(ev.event_date, ev)
// Group log entries by event_date, pick last forecast before actual and first actual
const logByDate = new Map<string, { actual: number | null; lastForecast: number | null }>()
for (const e of logEntries) {
const existing = logByDate.get(e.event_date) ?? { actual: null, lastForecast: null }
if (e.actual_value != null) existing.actual = e.actual_value
if (e.forecast_value != null && e.actual_value == null) existing.lastForecast = e.forecast_value
logByDate.set(e.event_date, existing)
}
return history.timeseries.map(pt => {
const ff = ffByDate.get(pt.event_date)
const logged = logByDate.get(pt.event_date)
return {
event_date: pt.event_date,
label: fmtDate(pt.event_date),
value: pt.actual_value,
forecast: logged?.lastForecast ?? (ff?.forecast_value ? parseFloat(ff.forecast_value) || null : null),
zscore: pt.surprise_zscore,
direction: pt.surprise_direction,
ff_event: ff?.event_name ?? null,
ff_actual: ff?.actual_value ?? null,
ff_forecast: ff?.forecast_value ?? null,
is_surprise: pt.surprise_zscore !== null && Math.abs(pt.surprise_zscore) >= 1.5,
}
})
})()
const color = CATEGORY_COLORS[history?.category ?? ''] ?? '#3b82f6'
// Stats
const values = chartData.map(d => d.value).filter((v): v is number => v !== null)
const latest = values[values.length - 1] ?? null
const prev = values[values.length - 2] ?? null
const minVal = values.length ? Math.min(...values) : null
const maxVal = values.length ? Math.max(...values) : null
// ── Chart 1 : Actual vs Forecast — source unique ff_calendar (même échelle) ──
// FRED timeseries (level) ≠ FF change mensuelle → on utilise UNIQUEMENT ff_calendar
const chartDataFF = (() => {
if (!history) return []
// Déduplique par event_date : garde le premier event du jour (NFP, pas ADP)
const seen = new Set<string>()
return history.events
.filter(ev => { if (seen.has(ev.event_date)) return false; seen.add(ev.event_date); return true })
.map(ev => {
const actual = ev.actual_value ? parseFloat(ev.actual_value) || null : null
const forecast = ev.forecast_value ? parseFloat(ev.forecast_value) || null : null
const isFuture = actual == null
return {
event_date: ev.event_date,
label: fmtDate(ev.event_date),
actual,
forecast,
isFuture,
event_name: ev.event_name,
}
})
})()
// ── Chart 2 : Évolution du forecast — source macro_series_log ──────────────
// Groupe par event_date → pour chaque release, une courbe chronologique du forecast
const forecastEvolution = (() => {
if (!logEntries.length) return { byDate: {}, allDates: [] as string[], allTimes: [] as string[] }
const byDate: Record<string, { time: string; forecast: number | null; actual: number | null }[]> = {}
for (const e of logEntries) {
if (!byDate[e.event_date]) byDate[e.event_date] = []
byDate[e.event_date].push({
time: e.logged_at.slice(0, 16),
forecast: e.forecast_value,
actual: e.actual_value,
})
}
// Sort each series by logged_at
for (const k of Object.keys(byDate)) byDate[k].sort((a, b) => a.time.localeCompare(b.time))
const allDates = Object.keys(byDate).sort()
// Collect all unique logged_at times across all event_dates for a unified X-axis
const timesSet = new Set<string>()
for (const pts of Object.values(byDate)) pts.forEach(p => timesSet.add(p.time))
const allTimes = [...timesSet].sort()
return { byDate, allDates, allTimes }
})()
// Build unified forecast evolution chart data (X = logged_at, Y = forecast per release)
const evoChartData = forecastEvolution.allTimes.map(t => {
const row: Record<string, number | string | null> = { time: t.slice(0, 10) }
for (const d of forecastEvolution.allDates) {
const pts = forecastEvolution.byDate[d]
// Last known forecast at time t
const last = [...pts].reverse().find(p => p.time <= t)
row[d] = last?.forecast ?? null
}
return row
})
// Stats from FF data (not FRED level)
const ffActuals = chartDataFF.map(d => d.actual).filter((v): v is number => v !== null)
const latest = ffActuals[ffActuals.length - 1] ?? null
const prev = ffActuals[ffActuals.length - 2] ?? null
const minVal = ffActuals.length ? Math.min(...ffActuals) : null
const maxVal = ffActuals.length ? Math.max(...ffActuals) : null
const trend = latest !== null && prev !== null ? (latest > prev ? 1 : latest < prev ? -1 : 0) : 0
// Big surprises for reference lines
const bigSurprises = chartData.filter(d => d.is_surprise)
const EVO_COLORS = ['#f59e0b', '#38bdf8', '#a78bfa', '#34d399', '#fb923c', '#f472b6']
return (
<div className="flex h-full overflow-hidden" style={{ minHeight: 0 }}>
@@ -414,17 +437,17 @@ export default function MacroSeriesPage() {
</div>
{/* KPI cards */}
{history && values.length > 0 && (
{history && ffActuals.length > 0 && (
<div className="grid grid-cols-4 gap-3">
{[
{
label: 'Latest',
value: fmtVal(latest, history.unit),
sub: history.timeseries[history.timeseries.length - 1]?.event_date,
sub: (() => { const a = chartDataFF.filter(d => d.actual != null); return a[a.length - 1]?.event_date })(),
accent: trend === 1 ? 'text-emerald-400' : trend === -1 ? 'text-red-400' : 'text-slate-200',
icon: trend === 1 ? <TrendingUp size={14}/> : trend === -1 ? <TrendingDown size={14}/> : <Minus size={14}/>,
},
{ label: 'Previous', value: fmtVal(prev, history.unit), sub: history.timeseries[history.timeseries.length - 2]?.event_date, accent: 'text-slate-300', icon: null },
{ label: 'Previous', value: fmtVal(prev, history.unit), sub: (() => { const a = chartDataFF.filter(d => d.actual != null); return a[a.length - 2]?.event_date })(), accent: 'text-slate-300', icon: null },
{ label: 'Min (period)', value: fmtVal(minVal, history.unit), sub: null, accent: 'text-sky-400', icon: null },
{ label: 'Max (period)', value: fmtVal(maxVal, history.unit), sub: null, accent: 'text-amber-400', icon: null },
].map(card => (
@@ -444,23 +467,21 @@ export default function MacroSeriesPage() {
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-300">
Actual vs Consensus
{history && <span className="text-slate-500 text-xs ml-2">({chartData.length} releases)</span>}
{history && <span className="text-slate-500 text-xs ml-2">({chartDataFF.length} releases)</span>}
</span>
{bigSurprises.length > 0 && (
<span className="text-xs text-slate-500">{bigSurprises.length} surprises (|z|1.5)</span>
)}
<span className="text-xs text-slate-500">source: ff_calendar</span>
</div>
{loading && <div className="h-64 flex items-center justify-center text-slate-600 text-sm">Chargement</div>}
{!loading && chartData.length === 0 && (
{!loading && chartDataFF.length === 0 && (
<div className="h-64 flex items-center justify-center text-slate-600 text-sm">
Pas de données lancez le bootstrap FRED depuis Calendar.
Pas de données aucun event ff_calendar lié à cette série.
</div>
)}
{!loading && chartData.length > 0 && (
{!loading && chartDataFF.length > 0 && (
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={chartData} margin={{ top: 4, right: 16, bottom: 4, left: 0 }}>
<ComposedChart data={chartDataFF} margin={{ top: 4, right: 16, bottom: 4, left: 0 }}>
<defs>
<linearGradient id={`grad-${selectedId}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
@@ -470,25 +491,32 @@ export default function MacroSeriesPage() {
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="label" tick={{ fontSize: 10, fill: '#64748b' }} interval="preserveStartEnd" tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: '#64748b' }} tickLine={false} axisLine={false} width={50} />
<Tooltip content={<ChartTooltip unit={history?.unit ?? ''} />} />
<Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null
const d = payload[0].payload
return (
<div className="bg-slate-900 border border-slate-600 rounded p-2.5 text-xs shadow-xl">
<div className="text-slate-400 mb-1">{d.event_date}</div>
<div className="text-slate-300 text-[10px] mb-1.5">{d.event_name}</div>
{d.actual != null && <div className="text-white font-semibold">Actual: {d.actual?.toFixed(2)} {history?.unit}</div>}
{d.forecast != null && <div className="text-amber-400">Forecast: {d.forecast?.toFixed(2)} {history?.unit}</div>}
{d.isFuture && <div className="text-slate-500 italic"> à venir </div>}
</div>
)
}} />
<Legend wrapperStyle={{ fontSize: 11, color: '#94a3b8' }} />
{minVal !== null && maxVal !== null && minVal < 0 && maxVal > 0 && (
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 2" />
)}
{bigSurprises.map(s => (
<ReferenceLine key={s.event_date} x={fmtDate(s.event_date)}
stroke={s.direction === 'bullish' ? '#10b981' : s.direction === 'bearish' ? '#ef4444' : '#64748b'}
strokeWidth={1} strokeDasharray="3 3" opacity={0.5} />
))}
{/* Actual — area + line */}
<Area type="monotone" dataKey="value" name="Actual"
{/* Actual — area + line (only past points with actual) */}
<Area type="monotone" dataKey="actual" name="Actual"
stroke={color} strokeWidth={2} fill={`url(#grad-${selectedId})`}
dot={false} activeDot={{ r: 4, fill: color }} />
dot={false} activeDot={{ r: 4, fill: color }} connectNulls />
{/* Forecast — dashed line */}
<Line type="monotone" dataKey="forecast" name="Forecast (consensus)"
{/* Forecast — dashed line (including future) */}
<Line type="monotone" dataKey="forecast" name="Forecast"
stroke="#f59e0b" strokeWidth={1.5} strokeDasharray="4 3"
dot={false} activeDot={{ r: 3 }} connectNulls />
</ComposedChart>
@@ -511,6 +539,33 @@ export default function MacroSeriesPage() {
</button>
)}
</div>
{/* Forecast evolution chart */}
{evoChartData.length > 0 && forecastEvolution.allDates.length > 0 && (
<div className="mb-4">
<div className="text-xs text-slate-500 mb-2">
Évolution du forecast une courbe par release ({forecastEvolution.allDates.length} releases)
</div>
<ResponsiveContainer width="100%" height={220}>
<ComposedChart data={evoChartData} margin={{ top: 4, right: 16, bottom: 4, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="time" tick={{ fontSize: 9, fill: '#64748b' }} interval="preserveStartEnd" tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: '#64748b' }} tickLine={false} axisLine={false} width={45} />
<Tooltip
contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 11 }}
labelStyle={{ color: '#64748b' }}
itemStyle={{ color: '#e2e8f0' }}
/>
<Legend wrapperStyle={{ fontSize: 10, color: '#94a3b8' }} />
{forecastEvolution.allDates.map((d, i) => (
<Line key={d} type="stepAfter" dataKey={d} name={d}
stroke={EVO_COLORS[i % EVO_COLORS.length]} strokeWidth={1.5}
dot={false} connectNulls />
))}
</ComposedChart>
</ResponsiveContainer>
</div>
)}
{logEntries.length === 0 ? (
<div className="text-slate-600 text-sm text-center py-8">
Aucune entrée cliquez "Importer historique" ou attendez le prochain sync (6h).
@@ -576,37 +631,37 @@ export default function MacroSeriesPage() {
</div>
{/* Z-score context */}
{history && chartData.some(d => d.zscore !== null) && (
{history && history.timeseries.some(d => d.surprise_zscore !== null) && (
<div className="bg-slate-800 rounded-lg border border-slate-700 p-4">
<div className="text-sm font-medium text-slate-300 mb-3">Recent Surprises (FRED z-score)</div>
<div className="flex flex-col gap-1">
{chartData
.filter(d => d.zscore !== null && d.zscore !== 0)
{history.timeseries
.filter(d => d.surprise_zscore !== null && d.surprise_zscore !== 0)
.slice(-15)
.reverse()
.map((d, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="text-slate-500 w-24 tabular-nums shrink-0">{d.event_date}</span>
<DirIcon dir={d.direction} />
<DirIcon dir={d.surprise_direction} />
<div className="flex-1 bg-slate-700 rounded-full h-1.5 overflow-hidden">
<div
className={clsx(
'h-full rounded-full',
d.direction === 'bullish' ? 'bg-emerald-500' :
d.direction === 'bearish' ? 'bg-red-500' : 'bg-slate-500'
d.surprise_direction === 'bullish' ? 'bg-emerald-500' :
d.surprise_direction === 'bearish' ? 'bg-red-500' : 'bg-slate-500'
)}
style={{ width: `${Math.min(100, Math.abs(d.zscore!) * 25)}%` }}
style={{ width: `${Math.min(100, Math.abs(d.surprise_zscore!) * 25)}%` }}
/>
</div>
<span className={clsx(
'w-12 text-right tabular-nums',
d.direction === 'bullish' ? 'text-emerald-400' :
d.direction === 'bearish' ? 'text-red-400' : 'text-slate-400'
d.surprise_direction === 'bullish' ? 'text-emerald-400' :
d.surprise_direction === 'bearish' ? 'text-red-400' : 'text-slate-400'
)}>
{d.zscore!.toFixed(2)}σ
{d.surprise_zscore!.toFixed(2)}σ
</span>
<span className="text-slate-500 tabular-nums">
{d.value?.toFixed(2)} {history.unit}
{d.actual_value?.toFixed(2)} {history.unit}
</span>
</div>
))}