diff --git a/README.md b/README.md index 4b50614..0bc95f6 100644 --- a/README.md +++ b/README.md @@ -168,11 +168,35 @@ gone. The page carries everything it had (order book included) plus the analysis and multi-period performance it never had. A pair page shows: price and spread, performance over 24h/7d/30d/90d/1y, the -52-week range with dates, session volume in the quote currency, the analysis, -the order book, and the technical indicators. Market capitalisation, -circulating supply and all-time high are deliberately absent — Alpaca does not -carry them, and deriving them would mean inventing a supply figure or mixing in -a second vendor. The page says so rather than leaving a silent gap. +52-week range with dates, session volume in the quote currency, market cap and +supply, the analysis, the order book, and the technical indicators. + +### The second data source + +Market capitalisation cannot be derived from a price without a circulating +supply, and Alpaca's market-data API carries neither. So **CoinGecko** is the +one non-Alpaca source on this path — keyless like the rest, cached for five +minutes, one batched request covering every asset, and a page that degrades to +"—" rather than failing when it is unreachable. + +Its figures are labelled as its own wherever they appear. They are market-wide +and priced by CoinGecko; everything else on a crypto page is Alpaca's US venue. +The 24h volume under *Supply & valuation* is aggregate market volume and is +**not** comparable to the venue volume under *Performance* — conflating them +would overstate liquidity by orders of magnitude. + +Two guards exist because CoinGecko keeps serving records for tokens that have +moved on, and a plausible-looking number is worse than a blank: + +- **Non-positive supply** → not shown. `MKR` migrated to SKY and now reports + zero circulating supply against a live price; rendering "$0.00 market cap" + would be a false statement. +- **Stale records** → not shown. `MATIC` migrated to POL and its record stopped + updating in February; a six-month-old supply figure beside a live price is + the same failure this codebase avoids everywhere else. + +In both cases the page names the reason rather than showing a dash that reads +as a bug. | Endpoint | Returns | | --- | --- | diff --git a/src/crypto/fundamentals.ts b/src/crypto/fundamentals.ts new file mode 100644 index 0000000..31c5845 --- /dev/null +++ b/src/crypto/fundamentals.ts @@ -0,0 +1,219 @@ +/** + * Crypto asset fundamentals — market capitalisation, supply, ATH. + * + * This is the one place the crypto path uses a vendor other than Alpaca, and + * that is deliberate rather than casual: Alpaca's market-data API carries no + * supply figure, and market cap cannot be derived from a price without one. + * The alternative was to invent a supply number, which is not an alternative. + * + * CoinGecko's public endpoint answers unauthenticated, matching the rest of + * this path — no new credential, and the page degrades to "—" if it is + * unreachable rather than failing. + * + * PROVENANCE. These figures are market-wide and priced by CoinGecko; every + * other number on a crypto page comes from Alpaca's US venue. The two are not + * interchangeable and are labelled separately wherever they appear together. + * In particular `volume24h` here is aggregate market volume, which is a + * different quantity from the venue volume shown beside it — conflating them + * would overstate liquidity by orders of magnitude. + */ + +const BASE_URL = "https://api.coingecko.com/api/v3"; + +/** + * Base asset -> CoinGecko id. Explicit ids rather than symbol search: tickers + * collide across hundreds of listings, and resolving "UNI" or "GRT" by symbol + * would eventually pick up an impostor. Verified against the API on + * 2026-08-07 — every id below returned the expected symbol. + */ +const COINGECKO_IDS: Record = { + AAVE: "aave", + ADA: "cardano", + AVAX: "avalanche-2", + BAT: "basic-attention-token", + BCH: "bitcoin-cash", + BTC: "bitcoin", + CRV: "curve-dao-token", + DOGE: "dogecoin", + DOT: "polkadot", + ETH: "ethereum", + GRT: "the-graph", + LDO: "lido-dao", + LINK: "chainlink", + LTC: "litecoin", + // MATIC and MKR are deliberately mapped to their legacy ids, which is what + // Alpaca actually lists. Both have since migrated (MATIC->POL, MKR->SKY), so + // those entries now report zero supply and stop updating. The staleness and + // non-positive guards below are what stop that being rendered as fact; the + // fix is NOT to point them at the successor token, which is a different + // asset from the one being priced. + MATIC: "matic-network", + MKR: "maker", + PEPE: "pepe", + SHIB: "shiba-inu", + SOL: "solana", + SUSHI: "sushi", + TRUMP: "official-trump", + UNI: "uniswap", + USDC: "usd-coin", + USDT: "tether", + XRP: "ripple", + XTZ: "tezos", + YFI: "yearn-finance", +}; + +/** + * Older than this and the record is treated as unavailable. A delisted or + * migrated token keeps returning its last known values indefinitely; printing + * a six-month-old supply figure next to a live price is the "stale data + * wearing the costume of live data" failure this codebase exists to avoid. + */ +const MAX_AGE_MS = 7 * 86_400_000; + +export interface AssetFundamentals { + base: string; + coingeckoId: string; + marketCap: number | null; + marketCapRank: number | null; + fullyDilutedValuation: number | null; + circulatingSupply: number | null; + totalSupply: number | null; + maxSupply: number | null; + ath: number | null; + athDate: string | null; + athChangePercent: number | null; + /** Aggregate 24h market volume — NOT the venue volume shown elsewhere. */ + volume24h: number | null; + /** CoinGecko's own timestamp, so the reader can judge freshness. */ + lastUpdated: string | null; + /** Set when the record was rejected, naming why. */ + unavailableReason?: string; +} + +/** A value only counts if it is a positive, finite number. */ +function positive(n: unknown): number | null { + const v = Number(n); + return Number.isFinite(v) && v > 0 ? v : null; +} + +export interface FundamentalsOptions { + baseUrl?: string; + requestTimeoutMs?: number; + /** How long a successful fetch is reused. CoinGecko's free tier is rate limited. */ + cacheTtlMs?: number; + now?: () => number; +} + +export class CryptoFundamentalsClient { + private readonly baseUrl: string; + private readonly timeoutMs: number; + private readonly cacheTtlMs: number; + private readonly now: () => number; + private cache: { at: number; byBase: Map } | null = null; + private inFlight: Promise> | null = null; + + constructor(options: FundamentalsOptions = {}) { + this.baseUrl = (options.baseUrl || BASE_URL).replace(/\/$/, ""); + this.timeoutMs = options.requestTimeoutMs ?? 8_000; + this.cacheTtlMs = options.cacheTtlMs ?? 5 * 60_000; + this.now = options.now ?? Date.now; + } + + /** Every asset in one request, cached — traffic does not scale upstream calls. */ + private async loadAll(): Promise> { + const fresh = this.cache && this.now() - this.cache.at < this.cacheTtlMs; + if (fresh) return this.cache!.byBase; + // Collapse concurrent misses into one request rather than stampeding a + // rate-limited free tier. + if (this.inFlight) return this.inFlight; + + this.inFlight = (async () => { + const ids = [...new Set(Object.values(COINGECKO_IDS))].join(","); + const url = new URL(`${this.baseUrl}/coins/markets`); + url.searchParams.set("vs_currency", "usd"); + url.searchParams.set("ids", ids); + url.searchParams.set("per_page", "250"); + + const res = await fetch(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(this.timeoutMs), + }); + if (!res.ok) throw new Error(`CoinGecko ${res.status}`); + const rows = (await res.json()) as any[]; + + const byId = new Map(rows.map((r) => [r.id, r])); + const byBase = new Map(); + for (const [base, id] of Object.entries(COINGECKO_IDS)) { + const r = byId.get(id); + if (!r) continue; + byBase.set(base, this.toFundamentals(base, id, r)); + } + this.cache = { at: this.now(), byBase }; + return byBase; + })(); + + try { + return await this.inFlight; + } finally { + this.inFlight = null; + } + } + + private toFundamentals(base: string, id: string, r: any): AssetFundamentals { + const empty: AssetFundamentals = { + base, coingeckoId: id, + marketCap: null, marketCapRank: null, fullyDilutedValuation: null, + circulatingSupply: null, totalSupply: null, maxSupply: null, + ath: null, athDate: null, athChangePercent: null, + volume24h: null, lastUpdated: r.last_updated ?? null, + }; + + const age = r.last_updated ? this.now() - Date.parse(r.last_updated) : NaN; + if (Number.isFinite(age) && age > MAX_AGE_MS) { + const days = Math.round(age / 86_400_000); + return { + ...empty, + unavailableReason: `the upstream record has not updated in ${days} days, so its figures are not current`, + }; + } + if (positive(r.market_cap) == null && positive(r.circulating_supply) == null) { + // Both zero is the signature of a migrated or delisted token. + return { + ...empty, + unavailableReason: + "the upstream reports no circulating supply for this asset, which usually means it has migrated to a successor token", + }; + } + + return { + base, + coingeckoId: id, + marketCap: positive(r.market_cap), + marketCapRank: positive(r.market_cap_rank), + fullyDilutedValuation: positive(r.fully_diluted_valuation), + circulatingSupply: positive(r.circulating_supply), + totalSupply: positive(r.total_supply), + maxSupply: positive(r.max_supply), + ath: positive(r.ath), + athDate: r.ath_date ?? null, + athChangePercent: Number.isFinite(Number(r.ath_change_percentage)) + ? Number(r.ath_change_percentage) + : null, + volume24h: positive(r.total_volume), + lastUpdated: r.last_updated ?? null, + }; + } + + /** Null when unknown or unreachable — never throws into a page render. */ + async get(base: string): Promise { + if (!COINGECKO_IDS[base]) return null; + try { + return (await this.loadAll()).get(base) ?? null; + } catch { + return null; + } + } +} + +/** Exposed for tests and for documenting coverage. */ +export const FUNDAMENTALS_ASSETS = Object.keys(COINGECKO_IDS); diff --git a/src/crypto/page.ts b/src/crypto/page.ts index 96a5395..a55d0ec 100644 --- a/src/crypto/page.ts +++ b/src/crypto/page.ts @@ -16,6 +16,7 @@ import { escapeHtml, escapeXml } from "../util/html.ts"; import { absoluteTime, num, score, shell, sparkline } from "../reports/page.ts"; import type { CryptoAnalysis } from "./analysis.ts"; import type { CryptoOrderbook, CryptoSnapshot } from "./client.ts"; +import type { AssetFundamentals } from "./fundamentals.ts"; import type { CryptoPerformance } from "./performance.ts"; import type { CryptoPair } from "./pairs.ts"; import type { MarketBar, TechnicalIndicatorSet, TechnicalScore } from "../types.ts"; @@ -50,6 +51,8 @@ export interface CryptoPageData { technicalScore?: TechnicalScore; analysis: CryptoAnalysis | null; performance?: CryptoPerformance; + /** Market cap / supply / ATH. Null when the second source was unreachable. */ + fundamentals?: AssetFundamentals | null; /** Top of book, when the upstream returned one. */ orderbook?: CryptoOrderbook; caveats: readonly string[]; @@ -65,6 +68,71 @@ export interface CryptoPageOptions { const signed = (n: number, dp = 2) => `${n >= 0 ? "+" : ""}${n.toFixed(dp)}%`; +/** Compact currency for figures in the billions. */ +function bigMoney(n: number | null): string { + if (n == null || !Number.isFinite(n)) return "—"; + const a = Math.abs(n); + if (a >= 1e12) return `$${(n / 1e12).toFixed(2)}T`; + if (a >= 1e9) return `$${(n / 1e9).toFixed(2)}B`; + if (a >= 1e6) return `$${(n / 1e6).toFixed(1)}M`; + return cryptoMoney(n); +} + +/** Supply counts are token units, not currency. */ +function tokens(n: number | null): string { + if (n == null || !Number.isFinite(n)) return "—"; + const a = Math.abs(n); + if (a >= 1e12) return `${(n / 1e12).toFixed(2)}T`; + if (a >= 1e9) return `${(n / 1e9).toFixed(2)}B`; + if (a >= 1e6) return `${(n / 1e6).toFixed(2)}M`; + return n.toLocaleString("en-US", { maximumFractionDigits: 0 }); +} + +/** + * Market cap, supply and all-time high — the one section sourced from + * CoinGecko rather than Alpaca, and labelled as such. Everything else on the + * page is venue data; presenting these together unattributed would imply a + * single source of truth that does not exist. + */ +function fundamentalsSection(f: AssetFundamentals | null, base: string): string { + if (!f) { + return `
+

Supply & valuation

+

Unavailable right now — this is the one figure set that does not come from the market-data feed, and its source could not be reached. Nothing is estimated in the meantime.

+
`; + } + if (f.unavailableReason) { + // Naming the reason matters: "—" alone reads as a bug, when the truth is + // that the asset itself has moved on. + return `
+

Supply & valuation

+

Not shown for ${e(base)}: ${e(f.unavailableReason)}. A market capitalisation cannot be computed without a circulating supply, and estimating one would be a guess presented as a fact.

+
`; + } + + const pctFromAth = f.athChangePercent != null ? signed(f.athChangePercent) : "—"; + return `
+

Supply & valuation CoinGecko

+
+ ${kv("Market cap", bigMoney(f.marketCap))} + ${kv("Rank", f.marketCapRank != null ? `#${f.marketCapRank}` : "—")} + ${kv("Fully diluted", bigMoney(f.fullyDilutedValuation))} + ${kv("Circulating supply", `${tokens(f.circulatingSupply)} ${e(base)}`)} + ${kv("Total supply", `${tokens(f.totalSupply)} ${e(base)}`)} + ${kv("Max supply", f.maxSupply == null ? "uncapped" : `${tokens(f.maxSupply)} ${e(base)}`)} + ${kv("All-time high", `${cryptoMoney(f.ath)}${f.athDate ? ` ${e(String(f.athDate).slice(0, 10))}` : ""}`)} + ${kv("From ATH", pctFromAth, f.athChangePercent != null && f.athChangePercent < 0 ? "neg" : "")} + ${kv("24h volume (all venues)", bigMoney(f.volume24h))} +
+

+ Source: CoinGecko${f.lastUpdated ? `, as of ${e(absoluteTime(f.lastUpdated))}` : ""}. These are + market-wide figures priced by CoinGecko; every other number on this page comes from Alpaca's US + venue. In particular the 24h volume above is aggregate market volume, which is a much larger + quantity than the venue volume shown under Performance — they are not comparable. +

+
`; +} + /** Multi-period performance — what the pair has been doing, not just its spread. */ function performanceSection(p: CryptoPerformance | undefined, quote: string): string { if (!p) return ""; @@ -88,7 +156,7 @@ function performanceSection(p: CryptoPerformance | undefined, quote: string): st ${kv("Daily bars", String(p.barCount))} ${thin ? `

A period showing “—” has less history than it needs. Measuring it from the oldest bar available would report a change over a window that does not exist.

` : ""} -

Market capitalisation, circulating supply and all-time high are not shown: Alpaca's market-data API does not carry them, and deriving them would mean inventing a supply figure or mixing in a second vendor.

+

Prices and volume in this section are Alpaca's US venue. Market-wide figures are under Supply & valuation below.

`; } @@ -196,6 +264,8 @@ export function renderCryptoPage(data: CryptoPageData, opts: CryptoPageOptions): ${performanceSection(data.performance, pair.quote)} + ${fundamentalsSection(data.fundamentals ?? null, pair.base)} + ${analysisSection(analysis)} ${orderbookSection(data.orderbook)} diff --git a/src/crypto/routes.ts b/src/crypto/routes.ts index bededbf..be03140 100644 --- a/src/crypto/routes.ts +++ b/src/crypto/routes.ts @@ -25,6 +25,7 @@ import type { BarTimeframe, IndicatorConfig, MarketBar } from "../types.ts"; import type { AlpacaCryptoClient } from "./client.ts"; import { analyzeCrypto } from "./analysis.ts"; import { computePerformance } from "./performance.ts"; +import type { CryptoFundamentalsClient } from "./fundamentals.ts"; import { renderCryptoIndexPage, renderCryptoPage, renderMissingCryptoPage } from "./page.ts"; import { SUPPORTED_PAIRS, getPair, lookupPairs, normalizePair, normalizePairs } from "./pairs.ts"; @@ -74,6 +75,8 @@ export interface CryptoRouteDeps { indicators: IndicatorConfig; /** Absolute site origin, for canonical URLs on the rendered pages. */ appUrl: string; + /** Market cap / supply. Optional: the pages render without it. */ + fundamentals?: CryptoFundamentalsClient; } /** @@ -507,10 +510,12 @@ async function report( const pair = getPair(s.pair)!; const horizon = Number(url.searchParams.get("horizon")) === 1 ? 1 : 2; - // One round of upstream calls covers both halves of the report. - const [snapshots, computed] = await Promise.all([ + // One round of upstream calls covers every part of the report. Fundamentals + // come from a different vendor and must not be able to fail the response. + const [snapshots, computed, funds] = await Promise.all([ deps.client.getSnapshots([s.pair]), computeTechnicals(s.pair, horizon, deps), + deps.fundamentals ? deps.fundamentals.get(pair.base) : Promise.resolve(null), ]); const snap = snapshots[0]; @@ -524,6 +529,9 @@ async function report( snapshot: snap ? { ...snap, change: change(snap) } : undefined, technical: computed?.indicators, technicalScore: computed?.score, + // Sourced from CoinGecko, not the market-data feed — flagged so a + // consumer never has to guess which vendor a number came from. + fundamentals: funds ? { ...funds, source: "coingecko" } : null, caveats: computed?.caveats, generatedAt: new Date().toISOString(), disclaimer: CRYPTO_DISCLAIMER, @@ -601,13 +609,16 @@ async function pairPage(raw: string, deps: CryptoRouteDeps): Promise { // Degrade to whatever we have rather than 502 the whole page. marketError = String(err).slice(0, 200); } - // The book is a nice-to-have: it must never take the page down with it, so - // it is fetched separately from the data the page is actually about. - try { - [orderbook] = await deps.client.getOrderbooks([symbol]); - } catch { - /* rendered without a book */ - } + // Both of these are nice-to-haves from sources other than the page's own + // feed, so neither may take the page down with it. `get` already swallows + // its own failures; the book gets an explicit guard. + let fundamentals: Awaited> = null; + const [bookResult, fundamentalsResult] = await Promise.allSettled([ + deps.client.getOrderbooks([symbol]), + deps.fundamentals ? deps.fundamentals.get(pair.base) : Promise.resolve(null), + ]); + if (bookResult.status === "fulfilled") [orderbook] = bookResult.value; + if (fundamentalsResult.status === "fulfilled") fundamentals = fundamentalsResult.value; const technical = bars.length >= 2 ? calculateIndicators(bars, deps.indicators) : undefined; const technicalScore = technical ? scoreTechnicalSetup(technical, 2) : undefined; @@ -622,6 +633,7 @@ async function pairPage(raw: string, deps: CryptoRouteDeps): Promise { technicalScore, analysis: analyzeCrypto(pair.symbol, pair.name, technical, technicalScore), performance: computePerformance(bars), + fundamentals, orderbook, caveats: SCORE_CAVEATS, fetchedAt: new Date().toISOString(), diff --git a/src/registry.ts b/src/registry.ts index b4199d2..b939002 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -4,6 +4,7 @@ import type { AppConfig } from "./config.ts"; import { AlpacaClient } from "./providers/alpaca.ts"; import { AlpacaCryptoClient } from "./crypto/client.ts"; +import { CryptoFundamentalsClient } from "./crypto/fundamentals.ts"; import { YahooMarketDataClient } from "./providers/yahoo.ts"; import { FallbackMarketDataClient } from "./providers/market-fallback.ts"; import { SecFundamentalsProvider } from "./providers/sec.ts"; @@ -47,6 +48,12 @@ export function buildRegistry(config: AppConfig) { maxRetries: config.alpaca.maxRetries, }); + // Market cap and supply are the one crypto figure Alpaca does not carry, and + // they cannot be derived from a price without a supply number. This is the + // only non-Alpaca source on the crypto path; it is keyless like the rest, and + // the page degrades to "—" rather than failing when it is unreachable. + const cryptoFundamentals = new CryptoFundamentalsClient(); + // News is registered separately from `transcripts` rather than folded into // it: it requires tickers to be meaningful and can spend metered search // credits, so it runs only when explicitly asked for (`transcripts news`). @@ -58,6 +65,7 @@ export function buildRegistry(config: AppConfig) { return { alpaca: market, crypto, + cryptoFundamentals, marketSource: hasAlpaca ? "alpaca (yahoo fallback)" : "yahoo", fundamentals: new SecFundamentalsProvider(config), transcripts: buildTranscriptProviders(config), diff --git a/src/server.ts b/src/server.ts index 4a6a4d5..9be650f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -819,6 +819,7 @@ const server = Bun.serve({ client: registry.crypto, indicators: INDICATOR_CONFIG, appUrl: config.appUrl, + fundamentals: registry.cryptoFundamentals, }); if (cryptoResponse) return cryptoResponse; diff --git a/test/crypto-fundamentals.test.ts b/test/crypto-fundamentals.test.ts new file mode 100644 index 0000000..1de0584 --- /dev/null +++ b/test/crypto-fundamentals.test.ts @@ -0,0 +1,158 @@ +/** + * Market cap and supply, from the one non-Alpaca source on the crypto path. + * + * The tests that matter most are the refusals. CoinGecko keeps serving records + * for tokens that have migrated away — MKR to SKY, MATIC to POL — and those + * records report zero circulating supply and stop updating, while still + * returning a plausible-looking price. Rendering "$0.00" market cap, or a + * six-month-old supply figure beside a live price, would be worse than showing + * nothing. + * + * Network is stubbed throughout; these never call CoinGecko. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { CryptoFundamentalsClient, FUNDAMENTALS_ASSETS } from "../src/crypto/fundamentals.ts"; + +const NOW = Date.parse("2026-08-07T00:00:00Z"); + +const row = (over: Record = {}) => ({ + id: "bitcoin", symbol: "btc", name: "Bitcoin", + current_price: 64399, market_cap: 1_292_252_659_205, market_cap_rank: 1, + fully_diluted_valuation: 1_292_252_659_205, total_volume: 18_396_856_097, + circulating_supply: 20_066_703, total_supply: 20_066_721, max_supply: 21_000_000, + ath: 126_080, ath_change_percentage: -48.92, ath_date: "2025-10-06T10:57:42.000Z", + last_updated: "2026-08-07T00:00:00.000Z", + ...over, +}); + +const realFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = realFetch; }); + +/** Serve `rows` to the next request; records the URLs asked for. */ +function stubFetch(rows: unknown[], calls: string[] = []) { + globalThis.fetch = (async (input: any) => { + calls.push(String(input)); + return { ok: true, status: 200, json: async () => rows } as any; + }) as any; + return calls; +} + +const client = (rows: unknown[], calls?: string[]) => { + stubFetch(rows, calls); + return new CryptoFundamentalsClient({ now: () => NOW }); +}; + +describe("fetching", () => { + test("maps a healthy record", async () => { + const f = (await client([row()]).get("BTC"))!; + expect(f.marketCap).toBe(1_292_252_659_205); + expect(f.marketCapRank).toBe(1); + expect(f.circulatingSupply).toBe(20_066_703); + expect(f.maxSupply).toBe(21_000_000); + expect(f.ath).toBe(126_080); + expect(f.volume24h).toBe(18_396_856_097); + expect(f.unavailableReason).toBeUndefined(); + }); + + test("asks for every asset in one request", async () => { + const calls: string[] = []; + await client([row()], calls).get("BTC"); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain("vs_currency=usd"); + // Explicit ids, never a symbol search — tickers collide across listings. + expect(calls[0]).toContain("bitcoin"); + expect(calls[0]).toContain("ethereum"); + }); + + test("a second lookup is served from cache, not a second request", async () => { + const calls: string[] = []; + const c = client([row(), row({ id: "ethereum", symbol: "eth" })], calls); + await c.get("BTC"); + await c.get("ETH"); + expect(calls).toHaveLength(1); + }); + + test("concurrent misses collapse into one request", async () => { + // A rate-limited free tier must not be stampeded by parallel page loads. + const calls: string[] = []; + const c = client([row()], calls); + await Promise.all([c.get("BTC"), c.get("BTC"), c.get("BTC")]); + expect(calls).toHaveLength(1); + }); +}); + +describe("refusing bad records", () => { + test("a migrated token reports no supply, so nothing is shown", async () => { + // This is MKR after the SKY migration: a live-looking price, zero supply. + const f = (await client([ + row({ id: "maker", symbol: "mkr", current_price: 1272.11, market_cap: 0, circulating_supply: 0 }), + ]).get("MKR"))!; + expect(f.marketCap).toBeNull(); + expect(f.circulatingSupply).toBeNull(); + expect(f.unavailableReason).toContain("migrated"); + }); + + test("a stale record is rejected even when its numbers look fine", async () => { + // This is MATIC: last updated six months ago, still returning figures. + const f = (await client([ + row({ + id: "matic-network", symbol: "matic", + market_cap: 5_000_000_000, circulating_supply: 9_000_000_000, + last_updated: "2026-02-03T01:57:00.000Z", + }), + ]).get("MATIC"))!; + expect(f.marketCap).toBeNull(); + expect(f.unavailableReason).toContain("has not updated"); + expect(f.unavailableReason).toContain("185 days"); + }); + + test("a fresh record just inside the window is kept", async () => { + const f = (await client([ + row({ last_updated: new Date(NOW - 6 * 86_400_000).toISOString() }), + ]).get("BTC"))!; + expect(f.marketCap).not.toBeNull(); + expect(f.unavailableReason).toBeUndefined(); + }); + + test("zero and negative values become null, never rendered figures", async () => { + const f = (await client([ + row({ market_cap: 0, fully_diluted_valuation: 0, max_supply: null, ath: -1 }), + ]).get("BTC"))!; + // circulating_supply is still positive, so the record itself is usable. + expect(f.unavailableReason).toBeUndefined(); + expect(f.marketCap).toBeNull(); + expect(f.fullyDilutedValuation).toBeNull(); + expect(f.maxSupply).toBeNull(); + expect(f.ath).toBeNull(); + }); +}); + +describe("failure is never fatal", () => { + test("an upstream error yields null rather than throwing into a page", async () => { + globalThis.fetch = (async () => ({ ok: false, status: 429, json: async () => ({}) })) as any; + expect(await new CryptoFundamentalsClient({ now: () => NOW }).get("BTC")).toBeNull(); + }); + + test("a network failure yields null", async () => { + globalThis.fetch = (async () => { throw new Error("offline"); }) as any; + expect(await new CryptoFundamentalsClient({ now: () => NOW }).get("BTC")).toBeNull(); + }); + + test("an asset we do not map yields null without a request", async () => { + const calls: string[] = []; + expect(await client([row()], calls).get("NOTACOIN")).toBeNull(); + expect(calls).toHaveLength(0); + }); + + test("an asset missing from the response yields null", async () => { + expect(await client([row()]).get("SOL")).toBeNull(); + }); +}); + +describe("coverage", () => { + test("every base asset advis0r lists has a mapping", async () => { + const { SUPPORTED_PAIRS } = await import("../src/crypto/pairs.ts"); + const bases = [...new Set(SUPPORTED_PAIRS.map((p) => p.base))]; + for (const base of bases) expect(FUNDAMENTALS_ASSETS).toContain(base); + }); +}); diff --git a/test/crypto-page.test.ts b/test/crypto-page.test.ts index a27e49b..f22fc2f 100644 --- a/test/crypto-page.test.ts +++ b/test/crypto-page.test.ts @@ -199,8 +199,10 @@ describe("crypto page", () => { expect(page).toContain("2026-01-04"); // A period without enough history says why rather than showing a number. expect(page).toContain("less history than it needs"); - // And the absent fields are named, not silently dropped. - expect(page).toContain("Market capitalisation, circulating supply"); + // Venue figures must be labelled as such, now that market-wide ones share + // the page — the two are easy to mistake for each other. + expect(page).toContain("Alpaca's US venue"); + expect(page).toContain("Supply & valuation"); }); test("shows the order book, the other thing only the modal had", () => { @@ -224,6 +226,67 @@ describe("crypto page", () => { expect(render({ orderbook: undefined })).not.toContain("Order book"); }); + test("shows market cap and supply, attributed to its own source", () => { + const page = render({ + fundamentals: { + base: "BTC", coingeckoId: "bitcoin", + marketCap: 1_292_252_659_205, marketCapRank: 1, + fullyDilutedValuation: 1_292_252_659_205, + circulatingSupply: 20_066_703, totalSupply: 20_066_721, maxSupply: 21_000_000, + ath: 126_080, athDate: "2025-10-06T10:57:42.000Z", athChangePercent: -48.92, + volume24h: 18_396_856_097, lastUpdated: "2026-08-06T13:00:00.000Z", + }, + }); + expect(page).toContain("Supply & valuation"); + expect(page).toContain("$1.29T"); // market cap + expect(page).toContain("#1"); // rank + expect(page).toContain("20.07M BTC"); // circulating supply + expect(page).toContain("21.00M BTC"); // max supply + expect(page).toContain("$126,080.00"); // ATH + expect(page).toContain("2025-10-06"); + expect(page).toContain("-48.92%"); + // Provenance: the reader must never have to guess which vendor a number + // came from when two are on one page. + expect(page).toContain("CoinGecko"); + expect(page).toContain("not comparable"); + }); + + test("an uncapped supply says so rather than showing a dash", () => { + const page = render({ + fundamentals: { + base: "ETH", coingeckoId: "ethereum", marketCap: 2e11, marketCapRank: 2, + fullyDilutedValuation: null, circulatingSupply: 1.2e8, totalSupply: 1.2e8, + maxSupply: null, ath: 4800, athDate: null, athChangePercent: null, + volume24h: 1e10, lastUpdated: null, + }, + }); + expect(page).toContain("uncapped"); + }); + + test("a migrated token explains itself instead of showing $0.00", () => { + // The MKR/MATIC case. "$0.00 market cap" would be a false statement. + const page = render({ + fundamentals: { + base: "MKR", coingeckoId: "maker", marketCap: null, marketCapRank: null, + fullyDilutedValuation: null, circulatingSupply: null, totalSupply: null, + maxSupply: null, ath: null, athDate: null, athChangePercent: null, + volume24h: null, lastUpdated: "2026-08-06T13:00:00.000Z", + unavailableReason: "the upstream reports no circulating supply for this asset, which usually means it has migrated to a successor token", + }, + }); + expect(page).toContain("Supply & valuation"); + expect(page).toContain("migrated to a successor token"); + expect(page).not.toContain("$0.00"); + expect(page).not.toContain("$NaN"); + }); + + test("an unreachable source says so rather than implying zero", () => { + const page = render({ fundamentals: null }); + expect(page).toContain("Supply & valuation"); + expect(page).toContain("could not be reached"); + expect(page).toContain("Nothing is estimated"); + }); + test("no longer points at the in-app modal", () => { // That link led to a second, weaker view of the same pair. expect(render()).not.toContain("/?pair=");