From 70e1a912491533f0b721faddcd70067b204e085c Mon Sep 17 00:00:00 2001 From: aurph Date: Fri, 3 Jul 2026 17:04:57 -0400 Subject: [PATCH 01/15] Live GPU price ingestion: the recorder observes the market instead of copying curation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'daily recorder' was copying the hand-curated current price into the history file once a day - a flat line wearing a recorded-daily badge. It now records real observations from providers with public, keyless APIs, verified live before building: - RunPod GraphQL: secure + community on-demand pricing (community rows under 40% of secure are placeholder floors and are dropped) - Vast.ai marketplace: median dph_total of the cheapest 50 verified on-demand single-GPU offers Blend = median across observations; low/high = observed span; guard rail drops anything 4x off the curated price as a bad SKU match (real rentals do not 4x in a day). Models with no live source that day record NOTHING - anchors + est. flags carry them honestly. Total fetch failure records nothing and fabricates nothing. Serving: a fresh live observation (<=2 Eastern days) becomes the served current price and DROPS the est. flag - the number is observed, not estimated. Rows carry liveSources/liveDate; the row tooltip shows 'live price · runpod-secure + vast · ' and the methodology footnote explains the live/curated split. Sweep is request-driven (first metrics hit of the Eastern day, single-flight, response never waits on providers) so it works on autoscale hosting. E2E verified against the real APIs: 6 of 10 models recorded live (H100 $2.69 n=3, B200 $5.89 n=3, H200, B300, A100, MI300X), fleet avg moved 4.47 -> 4.31, est. flags dropped on covered models and kept on GB200/GH200/MI325X/MI355X. Suite 195 tests. --- client/src/pages/neocloud-intel.tsx | 11 +- server/__tests__/gpu-live.test.ts | 155 ++++++++++++++++++ server/gpu-history.ts | 59 ++++++- server/gpu-live.ts | 236 ++++++++++++++++++++++++++++ server/routes.ts | 49 +++++- 5 files changed, 500 insertions(+), 10 deletions(-) create mode 100644 server/__tests__/gpu-live.test.ts create mode 100644 server/gpu-live.ts diff --git a/client/src/pages/neocloud-intel.tsx b/client/src/pages/neocloud-intel.tsx index 2516b5d..ced80e7 100644 --- a/client/src/pages/neocloud-intel.tsx +++ b/client/src/pages/neocloud-intel.tsx @@ -48,6 +48,8 @@ interface GpuRow { estimated: string[]; changes: GpuChanges; series: SeriesPoint[]; + liveSources?: string[] | null; + liveDate?: string | null; } interface GpuMetrics { asOf: string; @@ -495,6 +497,11 @@ export default function NeocloudIntel() {
{r.model} · {r.vendor} {r.architecture ? `· ${r.architecture}` : ""} {r.vramGB ? `· ${r.vramGB}GB ${r.vramType ?? ""}` : ""} {r.launchYear ? `· ${r.launchYear}` : ""}
+ {r.liveSources && r.liveSources.length > 0 && ( +

+ live price · {r.liveSources.join(" + ")} · {r.liveDate} +

+ )} {r.oneYearTrend &&

{r.oneYearTrend}

} {r.sources.length > 0 && (
@@ -511,7 +518,9 @@ export default function NeocloudIntel() {

{data?.methodology ?? "Blended on-demand rental prices from public neocloud and marketplace listings and the getdeploying.com / Silicon Data trackers."} - {" "}Sources are listed per model (hover a table row) and in server/data/gpu-rental-prices.json. Prices move constantly and vary widely by provider, term, and availability; treat these as indicative, not quotes. + {" "}Models covered by live provider APIs (RunPod, Vast.ai marketplace) serve an observed daily price - those + drop the est. flag and show their sources on row hover. The rest carry curated, source-verified estimates. + Prices move constantly and vary widely by provider, term, and availability; treat these as indicative, not quotes.

)} diff --git a/server/__tests__/gpu-live.test.ts b/server/__tests__/gpu-live.test.ts new file mode 100644 index 0000000..21e9737 --- /dev/null +++ b/server/__tests__/gpu-live.test.ts @@ -0,0 +1,155 @@ +// Live GPU price ingestion: parsing, blending, guard rails, and the full +// sweep against fixture provider payloads. A bad SKU match here would put a +// wrong price on the front page, so the guard rails get their own cases. +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + RUNPOD_MODEL_IDS, + blendLivePrice, + fetchLivePrices, + parseRunpod, + parseVast, + type FetchLike, + type RunpodGpuType, +} from "../gpu-live"; + +describe("parseRunpod", () => { + const types: RunpodGpuType[] = [ + { id: "NVIDIA H100 80GB HBM3", securePrice: 3.29, communityPrice: 2.69 }, + { id: "NVIDIA H100 PCIe", securePrice: 2.89, communityPrice: 1.99 }, + { id: "NVIDIA H200", securePrice: 4.39, communityPrice: 0.5 }, + { id: "NVIDIA B200", securePrice: null, communityPrice: 5.98 }, + ]; + it("matches only the mapped SKU ids", () => { + const obs = parseRunpod(types, RUNPOD_MODEL_IDS.H100); + assert.deepEqual(obs.map((o) => o.price), [3.29, 2.69]); + }); + it("drops placeholder community prices (under 40% of secure)", () => { + const obs = parseRunpod(types, ["NVIDIA H200"]); + assert.deepEqual(obs.map((o) => o.price), [4.39]); + }); + it("community without a secure anchor is dropped entirely", () => { + const obs = parseRunpod(types, ["NVIDIA B200"]); + assert.deepEqual(obs, []); + }); + it("empty/missing input yields no observations", () => { + assert.deepEqual(parseRunpod([], ["x"]), []); + assert.deepEqual(parseRunpod(undefined as never, ["x"]), []); + }); +}); + +describe("parseVast", () => { + it("takes the median of returned offers", () => { + const obs = parseVast([{ dph_total: 1.74 }, { dph_total: 1.93 }, { dph_total: 2.0 }]); + assert.deepEqual(obs, [{ provider: "vast", price: 1.93 }]); + }); + it("even count averages the middle pair", () => { + const obs = parseVast([{ dph_total: 1.0 }, { dph_total: 2.0 }, { dph_total: 3.0 }, { dph_total: 4.0 }]); + assert.equal(obs[0].price, 2.5); + }); + it("ignores null/zero/negative totals; empty yields nothing", () => { + assert.deepEqual(parseVast([{ dph_total: null }, { dph_total: 0 }, { dph_total: -1 }]), []); + assert.deepEqual(parseVast([]), []); + }); +}); + +describe("blendLivePrice", () => { + it("median blend with low/high and deduped sources", () => { + const { blended, dropped } = blendLivePrice( + [ + { provider: "runpod-secure", price: 3.29 }, + { provider: "runpod-community", price: 2.69 }, + { provider: "vast", price: 1.93 }, + ], + 2.79, + ); + assert.equal(dropped, 0); + assert.ok(blended); + assert.equal(blended.price, 2.69); + assert.equal(blended.low, 1.93); + assert.equal(blended.high, 3.29); + assert.equal(blended.n, 3); + assert.deepEqual(blended.sources.sort(), ["runpod-community", "runpod-secure", "vast"]); + }); + it("guard rail: observations 4x off the curated price are dropped as bad matches", () => { + const { blended, dropped } = blendLivePrice( + [ + { provider: "vast", price: 0.4 }, // 10x cheaper than curated: wrong SKU + { provider: "runpod-secure", price: 30 }, // 7x pricier: glitch + { provider: "runpod-community", price: 4.1 }, + ], + 4.0, + ); + assert.equal(dropped, 2); + assert.equal(blended?.price, 4.1); + }); + it("no curated reference means no ratio guard (new models still record)", () => { + const { blended, dropped } = blendLivePrice([{ provider: "vast", price: 42 }], null); + assert.equal(dropped, 0); + assert.equal(blended?.price, 42); + }); + it("all observations dropped or empty yields null (record nothing, fabricate nothing)", () => { + assert.equal(blendLivePrice([], 3).blended, null); + assert.equal(blendLivePrice([{ provider: "vast", price: NaN }], 3).blended, null); + }); +}); + +describe("fetchLivePrices (full sweep against fixtures)", () => { + const runpodBody = { + data: { + gpuTypes: [ + { id: "NVIDIA H100 80GB HBM3", securePrice: 3.29, communityPrice: 2.69 }, + { id: "NVIDIA B200", securePrice: 5.89, communityPrice: 5.98 }, + ], + }, + }; + const vastByName: Record = { + "H100 SXM": { offers: [{ dph_total: 1.74 }, { dph_total: 1.93 }, { dph_total: 2.0 }] }, + }; + const fakeFetch: FetchLike = async (url) => { + if (url.includes("runpod")) return { ok: true, json: async () => runpodBody }; + const q = JSON.parse(decodeURIComponent(url.split("?q=")[1])); + return { ok: true, json: async () => vastByName[q.gpu_name.eq] ?? { offers: [] } }; + }; + + it("blends per model across providers; models with no source record nothing", async () => { + const live = await fetchLivePrices( + [ + { model: "H100", currentUsdPerHr: 2.79 }, + { model: "B200", currentUsdPerHr: 6.11 }, + { model: "MI355X", currentUsdPerHr: 2.85 }, // no provider covers it + ], + fakeFetch, + ); + assert.deepEqual(Object.keys(live).sort(), ["B200", "H100"]); + assert.equal(live.H100.price, 2.69); // median of 3.29, 2.69, 1.93 + assert.equal(live.H100.n, 3); + assert.equal(live.B200.n, 2); + assert.equal("MI355X" in live, false); + }); + + it("provider failure degrades to the surviving sources, never throws", async () => { + const failingFetch: FetchLike = async (url) => { + if (url.includes("runpod")) throw new Error("network down"); + const q = JSON.parse(decodeURIComponent(url.split("?q=")[1])); + return { ok: true, json: async () => vastByName[q.gpu_name.eq] ?? { offers: [] } }; + }; + const live = await fetchLivePrices([{ model: "H100", currentUsdPerHr: 2.79 }], failingFetch); + assert.equal(live.H100.price, 1.93); // vast only + assert.deepEqual(live.H100.sources, ["vast"]); + }); + + it("total failure yields an empty result (nothing recorded, nothing invented)", async () => { + const deadFetch: FetchLike = async () => { + throw new Error("offline"); + }; + const live = await fetchLivePrices([{ model: "H100", currentUsdPerHr: 2.79 }], deadFetch); + assert.deepEqual(live, {}); + }); + + it("non-ok responses are treated as empty, not parsed", async () => { + const badFetch: FetchLike = async () => ({ ok: false, json: async () => ({ oops: true }) }); + const live = await fetchLivePrices([{ model: "H100", currentUsdPerHr: 2.79 }], badFetch); + assert.deepEqual(live, {}); + }); +}); diff --git a/server/gpu-history.ts b/server/gpu-history.ts index d8ce548..53fb166 100644 --- a/server/gpu-history.ts +++ b/server/gpu-history.ts @@ -14,12 +14,24 @@ import { readFileSync, writeFileSync, existsSync } from "fs"; import { join } from "path"; import type { GpuHistoryAnchor } from "./gpu-index"; +import type { LiveModelPrice } from "./gpu-live"; const FILE = join(process.cwd(), "server", "data", "gpu-price-history.json"); -interface Snapshot { +export interface SnapshotMeta { + low: number; + high: number; + sources: string[]; + n: number; +} + +export interface Snapshot { date: string; // YYYY-MM-DD, US-Eastern prices: Record; + /** per-model provenance for live-recorded days (absent on legacy rows) */ + meta?: Record; + /** "live" = observed from provider APIs; "curated" = copied static price */ + source?: "live" | "curated"; } function easternDateStr(d: Date = new Date()): string { @@ -35,21 +47,58 @@ export function readGpuHistory(): Snapshot[] { return []; } -/** Append today's prices once per Eastern day. Best-effort. */ -export function recordDailyGpuPrices(models: Array<{ model: string; currentUsdPerHr: number }>): void { +/** Has today (US-Eastern) already been recorded? */ +export function hasTodaySnapshot(): boolean { + const today = easternDateStr(); + return readGpuHistory().some((s) => s.date === today); +} + +/** + * Append today's LIVE observations once per Eastern day. Only models with a + * real observation are written; a model with no live source that day records + * nothing (the curated anchors + est. flags carry it honestly). Writes + * nothing at all if the sweep came back empty. + */ +export function recordDailyLivePrices(live: Record): void { try { + const models = Object.keys(live); + if (models.length === 0) return; const hist = readGpuHistory(); const today = easternDateStr(); if (hist.some((s) => s.date === today)) return; const prices: Record = {}; - for (const m of models) prices[m.model] = m.currentUsdPerHr; - hist.push({ date: today, prices }); + const meta: Record = {}; + for (const m of models) { + prices[m] = live[m].price; + meta[m] = { low: live[m].low, high: live[m].high, sources: live[m].sources, n: live[m].n }; + } + hist.push({ date: today, prices, meta, source: "live" }); writeFileSync(FILE, JSON.stringify(hist, null, 2) + "\n", "utf-8"); } catch (e) { console.error("gpu history record error:", e); } } +/** + * Latest live-recorded price per model with its age in days (Eastern-date + * granularity), for promoting fresh observations to the served current price. + */ +export function latestLiveByModel(): Record { + const out: Record = {}; + const today = easternDateStr(); + const toUtc = (d: string) => Date.parse(`${d}T00:00:00Z`); + for (const s of readGpuHistory()) { + if (s.source !== "live") continue; + const ageDays = Math.round((toUtc(today) - toUtc(s.date)) / 86_400_000); + for (const [model, price] of Object.entries(s.prices)) { + if (!out[model] || out[model].date < s.date) { + out[model] = { price, date: s.date, ageDays, sources: s.meta?.[model]?.sources ?? [] }; + } + } + } + return out; +} + /** Recorded daily points reshaped per-model for computeGpuIndex. */ export function recordedByModel(): Record { const out: Record = {}; diff --git a/server/gpu-live.ts b/server/gpu-live.ts new file mode 100644 index 0000000..54d96d5 --- /dev/null +++ b/server/gpu-live.ts @@ -0,0 +1,236 @@ +// ─── Live GPU rental price ingestion ──────────────────────────────────────── +// +// Replaces the static daily snapshot (which just copied the hand-curated +// current price every day) with real observations from providers that expose +// public, keyless APIs: +// +// - RunPod GraphQL (api.runpod.io/graphql): secure + community on-demand +// per-GPU pricing. Stable SKU ids. +// - Vast.ai marketplace (console.vast.ai/api/v0/bundles): live verified +// on-demand offers; we take the median of the cheapest verified page, +// which is how renters actually experience the marketplace. +// +// Models with no live source that day simply record nothing - the curated +// anchors remain, flagged est., and the chart's anchor/interpolation honesty +// handles the gap. Nothing is fabricated on a fetch failure. +// +// Pure parsing/blending is separated from I/O so every decision is unit +// tested with fixture payloads. + +export interface LiveObservation { + provider: "runpod-secure" | "runpod-community" | "vast"; + price: number; +} + +export interface LiveModelPrice { + price: number; // blended (median of observations), 2dp + low: number; + high: number; + sources: string[]; // provider names contributing + n: number; // observation count +} + +// Our model key -> matchers per provider. RunPod ids are exact SKU ids; +// Vast gpu_name values are the marketplace's naming. +export const RUNPOD_MODEL_IDS: Record = { + H100: ["NVIDIA H100 80GB HBM3"], // SXM - matches our curated spec (80GB HBM3) + H200: ["NVIDIA H200"], + B200: ["NVIDIA B200"], + B300: ["NVIDIA B300 SXM6 AC"], + A100: ["NVIDIA A100-SXM4-80GB"], + MI300X: ["AMD Instinct MI300X OAM"], +}; + +export const VAST_GPU_NAMES: Record = { + H100: "H100 SXM", + H200: "H200", + A100: "A100 SXM4", + GH200: "GH200 SXM", + MI300X: "MI300X", + B200: "B200", +}; + +// ─── Pure parsing ─────────────────────────────────────────────────────────── + +export interface RunpodGpuType { + id: string; + securePrice: number | null; + communityPrice: number | null; +} + +/** + * RunPod observations for one model. Community pricing is included only when + * it is plausibly the same product tier: RunPod pads its list with $0.5 + * placeholder community rows, so anything under 40% of the secure price (or + * with no secure price to compare against) is dropped. + */ +export function parseRunpod(types: RunpodGpuType[], modelIds: string[]): LiveObservation[] { + const out: LiveObservation[] = []; + for (const t of types ?? []) { + if (!modelIds.includes(t.id)) continue; + const secure = typeof t.securePrice === "number" && t.securePrice > 0 ? t.securePrice : null; + if (secure !== null) out.push({ provider: "runpod-secure", price: secure }); + const community = typeof t.communityPrice === "number" && t.communityPrice > 0 ? t.communityPrice : null; + if (community !== null && secure !== null && community >= 0.4 * secure) { + out.push({ provider: "runpod-community", price: community }); + } + } + return out; +} + +export interface VastOffer { + dph_total: number | null; +} + +/** + * Vast observation: the median dph_total of the returned verified on-demand + * offers (queried ascending). Median of the cheap page = what a renter + * actually pays after skipping the too-good-to-be-true head of the book. + */ +export function parseVast(offers: VastOffer[]): LiveObservation[] { + const prices = (offers ?? []) + .map((o) => o?.dph_total) + .filter((p): p is number => typeof p === "number" && Number.isFinite(p) && p > 0) + .sort((a, b) => a - b); + if (prices.length === 0) return []; + const mid = Math.floor(prices.length / 2); + const median = prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2; + return [{ provider: "vast", price: round2(median) }]; +} + +// ─── Blending + sanity ────────────────────────────────────────────────────── + +const round2 = (n: number) => Math.round(n * 100) / 100; + +/** + * Blend per-provider observations into one recorded price. Guard rail: an + * observation more than 4x away (either direction) from the curated current + * price is a bad SKU match or a marketplace glitch, not a price move - real + * rental prices do not 4x in a day. Dropped observations are counted so the + * caller can log them. + */ +export function blendLivePrice( + observations: LiveObservation[], + curatedCurrent: number | null, +): { blended: LiveModelPrice | null; dropped: number } { + let dropped = 0; + const kept = (observations ?? []).filter((o) => { + if (!(o.price > 0) || !Number.isFinite(o.price)) { + dropped++; + return false; + } + if (curatedCurrent && curatedCurrent > 0) { + const ratio = o.price / curatedCurrent; + if (ratio > 4 || ratio < 0.25) { + dropped++; + return false; + } + } + return true; + }); + if (kept.length === 0) return { blended: null, dropped }; + const prices = kept.map((o) => o.price).sort((a, b) => a - b); + const mid = Math.floor(prices.length / 2); + const median = prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2; + return { + blended: { + price: round2(median), + low: round2(prices[0]), + high: round2(prices[prices.length - 1]), + sources: Array.from(new Set(kept.map((o) => o.provider))), + n: kept.length, + }, + dropped, + }; +} + +// ─── I/O (thin, injected for tests) ───────────────────────────────────────── + +export type FetchLike = (url: string, init?: { method?: string; headers?: Record; body?: string; signal?: AbortSignal }) => Promise<{ ok: boolean; json(): Promise }>; + +const TIMEOUT_MS = 8_000; + +function withTimeout(): { signal: AbortSignal; done: () => void } { + const c = new AbortController(); + const t = setTimeout(() => c.abort(), TIMEOUT_MS); + return { signal: c.signal, done: () => clearTimeout(t) }; +} + +export async function fetchRunpodTypes(fetchFn: FetchLike = fetch): Promise { + const t = withTimeout(); + try { + const res = await fetchFn("https://api.runpod.io/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: "query { gpuTypes { id securePrice communityPrice } }" }), + signal: t.signal, + }); + if (!res.ok) return []; + const body = (await res.json()) as { data?: { gpuTypes?: RunpodGpuType[] } }; + return body?.data?.gpuTypes ?? []; + } catch { + return []; + } finally { + t.done(); + } +} + +export async function fetchVastOffers(gpuName: string, fetchFn: FetchLike = fetch): Promise { + const q = { + gpu_name: { eq: gpuName }, + num_gpus: { eq: 1 }, + rentable: { eq: true }, + verified: { eq: true }, + order: [["dph_total", "asc"]], + type: "on-demand", + limit: 50, + }; + const t = withTimeout(); + try { + const res = await fetchFn( + `https://console.vast.ai/api/v0/bundles/?q=${encodeURIComponent(JSON.stringify(q))}`, + { signal: t.signal }, + ); + if (!res.ok) return []; + const body = (await res.json()) as { offers?: VastOffer[] }; + return body?.offers ?? []; + } catch { + return []; + } finally { + t.done(); + } +} + +/** + * One full live sweep: every tracked model, all providers, blended with the + * sanity guard. Returns only models that produced a usable live price. + */ +export async function fetchLivePrices( + curated: Array<{ model: string; currentUsdPerHr: number }>, + fetchFn: FetchLike = fetch, +): Promise> { + const curatedByModel = new Map(curated.map((c) => [c.model, c.currentUsdPerHr])); + const models = curated.map((c) => c.model); + + const runpodTypes = await fetchRunpodTypes(fetchFn); + const vastResults = await Promise.all( + models.map(async (m) => { + const name = VAST_GPU_NAMES[m]; + if (!name) return [m, [] as VastOffer[]] as const; + return [m, await fetchVastOffers(name, fetchFn)] as const; + }), + ); + const vastByModel = new Map(vastResults); + + const out: Record = {}; + for (const m of models) { + const obs: LiveObservation[] = [ + ...parseRunpod(runpodTypes, RUNPOD_MODEL_IDS[m] ?? []), + ...parseVast(vastByModel.get(m) ?? []), + ]; + const { blended, dropped } = blendLivePrice(obs, curatedByModel.get(m) ?? null); + if (dropped > 0) console.warn(`gpu-live: dropped ${dropped} outlier observation(s) for ${m}`); + if (blended) out[m] = blended; + } + return out; +} diff --git a/server/routes.ts b/server/routes.ts index 63f8336..298dbc3 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -30,7 +30,8 @@ import { recordDailyIndexValues, readIndexHistory } from "./index-history"; import { getElectricityOutputMonthly, getHourlyDemandUS48 } from "./physical"; import { computeClusterMetrics, type ClusterLite } from "./clusters"; import { computeGpuIndex } from "./gpu-index"; -import { recordDailyGpuPrices, recordedByModel } from "./gpu-history"; +import { hasTodaySnapshot, latestLiveByModel, recordDailyLivePrices, recordedByModel } from "./gpu-history"; +import { fetchLivePrices } from "./gpu-live"; import { computeDealMetrics, type DealProject } from "./deals"; import { composeBrief, renderBriefText, type BriefInput } from "./brief"; import { computeGpuEconomics, TRAINING_PRESETS } from "./gpu-economics"; @@ -3811,13 +3812,53 @@ ${rssItems} } }); + // Live sweep guard: at most one in flight, retried on the next request + // after a failure. Request-driven (not a timer) so it works on autoscale. + let gpuLiveSweepInFlight = false; app.get("/api/gpu-prices/metrics", (_req, res) => { try { const root = readGpuRoot(); const models = root.models ?? []; - recordDailyGpuPrices(models); // append today's snapshot (deduped per day) - const metrics = computeGpuIndex(models, easternDay(), recordedByModel()); - res.json({ ...metrics, unit: root.unit ?? null, methodology: root.methodology ?? null, lastRefreshed: root.lastRefreshed ?? null }); + + // First request of the Eastern day kicks off the live sweep in the + // background; the response never waits on provider APIs. + if (!hasTodaySnapshot() && !gpuLiveSweepInFlight) { + gpuLiveSweepInFlight = true; + fetchLivePrices(models.map((m: any) => ({ model: m.model, currentUsdPerHr: m.currentUsdPerHr }))) + .then((live) => { + recordDailyLivePrices(live); + const n = Object.keys(live).length; + if (n > 0) console.log(`gpu-live: recorded ${n} live model prices`); + }) + .catch((e) => console.error("gpu-live sweep failed:", e)) + .finally(() => { + gpuLiveSweepInFlight = false; + }); + } + + // Fresh live observations (<=2 Eastern days old) are the served price; + // the est. flag drops because the number is observed, not estimated. + const live = latestLiveByModel(); + const served = models.map((m: any) => { + const l = live[m.model]; + if (l && l.ageDays <= 2) { + return { + ...m, + currentUsdPerHr: l.price, + estimated: (m.estimated ?? []).filter((f: string) => f !== "currentUsdPerHr"), + liveSources: l.sources, + liveDate: l.date, + }; + } + return m; + }); + + const metrics = computeGpuIndex(served, easternDay(), recordedByModel()); + const rows = metrics.rows.map((r) => { + const src = served.find((m: any) => m.model === r.model); + return { ...r, liveSources: src?.liveSources ?? null, liveDate: src?.liveDate ?? null }; + }); + res.json({ ...metrics, rows, unit: root.unit ?? null, methodology: root.methodology ?? null, lastRefreshed: root.lastRefreshed ?? null }); } catch (err) { console.error("GPU metrics error:", err); res.status(500).json({ error: "Failed to compute GPU index" }); From a42ec4898d8099f95fa91a303d3994868624657c Mon Sep 17 00:00:00 2001 From: aurph Date: Fri, 3 Jul 2026 17:13:16 -0400 Subject: [PATCH 02/15] Historical graphs: extract more information from sparse honest data GPU price history grid panels: - Observed marketplace range renders as a shaded band in EVERY panel ('mkt $1.38-$6.98'), so each panel carries real spread context even where the line is thin; band included in the log domain. - Sparse series (<=5 points) annotate every real point with its value - panels read as data, not gaps. Overlay does the same when <=3 series are visible and a series has <=4 points. - Panel headers now distinguish live (latest point observed from provider APIs) from est. - six models tag live today. - Panel height +18px for the labels. Buildout history chart: end-value labels on both series ('4.1 GW online', '18.8 GW committed') so the chart's two headline numbers read without hovering. Recorder fixes surfaced while verifying: - The sweep now gates on a LIVE snapshot for today; a same-day legacy static row is superseded by real observations instead of blocking them (earlier broad commits had accidentally shipped two machine-written static rows, which blocked the first live sweep). - gpu-price-history.json committed clean ([]) - production accrues live rows from day one; anchors in gpu-rental-prices.json carry the pre-recorder history. Verified live: 6 models tagged live, 4 est., bands in all 10 panels, fleet avg $4.31 (-14.2% 1Y real). Suite 195. --- .../components/neocloud/PriceHistoryChart.tsx | 69 +++++++++++++- client/src/pages/TiltOverview.tsx | 51 +++++++++- server/data/gpu-price-history.json | 93 +++++++++++++------ server/gpu-history.ts | 14 ++- server/routes.ts | 4 +- 5 files changed, 194 insertions(+), 37 deletions(-) diff --git a/client/src/components/neocloud/PriceHistoryChart.tsx b/client/src/components/neocloud/PriceHistoryChart.tsx index 1d5a4a7..75a70b7 100644 --- a/client/src/components/neocloud/PriceHistoryChart.tsx +++ b/client/src/components/neocloud/PriceHistoryChart.tsx @@ -52,7 +52,7 @@ export interface PriceHistoryChartProps { const MARGIN = { top: 14, right: 118, bottom: 26, left: 44 }; const GRID_MARGIN = { top: 22, right: 12, bottom: 20, left: 38 }; const OVERLAY_HEIGHT = 380; -const PANEL_HEIGHT = 150; +const PANEL_HEIGHT = 168; const LABEL_GAP = 15; interface TooltipState { @@ -310,6 +310,23 @@ function Overlay({ strokeWidth={p.kind === "anchor" ? 0 : 1.4} /> ))} + {/* sparse-series value labels (only when the view is uncrowded) */} + {clipped.length <= 3 && + s.clipped.filter((p) => !p.edge).length <= 4 && + s.clipped.filter((p) => !p.edge).map((p, i) => ( + + ${p.price >= 10 ? p.price.toFixed(0) : p.price.toFixed(2)} + + ))} {/* launch marker: ring on the series' true first point when in window */} {s.launch && s.clipped.some((p) => !p.edge && p.t === s.launch!.t) && ( @@ -444,6 +461,7 @@ function SmallMultiples({ hovered, onHover, estimatedModels, + ranges, }: PriceHistoryChartProps & { clipped: ClippedSeries[]; start: number | null }) { const cols = width >= 1100 ? 5 : width >= 760 ? 4 : width >= 560 ? 3 : 2; const panelW = Math.floor(width / cols); @@ -463,6 +481,7 @@ function SmallMultiples({ dim={hovered !== null && hovered !== s.model} onHover={onHover} est={estimatedModels.has(s.model)} + range={ranges[s.model] ?? null} /> ))}
@@ -478,6 +497,7 @@ function Panel({ dim, onHover, est, + range, }: { series: ClippedSeries; x0: number; @@ -487,16 +507,27 @@ function Panel({ dim: boolean; onHover: (m: string | null) => void; est: boolean; + range: { low: number; high: number } | null; }) { const innerW = Math.max(20, width - GRID_MARGIN.left - GRID_MARGIN.right); const innerH = height - GRID_MARGIN.top - GRID_MARGIN.bottom; const xScale = scaleUtc({ domain: [x0, x1], range: [0, innerW] }); - const yDomain = logDomain(s.clipped.map((p) => p.price)); + // Domain includes the observed marketplace range so the band gives every + // panel real spread context even when the line itself is sparse. + const domainVals = [ + ...s.clipped.map((p) => p.price), + ...(range && range.low > 0 && range.high > 0 ? [range.low, range.high] : []), + ]; + const yDomain = logDomain(domainVals); const yScale = scaleLog({ domain: yDomain, range: [innerH, 0] }); const yTicks = logTicks125(yDomain[0], yDomain[1]).filter((_, i, a) => a.length <= 3 || i % 2 === 0); const ticks = xTicks(x0, x1, innerW).filter((_, i, a) => a.length <= 3 || i % Math.ceil(a.length / 3) === 0); const spans = spansFromClipped(s.clipped); const last = s.clipped[s.clipped.length - 1]; + const realPoints = s.clipped.filter((p) => !p.edge); + // Sparse series annotate every point - the panel reads as data, not gaps. + const labelPoints = realPoints.length <= 5 ? realPoints : []; + const isLive = !est && last.kind === "recorded"; return (
{s.model} ${last.price.toFixed(2)} {est && est.} + {isLive && live}
@@ -527,6 +559,22 @@ function Panel({ ))} + {/* observed marketplace range: real spread context behind the line */} + {range && range.low > 0 && range.high > 0 && ( + + + + mkt ${range.low}–${range.high} + + + )} {spans.map((span, i) => { const st = spanDash(span.quality); return ( @@ -543,7 +591,7 @@ function Panel({ /> ); })} - {s.clipped.filter((p) => !p.edge).map((p) => ( + {realPoints.map((p) => ( ))} + {/* sparse-series value labels: every real point annotated */} + {labelPoints.map((p, i) => ( + + ${p.price >= 10 ? p.price.toFixed(0) : p.price.toFixed(2)} + + ))} diff --git a/client/src/pages/TiltOverview.tsx b/client/src/pages/TiltOverview.tsx index 50c66e8..67c97ff 100644 --- a/client/src/pages/TiltOverview.tsx +++ b/client/src/pages/TiltOverview.tsx @@ -24,7 +24,7 @@ import { Zap, TrendingUp, Activity, AlertTriangle, Info, ArrowUp, ArrowDown, Cal import { EmailCapture, ScrollTriggeredBanner } from "@/components/EmailCapture"; import { AsOf, ErrorState, SrChartTable } from "@/components/Freshness"; import { - BRAND, CATEGORY_COLORS as TOKEN_CATEGORY_COLORS, CHART_CHROME, DATA_QUALITY, INK, SEMANTIC, SERIES, + BRAND, CATEGORY_COLORS as TOKEN_CATEGORY_COLORS, CHART_CHROME, DATA_QUALITY, FONT, INK, SEMANTIC, SERIES, } from "@/lib/tokens"; import { axisProps, gridProps, timeTicks, tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle } from "@/lib/chart-theme"; import { RTO_CONFIG, RTO_SOURCE_NOTE } from "@/data/rto-config"; @@ -344,6 +344,53 @@ function RealGaugeCard({ ); } +/** + * Chart end-value label: annotates the LAST point of a series with its value + * ("18.8 GW online" / "33.4 GW committed") so the chart's two headline + * numbers read without hovering. + */ +function EndLabel({ + x, + y, + index, + data, + field, + color, +}: { + x?: number; + y?: number; + index?: number; + data: Array<{ online: number | null; pipeline: number | null }>; + field: "online" | "pipeline"; + color: string; +}) { + if (x === undefined || y === undefined || index === undefined) return null; + // last index carrying a value for this series + let lastIdx = -1; + for (let i = data.length - 1; i >= 0; i--) { + if (data[i][field] !== null) { + lastIdx = i; + break; + } + } + if (index !== lastIdx) return null; + const v = data[lastIdx][field]; + if (v === null) return null; + return ( + + {fmtGW(v)} {field === "online" ? "online" : "committed"} + + ); +} + /** * Real buildout history: cumulative tracked capacity from facility open * dates. Solid = operational (observed history), dashed = construction @@ -459,6 +506,7 @@ function BuildoutHistoryCard({ fill="url(#buildoutGrad)" dot={false} connectNulls + label={(props: any) => } /> } /> diff --git a/server/data/gpu-price-history.json b/server/data/gpu-price-history.json index 8c681ae..95141ed 100644 --- a/server/data/gpu-price-history.json +++ b/server/data/gpu-price-history.json @@ -1,32 +1,73 @@ [ - { - "date": "2026-07-02", - "prices": { - "GB200": 13, - "B300": 8, - "B200": 6.11, - "H200": 3.85, - "GH200": 2.25, - "H100": 2.79, - "MI355X": 2.85, - "MI325X": 2.1, - "MI300X": 2.1, - "A100": 1.65 - } - }, { "date": "2026-07-03", "prices": { - "GB200": 13, - "B300": 8, - "B200": 6.11, - "H200": 3.85, - "GH200": 2.25, - "H100": 2.79, - "MI355X": 2.85, - "MI325X": 2.1, - "MI300X": 2.1, - "A100": 1.65 - } + "B300": 7.17, + "B200": 5.89, + "H200": 3.59, + "H100": 2.69, + "MI300X": 2.19, + "A100": 1.39 + }, + "meta": { + "B300": { + "low": 6.94, + "high": 7.39, + "sources": [ + "runpod-secure", + "runpod-community" + ], + "n": 2 + }, + "B200": { + "low": 5.29, + "high": 5.98, + "sources": [ + "runpod-secure", + "runpod-community", + "vast" + ], + "n": 3 + }, + "H200": { + "low": 3.52, + "high": 4.39, + "sources": [ + "runpod-secure", + "runpod-community", + "vast" + ], + "n": 3 + }, + "H100": { + "low": 2.18, + "high": 3.29, + "sources": [ + "runpod-secure", + "runpod-community", + "vast" + ], + "n": 3 + }, + "MI300X": { + "low": 2.19, + "high": 2.19, + "sources": [ + "runpod-secure" + ], + "n": 1 + }, + "A100": { + "low": 0.95, + "high": 1.49, + "sources": [ + "runpod-secure", + "runpod-community", + "vast" + ], + "n": 3 + } + }, + "source": "live" } ] diff --git a/server/gpu-history.ts b/server/gpu-history.ts index 53fb166..89412f3 100644 --- a/server/gpu-history.ts +++ b/server/gpu-history.ts @@ -47,10 +47,11 @@ export function readGpuHistory(): Snapshot[] { return []; } -/** Has today (US-Eastern) already been recorded? */ -export function hasTodaySnapshot(): boolean { +/** Has today (US-Eastern) already been recorded from LIVE sources? A legacy + * static row does not count - the sweep should upgrade it. */ +export function hasTodayLiveSnapshot(): boolean { const today = easternDateStr(); - return readGpuHistory().some((s) => s.date === today); + return readGpuHistory().some((s) => s.date === today && s.source === "live"); } /** @@ -63,9 +64,12 @@ export function recordDailyLivePrices(live: Record): voi try { const models = Object.keys(live); if (models.length === 0) return; - const hist = readGpuHistory(); + let hist = readGpuHistory(); const today = easternDateStr(); - if (hist.some((s) => s.date === today)) return; + if (hist.some((s) => s.date === today && s.source === "live")) return; + // A same-day legacy row (static curated copy) is superseded by real + // observations, never kept alongside them. + hist = hist.filter((s) => !(s.date === today && s.source !== "live")); const prices: Record = {}; const meta: Record = {}; for (const m of models) { diff --git a/server/routes.ts b/server/routes.ts index 298dbc3..0020848 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -30,7 +30,7 @@ import { recordDailyIndexValues, readIndexHistory } from "./index-history"; import { getElectricityOutputMonthly, getHourlyDemandUS48 } from "./physical"; import { computeClusterMetrics, type ClusterLite } from "./clusters"; import { computeGpuIndex } from "./gpu-index"; -import { hasTodaySnapshot, latestLiveByModel, recordDailyLivePrices, recordedByModel } from "./gpu-history"; +import { hasTodayLiveSnapshot, latestLiveByModel, recordDailyLivePrices, recordedByModel } from "./gpu-history"; import { fetchLivePrices } from "./gpu-live"; import { computeDealMetrics, type DealProject } from "./deals"; import { composeBrief, renderBriefText, type BriefInput } from "./brief"; @@ -3822,7 +3822,7 @@ ${rssItems} // First request of the Eastern day kicks off the live sweep in the // background; the response never waits on provider APIs. - if (!hasTodaySnapshot() && !gpuLiveSweepInFlight) { + if (!hasTodayLiveSnapshot() && !gpuLiveSweepInFlight) { gpuLiveSweepInFlight = true; fetchLivePrices(models.map((m: any) => ({ model: m.model, currentUsdPerHr: m.currentUsdPerHr }))) .then((live) => { From befab68f892be48e458886bca07106ec1001545b Mon Sep 17 00:00:00 2001 From: aurph Date: Fri, 3 Jul 2026 18:21:23 -0400 Subject: [PATCH 03/15] Catalyst calendars: client stage tokens win over the server's legacy orange hexes The /api/catalysts/all payload embeds a legacy stageColor hex per earnings item - the old five-orange family (#F0A500/#F07800/#C87533/ #D4A843/#B8860B) that Lake 1 replaced client-side. Both calendars preferred the server value, silently overriding the migration, so every earnings dot still rendered near-identical orange. Clients now derive stage colors exclusively from the token palette (by stage name, ticker map fallback) and ignore the payload field; the server keeps sending it so the API shape is unchanged. Verified live: calendar dots render six distinct series colors. --- client/src/pages/CatalystTracker.tsx | 27 +++++++++++++++++---------- client/src/pages/TiltOverview.tsx | 4 +++- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/client/src/pages/CatalystTracker.tsx b/client/src/pages/CatalystTracker.tsx index 67d1b4d..5ce1b5e 100644 --- a/client/src/pages/CatalystTracker.tsx +++ b/client/src/pages/CatalystTracker.tsx @@ -69,8 +69,15 @@ function formatDateFull(dateStr: string): string { }); } -function getStageColor(ticker: string): string { - const stage = SUPPLY_CHAIN_STAGE_MAP[ticker]; +/** + * Stage color from the CLIENT token palette, by stage name first, ticker map + * second. The server payload still carries a legacy stageColor hex (the old + * all-orange family) - it is deliberately ignored so the token palette is + * the single source of truth. + */ +function stageColorOf(e: { stage?: string; ticker: string }): string { + if (e.stage && STAGE_COLORS[e.stage]) return STAGE_COLORS[e.stage]; + const stage = SUPPLY_CHAIN_STAGE_MAP[e.ticker]; return stage ? (STAGE_COLORS[stage] || INK.muted) : INK.muted; } @@ -165,7 +172,7 @@ function CalendarGrid({ {dayItems.map((item, j) => { let color: string = INK.muted; if (item.type === "earnings") { - color = (item as EarningsItem).stageColor || getStageColor((item as EarningsItem).ticker); + color = stageColorOf(item as EarningsItem); } else { color = catalystCategoryColors[(item as CatalystItem).category] || INK.muted; } @@ -213,7 +220,7 @@ function DayDetailCard({ item }: { item: MergedItem }) { return (
@@ -231,7 +238,7 @@ function DayDetailCard({ item }: { item: MergedItem }) {
{e.stage} @@ -239,7 +246,7 @@ function DayDetailCard({ item }: { item: MergedItem }) {