feat: macro series
This commit is contained in:
@@ -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, "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user