diff --git a/backend/services/macro_series_log.py b/backend/services/macro_series_log.py index 10d2f82..3be881c 100644 --- a/backend/services/macro_series_log.py +++ b/backend/services/macro_series_log.py @@ -10,10 +10,14 @@ logger = logging.getLogger(__name__) def _parse_num(v) -> Optional[float]: - """Convert FF text value (e.g. '178K', '0.3%', '-92K') to float or None.""" + """Convert FF text value (e.g. '178K', '0.3%', '-92K') to float in display unit. + Strips unit suffix without multiplying — '114K' → 114.0, '0.3%' → 0.3. + Matches JavaScript parseFloat behaviour so stored values match chart display.""" if v is None: return None - s = str(v).strip().replace('%', '').replace('K', 'e3').replace('M', 'e6').replace('B', 'e9') + s = str(v).strip().replace('%', '').replace(',', '') + if s and s[-1].upper() in ('K', 'M', 'B'): + s = s[:-1].strip() try: return float(s) except Exception: @@ -105,8 +109,14 @@ def backfill_from_ff_calendar(conn) -> dict: if actual_num is None and forecast_num is None: continue - logged_at = r['fetched_at'] or r['event_date'] - changed = 'actual' if actual_num is not None else 'forecast' + # For past events use fetched_at (when we got the data); for future/no-actual use now + now_str = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S') + if actual_num is not None: + logged_at = r['fetched_at'] or r['event_date'] # historical: keep original timestamp + changed = 'actual' + else: + logged_at = now_str # forecast-only: logged_at = time of backfill run + changed = 'forecast' conn.execute( """INSERT OR IGNORE INTO macro_series_log diff --git a/frontend/src/pages/MacroSeriesPage.tsx b/frontend/src/pages/MacroSeriesPage.tsx index 319ed41..cf6b8a5 100644 --- a/frontend/src/pages/MacroSeriesPage.tsx +++ b/frontend/src/pages/MacroSeriesPage.tsx @@ -265,10 +265,11 @@ export default function MacroSeriesPage() { const [selectedId, setSelectedId] = useState('PAYEMS') const [range, setRange] = useState('5y') const [history, setHistory] = useState(null) - const [logEntries, setLogEntries] = useState([]) - const [loading, setLoading] = useState(false) - const [backfilling, setBackfilling] = useState(false) - const [tab, setTab] = useState<'chart' | 'log'>('chart') + const [logEntries, setLogEntries] = useState([]) + const [loading, setLoading] = useState(false) + const [backfilling, setBackfilling] = useState(false) + const [tab, setTab] = useState<'chart' | 'log'>('chart') + const [selectedEventDate, setSelectedEventDate] = useState(null) useEffect(() => { fetch(`${API}/api/eco/series`).then(r => r.json()).then(setSeriesList).catch(() => {}) @@ -539,32 +540,94 @@ 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) + {/* Forecast evolution — focus one release at a time */} + {forecastEvolution.allDates.length > 0 && (() => { + const today = new Date().toISOString().slice(0, 10) + const upcoming = forecastEvolution.allDates.filter(d => d >= today).sort() + const past = forecastEvolution.allDates.filter(d => d < today).sort().reverse() + const ordered = [...upcoming, ...past] + const focusDate = selectedEventDate ?? ordered[0] ?? null + if (!focusDate) return null + + const allPts = forecastEvolution.byDate[focusDate] ?? [] + // Keep only the 4 weeks window before event_date + const cutoff = new Date(focusDate) + cutoff.setDate(cutoff.getDate() - 28) + const cutoffStr = cutoff.toISOString().slice(0, 16) + const pts = allPts.filter(p => p.time >= cutoffStr) + const chartPts = pts.map(p => ({ time: p.time.slice(0, 10), forecast: p.forecast, actual: p.actual })) + const hasActual = pts.some(p => p.actual != null) + const actualVal = pts.find(p => p.actual != null)?.actual ?? null + + return ( +
+ {/* Release selector chips */} +
+ Release : + {ordered.slice(0, 12).map(d => { + const isUpcoming = d >= today + const isFocus = d === focusDate + return ( + + ) + })} + {ordered.length > 12 && ( + +{ordered.length - 12} autres + )} +
+ +
+ Évolution du forecast — release du {focusDate} + {pts.length === 0 + ? ' — aucune révision détectée (syncs insuffisants)' + : ` — ${pts.length} entrée${pts.length > 1 ? 's' : ''}, fenêtre 4 semaines`} +
+ + {chartPts.length === 0 ? ( +
+ Les révisions apparaîtront après plusieurs syncs 6h consécutifs. +
+ ) : ( + + + + + `${v}${history?.unit ?? ''}`} /> + { + if (!active || !payload?.length) return null + const d = payload[0]?.payload + return ( +
+
{d.time}
+ {d.forecast != null &&
Forecast: {d.forecast} {history?.unit}
} + {d.actual != null &&
Actual: {d.actual} {history?.unit}
} +
+ ) + }} /> + + {hasActual && ( + + )} +
+
+ )}
- - - - - - - - {forecastEvolution.allDates.map((d, i) => ( - - ))} - - -
- )} + ) + })()} {logEntries.length === 0 ? (