diff --git a/backend/routers/eco.py b/backend/routers/eco.py index 1fb160d..0cf6dd0 100644 --- a/backend/routers/eco.py +++ b/backend/routers/eco.py @@ -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, " diff --git a/backend/services/ff_calendar.py b/backend/services/ff_calendar.py index 675de10..518484e 100644 --- a/backend/services/ff_calendar.py +++ b/backend/services/ff_calendar.py @@ -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 diff --git a/frontend/src/pages/MacroSeriesPage.tsx b/frontend/src/pages/MacroSeriesPage.tsx index fe97571..319ed41 100644 --- a/frontend/src/pages/MacroSeriesPage.tsx +++ b/frontend/src/pages/MacroSeriesPage.tsx @@ -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() - 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() - 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() + 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 = {} + 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() + 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 = { 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 (
@@ -414,17 +437,17 @@ export default function MacroSeriesPage() {
{/* KPI cards */} - {history && values.length > 0 && ( + {history && ffActuals.length > 0 && (
{[ { 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 ? : trend === -1 ? : , }, - { 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() {
Actual vs Consensus - {history && ({chartData.length} releases)} + {history && ({chartDataFF.length} releases)} - {bigSurprises.length > 0 && ( - {bigSurprises.length} surprises (|z|≥1.5) - )} + source: ff_calendar
{loading &&
Chargement…
} - {!loading && chartData.length === 0 && ( + {!loading && chartDataFF.length === 0 && (
- Pas de données — lancez le bootstrap FRED depuis Calendar. + Pas de données — aucun event ff_calendar lié à cette série.
)} - {!loading && chartData.length > 0 && ( + {!loading && chartDataFF.length > 0 && ( - + @@ -470,25 +491,32 @@ export default function MacroSeriesPage() { - } /> + { + if (!active || !payload?.length) return null + const d = payload[0].payload + return ( +
+
{d.event_date}
+
{d.event_name}
+ {d.actual != null &&
Actual: {d.actual?.toFixed(2)} {history?.unit}
} + {d.forecast != null &&
Forecast: {d.forecast?.toFixed(2)} {history?.unit}
} + {d.isFuture &&
— à venir —
} +
+ ) + }} /> {minVal !== null && maxVal !== null && minVal < 0 && maxVal > 0 && ( )} - {bigSurprises.map(s => ( - - ))} - {/* Actual — area + line */} - + dot={false} activeDot={{ r: 4, fill: color }} connectNulls /> - {/* Forecast — dashed line */} -
@@ -511,6 +539,33 @@ export default function MacroSeriesPage() { )}
+ {/* Forecast evolution chart */} + {evoChartData.length > 0 && forecastEvolution.allDates.length > 0 && ( +
+
+ Évolution du forecast — une courbe par release ({forecastEvolution.allDates.length} releases) +
+ + + + + + + + {forecastEvolution.allDates.map((d, i) => ( + + ))} + + +
+ )} + {logEntries.length === 0 ? (
Aucune entrée — cliquez "Importer historique" ou attendez le prochain sync (6h). @@ -576,37 +631,37 @@ export default function MacroSeriesPage() {
{/* Z-score context */} - {history && chartData.some(d => d.zscore !== null) && ( + {history && history.timeseries.some(d => d.surprise_zscore !== null) && (
Recent Surprises (FRED z-score)
- {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) => (
{d.event_date} - +
- {d.zscore!.toFixed(2)}σ + {d.surprise_zscore!.toFixed(2)}σ - {d.value?.toFixed(2)} {history.unit} + {d.actual_value?.toFixed(2)} {history.unit}
))}