Files
OpenFin/frontend/src/pages/MacroSeriesPage.tsx
OpenSquared afbfeff468 feat: FF→FRED series mapping + MacroSeries visualization page
Backend:
- ff_calendar: add series_id column (migration) + FF_TO_FRED mapping dict
  (NFP→PAYEMS, CPI→CPIAUCSL, Jobless Claims→ICSA, GDP→GDPC1, FEDFUNDS, PCE)
- import_csv + sync_live now populate series_id on each FF event
- New GET /api/eco/series/{id}/history: FRED time series + linked FF events
  (surprises, forecast, actual) merged by date — enables context queries

Frontend:
- New MacroSeriesPage.tsx: sidebar with 11 FRED series grouped by category,
  recharts ComposedChart with area + z-score surprise reference lines (|z|≥1.5),
  KPI cards (latest/prev/min/max), FF events table (actual vs forecast coloring),
  z-score bar chart for recent surprises, range selector (1Y/2Y/5Y/10Y/All)
- Route /macro-series + Sidebar entry "Macro Series"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 16:51:55 +02:00

547 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react'
import clsx from 'clsx'
import { RefreshCw, TrendingUp, TrendingDown, Minus } from 'lucide-react'
import {
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine, Area,
} from 'recharts'
const API = ''
// ── Types ─────────────────────────────────────────────────────────────────────
interface SeriesMeta {
id: string
name: string
category: string
freq: string
unit: string
assets: string[]
}
interface TSPoint {
event_date: string
actual_value: number | null
surprise_zscore: number | null
surprise_direction: string | null
}
interface FFEvent {
event_date: string
event_time: string | null
event_name: string
actual_value: string | null
forecast_value: string | null
previous_value: string | null
impact: string
}
interface SeriesHistory {
series_id: string
name: string
unit: string
category: string
freq: string
timeseries: TSPoint[]
events: FFEvent[]
}
// ── Constants ─────────────────────────────────────────────────────────────────
const CATEGORY_COLORS: Record<string, string> = {
employment: '#3b82f6',
inflation: '#f59e0b',
growth: '#10b981',
monetary: '#8b5cf6',
credit: '#ef4444',
rates: '#06b6d4',
}
const CATEGORY_LABELS: Record<string, string> = {
employment: 'Employment',
inflation: 'Inflation',
growth: 'Growth',
monetary: 'Monetary',
credit: 'Credit',
rates: 'Rates',
}
const RANGE_OPTIONS = [
{ key: '1y', label: '1Y', months: 12 },
{ key: '2y', label: '2Y', months: 24 },
{ key: '5y', label: '5Y', months: 60 },
{ key: '10y', label: '10Y', months: 120 },
{ key: 'all', label: 'All', months: 999 },
]
// ── Helpers ───────────────────────────────────────────────────────────────────
function fromDateForRange(months: number): string {
if (months >= 999) return '2007-01-01'
const d = new Date()
d.setMonth(d.getMonth() - months)
return d.toISOString().slice(0, 10)
}
function fmtDate(s: string): string {
return s.slice(0, 7) // YYYY-MM
}
function fmtVal(v: number | null, unit: string): string {
if (v === null) return '—'
const fixed = Math.abs(v) < 10 ? 2 : 1
return `${v.toFixed(fixed)} ${unit}`
}
// ── Custom Tooltip ────────────────────────────────────────────────────────────
interface TooltipPayload {
event_date: string
value: number | null
zscore: number | null
direction: string | null
ff_actual?: string
ff_forecast?: string
ff_event?: string
}
function ChartTooltip({ active, payload, unit }: {
active?: boolean
payload?: Array<{ payload: TooltipPayload }>
unit: string
}) {
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 min-w-[160px]">
<div className="text-slate-400 mb-1">{d.event_date}</div>
<div className="text-white font-semibold mb-1">
{d.value !== null ? `${d.value?.toFixed(2)} ${unit}` : '—'}
</div>
{d.zscore !== null && (
<div className={clsx(
'text-xs',
d.direction === 'bullish' ? 'text-emerald-400' :
d.direction === 'bearish' ? 'text-red-400' : 'text-slate-400'
)}>
z-score: {d.zscore?.toFixed(2)}
</div>
)}
{d.ff_event && (
<div className="mt-1.5 pt-1.5 border-t border-slate-700">
<div className="text-slate-500 text-[10px]">FF Release</div>
<div className="text-slate-200">{d.ff_event}</div>
{d.ff_forecast && <div className="text-slate-400">Forecast: {d.ff_forecast}</div>}
{d.ff_actual && <div className="text-slate-300">Actual: {d.ff_actual}</div>}
</div>
)}
</div>
)
}
// ── Direction Icon ─────────────────────────────────────────────────────────────
function DirIcon({ dir }: { dir: string | null }) {
if (dir === 'bullish') return <TrendingUp size={12} className="text-emerald-400" />
if (dir === 'bearish') return <TrendingDown size={12} className="text-red-400" />
return <Minus size={12} className="text-slate-500" />
}
// ── Series Sidebar ─────────────────────────────────────────────────────────────
function SeriesList({
series,
selected,
onSelect,
}: {
series: SeriesMeta[]
selected: string
onSelect: (id: string) => void
}) {
const grouped = series.reduce<Record<string, SeriesMeta[]>>((acc, s) => {
if (!acc[s.category]) acc[s.category] = []
acc[s.category].push(s)
return acc
}, {})
return (
<div className="flex flex-col gap-3">
{Object.entries(grouped).map(([cat, items]) => (
<div key={cat}>
<div className="text-xs font-semibold uppercase tracking-wider mb-1"
style={{ color: CATEGORY_COLORS[cat] ?? '#64748b' }}>
{CATEGORY_LABELS[cat] ?? cat}
</div>
<div className="flex flex-col gap-0.5">
{items.map(s => (
<button
key={s.id}
onClick={() => onSelect(s.id)}
className={clsx(
'text-left px-2.5 py-1.5 rounded text-sm transition-colors',
selected === s.id
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
)}
>
{s.name}
<span className="ml-1.5 text-xs text-slate-600">{s.unit}</span>
</button>
))}
</div>
</div>
))}
</div>
)
}
// ── Events Table ──────────────────────────────────────────────────────────────
function EventsTable({ events, unit }: { events: FFEvent[]; unit: string }) {
const recent = [...events].reverse().slice(0, 30)
if (!recent.length) return (
<div className="text-slate-600 text-sm text-center py-6">
No FF events linked to this series yet.<br/>
<span className="text-xs">Import the CSV and run the bootstrap first.</span>
</div>
)
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500 border-b border-slate-800">
<th className="text-left py-1.5 pr-3">Date</th>
<th className="text-left py-1.5 pr-3">Event</th>
<th className="text-right py-1.5 pr-3">Actual</th>
<th className="text-right py-1.5 pr-3">Forecast</th>
<th className="text-right py-1.5">Previous</th>
</tr>
</thead>
<tbody>
{recent.map((ev, i) => {
const aNum = parseFloat(ev.actual_value ?? '')
const fNum = parseFloat(ev.forecast_value ?? '')
const beat = !isNaN(aNum) && !isNaN(fNum) && aNum !== fNum
const better = beat && aNum > fNum
return (
<tr key={i} className="border-b border-slate-800/50 hover:bg-slate-800/30">
<td className="py-1.5 pr-3 text-slate-400 tabular-nums">{ev.event_date}</td>
<td className="py-1.5 pr-3 text-slate-300 max-w-[200px] truncate">{ev.event_name}</td>
<td className={clsx('py-1.5 pr-3 text-right tabular-nums font-medium',
beat ? (better ? 'text-emerald-400' : 'text-red-400') : 'text-slate-200')}>
{ev.actual_value ?? '—'}
</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-slate-400">
{ev.forecast_value ?? '—'}
</td>
<td className="py-1.5 text-right tabular-nums text-slate-500">
{ev.previous_value ?? '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function MacroSeriesPage() {
const [seriesList, setSeriesList] = useState<SeriesMeta[]>([])
const [selectedId, setSelectedId] = useState<string>('PAYEMS')
const [range, setRange] = useState('5y')
const [history, setHistory] = useState<SeriesHistory | null>(null)
const [loading, setLoading] = useState(false)
// Load series catalog
useEffect(() => {
fetch(`${API}/api/eco/series`).then(r => r.json()).then(setSeriesList).catch(() => {})
}, [])
const loadHistory = useCallback(async () => {
if (!selectedId) return
setLoading(true)
try {
const months = RANGE_OPTIONS.find(r => r.key === range)?.months ?? 60
const from = fromDateForRange(months)
const r: SeriesHistory = await fetch(
`${API}/api/eco/series/${selectedId}/history?from_date=${from}`
).then(x => x.json())
setHistory(r)
} catch {}
finally { setLoading(false) }
}, [selectedId, range])
useEffect(() => { loadHistory() }, [loadHistory])
// Build chart data — merge FRED time series + FF events by date
const chartData = (() => {
if (!history) return []
const ffByDate = new Map<string, FFEvent>()
for (const ev of history.events) ffByDate.set(ev.event_date, ev)
return history.timeseries.map(pt => {
const ff = ffByDate.get(pt.event_date)
return {
event_date: pt.event_date,
label: fmtDate(pt.event_date),
value: pt.actual_value,
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,
// For reference lines — mark surprise events
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
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)
return (
<div className="flex h-full overflow-hidden" style={{ minHeight: 0 }}>
{/* Sidebar */}
<div className="w-56 shrink-0 bg-slate-900 border-r border-slate-800 overflow-y-auto p-3">
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3">
FRED Series
</div>
<SeriesList
series={seriesList}
selected={selectedId}
onSelect={id => { setSelectedId(id); setHistory(null) }}
/>
</div>
{/* Main content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Header */}
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h1 className="text-xl font-bold text-white">
{history?.name ?? selectedId}
</h1>
<div className="text-slate-500 text-xs mt-0.5">
{history?.category && (
<span className="mr-2" style={{ color }}>
{CATEGORY_LABELS[history.category]}
</span>
)}
{history?.freq} · FRED/{selectedId}
</div>
</div>
<div className="flex items-center gap-2">
{/* Range selector */}
<div className="flex gap-1 bg-slate-800 rounded p-0.5">
{RANGE_OPTIONS.map(r => (
<button
key={r.key}
onClick={() => setRange(r.key)}
className={clsx(
'px-2.5 py-1 rounded text-xs font-medium transition-colors',
range === r.key ? 'bg-slate-600 text-white' : 'text-slate-400 hover:text-slate-200'
)}
>
{r.label}
</button>
))}
</div>
<button
onClick={loadHistory}
disabled={loading}
className="p-1.5 rounded bg-slate-700 hover:bg-slate-600 disabled:opacity-50"
>
<RefreshCw size={13} className={clsx(loading && 'animate-spin')} />
</button>
</div>
</div>
{/* KPI cards */}
{history && values.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,
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: '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 => (
<div key={card.label} className="bg-slate-800 rounded-lg p-3 border border-slate-700">
<div className="text-slate-500 text-xs mb-1">{card.label}</div>
<div className={clsx('text-base font-bold flex items-center gap-1', card.accent)}>
{card.icon}{card.value}
</div>
{card.sub && <div className="text-slate-600 text-xs mt-0.5">{card.sub}</div>}
</div>
))}
</div>
)}
{/* Chart */}
<div className="bg-slate-800 rounded-lg border border-slate-700 p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-300">
Time Series
{history && <span className="text-slate-500 text-xs ml-2">({chartData.length} data points)</span>}
</span>
{bigSurprises.length > 0 && (
<span className="text-xs text-slate-500">
{bigSurprises.length} big surprises (|z| 1.5)
</span>
)}
</div>
{loading && (
<div className="h-64 flex items-center justify-center text-slate-600 text-sm">
Loading
</div>
)}
{!loading && chartData.length === 0 && (
<div className="h-64 flex items-center justify-center text-slate-600 text-sm">
No data run FRED bootstrap first in the Calendar page.
</div>
)}
{!loading && chartData.length > 0 && (
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={chartData} 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.25} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<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}
tickFormatter={v => `${v}${history?.unit === '%' || history?.unit === 'pp' ? '' : ''}`}
/>
<Tooltip content={<ChartTooltip unit={history?.unit ?? ''} />} />
{/* Zero line if series crosses zero */}
{minVal !== null && maxVal !== null && minVal < 0 && maxVal > 0 && (
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 2" />
)}
{/* Big surprise markers */}
{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.6}
/>
))}
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
fill={`url(#grad-${selectedId})`}
dot={false}
activeDot={{ r: 4, fill: color }}
/>
</ComposedChart>
</ResponsiveContainer>
)}
{/* Legend for reference lines */}
{bigSurprises.length > 0 && (
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span className="flex items-center gap-1">
<span className="w-4 border-t border-dashed border-emerald-500 inline-block" />
Bullish surprise (|z|1.5)
</span>
<span className="flex items-center gap-1">
<span className="w-4 border-t border-dashed border-red-500 inline-block" />
Bearish surprise
</span>
</div>
)}
</div>
{/* Events table */}
<div className="bg-slate-800 rounded-lg border border-slate-700 p-4">
<div className="text-sm font-medium text-slate-300 mb-3">
FF Calendar Events
{history && <span className="text-slate-500 text-xs ml-2">({history.events.length} linked)</span>}
</div>
{history && <EventsTable events={history.events} unit={history.unit} />}
</div>
{/* Z-score context */}
{history && chartData.some(d => d.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)
.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} />
<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'
)}
style={{ width: `${Math.min(100, Math.abs(d.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.zscore!.toFixed(2)}σ
</span>
<span className="text-slate-500 tabular-nums">
{d.value?.toFixed(2)} {history.unit}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}