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