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>
This commit is contained in:
@@ -28,6 +28,7 @@ import InstrumentDashboard from './pages/InstrumentDashboard'
|
||||
import CycleActions from './pages/CycleActions'
|
||||
import MarketEvents from './pages/MarketEvents'
|
||||
import AIDesks from './pages/AIDesks'
|
||||
import MacroSeriesPage from './pages/MacroSeriesPage'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useCycleWatcher } from './hooks/useApi'
|
||||
|
||||
@@ -55,6 +56,7 @@ export default function App() {
|
||||
<Route path="/portfolio" element={<Portfolio />} />
|
||||
<Route path="/backtest" element={<Backtest />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/macro-series" element={<MacroSeriesPage />} />
|
||||
<Route path="/journal" element={<JournalDeBord />} />
|
||||
<Route path="/rapport" element={<RapportIA />} />
|
||||
<Route path="/super-contexte" element={<SuperContexte />} />
|
||||
|
||||
@@ -25,6 +25,7 @@ const nav = [
|
||||
{ to: '/position-history', icon: GitCompare, label: 'Position History' },
|
||||
{ to: '/backtest', icon: History, label: 'Backtest' },
|
||||
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
|
||||
{ to: '/macro-series', icon: TrendingUp, label: 'Macro Series' },
|
||||
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
|
||||
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
|
||||
{ to: '/instruments', icon: CandlestickChart, label: 'Instrument Analysis' },
|
||||
|
||||
546
frontend/src/pages/MacroSeriesPage.tsx
Normal file
546
frontend/src/pages/MacroSeriesPage.tsx
Normal file
@@ -0,0 +1,546 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user