From 2be824405d43a43e6e9ed93199f8e906a229d26e Mon Sep 17 00:00:00 2001 From: Joey Eamigh <55670930+JoeyEamigh@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:53:33 -0400 Subject: [PATCH] fixes and tweaks to iran war content --- Dockerfile | 9 +- prisma/sql/getDcPriceImpact.sql | 32 +- scripts/build-and-push.sh | 38 ++ src/actions/conflict.ts | 51 +++ src/actions/dc-impact.ts | 430 ++++++++++++++++++ src/app/conflict/_sections/hero-metrics.tsx | 1 - .../_sections/market-fear-section.tsx | 53 +-- src/app/conflict/_sections/steo-section.tsx | 16 + src/app/conflict/page.tsx | 6 + .../_sections/demand-growth-section.tsx | 16 + .../trends/_sections/multivariate-section.tsx | 16 + src/app/trends/page.tsx | 13 + .../charts/dc-price-impact-bars.tsx | 120 ++++- src/components/charts/demand-growth-chart.tsx | 164 +++++++ .../charts/normalized-conflict-chart.tsx | 1 - src/components/charts/oil-chart.tsx | 14 +- src/components/charts/residual-chart.tsx | 302 ++++++++++++ src/components/charts/spr-chart.tsx | 171 ++++++- .../charts/steo-projections-chart.tsx | 248 ++++++++++ src/components/dashboard/price-alert.tsx | 57 ++- src/components/dashboard/ticker-tape.tsx | 1 - src/lib/api/eia.ts | 90 ++++ 22 files changed, 1774 insertions(+), 75 deletions(-) create mode 100755 scripts/build-and-push.sh create mode 100644 src/app/conflict/_sections/steo-section.tsx create mode 100644 src/app/trends/_sections/demand-growth-section.tsx create mode 100644 src/app/trends/_sections/multivariate-section.tsx create mode 100644 src/components/charts/demand-growth-chart.tsx create mode 100644 src/components/charts/residual-chart.tsx create mode 100644 src/components/charts/steo-projections-chart.tsx diff --git a/Dockerfile b/Dockerfile index 19bcaea..9c68b7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,12 +9,17 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . # Prisma client + TypedSQL must be pre-generated locally (needs live DB for --sql). # src/generated/ is gitignored but included in Docker context from local dev. -ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder" +ARG DATABASE_URL ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ARG NEXT_PUBLIC_GOOGLE_MAP_ID +ARG EIA_API_KEY +ARG FRED_API_KEY +ENV DATABASE_URL=$DATABASE_URL ENV NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=$NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ENV NEXT_PUBLIC_GOOGLE_MAP_ID=$NEXT_PUBLIC_GOOGLE_MAP_ID -RUN npx next build +ENV EIA_API_KEY=$EIA_API_KEY +ENV FRED_API_KEY=$FRED_API_KEY +RUN --network=host npx next build FROM node:22-slim AS runner WORKDIR /app diff --git a/prisma/sql/getDcPriceImpact.sql b/prisma/sql/getDcPriceImpact.sql index 5e84614..cbfc9e7 100644 --- a/prisma/sql/getDcPriceImpact.sql +++ b/prisma/sql/getDcPriceImpact.sql @@ -1,4 +1,5 @@ -- Before/after price comparison for each datacenter opening event (year_opened >= 2019) +-- Includes natural gas price context for the same time windows WITH dc_events AS ( SELECT d.name AS dc_name, @@ -32,6 +33,26 @@ price_after AS ( AND ep.timestamp >= de.event_date AND ep.timestamp < de.event_date + INTERVAL '6 months' GROUP BY de.dc_name +), +gas_before AS ( + SELECT + de.dc_name, + AVG(cp.price) AS avg_gas_before + FROM dc_events de + JOIN commodity_prices cp ON cp.commodity = 'natural_gas' + AND cp.timestamp >= de.event_date - INTERVAL '6 months' + AND cp.timestamp < de.event_date + GROUP BY de.dc_name +), +gas_after AS ( + SELECT + de.dc_name, + AVG(cp.price) AS avg_gas_after + FROM dc_events de + JOIN commodity_prices cp ON cp.commodity = 'natural_gas' + AND cp.timestamp >= de.event_date + AND cp.timestamp < de.event_date + INTERVAL '6 months' + GROUP BY de.dc_name ) SELECT de.dc_name, @@ -45,10 +66,19 @@ SELECT WHEN pb.avg_price_before > 0 THEN ((pa.avg_price_after - pb.avg_price_before) / pb.avg_price_before * 100) ELSE NULL - END AS pct_change + END AS pct_change, + gb.avg_gas_before, + ga.avg_gas_after, + CASE + WHEN gb.avg_gas_before > 0 + THEN ((ga.avg_gas_after - gb.avg_gas_before) / gb.avg_gas_before * 100) + ELSE NULL + END AS gas_pct_change FROM dc_events de LEFT JOIN price_before pb ON pb.dc_name = de.dc_name LEFT JOIN price_after pa ON pa.dc_name = de.dc_name +LEFT JOIN gas_before gb ON gb.dc_name = de.dc_name +LEFT JOIN gas_after ga ON ga.dc_name = de.dc_name WHERE pb.avg_price_before IS NOT NULL AND pa.avg_price_after IS NOT NULL ORDER BY ABS(COALESCE(pa.avg_price_after - pb.avg_price_before, 0)) DESC diff --git a/scripts/build-and-push.sh b/scripts/build-and-push.sh new file mode 100755 index 0000000..00fad40 --- /dev/null +++ b/scripts/build-and-push.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="registry.claiborne.soy/busi488energy:latest" +KCTX="media" +DB_LOCAL_PORT=5434 + +# Source .env for build args +set -a +source "$(dirname "$0")/../.env" +set +a + +# Start port-forward to prod DB +echo "==> Port-forwarding postgis on :${DB_LOCAL_PORT}..." +kubectl --context "$KCTX" -n database port-forward svc/postgis "${DB_LOCAL_PORT}:5432" & +PF_PID=$! +trap "kill $PF_PID 2>/dev/null || true" EXIT +sleep 2 + +BUILD_DATABASE_URL="postgresql://busi488energy:busi488energy@host.docker.internal:${DB_LOCAL_PORT}/busi488energy" + +echo "==> Building ${IMAGE}..." +docker build --network=host \ + --build-arg DATABASE_URL="postgresql://busi488energy:busi488energy@127.0.0.1:${DB_LOCAL_PORT}/busi488energy" \ + --build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$NEXT_PUBLIC_GOOGLE_MAPS_API_KEY" \ + --build-arg NEXT_PUBLIC_GOOGLE_MAP_ID="$NEXT_PUBLIC_GOOGLE_MAP_ID" \ + --build-arg EIA_API_KEY="$EIA_API_KEY" \ + --build-arg FRED_API_KEY="$FRED_API_KEY" \ + -t "$IMAGE" . + +echo "==> Pushing ${IMAGE}..." +docker push "$IMAGE" + +echo "==> Restarting deployment..." +kubectl --context "$KCTX" -n random rollout restart deploy/busi488energy +kubectl --context "$KCTX" -n random rollout status deploy/busi488energy --timeout=120s + +echo "==> Done!" diff --git a/src/actions/conflict.ts b/src/actions/conflict.ts index 29d98c9..240d355 100644 --- a/src/actions/conflict.ts +++ b/src/actions/conflict.ts @@ -574,6 +574,57 @@ export async function fetchNgFuturesCurve(): Promise> { + 'use cache'; + cacheLife('conflict'); + cacheTag('steo-projections'); + + try { + const { getSteoProjections, STEO_SERIES } = await import('@/lib/api/eia.js'); + const data = await getSteoProjections({ start: '2025-01' }); + + // Current month — anything after this is a forecast + const now = new Date(); + const currentMonth = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`; + + const rows: SteoProjectionRow[] = data.map(d => { + const meta = STEO_SERIES[d.seriesId]; + return { + period: d.period, + seriesId: d.seriesId, + label: meta?.label ?? d.description, + value: d.value, + unit: meta?.unit ?? d.unit, + category: meta?.category ?? 'other', + timestamp: d.timestamp.getTime(), + isForecast: d.period > currentMonth, + }; + }); + + return { ok: true, data: serialize(rows) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch STEO projections: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + // --------------------------------------------------------------------------- // Conflict Hero Metrics (latest values for key indicators) // --------------------------------------------------------------------------- diff --git a/src/actions/dc-impact.ts b/src/actions/dc-impact.ts index 3f8867c..7d41f7e 100644 --- a/src/actions/dc-impact.ts +++ b/src/actions/dc-impact.ts @@ -18,6 +18,317 @@ interface ActionError { type ActionResult = ActionSuccess | ActionError; +// --------------------------------------------------------------------------- +// Multivariate Regression: DC Capacity effect on electricity prices +// controlling for fuel costs, demand, and generation mix +// --------------------------------------------------------------------------- + +export interface MultivariateDataPoint { + region_code: string; + region_name: string; + /** Total DC capacity in the region (MW) */ + dc_capacity_mw: number; + /** Average electricity price ($/MWh) */ + avg_price: number; + /** Average natural gas price during same period ($/MMBtu) */ + avg_gas_price: number; + /** Average demand (MW) */ + avg_demand: number; + /** Gas generation share (fraction 0–1) */ + gas_share: number; + /** Residual: price unexplained by fuel/demand model */ + residual: number; +} + +export interface RegressionCoefficient { + variable: string; + coefficient: number; + /** Standardized coefficient (beta weight) */ + beta: number; +} + +export interface MultivariateResult { + points: MultivariateDataPoint[]; + coefficients: RegressionCoefficient[]; + r2_full: number; + r2_without_dc: number; + /** Partial R² attributable to DC capacity */ + r2_partial_dc: number; + n: number; +} + +/** + * OLS multiple regression using normal equations. + * X is a matrix of [n x k] predictors (with intercept column prepended). + * Returns coefficient vector, R², and predicted values. + */ +function olsRegression( + y: number[], + X: number[][], + varNames: string[], +): { coefficients: RegressionCoefficient[]; r2: number; predicted: number[] } | null { + const n = y.length; + const k = X[0]!.length; + if (n <= k) return null; + + // Add intercept column + const Xa = X.map(row => [1, ...row]); + const ka = k + 1; + + // X'X + const XtX: number[][] = Array.from({ length: ka }, () => Array.from({ length: ka }).fill(0)); + for (let i = 0; i < ka; i++) { + for (let j = 0; j < ka; j++) { + let sum = 0; + for (let r = 0; r < n; r++) sum += Xa[r]![i]! * Xa[r]![j]!; + XtX[i]![j] = sum; + } + } + + // X'y + const Xty: number[] = Array.from({ length: ka }).fill(0); + for (let i = 0; i < ka; i++) { + let sum = 0; + for (let r = 0; r < n; r++) sum += Xa[r]![i]! * y[r]!; + Xty[i] = sum; + } + + // Solve via Gauss-Jordan elimination + const aug: number[][] = XtX.map((row, i) => [...row, Xty[i]!]); + for (let col = 0; col < ka; col++) { + // Partial pivoting + let maxRow = col; + for (let row = col + 1; row < ka; row++) { + if (Math.abs(aug[row]![col]!) > Math.abs(aug[maxRow]![col]!)) maxRow = row; + } + [aug[col], aug[maxRow]] = [aug[maxRow]!, aug[col]!]; + const pivot = aug[col]![col]!; + if (Math.abs(pivot) < 1e-12) return null; // singular + + for (let j = col; j <= ka; j++) aug[col]![j]! /= pivot; + for (let row = 0; row < ka; row++) { + if (row === col) continue; + const factor = aug[row]![col]!; + for (let j = col; j <= ka; j++) aug[row]![j]! -= factor * aug[col]![j]!; + } + } + + const beta = aug.map(row => row[ka]!); + + // Predicted and R² + const predicted = Xa.map(row => row.reduce((sum, val, i) => sum + val * beta[i]!, 0)); + const meanY = y.reduce((s, v) => s + v, 0) / n; + const ssTot = y.reduce((s, v) => s + (v - meanY) ** 2, 0); + const ssRes = y.reduce((s, v, i) => s + (v - predicted[i]!) ** 2, 0); + const r2 = ssTot === 0 ? 0 : 1 - ssRes / ssTot; + + // Compute standardized coefficients (beta weights) + // beta_std_j = beta_j * (sd_xj / sd_y) + const sdY = Math.sqrt(ssTot / n); + const coefficients: RegressionCoefficient[] = varNames.map((name, idx) => { + const colIdx = idx; // 0-indexed in original X (no intercept) + const xValues = X.map(row => row[colIdx]!); + const meanX = xValues.reduce((s, v) => s + v, 0) / n; + const sdX = Math.sqrt(xValues.reduce((s, v) => s + (v - meanX) ** 2, 0) / n); + const rawCoeff = beta[idx + 1]!; // +1 to skip intercept + return { + variable: name, + coefficient: rawCoeff, + beta: sdY > 0 && sdX > 0 ? rawCoeff * (sdX / sdY) : 0, + }; + }); + + return { coefficients, r2, predicted }; +} + +export async function fetchMultivariateAnalysis(): Promise> { + 'use cache'; + cacheLife('prices'); + cacheTag('multivariate-analysis'); + + try { + // Get all regions with DC data + const regions = await prisma.gridRegion.findMany({ + select: { + id: true, + code: true, + name: true, + datacenters: { select: { capacityMw: true } }, + }, + }); + + // For each region, compute monthly observations joining price + gas + demand + gen mix + // This gives us many more data points than just one per region + const monthlyData = await prisma.$queryRaw< + Array<{ + region_code: string; + region_name: string; + month: Date; + avg_price: number; + avg_demand: number; + }> + >` + SELECT + r.code AS region_code, + r.name AS region_name, + date_trunc('month', ep.timestamp) AS month, + AVG(ep.price_mwh) AS avg_price, + AVG(ep.demand_mw) AS avg_demand + FROM electricity_prices ep + JOIN grid_regions r ON ep.region_id = r.id + GROUP BY r.code, r.name, date_trunc('month', ep.timestamp) + HAVING COUNT(*) >= 10 + ORDER BY r.code, month + `; + + // Get monthly gas prices + const gasData = await prisma.$queryRaw>` + SELECT + date_trunc('month', timestamp) AS month, + AVG(price) AS avg_gas + FROM commodity_prices + WHERE commodity = 'natural_gas' + GROUP BY date_trunc('month', timestamp) + `; + const gasByMonth = new Map(gasData.map(g => [g.month.toISOString(), g.avg_gas])); + + // Get gas generation share per region (latest available) + const genData = await prisma.$queryRaw>` + SELECT + r.code AS region_code, + COALESCE( + SUM(CASE WHEN gm.fuel_type = 'NG' THEN gm.generation_mw ELSE 0 END) / + NULLIF(SUM(gm.generation_mw), 0), + 0 + ) AS gas_share + FROM generation_mix gm + JOIN grid_regions r ON gm.region_id = r.id + WHERE gm.timestamp >= NOW() - INTERVAL '30 days' + GROUP BY r.code + `; + const gasShareByRegion = new Map(genData.map(g => [g.region_code, Number(g.gas_share)])); + + // DC capacity per region + const dcCapByRegion = new Map(regions.map(r => [r.code, r.datacenters.reduce((sum, d) => sum + d.capacityMw, 0)])); + + // Build observation matrix: each monthly region observation is a row + const observations: Array<{ + region_code: string; + region_name: string; + price: number; + gas_price: number; + demand: number; + gas_share: number; + dc_capacity: number; + }> = []; + + for (const row of monthlyData) { + const gasPrice = gasByMonth.get(row.month.toISOString()); + if (gasPrice === undefined) continue; + + const gasShare = gasShareByRegion.get(row.region_code) ?? 0; + const dcCap = dcCapByRegion.get(row.region_code) ?? 0; + + observations.push({ + region_code: row.region_code, + region_name: row.region_name, + price: Number(row.avg_price), + gas_price: Number(gasPrice), + demand: Number(row.avg_demand), + gas_share: Number(gasShare), + dc_capacity: dcCap, + }); + } + + if (observations.length < 10) { + return { ok: false, error: 'Insufficient data for multivariate regression' }; + } + + // Full model: price ~ gas_price + demand + gas_share + dc_capacity + const y = observations.map(o => o.price); + const X_full = observations.map(o => [o.gas_price, o.demand, o.gas_share, o.dc_capacity]); + const fullResult = olsRegression(y, X_full, ['gas_price', 'demand', 'gas_share', 'dc_capacity']); + + // Reduced model: price ~ gas_price + demand + gas_share (no DC capacity) + const X_reduced = observations.map(o => [o.gas_price, o.demand, o.gas_share]); + const reducedResult = olsRegression(y, X_reduced, ['gas_price', 'demand', 'gas_share']); + + if (!fullResult || !reducedResult) { + return { ok: false, error: 'Regression failed — singular matrix or insufficient variation' }; + } + + // Partial R² for DC capacity = (R²_full - R²_reduced) / (1 - R²_reduced) + const r2PartialDc = reducedResult.r2 < 1 ? (fullResult.r2 - reducedResult.r2) / (1 - reducedResult.r2) : 0; + + // Compute residuals from the reduced model (fuel + demand only) + // These residuals show what's left after accounting for fuel costs and demand + const residuals = y.map((yi, i) => yi - reducedResult.predicted[i]!); + + // Aggregate to per-region points for the scatter chart + const regionAgg = new Map< + string, + { + name: string; + prices: number[]; + residuals: number[]; + gasPrices: number[]; + demands: number[]; + gasShare: number; + dcCap: number; + } + >(); + + for (let i = 0; i < observations.length; i++) { + const obs = observations[i]!; + const existing = regionAgg.get(obs.region_code) ?? { + name: obs.region_name, + prices: [], + residuals: [], + gasPrices: [], + demands: [], + gasShare: obs.gas_share, + dcCap: obs.dc_capacity, + }; + existing.prices.push(obs.price); + existing.residuals.push(residuals[i]!); + existing.gasPrices.push(obs.gas_price); + existing.demands.push(obs.demand); + regionAgg.set(obs.region_code, existing); + } + + const points: MultivariateDataPoint[] = Array.from(regionAgg.entries()).map(([code, agg]) => { + const avg = (arr: number[]) => arr.reduce((s, v) => s + v, 0) / arr.length; + return { + region_code: code, + region_name: agg.name, + dc_capacity_mw: agg.dcCap, + avg_price: Number(avg(agg.prices).toFixed(2)), + avg_gas_price: Number(avg(agg.gasPrices).toFixed(2)), + avg_demand: Number(avg(agg.demands).toFixed(0)), + gas_share: Number(agg.gasShare.toFixed(3)), + residual: Number(avg(agg.residuals).toFixed(2)), + }; + }); + + return { + ok: true, + data: serialize({ + points, + coefficients: fullResult.coefficients, + r2_full: Number(fullResult.r2.toFixed(4)), + r2_without_dc: Number(reducedResult.r2.toFixed(4)), + r2_partial_dc: Number(r2PartialDc.toFixed(4)), + n: observations.length, + }), + }; + } catch (err) { + return { + ok: false, + error: `Failed to run multivariate analysis: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + export async function fetchCapacityPriceTimeline( regionCode: string, ): Promise> { @@ -54,3 +365,122 @@ export async function fetchDcPriceImpact(): Promise> { + 'use cache'; + cacheLife('prices'); + cacheTag('demand-growth-attribution'); + + try { + // Get average demand per region per year + const yearlyDemand = await prisma.$queryRaw< + Array<{ region_code: string; region_name: string; yr: number; avg_demand: number }> + >` + SELECT + r.code AS region_code, + r.name AS region_name, + EXTRACT(YEAR FROM ep.timestamp)::int AS yr, + AVG(ep.demand_mw) AS avg_demand + FROM electricity_prices ep + JOIN grid_regions r ON ep.region_id = r.id + GROUP BY r.code, r.name, EXTRACT(YEAR FROM ep.timestamp) + HAVING COUNT(*) >= 50 + ORDER BY r.code, yr + `; + + // Get DC capacity by region, grouped by year_opened + const dcCapacity = await prisma.$queryRaw< + Array<{ region_code: string; year_opened: number; total_capacity: number }> + >` + SELECT + r.code AS region_code, + d.year_opened, + SUM(d.capacity_mw) AS total_capacity + FROM datacenters d + JOIN grid_regions r ON d.region_id = r.id + GROUP BY r.code, d.year_opened + `; + + // Build per-region results + const byRegion = new Map(); + for (const row of yearlyDemand) { + const arr = byRegion.get(row.region_code) ?? []; + arr.push(row); + byRegion.set(row.region_code, arr); + } + + const dcByRegion = new Map(); + for (const row of dcCapacity) { + const arr = dcByRegion.get(row.region_code) ?? []; + arr.push(row); + dcByRegion.set(row.region_code, arr); + } + + const results: DemandGrowthRow[] = []; + + for (const [code, years] of byRegion.entries()) { + if (years.length < 2) continue; + const sorted = years.sort((a, b) => a.yr - b.yr); + const earliest = sorted[0]!; + const latest = sorted[sorted.length - 1]!; + + const baselineDemand = Number(earliest.avg_demand); + const currentDemand = Number(latest.avg_demand); + const demandGrowth = currentDemand - baselineDemand; + const yearSpan = latest.yr - earliest.yr; + + // Sum DC capacity added in this period + const dcEntries = dcByRegion.get(code) ?? []; + const dcAdded = dcEntries + .filter(d => d.year_opened >= earliest.yr && d.year_opened <= latest.yr) + .reduce((sum, d) => sum + Number(d.total_capacity), 0); + + // DC load is typically ~85% of nameplate capacity (high utilization) + const dcLoadMw = dcAdded * 0.85; + const dcShareOfGrowth = demandGrowth > 0 ? Math.min(1, dcLoadMw / demandGrowth) : 0; + + const growthRate = + yearSpan > 0 && baselineDemand > 0 ? ((currentDemand / baselineDemand) ** (1 / yearSpan) - 1) * 100 : 0; + + results.push({ + region_code: code, + region_name: earliest.region_name, + baseline_demand_mw: Number(baselineDemand.toFixed(0)), + current_demand_mw: Number(currentDemand.toFixed(0)), + demand_growth_mw: Number(demandGrowth.toFixed(0)), + dc_capacity_added_mw: Number(dcAdded.toFixed(0)), + dc_share_of_growth: Number(dcShareOfGrowth.toFixed(3)), + growth_rate_pct: Number(growthRate.toFixed(2)), + }); + } + + results.sort((a, b) => b.dc_share_of_growth - a.dc_share_of_growth); + return { ok: true, data: serialize(results) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch demand growth attribution: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} diff --git a/src/app/conflict/_sections/hero-metrics.tsx b/src/app/conflict/_sections/hero-metrics.tsx index ed27b2a..bda8b47 100644 --- a/src/app/conflict/_sections/hero-metrics.tsx +++ b/src/app/conflict/_sections/hero-metrics.tsx @@ -57,7 +57,6 @@ export async function ConflictHeroMetrics() { alertThreshold={100} /> - diff --git a/src/app/conflict/_sections/market-fear-section.tsx b/src/app/conflict/_sections/market-fear-section.tsx index a998e24..787e415 100644 --- a/src/app/conflict/_sections/market-fear-section.tsx +++ b/src/app/conflict/_sections/market-fear-section.tsx @@ -5,14 +5,21 @@ import { deserialize } from '@/lib/superjson.js'; import type { MarketIndicatorRow } from '@/actions/conflict.js'; -/** Compute percentile-based thresholds from historical data */ -function computeThresholds(prices: number[], p50 = 0.6, p90 = 0.85): [number, number] { - if (prices.length < 5) return [0, 0]; - const sorted = [...prices].sort((a, b) => a - b); - const idx50 = Math.floor(sorted.length * p50); - const idx90 = Math.floor(sorted.length * p90); - return [sorted[idx50]!, sorted[idx90]!]; -} +/** + * Absolute thresholds based on well-established historical interpretation scales. + * Percentile-based thresholds are unreliable with limited/skewed data windows. + * + * VIX: <20 calm, 20-30 elevated, >30 extreme (>40 = panic) + * OVX: <25 calm, 25-40 elevated, >40 extreme + * DXY: <100 weak, 100-110 elevated, >110 strong (inverted — strong $ hurts commodities) + * STLFSI: <0 below-avg stress, 0-1.5 elevated, >1.5 extreme (>3 = crisis) + */ +const ABSOLUTE_THRESHOLDS: Record = { + ovx: [25, 40], + vix: [20, 30], + dxy: [100, 110], + financial_stress: [0, 1.5], +}; export async function MarketFearSection() { const result = await fetchMarketIndicators('1y'); @@ -20,14 +27,6 @@ export async function MarketFearSection() { const rows = deserialize(result.data); - // Collect all values per commodity for threshold computation - const historyByType = new Map(); - for (const row of rows) { - const arr = historyByType.get(row.commodity) ?? []; - arr.push(row.price); - historyByType.set(row.commodity, arr); - } - // Get latest value for each indicator (data is sorted by timestamp asc) const latestByType = new Map(); for (let i = rows.length - 1; i >= 0; i--) { @@ -37,26 +36,18 @@ export async function MarketFearSection() { } } - // Compute data-driven thresholds (60th and 85th percentile of 1-year data) - // Fall back to static thresholds if insufficient data - const ovxThresholds = historyByType.has('ovx') - ? computeThresholds(historyByType.get('ovx')!) - : ([25, 40] as [number, number]); - const vixThresholds = historyByType.has('vix') - ? computeThresholds(historyByType.get('vix')!) - : ([20, 35] as [number, number]); - const dxyThresholds = historyByType.has('dxy') - ? computeThresholds(historyByType.get('dxy')!) - : ([100, 120] as [number, number]); - const stressThresholds = historyByType.has('financial_stress') - ? computeThresholds(historyByType.get('financial_stress')!) - : ([1, 3] as [number, number]); + const ovxThresholds = ABSOLUTE_THRESHOLDS['ovx']!; + const vixThresholds = ABSOLUTE_THRESHOLDS['vix']!; + const dxyThresholds = ABSOLUTE_THRESHOLDS['dxy']!; + const stressThresholds = ABSOLUTE_THRESHOLDS['financial_stress']!; return ( Market Fear Indicators - Gauges colored by 1-year percentile trends (60th = elevated, 85th = extreme) + + Gauges colored by historical absolute thresholds (VIX >30, OVX >40, STLFSI >1.5 = extreme) +
diff --git a/src/app/conflict/_sections/steo-section.tsx b/src/app/conflict/_sections/steo-section.tsx new file mode 100644 index 0000000..29430dd --- /dev/null +++ b/src/app/conflict/_sections/steo-section.tsx @@ -0,0 +1,16 @@ +import { fetchSteoProjections } from '@/actions/conflict.js'; +import { SteoProjectionsChart } from '@/components/charts/steo-projections-chart.js'; + +export async function SteoSection() { + const result = await fetchSteoProjections(); + + if (!result.ok) { + return ( +
+

{result.error}

+
+ ); + } + + return ; +} diff --git a/src/app/conflict/page.tsx b/src/app/conflict/page.tsx index 10e764c..252c40b 100644 --- a/src/app/conflict/page.tsx +++ b/src/app/conflict/page.tsx @@ -14,6 +14,7 @@ import { ConflictHeroMetrics } from './_sections/hero-metrics.js'; import { MarketFearSection } from './_sections/market-fear-section.js'; import { NormalizedSection } from './_sections/normalized-section.js'; import { OilSection } from './_sections/oil-section.js'; +import { SteoSection } from './_sections/steo-section.js'; import { SupplySection } from './_sections/supply-section.js'; import { WarPremiumSection } from './_sections/war-premium-section.js'; @@ -113,6 +114,11 @@ export default function ConflictPage() { + {/* STEO Energy Projections — forecast vs actual */} + }> + + + {/* Market Fear Indicators */} }> diff --git a/src/app/trends/_sections/demand-growth-section.tsx b/src/app/trends/_sections/demand-growth-section.tsx new file mode 100644 index 0000000..f4d0a3b --- /dev/null +++ b/src/app/trends/_sections/demand-growth-section.tsx @@ -0,0 +1,16 @@ +import { fetchDemandGrowthAttribution } from '@/actions/dc-impact.js'; +import { DemandGrowthChart } from '@/components/charts/demand-growth-chart.js'; + +export async function DemandGrowthSection() { + const result = await fetchDemandGrowthAttribution(); + + if (!result.ok) { + return ( +
+

{result.error}

+
+ ); + } + + return ; +} diff --git a/src/app/trends/_sections/multivariate-section.tsx b/src/app/trends/_sections/multivariate-section.tsx new file mode 100644 index 0000000..929e1b0 --- /dev/null +++ b/src/app/trends/_sections/multivariate-section.tsx @@ -0,0 +1,16 @@ +import { fetchMultivariateAnalysis } from '@/actions/dc-impact.js'; +import { ResidualChart } from '@/components/charts/residual-chart.js'; + +export async function MultivariateSection() { + const result = await fetchMultivariateAnalysis(); + + if (!result.ok) { + return ( +
+

{result.error}

+
+ ); + } + + return ; +} diff --git a/src/app/trends/page.tsx b/src/app/trends/page.tsx index eef0428..6097c70 100644 --- a/src/app/trends/page.tsx +++ b/src/app/trends/page.tsx @@ -5,6 +5,8 @@ import type { Metadata } from 'next'; import { CorrelationSection } from './_sections/correlation-section.js'; import { DcImpactSection } from './_sections/dc-impact-section.js'; +import { DemandGrowthSection } from './_sections/demand-growth-section.js'; +import { MultivariateSection } from './_sections/multivariate-section.js'; import { PriceChartSection } from './_sections/price-chart-section.js'; export const metadata: Metadata = { @@ -30,9 +32,20 @@ export default function TrendsPage() {
+ {/* Multivariate analysis: price residuals after controlling for fuel/demand */} + }> + + + + {/* Simple bivariate correlation for comparison */} }> + + {/* Demand growth attribution: DC share of load growth */} + }> + +
); } diff --git a/src/components/charts/dc-price-impact-bars.tsx b/src/components/charts/dc-price-impact-bars.tsx index 245e054..3ac09f0 100644 --- a/src/components/charts/dc-price-impact-bars.tsx +++ b/src/components/charts/dc-price-impact-bars.tsx @@ -33,6 +33,9 @@ interface RegionRow { avg_price_after: number; pct_change: number; increased: boolean; + avg_gas_before: number | null; + avg_gas_after: number | null; + gas_pct_change: number | null; } interface DcRow { @@ -44,16 +47,38 @@ interface DcRow { avg_price_after: number; pct_change: number; increased: boolean; + avg_gas_before: number | null; + avg_gas_after: number | null; + gas_pct_change: number | null; } function aggregateByRegion(rows: getDcPriceImpact.Result[]): RegionRow[] { - const byRegion = new Map(); + const byRegion = new Map< + string, + { + before: number[]; + after: number[]; + gasBefore: number[]; + gasAfter: number[]; + count: number; + capacity: number; + } + >(); for (const r of rows) { if (r.avg_price_before === null || r.avg_price_after === null) continue; - const existing = byRegion.get(r.region_code) ?? { before: [], after: [], count: 0, capacity: 0 }; + const existing = byRegion.get(r.region_code) ?? { + before: [], + after: [], + gasBefore: [], + gasAfter: [], + count: 0, + capacity: 0, + }; existing.before.push(r.avg_price_before); existing.after.push(r.avg_price_after); + if (r.avg_gas_before !== null) existing.gasBefore.push(r.avg_gas_before); + if (r.avg_gas_after !== null) existing.gasAfter.push(r.avg_gas_after); existing.count++; existing.capacity += r.capacity_mw; byRegion.set(r.region_code, existing); @@ -64,6 +89,14 @@ function aggregateByRegion(rows: getDcPriceImpact.Result[]): RegionRow[] { const avgBefore = data.before.reduce((s, v) => s + v, 0) / data.before.length; const avgAfter = data.after.reduce((s, v) => s + v, 0) / data.after.length; const pctChange = avgBefore > 0 ? ((avgAfter - avgBefore) / avgBefore) * 100 : 0; + const avgGasBefore = + data.gasBefore.length > 0 ? data.gasBefore.reduce((s, v) => s + v, 0) / data.gasBefore.length : null; + const avgGasAfter = + data.gasAfter.length > 0 ? data.gasAfter.reduce((s, v) => s + v, 0) / data.gasAfter.length : null; + const gasPctChange = + avgGasBefore && avgGasBefore > 0 && avgGasAfter !== null + ? ((avgGasAfter - avgGasBefore) / avgGasBefore) * 100 + : null; return { region_code: code, dc_count: data.count, @@ -72,6 +105,9 @@ function aggregateByRegion(rows: getDcPriceImpact.Result[]): RegionRow[] { avg_price_after: avgAfter, pct_change: pctChange, increased: avgAfter >= avgBefore, + avg_gas_before: avgGasBefore, + avg_gas_after: avgGasAfter, + gas_pct_change: gasPctChange, }; }) .sort((a, b) => Math.abs(b.pct_change) - Math.abs(a.pct_change)); @@ -89,10 +125,27 @@ function transformDcData(rows: getDcPriceImpact.Result[]): DcRow[] { avg_price_after: r.avg_price_after!, pct_change: r.pct_change ?? 0, increased: (r.avg_price_after ?? 0) >= (r.avg_price_before ?? 0), + avg_gas_before: r.avg_gas_before, + avg_gas_after: r.avg_gas_after, + gas_pct_change: r.gas_pct_change, })) .sort((a, b) => Math.abs(b.pct_change) - Math.abs(a.pct_change)); } +/** Small inline indicator showing gas price movement direction */ +function GasContext({ gasPct, elecPct }: { gasPct: number | null; elecPct: number }) { + if (gasPct === null) return null; + const sameDirection = (gasPct >= 0 && elecPct >= 0) || (gasPct < 0 && elecPct < 0); + return ( + = 0 ? '+' : ''}${gasPct.toFixed(1)}% in same period${sameDirection ? ' (moved same direction — confounded)' : ' (moved opposite direction — DC effect likely real)'}`}> + ⛽{gasPct >= 0 ? '+' : ''} + {gasPct.toFixed(0)}% + + ); +} + export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) { const [viewMode, setViewMode] = useState<'region' | 'datacenter'>('region'); const rawRows = useMemo(() => deserialize(data), [data]); @@ -107,6 +160,21 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) { return barData.reduce((sum, r) => sum + r.pct_change, 0) / barData.length; }, [barData]); + // Count how many entries have gas moving same direction vs opposite + const confoundedCount = useMemo(() => { + let same = 0; + let opposite = 0; + for (const row of barData) { + if (row.gas_pct_change === null) continue; + if ((row.gas_pct_change >= 0 && row.pct_change >= 0) || (row.gas_pct_change < 0 && row.pct_change < 0)) { + same++; + } else { + opposite++; + } + } + return { same, opposite }; + }, [barData]); + if (barData.length === 0) { return ( @@ -184,16 +252,25 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) { { - const labelStr = String(label); + labelFormatter={_label => { + const labelStr = + typeof _label === 'string' ? _label : typeof _label === 'number' ? String(_label) : ''; if (viewMode === 'region') { const region = regionData.find(d => d.region_code === labelStr); if (!region) return labelStr; - return `${region.region_code} — ${region.dc_count} DCs, ${region.total_capacity_mw} MW total`; + const gasTxt = + region.gas_pct_change !== null + ? ` | Gas: ${region.gas_pct_change >= 0 ? '+' : ''}${region.gas_pct_change.toFixed(1)}%` + : ''; + return `${region.region_code} — ${region.dc_count} DCs, ${region.total_capacity_mw} MW${gasTxt}`; } const item = dcData.find(d => d.dc_name === labelStr); if (!item) return labelStr; - return `${item.dc_name} (${item.region_code}, ${item.year_opened}) — ${item.capacity_mw} MW`; + const gasTxt = + item.gas_pct_change !== null + ? ` | Gas: ${item.gas_pct_change >= 0 ? '+' : ''}${item.gas_pct_change.toFixed(1)}%` + : ''; + return `${item.dc_name} (${item.region_code}, ${item.year_opened}) — ${item.capacity_mw} MW${gasTxt}`; }} formatter={(value, name) => { const numVal = Number(value); @@ -214,7 +291,25 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) { -
+ {/* Gas price context annotations below chart */} + {viewMode === 'region' && ( +
+ {regionData.map(row => ( +
+ {row.region_code} + = 0 ? 'text-red-400/80' : 'text-emerald-400/80'}> + ⚡{row.pct_change >= 0 ? '+' : ''} + {row.pct_change.toFixed(0)}% + + +
+ ))} +
+ )} + +
Before DC Opening @@ -227,9 +322,20 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) { After (Price Decreased)
+
+ + Gas price change (same period) +

+ {confoundedCount.same > 0 && ( + <> + In {confoundedCount.same} of {confoundedCount.same + confoundedCount.opposite} cases, gas prices moved in + the same direction as electricity prices — the apparent DC effect may be confounded by fuel cost + changes.{' '} + + )} Correlation does not imply causation. Electricity prices are influenced by many factors including fuel costs, weather, grid congestion, regulatory changes, and seasonal demand patterns.

diff --git a/src/components/charts/demand-growth-chart.tsx b/src/components/charts/demand-growth-chart.tsx new file mode 100644 index 0000000..b1804e8 --- /dev/null +++ b/src/components/charts/demand-growth-chart.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useMemo } from 'react'; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'; +import type { SuperJSONResult } from 'superjson'; + +import type { DemandGrowthRow } from '@/actions/dc-impact.js'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart.js'; +import { deserialize } from '@/lib/superjson.js'; +import { cn } from '@/lib/utils.js'; + +interface DemandGrowthChartProps { + data: SuperJSONResult; +} + +const chartConfig: ChartConfig = { + dc_load: { + label: 'DC Load Contribution', + color: 'hsl(25, 90%, 55%)', + }, + other_growth: { + label: 'Other Growth', + color: 'hsl(210, 70%, 50%)', + }, +}; + +export function DemandGrowthChart({ data }: DemandGrowthChartProps) { + const rows = useMemo(() => deserialize(data), [data]); + + const chartData = useMemo( + () => + rows + .filter(r => r.demand_growth_mw !== 0 || r.dc_capacity_added_mw > 0) + .map(r => { + const dcLoad = r.dc_capacity_added_mw * 0.85; // 85% utilization + const otherGrowth = Math.max(0, r.demand_growth_mw - dcLoad); + return { + region_code: r.region_code, + dc_load: Number(dcLoad.toFixed(0)), + other_growth: Number(otherGrowth.toFixed(0)), + total_growth: r.demand_growth_mw, + dc_share: r.dc_share_of_growth, + growth_rate: r.growth_rate_pct, + dc_capacity: r.dc_capacity_added_mw, + }; + }) + .sort((a, b) => b.dc_share - a.dc_share), + [rows], + ); + + const totalDcLoad = useMemo(() => chartData.reduce((sum, r) => sum + r.dc_load, 0), [chartData]); + const totalGrowth = useMemo(() => chartData.reduce((sum, r) => sum + r.total_growth, 0), [chartData]); + const overallDcShare = totalGrowth > 0 ? totalDcLoad / totalGrowth : 0; + + if (chartData.length === 0) { + return ( + + + Demand Growth Attribution + No demand growth data available. + + + ); + } + + return ( + + +
+
+ Demand Growth Attribution + + Regional load growth decomposed into datacenter contribution vs. other sources + +
+
+
+ DC Share of Growth + 0.3 ? 'text-amber-400' : 'text-foreground', + )}> + {(overallDcShare * 100).toFixed(1)}% + +
+
+ Total DC Load + + {totalDcLoad >= 1000 ? `${(totalDcLoad / 1000).toFixed(1)} GW` : `${totalDcLoad} MW`} + +
+
+
+
+ + + + + + (v >= 1000 ? `${(v / 1000).toFixed(1)}GW` : `${v}MW`)} + label={{ + value: 'Load Growth (MW)', + angle: -90, + position: 'insideLeft', + style: { fontSize: 11, fill: 'var(--color-muted-foreground)' }, + }} + /> + { + const labelStr = + typeof _label === 'string' ? _label : typeof _label === 'number' ? String(_label) : ''; + const row = chartData.find(d => d.region_code === labelStr); + if (!row) return labelStr; + return `${row.region_code} — ${row.dc_capacity} MW DC capacity, ${row.growth_rate.toFixed(1)}%/yr growth`; + }} + formatter={(value, name) => { + const v = Number(value); + const nameStr = String(name); + if (nameStr === 'dc_load') return [`${v} MW (DC load at 85% utilization)`, undefined]; + if (nameStr === 'other_growth') return [`${v} MW (non-DC growth)`, undefined]; + return [`${v}`, undefined]; + }} + /> + } + /> + + + + + +
+
+ + Datacenter Load (85% of nameplate) +
+
+ + Other Demand Growth +
+
+ +

+ DC load estimated at 85% of nameplate capacity (high-utilization facilities). Regions where DCs account for a + large share of load growth are more likely to see price pressure from datacenter demand, independent of fuel + cost changes. +

+
+
+ ); +} diff --git a/src/components/charts/normalized-conflict-chart.tsx b/src/components/charts/normalized-conflict-chart.tsx index 9ed0b63..17da15e 100644 --- a/src/components/charts/normalized-conflict-chart.tsx +++ b/src/components/charts/normalized-conflict-chart.tsx @@ -30,7 +30,6 @@ const ALL_SERIES = [ { key: 'ovx', label: 'OVX', color: 'hsl(280, 70%, 60%)' }, { key: 'vix', label: 'VIX', color: 'hsl(320, 60%, 55%)' }, { key: 'gpr_daily', label: 'GPR Index', color: 'hsl(45, 90%, 50%)' }, - { key: 'dubai_crude', label: 'Dubai Crude', color: 'hsl(160, 60%, 50%)' }, ] as const; const chartConfig: ChartConfig = Object.fromEntries(ALL_SERIES.map(s => [s.key, { label: s.label, color: s.color }])); diff --git a/src/components/charts/oil-chart.tsx b/src/components/charts/oil-chart.tsx index e1820f4..a52fa3c 100644 --- a/src/components/charts/oil-chart.tsx +++ b/src/components/charts/oil-chart.tsx @@ -29,14 +29,12 @@ interface PivotedRow { label: string; wti_crude?: number; brent_crude?: number; - dubai_crude?: number; spread?: number; } const chartConfig: ChartConfig = { brent_crude: { label: 'Brent Crude', color: 'hsl(0, 70%, 55%)' }, wti_crude: { label: 'WTI Crude', color: 'hsl(210, 80%, 55%)' }, - dubai_crude: { label: 'Dubai Crude', color: 'hsl(160, 60%, 50%)' }, spread: { label: 'WTI-Brent Spread', color: 'hsl(45, 80%, 55%)' }, }; @@ -59,7 +57,6 @@ export function OilChart({ oilData, events }: OilChartProps) { const pivot = byDay.get(dayKey)!; if (row.commodity === 'wti_crude') pivot.wti_crude = row.price; if (row.commodity === 'brent_crude') pivot.brent_crude = row.price; - if (row.commodity === 'dubai_crude') pivot.dubai_crude = row.price; } const result = Array.from(byDay.values()).sort((a, b) => a.timestamp - b.timestamp); @@ -102,7 +99,7 @@ export function OilChart({ oilData, events }: OilChartProps) {
Oil Prices - WTI, Brent & Dubai crude benchmarks with geopolitical event annotations + WTI & Brent crude benchmarks with geopolitical event annotations
+ ))} +
+
+
+ {activeSeries.map(([id, label]) => ( + + ))} +
+ + + + + + + { + if (activeCategory === 'demand') return `${v}`; + return `$${v}`; + }} + /> + {firstForecast && ( + + )} + d.period >= WAR_MONTH)?.label} + stroke="hsl(0, 70%, 50%)" + strokeDasharray="4 4" + strokeWidth={1} + label={{ + value: 'War Start', + position: 'insideTopLeft', + style: { fontSize: 9, fill: 'hsl(0, 70%, 50%)' }, + }} + /> + { + const v = Number(value); + if (activeCategory === 'demand') return [`${v.toFixed(1)} billion kWh`, undefined]; + return [`$${v.toFixed(2)}`, undefined]; + }} + /> + } + /> + {activeSeries.map(([id]) => ( + + ))} + + + +

+ Data source: EIA Short-Term Energy Outlook (STEO). Projections are model-based estimates updated monthly. + Values past the "Forecast" line are EIA projections, not observed data. The war started Feb 28 — + compare pre-war projections with post-war actuals to see the conflict's impact on forecasts. +

+
+
+ ); +} diff --git a/src/components/dashboard/price-alert.tsx b/src/components/dashboard/price-alert.tsx index 98bf8aa..d749d09 100644 --- a/src/components/dashboard/price-alert.tsx +++ b/src/components/dashboard/price-alert.tsx @@ -13,9 +13,20 @@ const SPIKE_WARNING_SIGMA = 1.8; const JUMP_PCT_THRESHOLD = 0.15; const CHECK_INTERVAL_MS = 60_000; +/** + * Generate a unique key for each alert type. + * Alerts are deduplicated per session — once shown, the same alert won't fire again + * unless the underlying condition changes (different sigma band or price direction). + */ +function alertKey(type: 'spike' | 'warning' | 'jump', regionCode: string): string { + return `${type}:${regionCode}`; +} + export function PriceAlertMonitor() { const previousPricesRef = useRef>(new Map()); const initialLoadRef = useRef(true); + /** Set of alert keys already shown this session */ + const shownAlertsRef = useRef>(new Set()); useEffect(() => { async function checkForSpikes() { @@ -24,6 +35,7 @@ export function PriceAlertMonitor() { const prices = deserialize(result.data); const prevPrices = previousPricesRef.current; + const shownAlerts = shownAlertsRef.current; for (const p of prices) { const prevPrice = prevPrices.get(p.region_code); @@ -36,24 +48,43 @@ export function PriceAlertMonitor() { const sigmas = (p.price_mwh - avg) / sd; if (sigmas >= SPIKE_CRITICAL_SIGMA) { - toast.error(`Price Spike: ${p.region_code}`, { - description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`, - duration: 8000, - }); + const key = alertKey('spike', p.region_code); + if (!shownAlerts.has(key)) { + shownAlerts.add(key); + toast.error(`Price Spike: ${p.region_code}`, { + description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`, + duration: 8000, + }); + } } else if (sigmas >= SPIKE_WARNING_SIGMA) { - toast.warning(`Elevated Price: ${p.region_code}`, { - description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`, - duration: 6000, - }); + const key = alertKey('warning', p.region_code); + if (!shownAlerts.has(key)) { + shownAlerts.add(key); + toast.warning(`Elevated Price: ${p.region_code}`, { + description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`, + duration: 6000, + }); + } + } else { + // Price returned to normal — clear alerts so they can fire again if it spikes later + shownAlerts.delete(alertKey('spike', p.region_code)); + shownAlerts.delete(alertKey('warning', p.region_code)); } } if (prevPrice !== undefined && prevPrice > 0 && p.price_mwh > prevPrice * (1 + JUMP_PCT_THRESHOLD)) { - const jumpPct = ((p.price_mwh - prevPrice) / prevPrice) * 100; - toast.warning(`Price Jump: ${p.region_code}`, { - description: `$${p.price_mwh.toFixed(2)}/MWh — up ${jumpPct.toFixed(1)}% from last reading`, - duration: 6000, - }); + const key = alertKey('jump', p.region_code); + if (!shownAlerts.has(key)) { + shownAlerts.add(key); + const jumpPct = ((p.price_mwh - prevPrice) / prevPrice) * 100; + toast.warning(`Price Jump: ${p.region_code}`, { + description: `$${p.price_mwh.toFixed(2)}/MWh — up ${jumpPct.toFixed(1)}% from last reading`, + duration: 6000, + }); + } + } else { + // No jump — allow future jump alerts for this region + shownAlerts.delete(alertKey('jump', p.region_code)); } } diff --git a/src/components/dashboard/ticker-tape.tsx b/src/components/dashboard/ticker-tape.tsx index 736f2c9..b1e22a7 100644 --- a/src/components/dashboard/ticker-tape.tsx +++ b/src/components/dashboard/ticker-tape.tsx @@ -102,7 +102,6 @@ const COMMODITY_LABELS: Record = { vix: 'VIX', dxy: 'DXY', financial_stress: 'Fin Stress', - dubai_crude: 'Dubai', ng_futures_1: 'NG F1', ng_futures_2: 'NG F2', ng_futures_3: 'NG F3', diff --git a/src/lib/api/eia.ts b/src/lib/api/eia.ts index 0e3392a..a82e499 100644 --- a/src/lib/api/eia.ts +++ b/src/lib/api/eia.ts @@ -593,3 +593,93 @@ export async function getRetailElectricityPrices(options: GetRetailPriceOptions return results; } + +// --------------------------------------------------------------------------- +// EIA STEO — Short-Term Energy Outlook (monthly projections) +// --------------------------------------------------------------------------- + +/** STEO series IDs we fetch for energy price/demand projections */ +export const STEO_SERIES: Record = { + // Wholesale electricity prices by region + ELWHU_TX: { label: 'Wholesale Elec — ERCOT', unit: '$/MWh', category: 'electricity' }, + ELWHU_PJ: { label: 'Wholesale Elec — PJM', unit: '$/MWh', category: 'electricity' }, + ELWHU_CA: { label: 'Wholesale Elec — CAISO', unit: '$/MWh', category: 'electricity' }, + ELWHU_NY: { label: 'Wholesale Elec — NYISO', unit: '$/MWh', category: 'electricity' }, + ELWHU_NE: { label: 'Wholesale Elec — ISONE', unit: '$/MWh', category: 'electricity' }, + ELWHU_MW: { label: 'Wholesale Elec — Midwest', unit: '$/MWh', category: 'electricity' }, + ELWHU_SP: { label: 'Wholesale Elec — SPP', unit: '$/MWh', category: 'electricity' }, + // Retail electricity + ESTCU_US: { label: 'Retail Elec — All Sectors', unit: 'cents/kWh', category: 'electricity' }, + // Demand + ELCOTWH: { label: 'US Electricity Consumption', unit: 'billion kWh', category: 'demand' }, + // Fuel + NGHHUUS: { label: 'Henry Hub Gas Price', unit: '$/MMBtu', category: 'fuel' }, + BREPUUS: { label: 'Brent Crude Price', unit: '$/barrel', category: 'fuel' }, +}; + +const steoRowSchema = z.object({ + period: z.string(), + seriesId: z.string(), + seriesDescription: z.string(), + value: z.union([z.string(), z.number(), z.null()]), + unit: z.string(), +}); + +const steoResponseSchema = z.object({ + response: z.object({ + total: z.coerce.number(), + data: z.array(steoRowSchema), + }), +}); + +export interface SteoDataPoint { + period: string; // YYYY-MM + seriesId: string; + description: string; + value: number; + unit: string; + timestamp: Date; +} + +/** + * Fetch STEO monthly projections for key energy series. + * Returns historical + forecast data (typically 18 months out from current). + */ +export async function getSteoProjections(options: { start?: string; end?: string } = {}): Promise { + const seriesIds = Object.keys(STEO_SERIES); + + const params: EiaQueryParams = { + frequency: 'monthly', + start: options.start ?? '2025-01', + end: options.end, + facets: { seriesId: seriesIds }, + sort: [{ column: 'period', direction: 'asc' }], + }; + + const rows = await fetchAllPages('/steo/data/', params, json => { + const parsed = steoResponseSchema.parse(json); + return { total: parsed.response.total, data: parsed.response.data }; + }); + + const results: SteoDataPoint[] = []; + for (const row of rows) { + const val = row.value; + const numVal = typeof val === 'number' ? val : typeof val === 'string' ? parseFloat(val) : NaN; + if (isNaN(numVal)) continue; + + // Period is YYYY-MM, convert to first of month + const [year, month] = row.period.split('-').map(Number); + if (!year || !month) continue; + + results.push({ + period: row.period, + seriesId: row.seriesId, + description: row.seriesDescription, + value: numVal, + unit: row.unit, + timestamp: new Date(Date.UTC(year, month - 1, 1)), + }); + } + + return results; +}