diff --git a/src/actions/conflict.ts b/src/actions/conflict.ts index c76edc0..29d98c9 100644 --- a/src/actions/conflict.ts +++ b/src/actions/conflict.ts @@ -52,7 +52,7 @@ export async function fetchOilPrices(timeRange: TimeRange = '90d'): Promise> { + 'use cache'; + cacheLife('conflict'); + cacheTag(`crack-spread-${timeRange}`); + + try { + const startDate = timeRangeToStartDate(timeRange); + const rows = await prisma.commodityPrice.findMany({ + where: { + commodity: { in: ['gasoline', 'wti_crude'] }, + timestamp: { gte: startDate }, + }, + orderBy: { timestamp: 'asc' }, + select: { commodity: true, price: true, timestamp: true }, + }); + + // Pivot by week (gasoline is weekly, crude is daily) + const byWeek = new Map(); + + for (const row of rows) { + // Round to week start (Monday) + const d = new Date(row.timestamp); + const day = d.getUTCDay(); + const diff = d.getUTCDate() - day + (day === 0 ? -6 : 1); + const weekStart = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), diff)); + const weekKey = weekStart.toISOString().slice(0, 10); + + if (!byWeek.has(weekKey)) { + byWeek.set(weekKey, { + ts: weekStart.getTime(), + label: weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + }); + } + const entry = byWeek.get(weekKey)!; + if (row.commodity === 'gasoline') entry.gasoline = row.price; + if (row.commodity === 'wti_crude') entry.crude = row.price; + } + + const result: CrackSpreadRow[] = []; + for (const entry of byWeek.values()) { + if (entry.gasoline === undefined || entry.crude === undefined) continue; + // Convert gasoline $/gal to $/bbl (42 gal/bbl) + const gasolinePerBbl = entry.gasoline * 42; + result.push({ + timestamp: entry.ts, + label: entry.label, + gasolinePerBbl, + crudePrice: entry.crude, + crackSpread: gasolinePerBbl - entry.crude, + }); + } + + result.sort((a, b) => a.timestamp - b.timestamp); + return { ok: true, data: serialize(result) }; + } catch (err) { + return { ok: false, error: `Failed to fetch crack spread: ${err instanceof Error ? err.message : String(err)}` }; + } +} + // --------------------------------------------------------------------------- // Market Indicators (OVX, VIX, DXY, Financial Stress) // --------------------------------------------------------------------------- @@ -229,22 +300,40 @@ export async function fetchGeopoliticalEvents( } // --------------------------------------------------------------------------- -// War Premium Calculator +// War Premium Calculator — Multi-channel conflict energy impact // --------------------------------------------------------------------------- export interface WarPremiumRow { regionCode: string; regionName: string; gasGenerationShare: number; - currentGasPrice: number; - baselineGasPrice: number; + /** Gas-to-power cost change ($/MWh) — can be negative if gas fell */ + gasPremiumMwh: number; + /** Oil/petroleum cost passthrough to electricity ($/MWh) */ + oilPremiumMwh: number; + /** Combined premium ($/MWh) — clamped to >= 0 */ premiumMwh: number; + /** Brent crude % change from pre-war baseline */ + brentPctChange: number; + /** Gasoline price increase from pre-war baseline ($/gal) */ + gasolinePriceIncrease: number; + /** Estimated monthly household energy cost increase ($) */ + monthlyHouseholdImpact: number; } +const WAR_START = new Date('2026-02-28T00:00:00Z'); +const PRE_WAR_WINDOW_START = new Date('2026-01-15T00:00:00Z'); + /** - * Calculate the war premium for each region. - * Premium = (currentGas - baselineGas) * gasShare * heatRate - * where heatRate ≈ 7 MMBtu/MWh for efficient CCGT. + * Calculate the conflict energy premium for each region using dynamic + * pre-war baselines and multi-channel impact analysis. + * + * Channels: + * 1. Gas-to-power: (current_gas - baseline_gas) * gas_share * heat_rate + * 2. Oil passthrough: brent_increase% * oil_sensitivity * avg_elec_price + * Oil affects electricity via peaker dispatch, fuel transport costs, + * and general energy commodity correlation. + * 3. Consumer petroleum: gasoline/diesel increase → household cost impact */ export async function fetchWarPremium(): Promise> { 'use cache'; @@ -252,18 +341,51 @@ export async function fetchWarPremium(): Promise> cacheTag('war-premium'); try { - const HEAT_RATE_MMBTU_PER_MWH = 7; - const BASELINE_GAS_PRICE = 4.0; // $/MMBtu pre-war baseline (Feb 2026) + const HEAT_RATE = 7; // MMBtu/MWh for efficient CCGT - // Get current natural gas price (latest) - const latestGas = await prisma.commodityPrice.findFirst({ - where: { commodity: 'natural_gas' }, - orderBy: { timestamp: 'desc' }, - select: { price: true }, + // Dynamic baselines: median prices 6 weeks before war started + const preWarPrices = await prisma.commodityPrice.findMany({ + where: { + commodity: { in: ['natural_gas', 'brent_crude', 'gasoline', 'diesel'] }, + timestamp: { gte: PRE_WAR_WINDOW_START, lt: WAR_START }, + }, + select: { commodity: true, price: true }, }); - const currentGasPrice = latestGas?.price ?? BASELINE_GAS_PRICE; - // Get gas generation share per region from the last 7 days + function medianPrice(commodity: string): number { + const prices = preWarPrices.filter(p => p.commodity === commodity).map(p => p.price); + if (prices.length === 0) return 0; + prices.sort((a, b) => a - b); + const mid = Math.floor(prices.length / 2); + return prices.length % 2 === 0 ? (prices[mid - 1]! + prices[mid]!) / 2 : prices[mid]!; + } + + const baselineGas = medianPrice('natural_gas'); + const baselineBrent = medianPrice('brent_crude'); + const baselineGasoline = medianPrice('gasoline'); + + // Current prices (latest) + const latestPrices = await prisma.commodityPrice.findMany({ + where: { commodity: { in: ['natural_gas', 'brent_crude', 'gasoline', 'diesel'] } }, + orderBy: { timestamp: 'desc' }, + distinct: ['commodity'], + select: { commodity: true, price: true }, + }); + const currentByType = new Map(latestPrices.map(p => [p.commodity, p.price])); + + const currentGas = currentByType.get('natural_gas') ?? baselineGas; + const currentBrent = currentByType.get('brent_crude') ?? baselineBrent; + const currentGasoline = currentByType.get('gasoline') ?? baselineGasoline; + + const brentPctChange = baselineBrent > 0 ? ((currentBrent - baselineBrent) / baselineBrent) * 100 : 0; + const gasolinePriceIncrease = Math.max(0, currentGasoline - baselineGasoline); + + // Oil passthrough rate: ~5% of oil price increase transmits to electricity + // via transportation costs, peaker dispatch, and commodity correlation + const OIL_ELEC_PASSTHROUGH = 0.05; + const AVG_ELEC_PRICE_MWH = 45; // rough US average $/MWh + + // Get generation shares per region from the last 7 days const since = new Date(); since.setUTCDate(since.getUTCDate() - 7); @@ -274,7 +396,6 @@ export async function fetchWarPremium(): Promise> const results: WarPremiumRow[] = []; for (const region of regions) { - // Get total generation and gas generation for this region const genData = await prisma.generationMix.findMany({ where: { regionId: region.id, @@ -289,25 +410,38 @@ export async function fetchWarPremium(): Promise> let gasGen = 0; for (const row of genData) { totalGen += row.generationMw; - if (row.fuelType === 'NG') { - gasGen += row.generationMw; - } + if (row.fuelType === 'NG') gasGen += row.generationMw; } const gasShare = totalGen > 0 ? gasGen / totalGen : 0; - const premium = (currentGasPrice - BASELINE_GAS_PRICE) * gasShare * HEAT_RATE_MMBTU_PER_MWH; + + // Channel 1: Gas-to-power marginal cost change + const gasPremiumMwh = (currentGas - baselineGas) * gasShare * HEAT_RATE; + + // Channel 2: Oil passthrough to electricity costs + const oilPremiumMwh = Math.max(0, (brentPctChange / 100) * OIL_ELEC_PASSTHROUGH * AVG_ELEC_PRICE_MWH); + + // Combined premium (gas can be negative, oil is always >= 0) + const premiumMwh = Math.max(0, gasPremiumMwh + oilPremiumMwh); + + // Household impact: electricity + gasoline + const elecCostPerMonth = premiumMwh * 0.9; // avg household ~900 kWh/month + const gasCostPerMonth = gasolinePriceIncrease * 40; // avg ~40 gal/month + const monthlyHouseholdImpact = elecCostPerMonth + gasCostPerMonth; results.push({ regionCode: region.code, regionName: region.name, gasGenerationShare: gasShare, - currentGasPrice, - baselineGasPrice: BASELINE_GAS_PRICE, - premiumMwh: Math.max(0, premium), + gasPremiumMwh, + oilPremiumMwh, + premiumMwh, + brentPctChange, + gasolinePriceIncrease, + monthlyHouseholdImpact, }); } - // Sort by premium descending (highest impact first) results.sort((a, b) => b.premiumMwh - a.premiumMwh); return { ok: true, data: serialize(results) }; @@ -316,6 +450,130 @@ export async function fetchWarPremium(): Promise> } } +// --------------------------------------------------------------------------- +// Normalized "Since Conflict" data — all commodities rebased to 100 at war start +// --------------------------------------------------------------------------- + +export interface NormalizedRow { + [key: string]: number | string | undefined; + timestamp: number; // epoch ms + label: string; + brent_crude?: number; + wti_crude?: number; + natural_gas?: number; + gasoline?: number; + ovx?: number; + vix?: number; + gpr_daily?: number; + dubai_crude?: number; +} + +const NORMALIZED_COMMODITIES = [ + 'brent_crude', + 'wti_crude', + 'natural_gas', + 'gasoline', + 'ovx', + 'vix', + 'gpr_daily', + 'dubai_crude', +] as const; + +export async function fetchNormalizedSinceConflict(): Promise> { + 'use cache'; + cacheLife('conflict'); + cacheTag('normalized-conflict'); + + try { + const warStart = new Date('2026-02-28T00:00:00Z'); + // Fetch from 30 days before war to show the "before" baseline + const fetchStart = new Date('2026-01-28T00:00:00Z'); + + const rows = await prisma.commodityPrice.findMany({ + where: { + commodity: { in: [...NORMALIZED_COMMODITIES] }, + timestamp: { gte: fetchStart }, + }, + orderBy: { timestamp: 'asc' }, + select: { commodity: true, price: true, timestamp: true }, + }); + + // Get baseline price per commodity (last price before or on war start date) + const baselines = new Map(); + for (const commodity of NORMALIZED_COMMODITIES) { + const baselineRow = rows.filter(r => r.commodity === commodity && r.timestamp <= warStart).at(-1); + if (baselineRow) { + baselines.set(commodity, baselineRow.price); + } + } + + // Pivot by day, normalize to 100 + const byDay = new Map(); + + for (const row of rows) { + const baseline = baselines.get(row.commodity); + if (baseline === undefined || baseline === 0) continue; + + const dayKey = row.timestamp.toISOString().slice(0, 10); + if (!byDay.has(dayKey)) { + byDay.set(dayKey, { + timestamp: row.timestamp.getTime(), + label: row.timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + }); + } + const pivot = byDay.get(dayKey)!; + const normalized = (row.price / baseline) * 100; + pivot[row.commodity] = Number(normalized.toFixed(2)); + } + + const result = Array.from(byDay.values()).sort((a, b) => a.timestamp - b.timestamp); + + return { ok: true, data: serialize(result) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch normalized data: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +// --------------------------------------------------------------------------- +// Natural Gas Futures Curve +// --------------------------------------------------------------------------- + +export interface FuturesCurveRow { + commodity: string; + price: number; + timestamp: Date; +} + +export async function fetchNgFuturesCurve(): Promise> { + 'use cache'; + cacheLife('conflict'); + cacheTag('ng-futures'); + + try { + const since = new Date(); + since.setUTCDate(since.getUTCDate() - 90); + + const rows = await prisma.commodityPrice.findMany({ + where: { + commodity: { in: ['natural_gas', 'ng_futures_1', 'ng_futures_2', 'ng_futures_3', 'ng_futures_4'] }, + timestamp: { gte: since }, + }, + orderBy: { timestamp: 'asc' }, + select: { commodity: true, price: true, timestamp: true }, + }); + + return { ok: true, data: serialize(rows) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch NG futures: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + // --------------------------------------------------------------------------- // Conflict Hero Metrics (latest values for key indicators) // --------------------------------------------------------------------------- @@ -324,6 +582,7 @@ export interface ConflictHeroMetrics { brentPrice: number | null; brentChange: number | null; wtiPrice: number | null; + dubaiPrice: number | null; wtiBrentSpread: number | null; ovxLevel: number | null; gasolinePrice: number | null; @@ -337,7 +596,7 @@ export async function fetchConflictHeroMetrics(): Promise; +} diff --git a/src/app/conflict/_sections/futures-section.tsx b/src/app/conflict/_sections/futures-section.tsx new file mode 100644 index 0000000..4e92395 --- /dev/null +++ b/src/app/conflict/_sections/futures-section.tsx @@ -0,0 +1,9 @@ +import { fetchNgFuturesCurve } from '@/actions/conflict.js'; +import { NgFuturesChart } from '@/components/charts/ng-futures-chart.js'; + +export async function FuturesSection() { + const result = await fetchNgFuturesCurve(); + if (!result.ok) return null; + + return ; +} diff --git a/src/app/conflict/_sections/hero-metrics.tsx b/src/app/conflict/_sections/hero-metrics.tsx index 9c500f7..ed27b2a 100644 --- a/src/app/conflict/_sections/hero-metrics.tsx +++ b/src/app/conflict/_sections/hero-metrics.tsx @@ -48,7 +48,7 @@ export async function ConflictHeroMetrics() { const metrics = deserialize(result.data); return ( -
+
+ diff --git a/src/app/conflict/_sections/market-fear-section.tsx b/src/app/conflict/_sections/market-fear-section.tsx index 29bd860..a998e24 100644 --- a/src/app/conflict/_sections/market-fear-section.tsx +++ b/src/app/conflict/_sections/market-fear-section.tsx @@ -5,22 +5,30 @@ 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]!]; +} + export async function MarketFearSection() { - const result = await fetchMarketIndicators('90d'); + const result = await fetchMarketIndicators('1y'); if (!result.ok) return null; const rows = deserialize(result.data); - // Get latest value for each indicator - const latest = new Map(); + // Collect all values per commodity for threshold computation + const historyByType = new Map(); for (const row of rows) { - const existing = latest.get(row.commodity); - if (existing === undefined) { - latest.set(row.commodity, row.price); - } + const arr = historyByType.get(row.commodity) ?? []; + arr.push(row.price); + historyByType.set(row.commodity, arr); } - // The data is ordered by timestamp asc, so the last value per commodity is already there. - // But we need to get the LAST occurrence. Let's reverse iterate. + + // 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--) { const row = rows[i]!; @@ -29,40 +37,55 @@ 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]); + return ( Market Fear Indicators - Real-time volatility and stress gauges + Gauges colored by 1-year percentile trends (60th = elevated, 85th = extreme)
diff --git a/src/app/conflict/_sections/normalized-section.tsx b/src/app/conflict/_sections/normalized-section.tsx new file mode 100644 index 0000000..df9f521 --- /dev/null +++ b/src/app/conflict/_sections/normalized-section.tsx @@ -0,0 +1,9 @@ +import { fetchNormalizedSinceConflict } from '@/actions/conflict.js'; +import { NormalizedConflictChart } from '@/components/charts/normalized-conflict-chart.js'; + +export async function NormalizedSection() { + const result = await fetchNormalizedSinceConflict(); + if (!result.ok) return null; + + return ; +} diff --git a/src/app/conflict/page.tsx b/src/app/conflict/page.tsx index 5765aef..10e764c 100644 --- a/src/app/conflict/page.tsx +++ b/src/app/conflict/page.tsx @@ -7,9 +7,12 @@ import { Skeleton } from '@/components/ui/skeleton.js'; import { AlertTriangle } from 'lucide-react'; import type { Metadata } from 'next'; +import { CrackSpreadSection } from './_sections/crack-spread-section.js'; import { EventsSection } from './_sections/events-section.js'; +import { FuturesSection } from './_sections/futures-section.js'; 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 { SupplySection } from './_sections/supply-section.js'; import { WarPremiumSection } from './_sections/war-premium-section.js'; @@ -22,8 +25,8 @@ export const metadata: Metadata = { function HeroSkeleton() { return ( -
- {Array.from({ length: 7 }).map((_, i) => ( +
+ {Array.from({ length: 8 }).map((_, i) => ( ))}
@@ -79,6 +82,11 @@ export default function ConflictPage() { + {/* Normalized performance since conflict — the single most informative chart */} + }> + + + {/* War Premium */} }> @@ -89,17 +97,27 @@ export default function ConflictPage() { - {/* Supply + Market Fear side by side on large screens */} + {/* Natural Gas Futures + Supply side by side */}
+ }> + + + }> - - }> - -
+ {/* Crack Spread (refinery margins) */} + }> + + + + {/* Market Fear Indicators */} + }> + + + {/* Event Timeline */} }> diff --git a/src/components/charts/crack-spread-chart.tsx b/src/components/charts/crack-spread-chart.tsx new file mode 100644 index 0000000..152e30e --- /dev/null +++ b/src/components/charts/crack-spread-chart.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useMemo } from 'react'; +import { Area, AreaChart, CartesianGrid, ReferenceLine, XAxis, YAxis } from 'recharts'; +import type { SuperJSONResult } from 'superjson'; + +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'; + +import type { CrackSpreadRow } from '@/actions/conflict.js'; + +interface CrackSpreadChartProps { + data: SuperJSONResult; +} + +const chartConfig: ChartConfig = { + crackSpread: { label: 'Crack Spread', color: 'hsl(30, 80%, 55%)' }, +}; + +// War start +const WAR_START_TS = new Date('2026-02-28T00:00:00Z').getTime(); + +export function CrackSpreadChart({ data }: CrackSpreadChartProps) { + const rows = useMemo(() => deserialize(data), [data]); + + const { current, preWarAvg, warStartLabel } = useMemo(() => { + if (rows.length === 0) return { current: 0, preWarAvg: 0, warStartLabel: 'Feb 28' }; + + const preWar = rows.filter(r => r.timestamp < WAR_START_TS); + const avg = preWar.length > 0 ? preWar.reduce((s, r) => s + r.crackSpread, 0) / preWar.length : 0; + const warRow = rows.find(r => r.timestamp >= WAR_START_TS); + + return { + current: rows[rows.length - 1]!.crackSpread, + preWarAvg: avg, + warStartLabel: warRow?.label ?? 'Feb 28', + }; + }, [rows]); + + if (rows.length === 0) { + return ( + + + Crack Spread + No data available. + + + ); + } + + const spreadChange = current - preWarAvg; + + return ( + + +
+
+ Gasoline Crack Spread + + Refinery margin = gasoline ($/bbl) - crude oil ($/bbl). Widening spread = downstream supply stress. + +
+
+
+ Current + ${current.toFixed(2)}/bbl +
+
+ vs Pre-War + = 0 ? 'text-red-400' : 'text-emerald-400', + )}> + {spreadChange >= 0 ? '+' : ''}${spreadChange.toFixed(2)} + +
+
+
+
+ + + + + + `$${v}`} + /> + + + [`$${Number(value).toFixed(2)}/bbl`, undefined]} />} + /> + + + + +
+ ); +} diff --git a/src/components/charts/dc-price-impact-bars.tsx b/src/components/charts/dc-price-impact-bars.tsx index 3d25185..245e054 100644 --- a/src/components/charts/dc-price-impact-bars.tsx +++ b/src/components/charts/dc-price-impact-bars.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from 'recharts'; import type { SuperJSONResult } from 'superjson'; @@ -8,6 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart.js'; import type { getDcPriceImpact } from '@/generated/prisma/sql.js'; import { deserialize } from '@/lib/superjson.js'; +import { cn } from '@/lib/utils.js'; interface DcPriceImpactBarsProps { data: SuperJSONResult; @@ -24,7 +25,17 @@ const chartConfig: ChartConfig = { }, }; -interface BarRow { +interface RegionRow { + region_code: string; + dc_count: number; + total_capacity_mw: number; + avg_price_before: number; + avg_price_after: number; + pct_change: number; + increased: boolean; +} + +interface DcRow { dc_name: string; region_code: string; capacity_mw: number; @@ -35,7 +46,38 @@ interface BarRow { increased: boolean; } -function transformImpactData(rows: getDcPriceImpact.Result[]): BarRow[] { +function aggregateByRegion(rows: getDcPriceImpact.Result[]): RegionRow[] { + const byRegion = new Map(); + + 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 }; + existing.before.push(r.avg_price_before); + existing.after.push(r.avg_price_after); + existing.count++; + existing.capacity += r.capacity_mw; + byRegion.set(r.region_code, existing); + } + + return Array.from(byRegion.entries()) + .map(([code, data]) => { + 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; + return { + region_code: code, + dc_count: data.count, + total_capacity_mw: data.capacity, + avg_price_before: avgBefore, + avg_price_after: avgAfter, + pct_change: pctChange, + increased: avgAfter >= avgBefore, + }; + }) + .sort((a, b) => Math.abs(b.pct_change) - Math.abs(a.pct_change)); +} + +function transformDcData(rows: getDcPriceImpact.Result[]): DcRow[] { return rows .filter(r => r.avg_price_before !== null && r.avg_price_after !== null) .map(r => ({ @@ -52,8 +94,13 @@ function transformImpactData(rows: getDcPriceImpact.Result[]): BarRow[] { } export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) { + const [viewMode, setViewMode] = useState<'region' | 'datacenter'>('region'); const rawRows = useMemo(() => deserialize(data), [data]); - const barData = useMemo(() => transformImpactData(rawRows), [rawRows]); + const regionData = useMemo(() => aggregateByRegion(rawRows), [rawRows]); + const dcData = useMemo(() => transformDcData(rawRows), [rawRows]); + + const barData = viewMode === 'region' ? regionData : dcData; + const nameKey = viewMode === 'region' ? 'region_code' : 'dc_name'; const avgPctChange = useMemo(() => { if (barData.length === 0) return 0; @@ -76,18 +123,31 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
- Price Impact by Datacenter + Price Impact {viewMode === 'region' ? 'by Region' : 'by Datacenter'} - Average electricity price 6 months before vs. 6 months after datacenter opening + Average electricity price 6 months before vs. after datacenter opening + {viewMode === 'region' && ` (${rawRows.length} datacenters across ${regionData.length} regions)`}
-
- Avg price change: - = 0 ? 'text-red-400' : 'text-emerald-400'}`}> - {avgPctChange >= 0 ? '+' : ''} - {avgPctChange.toFixed(1)}% - +
+ +
+ Avg change: + = 0 ? 'text-red-400' : 'text-emerald-400'}`}> + {avgPctChange >= 0 ? '+' : ''} + {avgPctChange.toFixed(1)}% + +
@@ -95,19 +155,19 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) { { - const labelStr = typeof label === 'string' || typeof label === 'number' ? String(label) : ''; - const item = barData.find(d => d.dc_name === labelStr); + const labelStr = 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 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`; }} @@ -142,8 +207,8 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) { /> - {barData.map(entry => ( - + {barData.map((entry, i) => ( + ))} @@ -166,8 +231,7 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {

Correlation does not imply causation. Electricity prices are influenced by many factors including fuel costs, - weather, grid congestion, regulatory changes, and seasonal demand patterns. Datacenter openings are one of - many concurrent variables. + weather, grid congestion, regulatory changes, and seasonal demand patterns.

diff --git a/src/components/charts/ng-futures-chart.tsx b/src/components/charts/ng-futures-chart.tsx new file mode 100644 index 0000000..03ede68 --- /dev/null +++ b/src/components/charts/ng-futures-chart.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useMemo } from 'react'; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; +import type { SuperJSONResult } from 'superjson'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart.js'; +import { deserialize } from '@/lib/superjson.js'; + +import type { FuturesCurveRow } from '@/actions/conflict.js'; + +interface NgFuturesChartProps { + data: SuperJSONResult; +} + +const chartConfig: ChartConfig = { + natural_gas: { label: 'Spot (Henry Hub)', color: 'hsl(142, 60%, 50%)' }, + ng_futures_1: { label: 'Month 1', color: 'hsl(210, 80%, 55%)' }, + ng_futures_2: { label: 'Month 2', color: 'hsl(45, 80%, 55%)' }, + ng_futures_3: { label: 'Month 3', color: 'hsl(280, 70%, 55%)' }, + ng_futures_4: { label: 'Month 4', color: 'hsl(0, 70%, 55%)' }, +}; + +interface PivotedRow { + [key: string]: number | string | undefined; + timestamp: number; + label: string; + natural_gas?: number; + ng_futures_1?: number; + ng_futures_2?: number; + ng_futures_3?: number; + ng_futures_4?: number; +} + +export function NgFuturesChart({ data }: NgFuturesChartProps) { + const rows = useMemo(() => deserialize(data), [data]); + + const pivoted = useMemo(() => { + const byDay = new Map(); + + for (const row of rows) { + const dayKey = row.timestamp.toISOString().slice(0, 10); + if (!byDay.has(dayKey)) { + byDay.set(dayKey, { + timestamp: row.timestamp.getTime(), + label: row.timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + }); + } + const pivot = byDay.get(dayKey)!; + pivot[row.commodity] = row.price; + } + + return Array.from(byDay.values()).sort((a, b) => a.timestamp - b.timestamp); + }, [rows]); + + if (pivoted.length === 0) { + return ( + + + Natural Gas Futures + No futures data available yet. + + + ); + } + + // Determine contango vs backwardation from latest data + const latest = pivoted[pivoted.length - 1]!; + const spot = latest.natural_gas; + const front = latest.ng_futures_1; + const curveShape = + spot !== undefined && front !== undefined + ? front > spot + ? 'Contango (futures > spot)' + : 'Backwardation (spot > futures)' + : null; + + return ( + + +
+
+ Natural Gas Futures Curve + Spot vs. NYMEX futures contracts (1-4 months) +
+ {curveShape && ( + + {curveShape} + + )} +
+
+ + + + + + `$${v}`} + /> + [`$${Number(value).toFixed(2)}/MMBtu`, undefined]} />} + /> + } /> + + + + + + + + +
+ ); +} diff --git a/src/components/charts/normalized-conflict-chart.tsx b/src/components/charts/normalized-conflict-chart.tsx new file mode 100644 index 0000000..9ed0b63 --- /dev/null +++ b/src/components/charts/normalized-conflict-chart.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from 'recharts'; +import type { SuperJSONResult } from 'superjson'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart.js'; +import { deserialize } from '@/lib/superjson.js'; +import { cn } from '@/lib/utils.js'; + +import type { NormalizedRow } from '@/actions/conflict.js'; + +interface NormalizedConflictChartProps { + data: SuperJSONResult; +} + +const ALL_SERIES = [ + { key: 'brent_crude', label: 'Brent Crude', color: 'hsl(0, 70%, 55%)' }, + { key: 'wti_crude', label: 'WTI Crude', color: 'hsl(210, 80%, 55%)' }, + { key: 'natural_gas', label: 'Natural Gas', color: 'hsl(142, 60%, 50%)' }, + { key: 'gasoline', label: 'Gasoline', color: 'hsl(30, 80%, 55%)' }, + { 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 }])); + +// War start timestamp (Feb 28, 2026) +const WAR_START_TS = new Date('2026-02-28T00:00:00Z').getTime(); + +export function NormalizedConflictChart({ data }: NormalizedConflictChartProps) { + const rows = useMemo(() => deserialize(data), [data]); + const [hiddenSeries, setHiddenSeries] = useState>(new Set()); + + const toggleSeries = (key: string) => { + setHiddenSeries(prev => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + // Find the index of war start for reference line + const warStartLabel = useMemo(() => { + const warRow = rows.find(r => r.timestamp >= WAR_START_TS); + return warRow?.label ?? 'Feb 28'; + }, [rows]); + + // Determine which series have data + const availableSeries = useMemo(() => { + return ALL_SERIES.filter(s => rows.some(r => r[s.key] !== undefined)); + }, [rows]); + + if (rows.length === 0) { + return ( + + + Commodity Performance Since Conflict + No data available. + + + ); + } + + return ( + + +
+
+ Commodity Performance Since Conflict + + All commodities rebased to 100 at Feb 28, 2026 (war start). Values above 100 = price increase. + +
+
+ {availableSeries.map(s => ( + + ))} +
+
+
+ + + + + + `${v}`} + domain={['auto', 'auto']} + /> + + + { + const v = Number(value); + const change = v - 100; + return [`${v.toFixed(1)} (${change >= 0 ? '+' : ''}${change.toFixed(1)}%)`, undefined]; + }} + /> + } + /> + } /> + {availableSeries.map(s => ( + + ))} + + + +
+ ); +} diff --git a/src/components/charts/oil-chart.tsx b/src/components/charts/oil-chart.tsx index 9152e6e..e1820f4 100644 --- a/src/components/charts/oil-chart.tsx +++ b/src/components/charts/oil-chart.tsx @@ -29,12 +29,14 @@ 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%)' }, }; @@ -57,6 +59,7 @@ 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); @@ -99,7 +102,7 @@ export function OilChart({ oilData, events }: OilChartProps) {
Oil Prices - WTI & Brent crude with geopolitical event annotations + WTI, Brent & Dubai crude benchmarks with geopolitical event annotations