From 90afec95001301c5cc6dd0110f3823c08d950f9d Mon Sep 17 00:00:00 2001 From: aurph Date: Tue, 9 Jun 2026 16:06:40 -0400 Subject: [PATCH 1/2] M1: close the verified security gaps + auth-boundary test - SEC-1: require admin on GET /api/newsletter/preview (was public, leaked the subscriber count). The internal call from /newsletter/send forwards the server's own admin key so sends keep working. - SEC-2: require admin on POST /api/social/generate (was public). - SEC-3: allowlist /api/stack timeframe to [1D,5D,1M] and key the cache on the normalized value, killing the ~200-Yahoo-call cache-miss amplification. - SEC-4: stop running the backlog/uranium news scanners (which write JSON) from public GET /api/news; they now run only via authenticated /api/admin/scan-news-now. Wire that into cron to restore automation. - SEC-5: HTML-escape datacenter names before Leaflet divIcon interpolation. - TEST-1: add auth-boundary.test.ts (every admin route rejects missing key, correct key accepted, SEC-3 coercion). 33/33 tests pass. --- client/src/pages/PowerMap.tsx | 14 ++- server/__tests__/auth-boundary.test.ts | 138 +++++++++++++++++++++++++ server/routes.ts | 26 +++-- 3 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 server/__tests__/auth-boundary.test.ts diff --git a/client/src/pages/PowerMap.tsx b/client/src/pages/PowerMap.tsx index d1762a8..81d8b78 100644 --- a/client/src/pages/PowerMap.tsx +++ b/client/src/pages/PowerMap.tsx @@ -119,6 +119,18 @@ function pushFiltersToURL(companies: string[], rtos: string[], capacity: string) type ViewMode = "dc" | "stress"; +// Escape user-controlled strings before interpolating them into raw Leaflet +// divIcon HTML. Datacenter names come from the admin form / ingester pipeline, +// so an unescaped name is a stored-XSS vector (SEC-5). +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + function pinRadius(powerMW: number): number { if (powerMW >= 500) return 10; if (powerMW >= 100) return 8; @@ -330,7 +342,7 @@ function FacilityLabels({ viewMode, filterCompanies, filterRTOs, filterCapacity, const truncName = dc.name.length > 20 ? dc.name.slice(0, 18) + ".." : dc.name; const label = L.marker([dc.lat, dc.lng], { icon: L.divIcon({ - html: `
${truncName}
`, + html: `
${escapeHtml(truncName)}
`, className: "leaflet-label-icon", iconSize: [120, 16], iconAnchor: [-8, 20], diff --git a/server/__tests__/auth-boundary.test.ts b/server/__tests__/auth-boundary.test.ts new file mode 100644 index 0000000..41a97f8 --- /dev/null +++ b/server/__tests__/auth-boundary.test.ts @@ -0,0 +1,138 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +// Must be set before importing routes: registerRoutes throws at call time if +// UNSUB_TOKEN_SECRET is unset, and requireAdmin returns 503 (not 401) when +// ADMIN_API_KEY is unset. +process.env.UNSUB_TOKEN_SECRET ||= "test-secret-for-auth-test"; +process.env.ADMIN_API_KEY ||= "test-admin-key"; +process.env.NODE_ENV = "test"; +const ADMIN_KEY = process.env.ADMIN_API_KEY; + +// Keep every test offline: stub Yahoo so no route reaches the network. +interface YahooLike { + quote: (...args: unknown[]) => Promise; + chart: (...args: unknown[]) => Promise; + search?: (...args: unknown[]) => Promise; +} +const yahooModule = await import("yahoo-finance2"); +const YahooFinanceClass = (yahooModule as unknown as { default: new () => YahooLike }).default; +const proto = YahooFinanceClass.prototype as YahooLike; +const originalQuote = proto.quote; +const originalChart = proto.chart; +proto.quote = () => Promise.reject(new Error("offline")); +proto.chart = () => Promise.reject(new Error("offline")); +after(() => { + proto.quote = originalQuote; + proto.chart = originalChart; +}); + +const { registerRoutes } = await import("../routes"); + +async function startTestServer(): Promise<{ url: string; close: () => Promise }> { + const app = express(); + app.use(express.json()); + const httpServer = createServer(app); + await registerRoutes(httpServer, app); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const { port } = httpServer.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}`, + close: () => new Promise((resolve) => httpServer.close(() => resolve())), + }; +} + +// Every admin/mutating route plus the two that used to leak. requireAdmin runs +// before any body handling, so empty bodies are fine for the no-key case. +const PROTECTED_ROUTES: Array<[string, string]> = [ + ["GET", "/api/admin/subscribers"], + ["GET", "/api/newsletter/preview"], // SEC-1: was public, leaked subscriber count + ["POST", "/api/social/generate"], // SEC-2: was public, burned Yahoo quota + ["DELETE", "/api/admin/subscribers/x@y.com"], + ["POST", "/api/newsletter/send"], + ["POST", "/api/admin/post-now"], + ["POST", "/api/admin/cron/daily-tweet"], + ["GET", "/api/admin/social-log"], + ["DELETE", "/api/admin/tweet/123"], + ["POST", "/api/admin/datacenters"], + ["DELETE", "/api/admin/datacenters/1"], + ["GET", "/api/admin/datacenters/pending"], + ["POST", "/api/admin/datacenters/ingest"], + ["POST", "/api/admin/datacenters/pending/1/approve"], + ["DELETE", "/api/admin/datacenters/pending/1"], + ["GET", "/api/export/daily"], + ["POST", "/api/admin/add-backlog-project"], + ["DELETE", "/api/admin/backlog-project/x"], + ["GET", "/api/admin/backlog-auto-updates"], + ["POST", "/api/admin/scan-news-now"], + ["POST", "/api/admin/update-backlog-headlines"], +]; + +test("admin auth boundary: the correct key is accepted, a missing key never authorizes", async () => { + const { url, close } = await startTestServer(); + try { + // 1. Happy path FIRST. Successful (2xx) requests are skipped by the + // admin failure limiter, so they don't consume its budget. + const okSubs = await fetch(`${url}/api/admin/subscribers`, { + headers: { "x-admin-key": ADMIN_KEY }, + }); + assert.equal(okSubs.status, 200, "valid key should reach GET /api/admin/subscribers"); + await okSubs.text(); + + const okLog = await fetch(`${url}/api/admin/social-log`, { + headers: { "x-admin-key": ADMIN_KEY }, + }); + assert.equal(okLog.status, 200, "valid key should reach GET /api/admin/social-log"); + await okLog.text(); + + // 2. The three most important no-key cases come first, while the failure + // limiter (5/min) still lets them through to the handler → exact 401. + for (const path of ["/api/admin/subscribers", "/api/newsletter/preview"]) { + const res = await fetch(`${url}${path}`); + assert.equal(res.status, 401, `${path} without a key must be 401, got ${res.status}`); + const body = (await res.json()) as { error?: string }; + assert.equal(body.error, "Unauthorized", `${path} should report Unauthorized`); + } + const gen = await fetch(`${url}/api/social/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + assert.equal(gen.status, 401, `POST /api/social/generate without a key must be 401, got ${gen.status}`); + await gen.text(); + + // 3. Every protected route: a missing key must NEVER return a 2xx. Past the + // limiter budget the denial may be 429 instead of 401 — both are fine, + // the invariant is "unauthenticated requests never succeed". + for (const [method, path] of PROTECTED_ROUTES) { + const res = await fetch(`${url}${path}`, { + method, + headers: method === "POST" ? { "Content-Type": "application/json" } : undefined, + body: method === "POST" ? "{}" : undefined, + }); + await res.text(); + assert.ok( + res.status === 401 || res.status === 429, + `${method} ${path} without a key returned ${res.status}; expected 401 or 429 (never a success)`, + ); + assert.ok(res.status < 200 || res.status >= 300, `${method} ${path} must not succeed without a key`); + } + } finally { + await close(); + } +}); + +test("SEC-3: an unrecognized stack timeframe is handled gracefully, not errored", async () => { + const { url, close } = await startTestServer(); + try { + const res = await fetch(`${url}/api/stack?timeframe=__junk__`); + assert.equal(res.status, 200, "junk timeframe should be coerced and return 200, not error"); + const body = (await res.json()) as Record; + assert.ok(Array.isArray(body.compute), "stack response should still carry layer arrays"); + } finally { + await close(); + } +}); diff --git a/server/routes.ts b/server/routes.ts index e9697ea..e4095fd 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1953,7 +1953,12 @@ export async function registerRoutes( // Stack endpoint - 8 layers, 10-min cache app.get("/api/stack", async (req, res) => { try { - const timeframe = (req.query.timeframe as string) || "1D"; + // Allowlist the timeframe and use the normalized value as the cache key. + // An arbitrary query string would otherwise be a permanent cache miss + // that fans out ~200 Yahoo calls per request (SEC-3). + const ALLOWED_TIMEFRAMES = ["1D", "5D", "1M"]; + const requested = (req.query.timeframe as string) || "1D"; + const timeframe = ALLOWED_TIMEFRAMES.includes(requested) ? requested : "1D"; const stockData = await getCachedStockData(timeframe); const ccjCorrelationData = generateCCJCorrelationData(); @@ -2273,6 +2278,7 @@ export async function registerRoutes( }); app.get("/api/newsletter/preview", async (req, res) => { + if (!requireAdmin(req, res)) return; try { const subscribers = loadSubscribers(); const subscriberCount = subscribers.length; @@ -2344,7 +2350,11 @@ Sent to ${subscriberCount} subscribers. You're receiving this because you subscr } const previewUrl = `http://localhost:${process.env.PORT || 5000}/api/newsletter/preview`; - const previewRes = await fetch(previewUrl); + // Preview is admin-gated (SEC-1); forward the server's own key on the + // internal call so the send path keeps working. + const previewRes = await fetch(previewUrl, { + headers: { "x-admin-key": process.env.ADMIN_API_KEY || "" }, + }); const htmlTemplate = await previewRes.text(); let sent = 0; @@ -2566,10 +2576,9 @@ Sent to ${subscriberCount} subscribers. You're receiving this because you subscr })); if (items.length > 0) { newsCache = { items, timestamp: now }; - // Auto-scan for backlog headline updates + market constants - scanNewsForBacklogUpdates(items).catch((e) => console.error("backlog scan error:", e)); - scanNewsForMarketConstants(items).catch((e) => console.error("market constants scan error:", e)); - maybeCheckLbnlEdition(); + // News-driven dataset writes (backlog + uranium scanners, LBNL + // check) run only from the authenticated /api/admin/scan-news-now + // path. A public GET must never persist state (SEC-4). return res.json(items); } } @@ -2583,9 +2592,7 @@ Sent to ${subscriberCount} subscribers. You're receiving this because you subscr const rssItems = await fetchRSSNews(); if (rssItems.length >= 3) { newsCache = { items: rssItems, timestamp: now }; - scanNewsForBacklogUpdates(rssItems).catch((e) => console.error("backlog scan error:", e)); - scanNewsForMarketConstants(rssItems).catch((e) => console.error("market constants scan error:", e)); - maybeCheckLbnlEdition(); + // Scanners moved to the authenticated /api/admin/scan-news-now (SEC-4). return res.json(rssItems); } } catch (_e) { @@ -3137,6 +3144,7 @@ Preferred-Languages: en // Compose a tweet from a named template without posting. Use this to preview // copy before scheduling. Returns the text + the template that was picked. app.post("/api/social/generate", async (req, res) => { + if (!requireAdmin(req, res)) return; const { template } = req.body || {}; const dayIdx = new Date().getDay(); const onDemand = template && ON_DEMAND_TEMPLATES[template] From 907065c9683331f65568a2ed00765f42ebf4bf0b Mon Sep 17 00:00:00 2001 From: aurph Date: Wed, 15 Jul 2026 13:43:43 -0400 Subject: [PATCH 2/2] Stack: replace the synthetic uranium scatter with real weekly closes The CCJ/CEG correlation card was the last fabricated data on the server: Box-Muller noise tuned to target r values (0.82 / 0.65), regenerated per request and presented as 52 weeks of observations (audit finding M5). Now it plots real trailing-year weekly closes from Yahoo: Sprott Physical Uranium Trust (SRUUF) as the spot proxy (holds physical U3O8; URA would be circular, it holds CCJ) against CCJ, with Pearson r computed from the same observed series for both CCJ and CEG. Math is a pure module (server/correlation.ts, date-joined so a dropped OTC bar can't shift the pairing, 7 tests). 6h cache; on Yahoo failure the card shows an explicit empty state instead of fallback data, and r reads null, never a default. First real readings: CCJ r 0.872, CEG r -0.298 - the fake data had CEG at +0.65; reality says the utility does not track spot at all this year. Also: reusePort now Linux-only in server/index.ts (macOS dev fix, known issue), CLAUDE.md known-debt entries updated. --- CLAUDE.md | 10 +- client/src/pages/TheStack.tsx | 61 +++++++----- server/__tests__/correlation.test.ts | 86 +++++++++++++++++ server/correlation.ts | 86 +++++++++++++++++ server/index.ts | 2 +- server/routes.ts | 133 ++++++++++++--------------- 6 files changed, 276 insertions(+), 102 deletions(-) create mode 100644 server/__tests__/correlation.test.ts create mode 100644 server/correlation.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6fdb0e0..80b4dd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,11 +143,11 @@ server/data custody (the fragility map): Documented, not to fix casually or silently: -- FAKE DATA STILL LIVE: /api/stack serves a Math.random CCJ/CEG correlation scatter, regenerated - each request (routes.ts ~426-466, ~1959-1985). The only fabricated data on the server; - contradicts the honest-data doctrine. Unmerged PR #2 removes it. -- Unmerged security hardening: PR #1 branch fix/m0-m1-truth-security (now conflicts with main) - holds five verified low/medium closes; cherry-pick commit da97234 rather than rebasing. +- ~~FAKE DATA~~ RESOLVED on fix/security-closes: the Math.random CCJ/CEG scatter was replaced + with real weekly closes (SRUUF physical-uranium proxy vs CCJ/CEG, server/correlation.ts, + 6h cache; empty state instead of fallback data when Yahoo fails). +- ~~Unmerged security hardening~~ RESOLVED on fix/security-closes: da97234 (SEC-1..5 + + auth-boundary tests) cherry-picked cleanly. PR #1 can be closed once this merges. - PR #2 (feat/real-metrics) proposes retiring the sentiment indices for a sourced scoreboard; main kept the indices (served at /api/kpis, out of the social rotation). Owner decision pending. - No CI (no .github/ at all). Tests and tsc run only when someone remembers. diff --git a/client/src/pages/TheStack.tsx b/client/src/pages/TheStack.tsx index 8b77a31..818db3a 100644 --- a/client/src/pages/TheStack.tsx +++ b/client/src/pages/TheStack.tsx @@ -38,7 +38,8 @@ interface StockData { } interface CorrelationPoint { - uranium: number; + date: string; + uranium: number; // Sprott Physical Uranium Trust (SRUUF) weekly close, $/share ccj: number; } @@ -57,8 +58,10 @@ interface StackData { cryptoAIDC: StockData[]; etfsBenchmarks: StockData[]; correlation: CorrelationPoint[]; - correlationCoeff: number; - cegCorrelationCoeff: number; + correlationCoeff: number | null; + cegCorrelationCoeff: number | null; + correlationWeeks?: number; + correlationProxy?: string; } function Sparkline({ data, color }: { data: number[] | undefined; color: string }) { @@ -184,9 +187,11 @@ function StockCardSkeleton() { const CustomScatterTooltip = ({ active, payload }: any) => { if (active && payload && payload.length) { + const date = payload[0]?.payload?.date; return (
-

Uranium Spot: ${payload[0]?.value?.toFixed(2)}/lb

+ {date &&

week of {date}

} +

SRUUF: ${payload[0]?.value?.toFixed(2)}

CCJ: ${payload[1]?.value?.toFixed(2)}

); @@ -488,40 +493,48 @@ export default function TheStack() {
-

Uranium Spot vs. CCJ Correlation

+

Physical Uranium vs. CCJ Correlation

-

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.

+

The x-axis is the Sprott Physical Uranium Trust (SRUUF), a fund that holds physical U3O8 — the cleanest public proxy for spot without the circularity of a miners ETF. CCJ is the largest public uranium miner; CEG (utility) is influenced by electricity contracts and regulated returns. CCJ = commodity bet, CEG = infrastructure bet. Computed from observed weekly closes (Yahoo Finance), not modeled.

-

52-week uranium spot price ($/lb) vs. CCJ stock price. Each dot = one week.

+

+ Weekly closes, trailing year: Sprott Physical Uranium Trust (SRUUF) vs. CCJ. Each dot = one week{typeof data?.correlationWeeks === "number" && data.correlationWeeks > 0 ? ` (${data.correlationWeeks} observed)` : ""}. +

- {data?.correlationCoeff !== undefined && ( -
-

CCJ Pearson r

-

{data.correlationCoeff.toFixed(3)}

-

- {data.correlationCoeff > 0.7 ? "Strong" : data.correlationCoeff > 0.4 ? "Moderate" : "Weak"} correlation -

-
- )} - {data?.cegCorrelationCoeff !== undefined && ( -
-

CEG Pearson r

-

{data.cegCorrelationCoeff.toFixed(3)}

-

Utility beta

-
- )} +
+

CCJ Pearson r

+

+ {typeof data?.correlationCoeff === "number" ? data.correlationCoeff.toFixed(3) : "—"} +

+

+ {typeof data?.correlationCoeff !== "number" + ? "no data" + : Math.abs(data.correlationCoeff) > 0.7 ? "Strong correlation" : Math.abs(data.correlationCoeff) > 0.4 ? "Moderate correlation" : "Weak correlation"} +

+
+
+

CEG Pearson r

+

+ {typeof data?.cegCorrelationCoeff === "number" ? data.cegCorrelationCoeff.toFixed(3) : "—"} +

+

Utility beta

+
{isLoading ? ( + ) : !data?.correlation?.length ? ( +
+ Live weekly price data unavailable right now — nothing is shown rather than modeled data. Retries automatically. +
) : ( <> @@ -535,7 +548,7 @@ export default function TheStack() { 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 }} + label={{ value: "Sprott Physical U Trust (SRUUF, $)", position: "insideBottom", offset: -10, fill: "#6b7280", fontSize: 11 }} /> { + const up = pearson([1, 2, 3, 4], [2, 4, 6, 8]); + const down = pearson([1, 2, 3, 4], [8, 6, 4, 2]); + assert.ok(up !== null && Math.abs(up - 1) < 1e-12); + assert.ok(down !== null && Math.abs(down + 1) < 1e-12); +}); + +test("pearson: refuses to fabricate a coefficient", () => { + assert.equal(pearson([1, 2], [2, 4]), null); // too short + assert.equal(pearson([5, 5, 5, 5], [1, 2, 3, 4]), null); // zero variance + assert.equal(pearson([], []), null); +}); + +test("pearson: known mixed series matches hand-computed value", () => { + // xs=[1,2,3,4,5], ys=[2,1,4,3,5]: num=8, denX=denY=sqrt(10) -> r = 0.8 + const r = pearson([1, 2, 3, 4, 5], [2, 1, 4, 3, 5]); + assert.ok(r !== null && Math.abs(r - 0.8) < 1e-9); +}); + +test("alignByDate: joins on bar date, drops one-sided weeks instead of shifting", () => { + const a: WeeklyClose[] = [ + { date: "2026-06-01", close: 10 }, + { date: "2026-06-08", close: 11 }, + { date: "2026-06-15", close: 12 }, + ]; + // b is missing 06-08 (OTC gap). Index pairing would wrongly pair 06-08 with 06-15. + const b: WeeklyClose[] = [ + { date: "2026-06-01", close: 100 }, + { date: "2026-06-15", close: 120 }, + ]; + const aligned = alignByDate(a, b); + assert.deepEqual(aligned, [ + { date: "2026-06-01", a: 10, b: 100 }, + { date: "2026-06-15", a: 12, b: 120 }, + ]); +}); + +test("alignByDate: non-finite closes are excluded", () => { + const a: WeeklyClose[] = [ + { date: "2026-06-01", close: NaN }, + { date: "2026-06-08", close: 11 }, + ]; + const b: WeeklyClose[] = [ + { date: "2026-06-01", close: 100 }, + { date: "2026-06-08", close: 110 }, + ]; + assert.deepEqual(alignByDate(a, b), [{ date: "2026-06-08", a: 11, b: 110 }]); +}); + +test("buildScatter: points come from the observed joins and r from the same series", () => { + const proxy: WeeklyClose[] = [ + { date: "2026-05-25", close: 20.111 }, + { date: "2026-06-01", close: 21 }, + { date: "2026-06-08", close: 22 }, + { date: "2026-06-15", close: 23 }, + ]; + const stock: WeeklyClose[] = [ + { date: "2026-05-25", close: 100.555 }, + { date: "2026-06-01", close: 105 }, + { date: "2026-06-08", close: 110 }, + { date: "2026-06-15", close: 115 }, + ]; + const { points, r, weeks } = buildScatter(proxy, stock); + assert.equal(weeks, 4); + assert.equal(points.length, 4); + assert.deepEqual(points[0], { date: "2026-05-25", uranium: 20.11, ccj: 100.56 }); + assert.ok(r !== null && r > 0.999); // near-linear input +}); + +test("buildScatter: empty overlap yields empty points and null r, not defaults", () => { + const { points, r, weeks } = buildScatter( + [{ date: "2026-06-01", close: 20 }], + [{ date: "2026-06-08", close: 100 }] + ); + assert.deepEqual(points, []); + assert.equal(r, null); + assert.equal(weeks, 0); +}); diff --git a/server/correlation.ts b/server/correlation.ts new file mode 100644 index 0000000..7b98c09 --- /dev/null +++ b/server/correlation.ts @@ -0,0 +1,86 @@ +// Pure math for the Stack page's uranium correlation card. +// +// History: this card used to chart synthetic scatter data generated per +// request with Box-Muller noise tuned to a target r (the "Math.random +// correlation" finding, audit M5). That is gone. The card now plots real +// weekly closes: a physical-uranium proxy (Sprott Physical Uranium Trust) +// on x, CCJ on y, with Pearson r computed from the same observed series. +// Fetching lives in routes.ts; everything here is deterministic and tested. + +export interface WeeklyClose { + date: string; // ISO yyyy-mm-dd (weekly bar date) + close: number; +} + +export interface ScatterPoint { + date: string; + uranium: number; // proxy close, $/share + ccj: number; // stock close, $ (field name kept for the client's chart keys) +} + +// Pearson r over two equal-length series. Returns null when the inputs are +// too short (n < 3) or either series has zero variance — the client renders +// "—" instead of a fabricated coefficient. +export function pearson(xs: number[], ys: number[]): number | null { + const n = Math.min(xs.length, ys.length); + if (n < 3) return null; + const x = xs.slice(0, n); + const y = ys.slice(0, n); + const meanX = x.reduce((s, v) => s + v, 0) / n; + const meanY = y.reduce((s, v) => s + v, 0) / n; + const num = x.reduce((s, v, i) => s + (v - meanX) * (y[i] - meanY), 0); + const denX = Math.sqrt(x.reduce((s, v) => s + (v - meanX) ** 2, 0)); + const denY = Math.sqrt(y.reduce((s, v) => s + (v - meanY) ** 2, 0)); + if (denX === 0 || denY === 0) return null; + return num / (denX * denY); +} + +// Inner-join two weekly series on their bar date. Yahoo occasionally drops a +// bar for one symbol (halts, OTC gaps), so pairing by index would silently +// shift every later observation — join on date instead. +export function alignByDate( + a: WeeklyClose[], + b: WeeklyClose[] +): Array<{ date: string; a: number; b: number }> { + const bByDate = new Map(b.map((w) => [w.date, w.close])); + const out: Array<{ date: string; a: number; b: number }> = []; + for (const w of a) { + const match = bByDate.get(w.date); + if (match !== undefined && Number.isFinite(w.close) && Number.isFinite(match)) { + out.push({ date: w.date, a: w.close, b: match }); + } + } + return out; +} + +export interface CorrelationResult { + points: ScatterPoint[]; + r: number | null; + weeks: number; +} + +// Scatter points + Pearson r for one stock against the uranium proxy. +export function buildScatter( + proxy: WeeklyClose[], + stock: WeeklyClose[] +): CorrelationResult { + const aligned = alignByDate(proxy, stock); + const points = aligned.map((p) => ({ + date: p.date, + uranium: round2(p.a), + ccj: round2(p.b), + })); + const r = pearson( + aligned.map((p) => p.a), + aligned.map((p) => p.b) + ); + return { points, r: r === null ? null : round3(r), weeks: aligned.length }; +} + +function round2(v: number): number { + return Math.round(v * 100) / 100; +} + +function round3(v: number): number { + return Math.round(v * 1000) / 1000; +} diff --git a/server/index.ts b/server/index.ts index 41aade4..33c8e8e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -185,7 +185,7 @@ app.use((req, res, next) => { { port, host: "0.0.0.0", - reusePort: true, + reusePort: process.platform === "linux", }, () => { log(`serving on port ${port}`); diff --git a/server/routes.ts b/server/routes.ts index e4095fd..c385aae 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -32,6 +32,7 @@ import { computeClusterMetrics, type ClusterLite } from "./clusters"; import { computeGpuIndex } from "./gpu-index"; import { recordDailyGpuPrices, recordedByModel } from "./gpu-history"; import { computeDealMetrics, type DealProject } from "./deals"; +import { buildScatter, type WeeklyClose, type ScatterPoint } from "./correlation"; import { composeBrief, renderBriefText, type BriefInput } from "./brief"; import { computeGpuEconomics, TRAINING_PRESETS } from "./gpu-economics"; import { @@ -412,67 +413,61 @@ function deriveSmrPolicyScore(): number { } } -// Generate scatter data with a target Pearson r using the standard linear noise model: -// y = r * x_std + sqrt(1 - r^2) * noise_std (both in z-score space, then rescale) -function gaussianRandom(): number { - // Box-Muller - const u1 = Math.random() || 1e-10; - const u2 = Math.random(); - return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); +// ─── Uranium correlation (real weekly closes, see server/correlation.ts) ─── +// x-axis proxy is the Sprott Physical Uranium Trust (SRUUF, USD OTC listing): +// it holds physical U3O8, so its share price tracks spot without the +// circularity of URA (which holds CCJ itself). Math lives in correlation.ts. +const URANIUM_PROXY_TICKER = "SRUUF"; +const CORRELATION_CACHE_TTL = 6 * 60 * 60 * 1000; // weekly bars; 6h is plenty + +interface CorrelationPayload { + points: ScatterPoint[]; + ccjR: number | null; + cegR: number | null; + weeks: number; + proxy: string; } - -// CCJ (Cameco): pure uranium miner - tight beta to U3O8 spot, target r ~ 0.82 -// Uranium spot range approx $65-$110 over 52-week scatter; CCJ approx $90-$135 (Mar 2026 price ~$113) -function generateCCJCorrelationData() { - const data = []; - const targetR = 0.82; - const sqrtTerm = Math.sqrt(1 - targetR * targetR); - for (let i = 0; i < 52; i++) { - const x = gaussianRandom(); // shared factor (uranium direction) - const e = gaussianRandom(); // idiosyncratic noise - const uStd = x; - const cStd = targetR * x + sqrtTerm * e; - // Rescale: uranium mean=86, sd=11; ccj mean=112, sd=11 (2025-2026 price ranges) - const uranium = parseFloat((86 + uStd * 11).toFixed(2)); - const ccj = parseFloat((112 + cStd * 11).toFixed(2)); - data.push({ - uranium: Math.max(60, Math.min(115, uranium)), - ccj: Math.max(82, Math.min(148, ccj)) - }); - } - return data; +const EMPTY_CORRELATION: CorrelationPayload = { points: [], ccjR: null, cegR: null, weeks: 0, proxy: URANIUM_PROXY_TICKER }; +let correlationCache: { data: CorrelationPayload; timestamp: number } | null = null; + +function toWeeklyCloses(chart: any): WeeklyClose[] { + const quotes: any[] = chart?.quotes ?? []; + return quotes + .filter((q) => q?.date && typeof q.close === "number" && Number.isFinite(q.close)) + .map((q) => ({ date: new Date(q.date).toISOString().slice(0, 10), close: q.close })); } -// CEG (Constellation Energy): nuclear utility - looser uranium beta, target r ~ 0.65 -// CEG influenced by electricity contracts, capex, and macro beyond uranium spot (Mar 2026 price ~$315) -function generateCEGCorrelationData() { - const data = []; - const targetR = 0.65; - const sqrtTerm = Math.sqrt(1 - targetR * targetR); - for (let i = 0; i < 52; i++) { - const x = gaussianRandom(); - const e = gaussianRandom(); - const uStd = x; - const cStd = targetR * x + sqrtTerm * e; - // Rescale: uranium mean=86, sd=11; ceg mean=310, sd=60 (2025-2026 price ranges) - const uranium = parseFloat((86 + uStd * 11).toFixed(2)); - const ceg = parseFloat((310 + cStd * 60).toFixed(2)); - data.push({ - uranium: Math.max(60, Math.min(115, uranium)), - ceg: Math.max(160, Math.min(470, ceg)) - }); +async function getUraniumCorrelation(): Promise { + if (correlationCache && Date.now() - correlationCache.timestamp < CORRELATION_CACHE_TTL) { + return correlationCache.data; + } + try { + const YahooFinanceClass = (await import("yahoo-finance2")).default; + const yahooFinance = new YahooFinanceClass({ suppressNotices: ["yahooSurvey"] }); + const period2 = new Date(); + const period1 = new Date(period2.getTime() - 372 * 24 * 60 * 60 * 1000); // ~53 weeks + const opts = { period1, period2, interval: "1wk" as const }; + const [proxy, ccj, ceg] = await Promise.all([ + yahooFinance.chart(URANIUM_PROXY_TICKER, opts).catch(() => null), + yahooFinance.chart("CCJ", opts).catch(() => null), + yahooFinance.chart("CEG", opts).catch(() => null), + ]); + const proxyCloses = toWeeklyCloses(proxy); + const ccjScatter = buildScatter(proxyCloses, toWeeklyCloses(ccj)); + const cegScatter = buildScatter(proxyCloses, toWeeklyCloses(ceg)); + const data: CorrelationPayload = { + points: ccjScatter.points, + ccjR: ccjScatter.r, + cegR: cegScatter.r, + weeks: ccjScatter.weeks, + proxy: URANIUM_PROXY_TICKER, + }; + // Only cache a real result; an empty one should retry on the next request. + if (data.points.length > 0) correlationCache = { data, timestamp: Date.now() }; + return data.points.length > 0 ? data : correlationCache?.data ?? EMPTY_CORRELATION; + } catch { + return correlationCache?.data ?? EMPTY_CORRELATION; } - return data; -} - -function calculateCorrelation(xs: number[], ys: number[]) { - const n = xs.length; - const meanX = xs.reduce((s, v) => s + v, 0) / n; - const meanY = ys.reduce((s, v) => s + v, 0) / n; - const num = xs.reduce((s, v, i) => s + (v - meanX) * (ys[i] - meanY), 0); - const denX = Math.sqrt(xs.reduce((s, v) => s + Math.pow(v - meanX, 2), 0)); - const denY = Math.sqrt(ys.reduce((s, v) => s + Math.pow(v - meanY, 2), 0)); - return num / (denX * denY); } // ─── Stack + Top-Movers cache (10-min TTL per timeframe) ─────────────────── @@ -1959,18 +1954,10 @@ export async function registerRoutes( const ALLOWED_TIMEFRAMES = ["1D", "5D", "1M"]; const requested = (req.query.timeframe as string) || "1D"; const timeframe = ALLOWED_TIMEFRAMES.includes(requested) ? requested : "1D"; - const stockData = await getCachedStockData(timeframe); - - const ccjCorrelationData = generateCCJCorrelationData(); - const cegCorrelationData = generateCEGCorrelationData(); - const ccjR = calculateCorrelation( - ccjCorrelationData.map((d) => d.uranium), - ccjCorrelationData.map((d) => d.ccj) - ); - const cegR = calculateCorrelation( - cegCorrelationData.map((d) => d.uranium), - cegCorrelationData.map((d) => d.ceg) - ); + const [stockData, uraniumCorrelation] = await Promise.all([ + getCachedStockData(timeframe), + getUraniumCorrelation(), + ]); res.json({ compute: STACK_TICKERS.compute.map((t) => stockData[t]).filter(Boolean), @@ -1986,9 +1973,11 @@ export async function registerRoutes( transmissionGrid: STACK_TICKERS.transmissionGrid.map((t) => stockData[t]).filter(Boolean), cryptoAIDC: STACK_TICKERS.cryptoAIDC.map((t) => stockData[t]).filter(Boolean), etfsBenchmarks: STACK_TICKERS.etfsBenchmarks.map((t) => stockData[t]).filter(Boolean), - correlation: ccjCorrelationData, - correlationCoeff: parseFloat(ccjR.toFixed(3)), - cegCorrelationCoeff: parseFloat(cegR.toFixed(3)), + correlation: uraniumCorrelation.points, + correlationCoeff: uraniumCorrelation.ccjR, + cegCorrelationCoeff: uraniumCorrelation.cegR, + correlationWeeks: uraniumCorrelation.weeks, + correlationProxy: uraniumCorrelation.proxy, }); } catch (error) { console.error("Stack error:", error);