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/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/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 }}
/>
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/__tests__/correlation.test.ts b/server/__tests__/correlation.test.ts
new file mode 100644
index 0000000..26bf240
--- /dev/null
+++ b/server/__tests__/correlation.test.ts
@@ -0,0 +1,86 @@
+// Locks the uranium correlation math. The scatter is built only from real
+// observed weekly closes joined on bar date; r reads null (never a made-up
+// number) when there is no usable overlap. This test exists because the card
+// previously shipped Box-Muller synthetic data tuned to a target r.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { pearson, alignByDate, buildScatter, type WeeklyClose } from "../correlation";
+
+test("pearson: perfectly linear series reads 1 / -1", () => {
+ 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 e9697ea..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) ───────────────────
@@ -1953,19 +1948,16 @@ 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";
- 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)
- );
+ // 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, uraniumCorrelation] = await Promise.all([
+ getCachedStockData(timeframe),
+ getUraniumCorrelation(),
+ ]);
res.json({
compute: STACK_TICKERS.compute.map((t) => stockData[t]).filter(Boolean),
@@ -1981,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);
@@ -2273,6 +2267,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 +2339,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 +2565,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 +2581,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 +3133,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]