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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand Down
219 changes: 219 additions & 0 deletions src/crypto/fundamentals.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, AssetFundamentals> } | null = null;
private inFlight: Promise<Map<string, AssetFundamentals>> | 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<Map<string, AssetFundamentals>> {
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<string, any>(rows.map((r) => [r.id, r]));
const byBase = new Map<string, AssetFundamentals>();
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<AssetFundamentals | null> {
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);
72 changes: 71 additions & 1 deletion src/crypto/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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[];
Expand All @@ -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 `<section class="rp-section">
<h2>Supply &amp; valuation</h2>
<p class="rp-note">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.</p>
</section>`;
}
if (f.unavailableReason) {
// Naming the reason matters: "—" alone reads as a bug, when the truth is
// that the asset itself has moved on.
return `<section class="rp-section">
<h2>Supply &amp; valuation</h2>
<p class="rp-note">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.</p>
</section>`;
}

const pctFromAth = f.athChangePercent != null ? signed(f.athChangePercent) : "—";
return `<section class="rp-section">
<h2>Supply &amp; valuation <span class="rp-badge conservative">CoinGecko</span></h2>
<dl class="rp-grid">
${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 ? ` <span class="rp-when">${e(String(f.athDate).slice(0, 10))}</span>` : ""}`)}
${kv("From ATH", pctFromAth, f.athChangePercent != null && f.athChangePercent < 0 ? "neg" : "")}
${kv("24h volume (all venues)", bigMoney(f.volume24h))}
</dl>
<p class="rp-note">
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.
</p>
</section>`;
}

/** Multi-period performance — what the pair has been doing, not just its spread. */
function performanceSection(p: CryptoPerformance | undefined, quote: string): string {
if (!p) return "";
Expand All @@ -88,7 +156,7 @@ function performanceSection(p: CryptoPerformance | undefined, quote: string): st
${kv("Daily bars", String(p.barCount))}
</dl>
${thin ? `<p class="rp-note">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.</p>` : ""}
<p class="rp-note">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.</p>
<p class="rp-note">Prices and volume in this section are Alpaca's US venue. Market-wide figures are under Supply &amp; valuation below.</p>
</section>`;
}

Expand Down Expand Up @@ -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)}
Expand Down
Loading
Loading