feat: macro series
This commit is contained in:
@@ -658,13 +658,11 @@ def series_history(
|
|||||||
ts_params,
|
ts_params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
# ── FF events for this series ─────────────────────────────────────
|
# ── FF events for this series (no date ceiling — include future releases) ──
|
||||||
ff_where = ["series_id = ?", "currency = 'USD'"]
|
ff_where = ["series_id = ?"]
|
||||||
ff_params: list = [series_id]
|
ff_params: list = [series_id]
|
||||||
if from_date:
|
if from_date:
|
||||||
ff_where.append("event_date >= ?"); ff_params.append(from_date)
|
ff_where.append("event_date >= ?"); ff_params.append(from_date)
|
||||||
if to_date:
|
|
||||||
ff_where.append("event_date <= ?"); ff_params.append(to_date)
|
|
||||||
|
|
||||||
ff_rows = conn.execute(
|
ff_rows = conn.execute(
|
||||||
f"SELECT event_date, event_time, event_name, actual_value, "
|
f"SELECT event_date, event_time, event_name, actual_value, "
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ SUPPORTED_CURRENCIES = {"USD", "EUR", "GBP", "JPY", "AUD", "CAD", "NZD", "CHF",
|
|||||||
FF_TO_FRED: dict[str, str] = {
|
FF_TO_FRED: dict[str, str] = {
|
||||||
# Employment
|
# Employment
|
||||||
"Non-Farm Employment Change": "PAYEMS",
|
"Non-Farm Employment Change": "PAYEMS",
|
||||||
"ADP Non-Farm Employment Change": "PAYEMS",
|
"ADP Non-Farm Employment Change": "ADP_NFP",
|
||||||
"Unemployment Rate": "UNRATE",
|
"Unemployment Rate": "UNRATE",
|
||||||
"Unemployment Claims": "ICSA",
|
"Unemployment Claims": "ICSA",
|
||||||
# Inflation — CPI
|
# Inflation — CPI
|
||||||
|
|||||||
@@ -301,52 +301,75 @@ export default function MacroSeriesPage() {
|
|||||||
finally { setBackfilling(false) }
|
finally { setBackfilling(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build chart data — merge FRED time series + log entries by event_date
|
|
||||||
// For each release date: actual from log (last entry with actual) + last forecast before actual
|
|
||||||
const chartData = (() => {
|
|
||||||
if (!history) return []
|
|
||||||
const ffByDate = new Map<string, FFEvent>()
|
|
||||||
for (const ev of history.events) ffByDate.set(ev.event_date, ev)
|
|
||||||
|
|
||||||
// Group log entries by event_date, pick last forecast before actual and first actual
|
|
||||||
const logByDate = new Map<string, { actual: number | null; lastForecast: number | null }>()
|
|
||||||
for (const e of logEntries) {
|
|
||||||
const existing = logByDate.get(e.event_date) ?? { actual: null, lastForecast: null }
|
|
||||||
if (e.actual_value != null) existing.actual = e.actual_value
|
|
||||||
if (e.forecast_value != null && e.actual_value == null) existing.lastForecast = e.forecast_value
|
|
||||||
logByDate.set(e.event_date, existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
return history.timeseries.map(pt => {
|
|
||||||
const ff = ffByDate.get(pt.event_date)
|
|
||||||
const logged = logByDate.get(pt.event_date)
|
|
||||||
return {
|
|
||||||
event_date: pt.event_date,
|
|
||||||
label: fmtDate(pt.event_date),
|
|
||||||
value: pt.actual_value,
|
|
||||||
forecast: logged?.lastForecast ?? (ff?.forecast_value ? parseFloat(ff.forecast_value) || null : null),
|
|
||||||
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,
|
|
||||||
is_surprise: pt.surprise_zscore !== null && Math.abs(pt.surprise_zscore) >= 1.5,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})()
|
|
||||||
|
|
||||||
const color = CATEGORY_COLORS[history?.category ?? ''] ?? '#3b82f6'
|
const color = CATEGORY_COLORS[history?.category ?? ''] ?? '#3b82f6'
|
||||||
|
|
||||||
// Stats
|
// ── Chart 1 : Actual vs Forecast — source unique ff_calendar (même échelle) ──
|
||||||
const values = chartData.map(d => d.value).filter((v): v is number => v !== null)
|
// FRED timeseries (level) ≠ FF change mensuelle → on utilise UNIQUEMENT ff_calendar
|
||||||
const latest = values[values.length - 1] ?? null
|
const chartDataFF = (() => {
|
||||||
const prev = values[values.length - 2] ?? null
|
if (!history) return []
|
||||||
const minVal = values.length ? Math.min(...values) : null
|
// Déduplique par event_date : garde le premier event du jour (NFP, pas ADP)
|
||||||
const maxVal = values.length ? Math.max(...values) : null
|
const seen = new Set<string>()
|
||||||
|
return history.events
|
||||||
|
.filter(ev => { if (seen.has(ev.event_date)) return false; seen.add(ev.event_date); return true })
|
||||||
|
.map(ev => {
|
||||||
|
const actual = ev.actual_value ? parseFloat(ev.actual_value) || null : null
|
||||||
|
const forecast = ev.forecast_value ? parseFloat(ev.forecast_value) || null : null
|
||||||
|
const isFuture = actual == null
|
||||||
|
return {
|
||||||
|
event_date: ev.event_date,
|
||||||
|
label: fmtDate(ev.event_date),
|
||||||
|
actual,
|
||||||
|
forecast,
|
||||||
|
isFuture,
|
||||||
|
event_name: ev.event_name,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
|
||||||
|
// ── Chart 2 : Évolution du forecast — source macro_series_log ──────────────
|
||||||
|
// Groupe par event_date → pour chaque release, une courbe chronologique du forecast
|
||||||
|
const forecastEvolution = (() => {
|
||||||
|
if (!logEntries.length) return { byDate: {}, allDates: [] as string[], allTimes: [] as string[] }
|
||||||
|
const byDate: Record<string, { time: string; forecast: number | null; actual: number | null }[]> = {}
|
||||||
|
for (const e of logEntries) {
|
||||||
|
if (!byDate[e.event_date]) byDate[e.event_date] = []
|
||||||
|
byDate[e.event_date].push({
|
||||||
|
time: e.logged_at.slice(0, 16),
|
||||||
|
forecast: e.forecast_value,
|
||||||
|
actual: e.actual_value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Sort each series by logged_at
|
||||||
|
for (const k of Object.keys(byDate)) byDate[k].sort((a, b) => a.time.localeCompare(b.time))
|
||||||
|
const allDates = Object.keys(byDate).sort()
|
||||||
|
// Collect all unique logged_at times across all event_dates for a unified X-axis
|
||||||
|
const timesSet = new Set<string>()
|
||||||
|
for (const pts of Object.values(byDate)) pts.forEach(p => timesSet.add(p.time))
|
||||||
|
const allTimes = [...timesSet].sort()
|
||||||
|
return { byDate, allDates, allTimes }
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Build unified forecast evolution chart data (X = logged_at, Y = forecast per release)
|
||||||
|
const evoChartData = forecastEvolution.allTimes.map(t => {
|
||||||
|
const row: Record<string, number | string | null> = { time: t.slice(0, 10) }
|
||||||
|
for (const d of forecastEvolution.allDates) {
|
||||||
|
const pts = forecastEvolution.byDate[d]
|
||||||
|
// Last known forecast at time t
|
||||||
|
const last = [...pts].reverse().find(p => p.time <= t)
|
||||||
|
row[d] = last?.forecast ?? null
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stats from FF data (not FRED level)
|
||||||
|
const ffActuals = chartDataFF.map(d => d.actual).filter((v): v is number => v !== null)
|
||||||
|
const latest = ffActuals[ffActuals.length - 1] ?? null
|
||||||
|
const prev = ffActuals[ffActuals.length - 2] ?? null
|
||||||
|
const minVal = ffActuals.length ? Math.min(...ffActuals) : null
|
||||||
|
const maxVal = ffActuals.length ? Math.max(...ffActuals) : null
|
||||||
const trend = latest !== null && prev !== null ? (latest > prev ? 1 : latest < prev ? -1 : 0) : 0
|
const trend = latest !== null && prev !== null ? (latest > prev ? 1 : latest < prev ? -1 : 0) : 0
|
||||||
|
|
||||||
// Big surprises for reference lines
|
const EVO_COLORS = ['#f59e0b', '#38bdf8', '#a78bfa', '#34d399', '#fb923c', '#f472b6']
|
||||||
const bigSurprises = chartData.filter(d => d.is_surprise)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full overflow-hidden" style={{ minHeight: 0 }}>
|
<div className="flex h-full overflow-hidden" style={{ minHeight: 0 }}>
|
||||||
@@ -414,17 +437,17 @@ export default function MacroSeriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI cards */}
|
{/* KPI cards */}
|
||||||
{history && values.length > 0 && (
|
{history && ffActuals.length > 0 && (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
label: 'Latest',
|
label: 'Latest',
|
||||||
value: fmtVal(latest, history.unit),
|
value: fmtVal(latest, history.unit),
|
||||||
sub: history.timeseries[history.timeseries.length - 1]?.event_date,
|
sub: (() => { const a = chartDataFF.filter(d => d.actual != null); return a[a.length - 1]?.event_date })(),
|
||||||
accent: trend === 1 ? 'text-emerald-400' : trend === -1 ? 'text-red-400' : 'text-slate-200',
|
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}/>,
|
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: 'Previous', value: fmtVal(prev, history.unit), sub: (() => { const a = chartDataFF.filter(d => d.actual != null); return a[a.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: '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 },
|
{ label: 'Max (period)', value: fmtVal(maxVal, history.unit), sub: null, accent: 'text-amber-400', icon: null },
|
||||||
].map(card => (
|
].map(card => (
|
||||||
@@ -444,23 +467,21 @@ export default function MacroSeriesPage() {
|
|||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-sm font-medium text-slate-300">
|
<span className="text-sm font-medium text-slate-300">
|
||||||
Actual vs Consensus
|
Actual vs Consensus
|
||||||
{history && <span className="text-slate-500 text-xs ml-2">({chartData.length} releases)</span>}
|
{history && <span className="text-slate-500 text-xs ml-2">({chartDataFF.length} releases)</span>}
|
||||||
</span>
|
</span>
|
||||||
{bigSurprises.length > 0 && (
|
<span className="text-xs text-slate-500">source: ff_calendar</span>
|
||||||
<span className="text-xs text-slate-500">{bigSurprises.length} surprises (|z|≥1.5)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <div className="h-64 flex items-center justify-center text-slate-600 text-sm">Chargement…</div>}
|
{loading && <div className="h-64 flex items-center justify-center text-slate-600 text-sm">Chargement…</div>}
|
||||||
{!loading && chartData.length === 0 && (
|
{!loading && chartDataFF.length === 0 && (
|
||||||
<div className="h-64 flex items-center justify-center text-slate-600 text-sm">
|
<div className="h-64 flex items-center justify-center text-slate-600 text-sm">
|
||||||
Pas de données — lancez le bootstrap FRED depuis Calendar.
|
Pas de données — aucun event ff_calendar lié à cette série.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && chartData.length > 0 && (
|
{!loading && chartDataFF.length > 0 && (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<ComposedChart data={chartData} margin={{ top: 4, right: 16, bottom: 4, left: 0 }}>
|
<ComposedChart data={chartDataFF} margin={{ top: 4, right: 16, bottom: 4, left: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`grad-${selectedId}`} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id={`grad-${selectedId}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||||
@@ -470,25 +491,32 @@ export default function MacroSeriesPage() {
|
|||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||||
<XAxis dataKey="label" tick={{ fontSize: 10, fill: '#64748b' }} interval="preserveStartEnd" tickLine={false} />
|
<XAxis dataKey="label" tick={{ fontSize: 10, fill: '#64748b' }} interval="preserveStartEnd" tickLine={false} />
|
||||||
<YAxis tick={{ fontSize: 10, fill: '#64748b' }} tickLine={false} axisLine={false} width={50} />
|
<YAxis tick={{ fontSize: 10, fill: '#64748b' }} tickLine={false} axisLine={false} width={50} />
|
||||||
<Tooltip content={<ChartTooltip unit={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.5 text-xs shadow-xl">
|
||||||
|
<div className="text-slate-400 mb-1">{d.event_date}</div>
|
||||||
|
<div className="text-slate-300 text-[10px] mb-1.5">{d.event_name}</div>
|
||||||
|
{d.actual != null && <div className="text-white font-semibold">Actual: {d.actual?.toFixed(2)} {history?.unit}</div>}
|
||||||
|
{d.forecast != null && <div className="text-amber-400">Forecast: {d.forecast?.toFixed(2)} {history?.unit}</div>}
|
||||||
|
{d.isFuture && <div className="text-slate-500 italic">— à venir —</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11, color: '#94a3b8' }} />
|
<Legend wrapperStyle={{ fontSize: 11, color: '#94a3b8' }} />
|
||||||
|
|
||||||
{minVal !== null && maxVal !== null && minVal < 0 && maxVal > 0 && (
|
{minVal !== null && maxVal !== null && minVal < 0 && maxVal > 0 && (
|
||||||
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 2" />
|
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 2" />
|
||||||
)}
|
)}
|
||||||
{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.5} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Actual — area + line */}
|
{/* Actual — area + line (only past points with actual) */}
|
||||||
<Area type="monotone" dataKey="value" name="Actual"
|
<Area type="monotone" dataKey="actual" name="Actual"
|
||||||
stroke={color} strokeWidth={2} fill={`url(#grad-${selectedId})`}
|
stroke={color} strokeWidth={2} fill={`url(#grad-${selectedId})`}
|
||||||
dot={false} activeDot={{ r: 4, fill: color }} />
|
dot={false} activeDot={{ r: 4, fill: color }} connectNulls />
|
||||||
|
|
||||||
{/* Forecast — dashed line */}
|
{/* Forecast — dashed line (including future) */}
|
||||||
<Line type="monotone" dataKey="forecast" name="Forecast (consensus)"
|
<Line type="monotone" dataKey="forecast" name="Forecast"
|
||||||
stroke="#f59e0b" strokeWidth={1.5} strokeDasharray="4 3"
|
stroke="#f59e0b" strokeWidth={1.5} strokeDasharray="4 3"
|
||||||
dot={false} activeDot={{ r: 3 }} connectNulls />
|
dot={false} activeDot={{ r: 3 }} connectNulls />
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
@@ -511,6 +539,33 @@ export default function MacroSeriesPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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)
|
||||||
|
</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 ? (
|
{logEntries.length === 0 ? (
|
||||||
<div className="text-slate-600 text-sm text-center py-8">
|
<div className="text-slate-600 text-sm text-center py-8">
|
||||||
Aucune entrée — cliquez "Importer historique" ou attendez le prochain sync (6h).
|
Aucune entrée — cliquez "Importer historique" ou attendez le prochain sync (6h).
|
||||||
@@ -576,37 +631,37 @@ export default function MacroSeriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Z-score context */}
|
{/* Z-score context */}
|
||||||
{history && chartData.some(d => d.zscore !== null) && (
|
{history && history.timeseries.some(d => d.surprise_zscore !== null) && (
|
||||||
<div className="bg-slate-800 rounded-lg border border-slate-700 p-4">
|
<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="text-sm font-medium text-slate-300 mb-3">Recent Surprises (FRED z-score)</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{chartData
|
{history.timeseries
|
||||||
.filter(d => d.zscore !== null && d.zscore !== 0)
|
.filter(d => d.surprise_zscore !== null && d.surprise_zscore !== 0)
|
||||||
.slice(-15)
|
.slice(-15)
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((d, i) => (
|
.map((d, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 text-xs">
|
<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>
|
<span className="text-slate-500 w-24 tabular-nums shrink-0">{d.event_date}</span>
|
||||||
<DirIcon dir={d.direction} />
|
<DirIcon dir={d.surprise_direction} />
|
||||||
<div className="flex-1 bg-slate-700 rounded-full h-1.5 overflow-hidden">
|
<div className="flex-1 bg-slate-700 rounded-full h-1.5 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-full rounded-full',
|
'h-full rounded-full',
|
||||||
d.direction === 'bullish' ? 'bg-emerald-500' :
|
d.surprise_direction === 'bullish' ? 'bg-emerald-500' :
|
||||||
d.direction === 'bearish' ? 'bg-red-500' : 'bg-slate-500'
|
d.surprise_direction === 'bearish' ? 'bg-red-500' : 'bg-slate-500'
|
||||||
)}
|
)}
|
||||||
style={{ width: `${Math.min(100, Math.abs(d.zscore!) * 25)}%` }}
|
style={{ width: `${Math.min(100, Math.abs(d.surprise_zscore!) * 25)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
'w-12 text-right tabular-nums',
|
'w-12 text-right tabular-nums',
|
||||||
d.direction === 'bullish' ? 'text-emerald-400' :
|
d.surprise_direction === 'bullish' ? 'text-emerald-400' :
|
||||||
d.direction === 'bearish' ? 'text-red-400' : 'text-slate-400'
|
d.surprise_direction === 'bearish' ? 'text-red-400' : 'text-slate-400'
|
||||||
)}>
|
)}>
|
||||||
{d.zscore!.toFixed(2)}σ
|
{d.surprise_zscore!.toFixed(2)}σ
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-500 tabular-nums">
|
<span className="text-slate-500 tabular-nums">
|
||||||
{d.value?.toFixed(2)} {history.unit}
|
{d.actual_value?.toFixed(2)} {history.unit}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user