data and chart improvements

This commit is contained in:
Joey Eamigh 2026-04-05 15:20:15 -04:00
parent 7423b8e622
commit 2846a1305e
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
18 changed files with 1124 additions and 106 deletions

View File

@ -52,7 +52,7 @@ export async function fetchOilPrices(timeRange: TimeRange = '90d'): Promise<Acti
const startDate = timeRangeToStartDate(timeRange);
const rows = await prisma.commodityPrice.findMany({
where: {
commodity: { in: ['wti_crude', 'brent_crude'] },
commodity: { in: ['wti_crude', 'brent_crude', 'dubai_crude'] },
timestamp: { gte: startDate },
},
orderBy: { timestamp: 'asc' },
@ -64,6 +64,77 @@ export async function fetchOilPrices(timeRange: TimeRange = '90d'): Promise<Acti
}
}
// ---------------------------------------------------------------------------
// Crack Spread (Gasoline - Crude Oil margin)
// ---------------------------------------------------------------------------
export interface CrackSpreadRow {
timestamp: number;
label: string;
gasolinePerBbl: number;
crudePrice: number;
crackSpread: number;
}
export async function fetchCrackSpread(timeRange: TimeRange = '1y'): Promise<ActionResult<CrackSpreadRow[]>> {
'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<string, { gasoline?: number; crude?: number; ts: number; label: string }>();
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<ActionResult<WarPremiumRow[]>> {
'use cache';
@ -252,18 +341,51 @@ export async function fetchWarPremium(): Promise<ActionResult<WarPremiumRow[]>>
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<ActionResult<WarPremiumRow[]>>
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<ActionResult<WarPremiumRow[]>>
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<ActionResult<WarPremiumRow[]>>
}
}
// ---------------------------------------------------------------------------
// 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<ActionResult<NormalizedRow[]>> {
'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<string, number>();
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<string, NormalizedRow>();
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<ActionResult<FuturesCurveRow[]>> {
'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<ActionResult<ConflictH
cacheTag('conflict-hero-metrics');
try {
const commoditiesToFetch = ['brent_crude', 'wti_crude', 'ovx', 'gasoline', 'spr_level', 'gpr_daily'];
const commoditiesToFetch = ['brent_crude', 'wti_crude', 'dubai_crude', 'ovx', 'gasoline', 'spr_level', 'gpr_daily'];
const latest = await prisma.commodityPrice.findMany({
where: { commodity: { in: commoditiesToFetch } },
@ -366,6 +625,7 @@ export async function fetchConflictHeroMetrics(): Promise<ActionResult<ConflictH
}
const wti = byType.get('wti_crude');
const dubai = byType.get('dubai_crude');
return {
ok: true,
@ -373,6 +633,7 @@ export async function fetchConflictHeroMetrics(): Promise<ActionResult<ConflictH
brentPrice: brent?.price ?? null,
brentChange,
wtiPrice: wti?.price ?? null,
dubaiPrice: dubai?.price ?? null,
wtiBrentSpread: brent && wti ? brent.price - wti.price : null,
ovxLevel: byType.get('ovx')?.price ?? null,
gasolinePrice: byType.get('gasoline')?.price ?? null,

View File

@ -237,6 +237,29 @@ async function fetchAllCommodities(start?: string, end?: string): Promise<{ rows
errors++;
}
// FRED: Dubai Crude (monthly, POILDUBUSDM)
const fredDubai = await fred.getDubaiCrudePrice(start, end);
if (fredDubai.ok) {
for (const p of fredDubai.data) {
rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source });
}
} else {
console.error('Failed to fetch FRED Dubai crude:', fredDubai.error);
errors++;
}
// EIA: Natural Gas Futures (RNGC1-4)
try {
const futuresData = await eia.getNaturalGasFutures({ start, end });
for (const p of futuresData) {
if (p.price === null) continue;
rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source });
}
} catch (err) {
console.error('Failed to fetch EIA NG futures:', err);
errors++;
}
// GPR: Geopolitical Risk Index
try {
const gprData = await getGeopoliticalRiskIndex({ start, end });

View File

@ -0,0 +1,9 @@
import { fetchCrackSpread } from '@/actions/conflict.js';
import { CrackSpreadChart } from '@/components/charts/crack-spread-chart.js';
export async function CrackSpreadSection() {
const result = await fetchCrackSpread('1y');
if (!result.ok) return null;
return <CrackSpreadChart data={result.data} />;
}

View File

@ -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 <NgFuturesChart data={result.data} />;
}

View File

@ -48,7 +48,7 @@ export async function ConflictHeroMetrics() {
const metrics = deserialize<ConflictHeroMetrics>(result.data);
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-8">
<MetricCard
label="Brent Crude"
value={metrics.brentPrice}
@ -57,6 +57,7 @@ export async function ConflictHeroMetrics() {
alertThreshold={100}
/>
<MetricCard label="WTI Crude" value={metrics.wtiPrice} unit="$/bbl" alertThreshold={100} />
<MetricCard label="Dubai Crude" value={metrics.dubaiPrice} unit="$/bbl" alertThreshold={100} />
<MetricCard label="WTI-Brent Spread" value={metrics.wtiBrentSpread} unit="$/bbl" alertThreshold={5} />
<MetricCard label="Oil Volatility" value={metrics.ovxLevel} unit="OVX" alertThreshold={40} />
<MetricCard label="US Gasoline" value={metrics.gasolinePrice} unit="$/gal" alertThreshold={4.0} />

View File

@ -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<MarketIndicatorRow[]>(result.data);
// Get latest value for each indicator
const latest = new Map<string, number>();
// Collect all values per commodity for threshold computation
const historyByType = new Map<string, number[]>();
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<string, number>();
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 (
<Card>
<CardHeader>
<CardTitle>Market Fear Indicators</CardTitle>
<CardDescription>Real-time volatility and stress gauges</CardDescription>
<CardDescription>Gauges colored by 1-year percentile trends (60th = elevated, 85th = extreme)</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<VolatilityGauge
label="Oil Fear Gauge"
value={latestByType.get('ovx') ?? null}
maxValue={100}
thresholds={[25, 40]}
maxValue={120}
thresholds={ovxThresholds}
unit="OVX Index"
/>
<VolatilityGauge
label="Market Fear"
value={latestByType.get('vix') ?? null}
maxValue={80}
thresholds={[20, 35]}
thresholds={vixThresholds}
unit="VIX Index"
/>
<VolatilityGauge
label="Dollar Strength"
value={latestByType.get('dxy') ?? null}
maxValue={150}
thresholds={[100, 120]}
thresholds={dxyThresholds}
unit="DXY Index"
/>
<VolatilityGauge
label="Financial Stress"
value={latestByType.get('financial_stress') ?? null}
maxValue={10}
thresholds={[1, 3]}
thresholds={stressThresholds}
unit="STLFSI Index"
/>
</div>

View File

@ -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 <NormalizedConflictChart data={result.data} />;
}

View File

@ -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 (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7">
{Array.from({ length: 7 }).map((_, i) => (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-8">
{Array.from({ length: 8 }).map((_, i) => (
<MetricCardSkeleton key={i} />
))}
</div>
@ -79,6 +82,11 @@ export default function ConflictPage() {
<ConflictHeroMetrics />
</Suspense>
{/* Normalized performance since conflict — the single most informative chart */}
<Suspense fallback={<ChartSkeleton />}>
<NormalizedSection />
</Suspense>
{/* War Premium */}
<Suspense fallback={<ChartSkeleton />}>
<WarPremiumSection />
@ -89,17 +97,27 @@ export default function ConflictPage() {
<OilSection />
</Suspense>
{/* Supply + Market Fear side by side on large screens */}
{/* Natural Gas Futures + Supply side by side */}
<div className="grid gap-6 lg:grid-cols-2">
<Suspense fallback={<ChartSkeleton />}>
<FuturesSection />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<SupplySection />
</Suspense>
<Suspense fallback={<GaugeSkeleton />}>
<MarketFearSection />
</Suspense>
</div>
{/* Crack Spread (refinery margins) */}
<Suspense fallback={<ChartSkeleton />}>
<CrackSpreadSection />
</Suspense>
{/* Market Fear Indicators */}
<Suspense fallback={<GaugeSkeleton />}>
<MarketFearSection />
</Suspense>
{/* Event Timeline */}
<Suspense fallback={<ChartSkeleton />}>
<EventsSection />

View File

@ -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<CrackSpreadRow[]>(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 (
<Card>
<CardHeader>
<CardTitle>Crack Spread</CardTitle>
<CardDescription>No data available.</CardDescription>
</CardHeader>
</Card>
);
}
const spreadChange = current - preWarAvg;
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Gasoline Crack Spread</CardTitle>
<CardDescription>
Refinery margin = gasoline ($/bbl) - crude oil ($/bbl). Widening spread = downstream supply stress.
</CardDescription>
</div>
<div className="flex gap-3">
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-center">
<span className="block text-[10px] text-muted-foreground">Current</span>
<span className="text-sm font-bold text-amber-400 tabular-nums">${current.toFixed(2)}/bbl</span>
</div>
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-center">
<span className="block text-[10px] text-muted-foreground">vs Pre-War</span>
<span
className={cn(
'text-sm font-bold tabular-nums',
spreadChange >= 0 ? 'text-red-400' : 'text-emerald-400',
)}>
{spreadChange >= 0 ? '+' : ''}${spreadChange.toFixed(2)}
</span>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[30vh] max-h-[350px] min-h-[200px] w-full">
<AreaChart data={rows} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `$${v}`}
/>
<ReferenceLine x={warStartLabel} stroke="hsl(0, 70%, 50%)" strokeDasharray="4 4" strokeWidth={1.5} />
<ReferenceLine
y={preWarAvg}
stroke="hsl(var(--border))"
strokeDasharray="8 4"
label={{
value: `Pre-war avg: $${preWarAvg.toFixed(0)}`,
position: 'insideTopLeft',
style: { fontSize: 10, fill: 'var(--color-muted-foreground)' },
}}
/>
<ChartTooltip
content={<ChartTooltipContent formatter={value => [`$${Number(value).toFixed(2)}/bbl`, undefined]} />}
/>
<Area
type="monotone"
dataKey="crackSpread"
fill="var(--color-crackSpread)"
fillOpacity={0.2}
stroke="var(--color-crackSpread)"
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@ -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<string, { before: number[]; after: 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 };
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<getDcPriceImpact.Result[]>(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) {
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Price Impact by Datacenter</CardTitle>
<CardTitle>Price Impact {viewMode === 'region' ? 'by Region' : 'by Datacenter'}</CardTitle>
<CardDescription>
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)`}
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-2 rounded-lg border border-border/50 bg-muted/30 px-3 py-2">
<span className="text-xs text-muted-foreground">Avg price change:</span>
<span
className={`text-sm font-semibold tabular-nums ${avgPctChange >= 0 ? 'text-red-400' : 'text-emerald-400'}`}>
{avgPctChange >= 0 ? '+' : ''}
{avgPctChange.toFixed(1)}%
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode(viewMode === 'region' ? 'datacenter' : 'region')}
className={cn(
'rounded-full border px-3 py-1 text-xs font-medium transition-colors',
viewMode === 'datacenter'
? 'border-blue-500/30 bg-blue-500/10 text-blue-300'
: 'border-border text-muted-foreground',
)}>
{viewMode === 'region' ? 'Show Individual DCs' : 'Show by Region'}
</button>
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2">
<span className="text-xs text-muted-foreground">Avg change: </span>
<span
className={`text-sm font-semibold tabular-nums ${avgPctChange >= 0 ? 'text-red-400' : 'text-emerald-400'}`}>
{avgPctChange >= 0 ? '+' : ''}
{avgPctChange.toFixed(1)}%
</span>
</div>
</div>
</div>
</CardHeader>
@ -95,19 +155,19 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
<ChartContainer config={chartConfig} className="h-[40vh] max-h-[500px] min-h-[250px] w-full">
<BarChart
data={barData}
margin={{ top: 10, right: 20, left: 10, bottom: 40 }}
margin={{ top: 10, right: 20, left: 10, bottom: viewMode === 'datacenter' ? 40 : 20 }}
barCategoryGap="20%"
barGap={2}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" vertical={false} />
<XAxis
dataKey="dc_name"
tick={{ fontSize: 10, fill: 'var(--color-muted-foreground)' }}
dataKey={nameKey}
tick={{ fontSize: viewMode === 'region' ? 12 : 10, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
angle={-35}
textAnchor="end"
angle={viewMode === 'datacenter' ? -35 : 0}
textAnchor={viewMode === 'datacenter' ? 'end' : 'middle'}
interval={0}
height={60}
height={viewMode === 'datacenter' ? 60 : 30}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
@ -125,8 +185,13 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
content={
<ChartTooltipContent
labelFormatter={label => {
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) {
/>
<Bar dataKey="avg_price_before" fill="var(--color-avg_price_before)" radius={[3, 3, 0, 0]} />
<Bar dataKey="avg_price_after" radius={[3, 3, 0, 0]}>
{barData.map(entry => (
<Cell key={entry.dc_name} fill={entry.increased ? 'hsl(0, 70%, 55%)' : 'hsl(145, 60%, 45%)'} />
{barData.map((entry, i) => (
<Cell key={i} fill={entry.increased ? 'hsl(0, 70%, 55%)' : 'hsl(145, 60%, 45%)'} />
))}
</Bar>
</BarChart>
@ -166,8 +231,7 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
<p className="mt-3 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs leading-relaxed text-amber-300/80">
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.
</p>
</CardContent>
</Card>

View File

@ -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<FuturesCurveRow[]>(data), [data]);
const pivoted = useMemo(() => {
const byDay = new Map<string, PivotedRow>();
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 (
<Card>
<CardHeader>
<CardTitle>Natural Gas Futures</CardTitle>
<CardDescription>No futures data available yet.</CardDescription>
</CardHeader>
</Card>
);
}
// 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 (
<Card>
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Natural Gas Futures Curve</CardTitle>
<CardDescription>Spot vs. NYMEX futures contracts (1-4 months)</CardDescription>
</div>
{curveShape && (
<span className="rounded-full border border-border px-3 py-1 text-xs font-medium text-muted-foreground">
{curveShape}
</span>
)}
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[35vh] max-h-[400px] min-h-[220px] w-full">
<LineChart data={pivoted} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `$${v}`}
/>
<ChartTooltip
content={<ChartTooltipContent formatter={value => [`$${Number(value).toFixed(2)}/MMBtu`, undefined]} />}
/>
<ChartLegend content={<ChartLegendContent />} />
<Line
type="monotone"
dataKey="natural_gas"
stroke="var(--color-natural_gas)"
strokeWidth={2.5}
dot={false}
connectNulls
/>
<Line
type="monotone"
dataKey="ng_futures_1"
stroke="var(--color-ng_futures_1)"
strokeWidth={1.5}
dot={false}
connectNulls
/>
<Line
type="monotone"
dataKey="ng_futures_2"
stroke="var(--color-ng_futures_2)"
strokeWidth={1.5}
dot={false}
connectNulls
/>
<Line
type="monotone"
dataKey="ng_futures_3"
stroke="var(--color-ng_futures_3)"
strokeWidth={1.5}
dot={false}
connectNulls
/>
<Line
type="monotone"
dataKey="ng_futures_4"
stroke="var(--color-ng_futures_4)"
strokeWidth={1.5}
dot={false}
connectNulls
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@ -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<NormalizedRow[]>(data), [data]);
const [hiddenSeries, setHiddenSeries] = useState<Set<string>>(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 (
<Card>
<CardHeader>
<CardTitle>Commodity Performance Since Conflict</CardTitle>
<CardDescription>No data available.</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Commodity Performance Since Conflict</CardTitle>
<CardDescription>
All commodities rebased to 100 at Feb 28, 2026 (war start). Values above 100 = price increase.
</CardDescription>
</div>
<div className="flex flex-wrap gap-1">
{availableSeries.map(s => (
<button
key={s.key}
onClick={() => toggleSeries(s.key)}
className={cn(
'rounded-full border px-2 py-0.5 text-[10px] font-medium transition-colors',
hiddenSeries.has(s.key) ? 'border-border/30 text-muted-foreground/40 line-through' : 'border-border',
)}
style={{ color: hiddenSeries.has(s.key) ? undefined : s.color, borderColor: `${s.color}40` }}>
{s.label}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[45vh] max-h-[550px] min-h-[300px] w-full">
<LineChart data={rows} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v}`}
domain={['auto', 'auto']}
/>
<ReferenceLine y={100} stroke="hsl(var(--border))" strokeDasharray="8 4" strokeWidth={1.5} />
<ReferenceLine
x={warStartLabel}
stroke="hsl(0, 70%, 50%)"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: 'War Start',
position: 'insideTopRight',
style: { fontSize: 10, fill: 'hsl(0, 70%, 50%)' },
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, _name) => {
const v = Number(value);
const change = v - 100;
return [`${v.toFixed(1)} (${change >= 0 ? '+' : ''}${change.toFixed(1)}%)`, undefined];
}}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{availableSeries.map(s => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
stroke={s.color}
strokeWidth={s.key === 'brent_crude' ? 2.5 : 1.5}
dot={false}
connectNulls
hide={hiddenSeries.has(s.key)}
/>
))}
</LineChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@ -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) {
<div className="flex items-center justify-between">
<div>
<CardTitle>Oil Prices</CardTitle>
<CardDescription>WTI & Brent crude with geopolitical event annotations</CardDescription>
<CardDescription>WTI, Brent & Dubai crude benchmarks with geopolitical event annotations</CardDescription>
</div>
<button
onClick={() => setShowSpread(prev => !prev)}
@ -180,6 +183,15 @@ export function OilChart({ oilData, events }: OilChartProps) {
dot={false}
connectNulls
/>
<Line
type="monotone"
dataKey="dubai_crude"
stroke="var(--color-dubai_crude)"
strokeWidth={1.5}
dot={false}
connectNulls
strokeDasharray="4 2"
/>
</LineChart>
)}
</ChartContainer>

View File

@ -102,6 +102,11 @@ const COMMODITY_LABELS: Record<string, string> = {
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',
ng_futures_4: 'NG F4',
spr_level: 'SPR',
us_crude_production: 'US Prod',
gpr_daily: 'GPR',

View File

@ -14,16 +14,16 @@ interface WarPremiumCardProps {
}
function severityColor(premiumMwh: number): string {
if (premiumMwh >= 10) return 'text-red-400';
if (premiumMwh >= 5) return 'text-amber-400';
if (premiumMwh >= 2) return 'text-yellow-400';
if (premiumMwh >= 8) return 'text-red-400';
if (premiumMwh >= 4) return 'text-amber-400';
if (premiumMwh >= 1) return 'text-yellow-400';
return 'text-emerald-400';
}
function severityBg(premiumMwh: number): string {
if (premiumMwh >= 10) return 'bg-red-500/10 border-red-500/20';
if (premiumMwh >= 5) return 'bg-amber-500/10 border-amber-500/20';
if (premiumMwh >= 2) return 'bg-yellow-500/10 border-yellow-500/20';
if (premiumMwh >= 8) return 'bg-red-500/10 border-red-500/20';
if (premiumMwh >= 4) return 'bg-amber-500/10 border-amber-500/20';
if (premiumMwh >= 1) return 'bg-yellow-500/10 border-yellow-500/20';
return 'bg-emerald-500/10 border-emerald-500/20';
}
@ -34,28 +34,50 @@ export function WarPremiumCard({ data }: WarPremiumCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>War Premium by Region</CardTitle>
<CardTitle>Conflict Energy Premium</CardTitle>
<CardDescription>No generation data available to calculate premium.</CardDescription>
</CardHeader>
</Card>
);
}
const avgPremium = rows.reduce((sum, r) => sum + r.premiumMwh, 0) / rows.length;
const avgHouseholdImpact = rows.reduce((sum, r) => sum + r.monthlyHouseholdImpact, 0) / rows.length;
return (
<Card>
<CardHeader>
<CardTitle>Conflict Energy Premium</CardTitle>
<CardDescription>
Estimated $/MWh premium per region from elevated gas prices. Based on gas generation share and current vs.
pre-war baseline ($4/MMBtu).
</CardDescription>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Conflict Energy Premium</CardTitle>
<CardDescription>
Multi-channel impact: gas-to-power cost change + oil passthrough via transport & peaker dispatch.
Baselines from pre-war median prices (Jan 15 Feb 27).
</CardDescription>
</div>
<div className="flex shrink-0 gap-3">
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-center">
<span className="block text-[10px] text-muted-foreground">Avg Premium</span>
<span className={cn('text-sm font-bold tabular-nums', severityColor(avgPremium))}>
+${avgPremium.toFixed(2)}/MWh
</span>
</div>
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-center">
<span className="block text-[10px] text-muted-foreground">Avg Household</span>
<span className="text-sm font-bold text-red-400 tabular-nums">+${avgHouseholdImpact.toFixed(0)}/mo</span>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{rows.map(row => (
<div
key={row.regionCode}
className={cn('flex flex-col gap-1 rounded-lg border p-3 transition-colors', severityBg(row.premiumMwh))}>
className={cn(
'flex flex-col gap-1.5 rounded-lg border p-3 transition-colors',
severityBg(row.premiumMwh),
)}>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{row.regionCode}</span>
<span className={cn('text-lg font-bold tabular-nums', severityColor(row.premiumMwh))}>
@ -63,24 +85,53 @@ export function WarPremiumCard({ data }: WarPremiumCardProps) {
</span>
</div>
<span className="text-[10px] text-muted-foreground">{row.regionName}</span>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
<span>Gas share: {(row.gasGenerationShare * 100).toFixed(0)}%</span>
<span className="text-border">|</span>
<span>$/MWh premium</span>
{/* Channel breakdown */}
<div className="mt-0.5 space-y-0.5 text-[10px]">
<div className="flex justify-between text-muted-foreground">
<span>Gas channel</span>
<span
className={cn('tabular-nums', row.gasPremiumMwh >= 0 ? 'text-red-400/70' : 'text-emerald-400/70')}>
{row.gasPremiumMwh >= 0 ? '+' : ''}${row.gasPremiumMwh.toFixed(2)}
</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Oil passthrough</span>
<span className="text-red-400/70 tabular-nums">+${row.oilPremiumMwh.toFixed(2)}</span>
</div>
</div>
{/* Mini bar showing gas share */}
<div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(row.gasGenerationShare * 100, 100)}%`,
backgroundColor: row.gasGenerationShare > 0.4 ? 'hsl(0, 70%, 55%)' : 'hsl(210, 70%, 55%)',
}}
/>
{/* Gas share bar */}
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted-foreground">
<span>Gas: {(row.gasGenerationShare * 100).toFixed(0)}%</span>
<div className="h-1 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(row.gasGenerationShare * 100, 100)}%`,
backgroundColor: row.gasGenerationShare > 0.4 ? 'hsl(0, 70%, 55%)' : 'hsl(210, 70%, 55%)',
}}
/>
</div>
</div>
{/* Household impact */}
<div className="mt-0.5 flex justify-between border-t border-border/30 pt-1 text-[10px]">
<span className="text-muted-foreground">Household impact</span>
<span className="font-medium text-amber-400 tabular-nums">
+${row.monthlyHouseholdImpact.toFixed(0)}/mo
</span>
</div>
</div>
))}
</div>
<p className="mt-3 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs leading-relaxed text-amber-300/80">
Oil passthrough estimates 5% of Brent crude increase transmits to electricity via fuel transport costs and
peaker dispatch. Household impact combines electricity premium (900 kWh/mo) + gasoline increase (40 gal/mo).
Brent crude: +{rows[0]?.brentPctChange.toFixed(0) ?? 0}% from pre-war. Gasoline: +$
{rows[0]?.gasolinePriceIncrease.toFixed(2) ?? '0.00'}/gal.
</p>
</CardContent>
</Card>
);

View File

@ -480,6 +480,55 @@ export async function getBrentCrudePrice(options: GetCommodityPriceOptions = {})
}));
}
// ---------------------------------------------------------------------------
// Natural Gas Futures (NYMEX, monthly contracts 1-4)
// ---------------------------------------------------------------------------
type NgFuturesType = 'ng_futures_1' | 'ng_futures_2' | 'ng_futures_3' | 'ng_futures_4';
const NG_FUTURES_CONTRACTS: Array<{ series: string; commodity: NgFuturesType }> = [
{ series: 'RNGC1', commodity: 'ng_futures_1' },
{ series: 'RNGC2', commodity: 'ng_futures_2' },
{ series: 'RNGC3', commodity: 'ng_futures_3' },
{ series: 'RNGC4', commodity: 'ng_futures_4' },
];
/**
* Fetch NYMEX natural gas futures contracts (1-4 months out).
* Endpoint: /v2/natural-gas/pri/fut/data/ with individual series facets.
*/
export async function getNaturalGasFutures(options: GetCommodityPriceOptions = {}): Promise<CommodityPricePoint[]> {
const results: CommodityPricePoint[] = [];
for (const contract of NG_FUTURES_CONTRACTS) {
const params: EiaQueryParams = {
frequency: 'daily',
start: options.start,
end: options.end,
facets: { series: [contract.series] },
sort: [{ column: 'period', direction: 'desc' }],
length: options.limit ? Math.min(options.limit, MAX_ROWS_PER_REQUEST) : MAX_ROWS_PER_REQUEST,
};
const rows = await fetchAllPages('/natural-gas/pri/fut/data/', params, json => {
const parsed = eiaNaturalGasResponseSchema.parse(json);
return { total: parsed.response.total, data: parsed.response.data };
});
for (const row of rows) {
results.push({
timestamp: parseEiaCommodityPeriod(row.period),
commodity: contract.commodity,
price: row.value,
unit: row.units ?? '$/Million BTU',
source: 'EIA',
});
}
}
return results;
}
// ---------------------------------------------------------------------------
// Retail electricity prices (monthly, by state)
// ---------------------------------------------------------------------------

View File

@ -11,9 +11,14 @@ const FRED_SERIES = {
wti_crude: 'DCOILWTICO',
coal: 'PCOALAUUSDM',
brent_crude: 'DCOILBRENTEU',
dubai_crude: 'POILDUBUSDM',
gasoline: 'GASREGW',
diesel: 'GASDESW',
heating_oil: 'DHOILNYH',
ng_futures_1: '',
ng_futures_2: '',
ng_futures_3: '',
ng_futures_4: '',
ovx: 'OVXCLS',
vix: 'VIXCLS',
dxy: 'DTWEXBGS',
@ -284,6 +289,13 @@ export async function getDollarIndex(
return getCommodityPrices('dxy', startDate, endDate);
}
export async function getDubaiCrudePrice(
startDate?: Date | string,
endDate?: Date | string,
): Promise<FredApiResult<CommodityPrice[]>> {
return getCommodityPrices('dubai_crude', startDate, endDate);
}
export async function getFinancialStress(
startDate?: Date | string,
endDate?: Date | string,

View File

@ -5,20 +5,26 @@ export const CommodityType = z.enum([
'natural_gas',
'wti_crude',
'coal',
// Petroleum (new)
// Petroleum
'brent_crude',
'dubai_crude',
'gasoline',
'diesel',
'heating_oil',
// Market indicators (new)
// Natural gas futures
'ng_futures_1',
'ng_futures_2',
'ng_futures_3',
'ng_futures_4',
// Market indicators
'ovx',
'vix',
'dxy',
'financial_stress',
// Supply metrics (new)
// Supply metrics
'spr_level',
'us_crude_production',
// Geopolitical risk (new)
// Geopolitical risk
'gpr_daily',
]);
export type CommodityType = z.infer<typeof CommodityType>;
@ -37,9 +43,14 @@ export const COMMODITY_UNITS: Record<CommodityType, string> = {
wti_crude: '$/Barrel',
coal: '$/Metric Ton',
brent_crude: '$/Barrel',
dubai_crude: '$/Barrel',
gasoline: '$/Gallon',
diesel: '$/Gallon',
heating_oil: '$/Gallon',
ng_futures_1: '$/Million BTU',
ng_futures_2: '$/Million BTU',
ng_futures_3: '$/Million BTU',
ng_futures_4: '$/Million BTU',
ovx: 'Index',
vix: 'Index',
dxy: 'Index',
@ -54,9 +65,14 @@ export const COMMODITY_DISPLAY_NAMES: Record<CommodityType, string> = {
wti_crude: 'WTI Crude',
coal: 'Coal',
brent_crude: 'Brent Crude',
dubai_crude: 'Dubai Crude',
gasoline: 'Gasoline',
diesel: 'Diesel',
heating_oil: 'Heating Oil',
ng_futures_1: 'NG Futures (Month 1)',
ng_futures_2: 'NG Futures (Month 2)',
ng_futures_3: 'NG Futures (Month 3)',
ng_futures_4: 'NG Futures (Month 4)',
ovx: 'Oil Volatility (OVX)',
vix: 'Market Volatility (VIX)',
dxy: 'US Dollar Index',
@ -67,8 +83,9 @@ export const COMMODITY_DISPLAY_NAMES: Record<CommodityType, string> = {
};
export const COMMODITY_CATEGORIES = {
petroleum: ['wti_crude', 'brent_crude', 'gasoline', 'diesel', 'heating_oil'],
petroleum: ['wti_crude', 'brent_crude', 'dubai_crude', 'gasoline', 'diesel', 'heating_oil'],
energy_commodity: ['natural_gas', 'coal'],
futures: ['ng_futures_1', 'ng_futures_2', 'ng_futures_3', 'ng_futures_4'],
market_indicator: ['ovx', 'vix', 'dxy', 'financial_stress'],
supply: ['spr_level', 'us_crude_production'],
geopolitical: ['gpr_daily'],