Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Live: **[gridtilt.com](https://gridtilt.com)**

| Module | What's inside |
|---|---|
| **Tilt Overview** | Top movers, sector pulse, catalyst calendar, US electricity demand chart (2010 → 2030 projection), thesis-health KPIs. |
| **Tilt Overview** | The buildout scoreboard (signed nuclear GW, DC pipeline GW, interconnection queue, grid pulse), top movers, catalyst calendar, US electricity demand chart (2010 → 2030 projection). |
| **The Stack** | 100 tickers across 13 supply-chain layers (compute, nuclear, uranium, power hardware, utilities, data-center REITs, construction, mining, natural gas, renewables, grid hardware, crypto/AI DC, ETF benchmarks). |
| **Power Map** | US data center locations with power capacity and the utility / RTO they sit on. |
| **Supply Chain** | D3 force graph of 24 nodes and 52 real supply relationships from raw materials to end-use compute. |
Expand All @@ -32,23 +32,27 @@ Live: **[gridtilt.com](https://gridtilt.com)**
| Physical electricity output | FRED [`IPG2211A2N`](https://fred.stlouisfed.org/series/IPG2211A2N) served live at `/api/physical/electricity-output`; EIA US48 hourly demand at `/api/physical/load-hourly` once `EIA_API_KEY` is set | Live (FRED daily cache; EIA 30-min cache) |
| Data center locations | Public announcements (Microsoft, Google, Amazon, Meta, Apple, xAI, OpenAI, Oracle), curated through a reviewed RSS ingestion pipeline | Curated, refreshed as announcements land |
| Industry news | Live RSS from 8 publications | Live, refreshed hourly |
| AI Demand, Grid Stress, NPI | Composite indices computed from constituent **equity moves** (NPI also uses uranium spot and a hand-derived policy score). They are market-based gauges, **not** physical grid measurements. See [Index methodology](#index-methodology). | Live, with labeled static fallback |
| Buildout scoreboard (nuclear deals, DC pipeline, queue, capex) | Curated datasets in `server/data/` summed in real units; every group carries its source and as-of date. See [The scoreboard](#the-scoreboard). | Curated, refreshed as deals and filings land |

No proprietary data feeds and no scraped paywalled sources. All projections are clearly labeled as such.

---

## Index methodology
## The scoreboard

The three headline indices are deterministic functions of their published constituents. Exact formulas, so you can check the math:
The headline numbers are sums over curated datasets, in real units. No baselines, no clamps, no index anchors, nothing rebased to 100.

- **AI Demand** = clamp(52–94, `72 + 1.2 × (NVDA% × 0.40 + TSM% × 0.25 + EQIX% × 0.20 + MU% × 0.15)`) using today's intraday percent changes. It reads how the market is pricing the AI-buildout complex **today**; it does not measure data-center load.
- **Grid Stress** = clamp(52–92, `68 + (VST% × 0.40 + CEG% × 0.35 + EQIX% × 0.25)`). Same construction; EQIX appears in both baskets. It reads power-equity momentum, not reserve margins or LMPs.
- **NPI (Nuclear Power Index)** = `100 × (0.25·CEG + 0.20·VST + 0.15·CCJ + 0.20·NLR + 0.10·uranium spot + 0.10·policy)` as price relatives to Jan 1, 2024 bases, times a 0.9–1.1 policy multiplier from a hand-derived SMR policy score. Weights are judgment calls and labeled as such.
- **Nuclear-for-AI, signed** = sum of `capacityMW` over active, datacenter-relevant nuclear projects marked `firmness: "signed"` in `server/data/interconnection-queue.json`. Signed means executed contracts and restarts underway. Options, proposals, and aggregate LOI pipelines live in separate buckets and never inflate the headline.
- **DC pipeline** = sums by build status over `server/data/datacenters.json` (tracked US sites at 400 MW or more; a curated registry, not a census), plus disclosed FY2025 hyperscaler capex with per-company source links.
- **Interconnection queue** = LBNL "Queued Up" headline stats plus ISO filings, with as-of dates shipped in the data.
- **Grid pulse** = live US48 demand from EIA's hourly grid monitor (free key) and year-over-year US electric output from FRED `IPG2211A2N`. Measurements, not sentiment.
- The one market element left is a single line: an equal-weight mean of today's percent moves across the tracked tickers, with stale tickers excluded and the live count disclosed. A percent, never a level.

Baselines (72, 68) and clamps are presentation choices that keep the gauges readable; they are disclosed here so nobody mistakes them for measurements. Constituent values are exposed at `/api/kpis`.
Everything is served at `/api/metrics` with a source and as-of per group; daily snapshots append at `/api/metrics/history`.

**Validation:** the gauges are backtested against physical electricity output (FRED `IPG2211A2N`, 2019–2026, leads 0–3 months) in [docs/INDEX_VALIDATION.md](./docs/INDEX_VALIDATION.md). Result: neither AI Demand nor Grid Stress shows a physical signal, so both are labeled **market sentiment gauges** in the UI, not measurements. Reproduce it yourself: `npm run backtest:indices`. The reconstructed daily series lives in `server/data/index-history.json`.
### What happened to the indices

GridTilt used to headline three composite indices (AI Demand, Grid Stress, NPI). We backtested them against physical electricity output (FRED, 2019–2026) and published the result: no physical signal at any lead, and NPI moved at r = 0.95 with a single constituent stock. So on 2026-06-10 we retired them and replaced them with the real numbers above. The study stays public in [docs/INDEX_VALIDATION.md](./docs/INDEX_VALIDATION.md), the formulas remain in `server/indices.ts`, the archived daily series is still served at `/api/index-history`, and `npm run backtest:indices` still reproduces it from public prices.

---

Expand Down
16 changes: 8 additions & 8 deletions client/src/components/home/Hero.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Link } from "wouter";
import { useQuery } from "@tanstack/react-query";
import { Wordmark } from "./Wordmark";
import type { KpiData } from "@/lib/types";
import type { MetricsSummary } from "@/lib/types";
import logoPath from "@assets/Image_[Vectorized]_(2)_1773890483514.png";
import powerMapSvg from "@assets/previews/power-map.svg";

Expand All @@ -19,14 +19,12 @@ function formatRefreshTimeFromIso(iso: string | undefined): string | null {
}

export function Hero() {
const { data } = useQuery<KpiData>({
queryKey: ["/api/kpis"],
const { data } = useQuery<MetricsSummary>({
queryKey: ["/api/metrics"],
refetchInterval: 5 * 60_000,
refetchIntervalInBackground: false,
});
const refresh = formatRefreshTimeFromIso(data?.asOf);
const sourceKnown = data?.source === "live" || data?.source === "static";
const isLive = data?.source === "live";

return (
<section
Expand Down Expand Up @@ -81,20 +79,22 @@ export function Hero() {
data-testid="home-logo-mark"
/>
</Link>
{sourceKnown && refresh && (
{data && refresh && (
<span
style={{
fontFamily: "JetBrains Mono, monospace",
fontSize: 11,
color: "var(--mkt-ink-muted)",
letterSpacing: "0.04em",
textAlign: "right",
lineHeight: 1.4,
lineHeight: 1.5,
paddingTop: 14,
}}
data-testid="hero-refresh"
>
{isLive ? "data refreshed" : "static fallback"} {refresh}
{data.nuclear.signedGW} GW signed nuclear · {data.pipeline.constructionGW} GW dc construction · {data.backlog.queueOverallGW.toLocaleString()} GW queued
<br />
scoreboard refreshed {refresh}
</span>
)}
</div>
Expand Down
21 changes: 7 additions & 14 deletions client/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,11 @@ export interface AllCatalystsResponse {
items: MergedCatalystItem[];
}

export interface KpiData {
aiPowerIndex: number;
npiValue: number;
gridStress: number;
smrPolicyScore: number;
npiBaseDate: string;
source?: "live" | "static";
asOf?: string;
constituents: {
nvdaChange: number; tsmChange: number; eqixChange: number; muChange: number;
cegPerf: number; vstPerf: number; ccjPerf: number; nlrPerf: number;
uPerf: number; policyPerf: number; npiPolicyMultiplier: number; npiMomentum: number;
vstChange: number; cegChange: number;
};
// Minimal shape of GET /api/metrics for surfaces that only need the
// headline numbers (the dashboard keeps its richer local interface).
export interface MetricsSummary {
nuclear: { signedGW: number; announcedGW: number; signedDeals: number; totalDeals: number };
pipeline: { operationalGW: number; constructionGW: number; announcedGW: number; siteCount: number };
backlog: { queueOverallGW: number; medianWaitMonths: number };
asOf: string;
}
201 changes: 5 additions & 196 deletions client/src/pages/TheStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
LineChart,
Line,
ResponsiveContainer,
ScatterChart,
Scatter,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from "recharts";
import { LineChart, Line, ResponsiveContainer } from "recharts";
import { Cpu, Server, Zap, TrendingUp, TrendingDown, Info, Clock } from "lucide-react";

interface StockData {
Expand All @@ -37,11 +26,6 @@ interface StockData {
stale?: boolean;
}

interface CorrelationPoint {
uranium: number;
ccj: number;
}

interface StackData {
compute: StockData[];
nuclear: StockData[];
Expand All @@ -56,9 +40,6 @@ interface StackData {
transmissionGrid: StockData[];
cryptoAIDC: StockData[];
etfsBenchmarks: StockData[];
correlation: CorrelationPoint[];
correlationCoeff: number;
cegCorrelationCoeff: number;
}

function Sparkline({ data, color }: { data: number[] | undefined; color: string }) {
Expand Down Expand Up @@ -182,47 +163,10 @@ function StockCardSkeleton() {
);
}

const CustomScatterTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-card border border-card-border rounded-lg p-3 text-xs shadow-xl">
<p className="text-muted-foreground">Uranium Spot: <span className="text-foreground font-mono font-medium">${payload[0]?.value?.toFixed(2)}/lb</span></p>
<p className="text-muted-foreground">CCJ: <span className="text-foreground font-mono font-medium">${payload[1]?.value?.toFixed(2)}</span></p>
</div>
);
}
return null;
};

// Compute OLS regression line + confidence band from scatter data
function computeRegression(points: { uranium: number; ccj: number }[]) {
if (!points || points.length < 3) return { line: [], upper: [], lower: [] };
const n = points.length;
const xs = points.map((p) => p.uranium);
const ys = points.map((p) => p.ccj);
const meanX = xs.reduce((s, v) => s + v, 0) / n;
const meanY = ys.reduce((s, v) => s + v, 0) / n;
const sxx = xs.reduce((s, x) => s + (x - meanX) ** 2, 0);
const sxy = xs.reduce((s, x, i) => s + (x - meanX) * (ys[i] - meanY), 0);
const slope = sxy / sxx;
const intercept = meanY - slope * meanX;
const residuals = xs.map((x, i) => ys[i] - (slope * x + intercept));
const se = Math.sqrt(residuals.reduce((s, r) => s + r * r, 0) / (n - 2));
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const steps = 30;
const line = [];
const upper = [];
const lower = [];
for (let i = 0; i <= steps; i++) {
const x = minX + ((maxX - minX) * i) / steps;
const fit = slope * x + intercept;
line.push({ uranium: parseFloat(x.toFixed(2)), ccj: parseFloat(fit.toFixed(2)) });
upper.push({ uranium: parseFloat(x.toFixed(2)), ccj: parseFloat((fit + 1.5 * se).toFixed(2)) });
lower.push({ uranium: parseFloat(x.toFixed(2)), ccj: parseFloat((fit - 1.5 * se).toFixed(2)) });
}
return { line, upper, lower };
}
// (The uranium-vs-CCJ/CEG correlation scatter that lived here was removed on
// 2026-06-10: the server generated its points with Math.random tuned to a
// target Pearson r. No honest free daily uranium series exists to draw the
// real chart, so it is gone rather than faked.)

type Timeframe = "1D" | "5D" | "1M";
type SortBy = "change" | "marketcap" | "alpha";
Expand Down Expand Up @@ -258,11 +202,6 @@ export default function TheStack() {
refetchInterval: 900000,
});

const regression = useMemo(
() => computeRegression(data?.correlation ?? []),
[data?.correlation]
);

const layerConfig = [
{
key: "compute",
Expand Down Expand Up @@ -482,136 +421,6 @@ export default function TheStack() {
);
})}

{/* Uranium vs CCJ Correlation scatter */}
<div>
<Card className="p-6 border-card-border">
<div className="flex items-start justify-between gap-4 mb-4 flex-wrap">
<div>
<div className="flex items-center gap-2 mb-1">
<h2 className="font-semibold text-foreground">Uranium Spot vs. CCJ Correlation</h2>
<UITooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs leading-relaxed">CCJ is the largest public uranium miner with the highest direct spot price beta. CEG (utility) is influenced by electricity contracts and regulated returns. CCJ = commodity bet, CEG = infrastructure bet.</p>
</TooltipContent>
</UITooltip>
</div>
<p className="text-xs text-muted-foreground">52-week uranium spot price ($/lb) vs. CCJ stock price. Each dot = one week.</p>
</div>
<div className="flex items-center gap-6">
{data?.correlationCoeff !== undefined && (
<div className="text-right">
<p className="text-xs text-muted-foreground font-mono">CCJ Pearson r</p>
<p className="text-2xl font-bold font-mono text-[#F0A500]">{data.correlationCoeff.toFixed(3)}</p>
<p className="text-xs text-muted-foreground">
{data.correlationCoeff > 0.7 ? "Strong" : data.correlationCoeff > 0.4 ? "Moderate" : "Weak"} correlation
</p>
</div>
)}
{data?.cegCorrelationCoeff !== undefined && (
<div className="text-right">
<p className="text-xs text-muted-foreground font-mono">CEG Pearson r</p>
<p className="text-2xl font-bold font-mono text-foreground">{data.cegCorrelationCoeff.toFixed(3)}</p>
<p className="text-xs text-muted-foreground">Utility beta</p>
</div>
)}
</div>
</div>

{isLoading ? (
<Skeleton className="h-48 w-full" />
) : (
<>
<ResponsiveContainer width="100%" height={260}>
<ScatterChart margin={{ top: 10, right: 20, bottom: 24, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<XAxis
dataKey="uranium"
type="number"
name="Uranium"
domain={["auto", "auto"]}
tick={{ fill: "#6b7280", fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "rgba(255,255,255,0.08)" }}
label={{ value: "Uranium Spot ($/lb)", position: "insideBottom", offset: -10, fill: "#6b7280", fontSize: 11 }}
/>
<YAxis
dataKey="ccj"
type="number"
name="CCJ"
domain={["auto", "auto"]}
tick={{ fill: "#6b7280", fontSize: 11 }}
tickLine={false}
axisLine={false}
label={{ value: "CCJ ($)", angle: -90, position: "insideLeft", offset: 10, fill: "#6b7280", fontSize: 11 }}
/>
<Tooltip content={<CustomScatterTooltip />} />
{/* Upper confidence band */}
<Scatter
data={regression.upper}
fill="none"
line={{ stroke: "#F0A500", strokeWidth: 1, strokeDasharray: "5 4", strokeOpacity: 0.35 }}
shape={() => null as any}
legendType="none"
name="Upper Band"
/>
{/* Lower confidence band */}
<Scatter
data={regression.lower}
fill="none"
line={{ stroke: "#F0A500", strokeWidth: 1, strokeDasharray: "5 4", strokeOpacity: 0.35 }}
shape={() => null as any}
legendType="none"
name="Lower Band"
/>
{/* OLS regression line */}
<Scatter
data={regression.line}
fill="none"
line={{ stroke: "#F0A500", strokeWidth: 2, strokeOpacity: 0.85 }}
shape={() => null as any}
legendType="none"
name="OLS Fit"
/>
{/* Raw scatter dots */}
<Scatter
data={data?.correlation ?? []}
fill="#F0A500"
opacity={0.65}
r={4}
name="Weekly Obs."
/>
</ScatterChart>
</ResponsiveContainer>
<div className="flex items-center gap-5 text-xs text-muted-foreground mt-1 mb-1">
<div className="flex items-center gap-1.5">
<div className="h-2 w-2 rounded-full bg-[#F0A500] opacity-70" />
<span>Weekly observation</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-6 border-t-2 border-[#F0A500]" style={{ opacity: 0.85 }} />
<span>OLS trend line</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-6 border-t border-[#F0A500] border-dashed" style={{ opacity: 0.45 }} />
<span>±1.5σ channel</span>
</div>
</div>
</>
)}

<div className="mt-3 pt-3 border-t border-border grid grid-cols-1 md:grid-cols-2 gap-3 text-xs text-muted-foreground">
<p>
<span className="text-[#F0A500] font-semibold">CCJ (pure miner)</span> has higher uranium spot beta. Its P&L moves directly with U3O8 pricing.
</p>
<p>
<span className="text-slate-400 font-semibold">CEG (nuclear utility)</span> is influenced by electricity contracts and regulated returns. Smoother, less volatile nuclear exposure.
</p>
</div>
</Card>
</div>
</div>
</div>
);
Expand Down
Loading