feat: macro series

This commit is contained in:
OpenSquared
2026-07-01 17:38:33 +02:00
parent 8b59eff744
commit 1829335ad1
2 changed files with 106 additions and 33 deletions

View File

@@ -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

View File

@@ -265,10 +265,11 @@ export default function MacroSeriesPage() {
const [selectedId, setSelectedId] = useState<string>('PAYEMS')
const [range, setRange] = useState('5y')
const [history, setHistory] = useState<SeriesHistory | null>(null)
const [logEntries, setLogEntries] = useState<LogEntry[]>([])
const [loading, setLoading] = useState(false)
const [backfilling, setBackfilling] = useState(false)
const [tab, setTab] = useState<'chart' | 'log'>('chart')
const [logEntries, setLogEntries] = useState<LogEntry[]>([])
const [loading, setLoading] = useState(false)
const [backfilling, setBackfilling] = useState(false)
const [tab, setTab] = useState<'chart' | 'log'>('chart')
const [selectedEventDate, setSelectedEventDate] = useState<string | null>(null)
useEffect(() => {
fetch(`${API}/api/eco/series`).then(r => r.json()).then(setSeriesList).catch(() => {})
@@ -539,32 +540,94 @@ 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)
{/* 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 (
<div className="mb-5">
{/* Release selector chips */}
<div className="flex items-center gap-1.5 flex-wrap mb-3">
<span className="text-xs text-slate-500 shrink-0">Release :</span>
{ordered.slice(0, 12).map(d => {
const isUpcoming = d >= today
const isFocus = d === focusDate
return (
<button key={d} onClick={() => setSelectedEventDate(d)}
className={clsx(
'px-2 py-0.5 rounded text-xs font-mono transition-colors border',
isFocus
? 'bg-blue-600 border-blue-500 text-white'
: isUpcoming
? 'bg-amber-900/30 border-amber-700/50 text-amber-400 hover:bg-amber-800/40'
: 'bg-slate-700/40 border-slate-700 text-slate-400 hover:bg-slate-700'
)}>
{d}
{isUpcoming && <span className="ml-1 text-[9px] opacity-70"></span>}
</button>
)
})}
{ordered.length > 12 && (
<span className="text-xs text-slate-600">+{ordered.length - 12} autres</span>
)}
</div>
<div className="text-xs text-slate-500 mb-2">
Évolution du forecast release du <span className="text-slate-300">{focusDate}</span>
{pts.length === 0
? ' — aucune révision détectée (syncs insuffisants)'
: `${pts.length} entrée${pts.length > 1 ? 's' : ''}, fenêtre 4 semaines`}
</div>
{chartPts.length === 0 ? (
<div className="h-24 flex items-center justify-center text-slate-600 text-xs">
Les révisions apparaîtront après plusieurs syncs 6h consécutifs.
</div>
) : (
<ResponsiveContainer width="100%" height={200}>
<ComposedChart data={chartPts} 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}
tickFormatter={v => `${v}${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 text-xs shadow-xl">
<div className="text-slate-400 mb-1">{d.time}</div>
{d.forecast != null && <div className="text-amber-400">Forecast: {d.forecast} {history?.unit}</div>}
{d.actual != null && <div className="text-emerald-400 font-semibold">Actual: {d.actual} {history?.unit}</div>}
</div>
)
}} />
<Line type="stepAfter" dataKey="forecast" name="Forecast"
stroke="#f59e0b" strokeWidth={2} dot={{ r: 3, fill: '#f59e0b' }} connectNulls />
{hasActual && (
<ReferenceLine y={actualVal!} stroke="#34d399" strokeWidth={1.5}
strokeDasharray="5 3" label={{ value: `Actual ${actualVal}`, fill: '#34d399', fontSize: 10, position: 'insideTopRight' }} />
)}
</ComposedChart>
</ResponsiveContainer>
)}
</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">