data and chart improvements
This commit is contained in:
parent
7423b8e622
commit
2846a1305e
@ -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,
|
||||
|
||||
@ -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 });
|
||||
|
||||
9
src/app/conflict/_sections/crack-spread-section.tsx
Normal file
9
src/app/conflict/_sections/crack-spread-section.tsx
Normal 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} />;
|
||||
}
|
||||
9
src/app/conflict/_sections/futures-section.tsx
Normal file
9
src/app/conflict/_sections/futures-section.tsx
Normal 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} />;
|
||||
}
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
|
||||
9
src/app/conflict/_sections/normalized-section.tsx
Normal file
9
src/app/conflict/_sections/normalized-section.tsx
Normal 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} />;
|
||||
}
|
||||
@ -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 />
|
||||
|
||||
127
src/components/charts/crack-spread-chart.tsx
Normal file
127
src/components/charts/crack-spread-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
167
src/components/charts/ng-futures-chart.tsx
Normal file
167
src/components/charts/ng-futures-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
src/components/charts/normalized-conflict-chart.tsx
Normal file
161
src/components/charts/normalized-conflict-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user