From b0c66e4821983aefba7bd75cb9913e968809e63a Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Tue, 10 Nov 2026 23:50:27 +0800 Subject: [PATCH] feat(usd): the site shows volume and fees valued at the time of each trade The read side of #133. The all-time volume, the fee totals, each token's "Total volume" and the all-time Volume sort now read the stored trade-time values (each trade and fee at its own hour), instead of quote volume at today's price. Production was backfilled on 2026-11-10: $16,601,255 stored, against the $11.7M the old method showed. - trade-time-usd.ts (pure): tradeTimeUsd = the stored sum plus the raw amount with no stored value at today's price. That second part covers registry stocks, community pairs and unlisted tokens traded before the live capture started (no price history: 4,711 of 133k trades), so the totals stay complete. missingNow decides "partial": only that part can be missing a price. - queries.ts: - the list SELECT joins each launch's unpriced volume once (one grouped read on the usd-IS-NULL partial index) - shape() computes volume_usd from it and no longer passes the stored columns through - the all-time Volume sort ranks by the same figure - getLaunchTotals sums the stored values plus the unpriced parts; fees to recipients = collected - burned, both in trade-time USD - 1h/24h volume stays quote volume at today's price, where every trade is priced live anyway - LaunchMechanism: the tooltips say how the figures are valued (each at the hour it happened; stock and community-pair trades before Nov 10, 2026 at today's rate), not "valued at current rates". - Tests: trade-time-usd.test.ts. The totals fixture (price-source) moves to the new model, plus a case where a trade priced at $3,100 stays $3,100 at an ETH price of $2,500. The source checks for the partial rule, the select's aliases (the new `u` join) and the hero tooltip are updated; the db mock knows the new totals query. Not changed: CollectPanel's per-token fee figures and the live tape (recent trades, at today's price), points scoring (this season). Verified on a throwaway Postgres 16: the real migration, a fixture backfilled by the real apply SQL, then the real getLaunch / listLaunchesPage(volume, all) / getLaunchTotals at ETH = $2,500: - ETH $4,789.65, USDC $100, GITLAWB $18,893.73 - an unlisted quote null - an ETH trade from before the backfill range at today's price ($2,500) - sort CGIT > AETH > EOLD > BUSDC > DUNL - total $26,283.38, not partial - no stored column leaks into the API rows tsc and lint clean; npm test 1408 (1397 pass, 11 skipped, 0 fail). Co-Authored-By: Claude Opus 5.5 --- .../components/launchpad/LaunchMechanism.tsx | 4 +- .../components/launchpad/hero-layout.test.ts | 4 +- app/src/lib/db-mock.ts | 4 +- app/src/lib/launchpad/price-source.test.ts | 15 ++++- app/src/lib/launchpad/queries.ts | 61 +++++++++++++------ app/src/lib/launchpad/trade-time-usd.test.ts | 58 ++++++++++++++++++ app/src/lib/launchpad/trade-time-usd.ts | 28 +++++++++ app/src/lib/launchpad/unlisted-quote.test.ts | 2 +- app/src/lib/launchpad/volume-window.test.ts | 6 +- 9 files changed, 155 insertions(+), 27 deletions(-) create mode 100644 app/src/lib/launchpad/trade-time-usd.test.ts create mode 100644 app/src/lib/launchpad/trade-time-usd.ts diff --git a/app/src/components/launchpad/LaunchMechanism.tsx b/app/src/components/launchpad/LaunchMechanism.tsx index f1f2a0c2..a3aa5597 100644 --- a/app/src/components/launchpad/LaunchMechanism.tsx +++ b/app/src/components/launchpad/LaunchMechanism.tsx @@ -39,7 +39,9 @@ export default function LaunchMechanism() { // `live` figures roll as they change (compact); the rest stay exact text. Both carry the same "≈" guard. const usd = (v: number, live = false) => (live ? : `${t.usd_partial ? "≈" : ""}${fmtUsd(v)}`); // the compact figure rounds, so its tooltip carries the exact sum and says what kind of number it is - const usdTitle = (v: number, what: string) => usdNote ?? `${fmtUsd(v)} · ${what} valued at current rates`; + // trade-time USD (lib/launchpad/trade-time-usd.ts): each trade and fee at the hour it happened; trades in a quote with no + // price history (registry stocks, community pairs and unlisted tokens before Nov 10, 2026) at today's rate + const usdTitle = (v: number, what: string) => usdNote ?? `${fmtUsd(v)} · ${what}, each valued in USD at the hour it happened (stock and community-pair trades before Nov 10, 2026 at today's rate)`; const usdChars = USD_CHARS + (t.usd_partial ? 1 : 0); const gitlawbBurnedExact = `${fmtUnitsExact(t.gitlawb_burned, GITLAWB_DECIMALS)} ${GITLAWB_SYMBOL}`; // every digit of the raw amount, no float // TWIG burned: its own figure (the GITLAWB behind it stays in the TWIG contract), shown once there is any; null-safe for a poll from an older machine diff --git a/app/src/components/launchpad/hero-layout.test.ts b/app/src/components/launchpad/hero-layout.test.ts index f8d1e410..ad8d5547 100644 --- a/app/src/components/launchpad/hero-layout.test.ts +++ b/app/src/components/launchpad/hero-layout.test.ts @@ -140,7 +140,9 @@ test("the dollar readings are latched: shown totals change only when something h }); test("the two dollar figures explain themselves, and the ≈ note is readable without a mouse", () => { - assert.match(totals, /usdNote \?\? `\$\{fmtUsd\(v\)\} · \$\{what\} valued at current rates`/); + // trade-time USD: each figure says it is valued at the hour each trade happened, and names the part that is not + assert.match(totals, /usdNote \?\? `\$\{fmtUsd\(v\)\} · \$\{what\}, each valued in USD at the hour it happened \(stock and community-pair trades before Nov 10, 2026 at today's rate\)`/); + assert.doesNotMatch(totals, /valued at current rates/); assert.match(totals, /title=\{usdTitle\(t\.volume_usd, "quote volume"\)\}/); assert.match(totals, /title=\{usdTitle\(t\.fees_to_creators_usd, "quote-asset fees"\)\}/, "fees are not volume"); const details = totals.slice(totals.indexOf(" | null; b20SupplyReads: number; @@ -97,7 +97,7 @@ function fakeDb(strings: TemplateStringsArray, ...values: unknown[]): Promise { offline(t); resetMock(); - const row = (quote: string, volume: bigint) => ({ chain_id: BASE, quote, launches: 1n, trades: 4n, volume: volume.toString(), burned: "0", creators: "0" }); + // trade-time model (trade-time-usd.ts): a trade with no stored value (here, all of them) counts at the quote's price now + const row = (quote: string, volume: bigint, storedUsd = 0, unpriced = volume) => ({ + chain_id: BASE, quote, launches: 1n, trades: 4n, volume: volume.toString(), burned: "0", creators: "0", + volume_usd: storedUsd, collected_usd: 0, burned_usd: 0, volume_unpriced: unpriced.toString(), collected_unpriced: "0", burned_unpriced: "0", + }); getMock().launchTotalsRows = [row(NATIVE, 10n ** 18n), row(NFLX, 5n * 10n ** 8n), row(NOT_A_STOCK, 10n ** 18n)]; const quiet = await getLaunchTotals(2500); assert.equal(quiet.launches, 3); @@ -59,6 +63,13 @@ test("hero totals: volume in a feed-less stock is left out without the undercoun // an idle launch (no volume, no fees) never marks the totals, whatever its quote getMock().launchTotalsRows = [row(NATIVE, 10n ** 18n), row(NVDA, 0n)]; assert.equal((await getLaunchTotals(2500)).usd_partial, false); + + // trades priced at their own hour count at that value, whatever the price is now; and a quote with no price right now + // does not mark the totals for its trades that already have one + getMock().launchTotalsRows = [row(NATIVE, 10n ** 18n, 3_100, 0n), row(NVDA, 10n ** 8n, 180, 0n)]; + const stored = await getLaunchTotals(2500); + assert.equal(stored.volume_usd, 3_280, "1 ETH traded at $3,100 stays $3,100 at an ETH price of $2,500; NVDAc's $180 needs no price now"); + assert.equal(stored.usd_partial, false); resetMock(); }); @@ -69,7 +80,7 @@ test("the points run waits for a missing price only when the quote has a price s // and that answer is the same one the hero totals use const queries = read("./queries.ts"); assert.match(queries, /return \{ key: q\.key, decimals: q\.decimals, usd: quoteUsd\(q, ethUsd\), priced: hasPriceSource\(q\) \};/); - assert.match(queries, /if \(usd === null && hasPriceSource\(q\) && \(/); + assert.match(queries, /const priced = hasPriceSource\(q\);\s*if \(missingNow\(r\.volume_unpriced, usd, priced\) \|\| missingNow\(r\.collected_unpriced, usd, priced\) \|\| missingNow\(r\.burned_unpriced, usd, priced\)\) t\.usd_partial = true;/); assert.match(queries, /\.\.\.\(stock\.priced \? \{\} : \{ unpriced: true \}\)/); // every launch row carries the same answer to the client (the wallet page's holdings total reads it) assert.match(queries, /quote_decimals_known: q\.decimalsKnown !== false,\s*quote_priced: hasPriceSource\(q\),/); diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index d45d61e8..d8ac85c5 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -21,6 +21,7 @@ import { collectNonDust } from "./feed-dust"; import { PIPS_LEADER, PIPS_RUNNER, pipFromSwapRow, tapeOrNone, type Pip } from "./trending-live"; import { launchKey } from "./list-state"; import { fdvQuote, quotePerToken, tickToTokensPerQuote, units } from "./math"; +import { UNPRICED_SWAPS_SQL, missingNow, tradeTimeUsd } from "./trade-time-usd"; import type { RawCandle } from "./candles"; import { normalizeQuery, isAddressQuery, escapeLike, compareSearchHit, compareSearchHitByHolders, type LaunchFilter } from "./search"; @@ -196,10 +197,12 @@ function quoteUsd(q: Quote, ethUsd: number | null): number | null { } /** A launch row as the app uses it: indexer-only bigint columns dropped (JSON-safe), price, FDV and USD figures worked out. */ -function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; log_index?: number; holders_synced_block?: bigint | null }, ethUsd: number | null): LaunchRow { - // drop indexer-only bigint columns so the row is JSON-safe +/** Columns a row carries for the trade-time USD figures (trade-time-usd.ts); read here, never passed on as they are. */ +type TradeTimeCols = { volume_usd?: number | null; fees_collected_usd?: number | null; fees_burned_usd?: number | null; volume_quote_unpriced?: string | null }; +function shape(raw: Raw & TradeTimeCols & { last_swap_block?: bigint; last_swap_log?: number; log_index?: number; holders_synced_block?: bigint | null }, ethUsd: number | null): LaunchRow { + // drop indexer-only bigint columns so the row is JSON-safe, and the stored trade-time sums (volume_usd below is computed from them) // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { last_swap_block, last_swap_log, log_index, holders_synced_block, ...r } = raw; + const { last_swap_block, last_swap_log, log_index, holders_synced_block, volume_usd: storedVolumeUsd, fees_collected_usd, fees_burned_usd, volume_quote_unpriced, ...r } = raw; const chain = chainKeyOf(r.chain_id) ?? DEFAULT_CHAIN; const q = quoteInfo(chain, r.quote); const supply = BigInt(r.supply); @@ -240,7 +243,8 @@ function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; lo quote_usd: qu, price_usd: qu === null ? null : price * qu, fdv_usd: qu === null ? null : fdv * qu, - volume_usd: usd(r.volume_quote), + // all-time volume at each trade's own hour; trades with no stored value (no price history) at today's price + volume_usd: tradeTimeUsd(storedVolumeUsd, volume_quote_unpriced, q.decimals, qu), volume_1h_usd: usd(String(r.volume_1h ?? "0")), volume_24h_usd: usd(String(r.volume_24h ?? "0")), }; @@ -256,9 +260,11 @@ function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; lo const OUTSIDE = `s.trader <> l.launcher AND s.block_number > l.block_number + ${SNIPER_BLOCKS}`; const HOUR = `s.block_time > now() - interval '1 hour'`; export const SELECT = `SELECT l.*, m.description, m.image_url, m.banner_url, m.website, m.x_handle, - w.n1 AS trades_1h, w.t1 AS traders_1h, w.t1_ex AS traders_1h_ex, w.v1 AS volume_1h, w.n24 AS trades_24h, w.t24_ex AS traders_24h_ex, w.v24 AS volume_24h, w.last_outside_at AS last_outside_trade_at + w.n1 AS trades_1h, w.t1 AS traders_1h, w.t1_ex AS traders_1h_ex, w.v1 AS volume_1h, w.n24 AS trades_24h, w.t24_ex AS traders_24h_ex, w.v24 AS volume_24h, w.last_outside_at AS last_outside_trade_at, + COALESCE(u.uq, 0)::text AS volume_quote_unpriced FROM bb_launches l LEFT JOIN bb_launch_meta m ON m.chain_id = l.chain_id AND m.token = l.token + LEFT JOIN (${UNPRICED_SWAPS_SQL}) u ON u.chain_id = l.chain_id AND u.token = l.token LEFT JOIN LATERAL ( SELECT count(*) FILTER (WHERE ${HOUR})::int AS n1, count(DISTINCT s.trader) FILTER (WHERE ${HOUR})::int AS t1, count(DISTINCT s.trader) FILTER (WHERE ${HOUR} AND ${OUTSIDE})::int AS t1_ex, COALESCE(sum(abs(amount0)) FILTER (WHERE ${HOUR}), 0)::text AS v1, @@ -358,7 +364,11 @@ export async function listLaunchesPage(opts: ListOpts = {}): Promise { const pairDecCase = pairDec.length ? pairDec.map((p) => db`WHEN (l.chain_id = ${chainIdOf(p.chain)} AND l.quote = ${p.address}) THEN ${p.decimals}`).reduce((acc, c) => db`${acc} ${c}`) : db``; const qd = db`(CASE ${stableDecCase} ${decCase} ${pairDecCase} ELSE 18 END)`; const volCol = db.unsafe(VOLUME_COLUMN_SQL[win]); - const volUsd = db`(${volCol} / power(10, ${qd}) * ${usdPerUnit})`; + // the all-time window ranks by trade-time USD (each trade at its own hour, trades with no stored value at today's price, as + // shape() shows it); 1h and 24h stay quote volume at today's price, where every trade is priced live anyway + const volUsd = win === "all" + ? db`(l.volume_usd + COALESCE(u.uq, 0) / power(10, ${qd}) * ${usdPerUnit})` + : db`(${volCol} / power(10, ${qd}) * ${usdPerUnit})`; // quote per token = 1 / (1.0001^tick · 10^(qd-18)); mcap = that · supply/1e18 · usd const mcapUsd = db`((1.0 / (power(1.0001, COALESCE(l.tick, l.start_tick)::double precision) * power(10, ${qd} - 18))) * (l.supply / 1e18) * ${usdPerUnit})`; const newest = db`l.block_time DESC, l.chain_id DESC, l.block_number DESC`; @@ -560,11 +570,22 @@ export async function getLaunchTotals(ethUsd: number | null = null): Promise` - SELECT chain_id, quote, count(*)::bigint AS launches, COALESCE(sum(buys + sells), 0)::bigint AS trades, - COALESCE(sum(volume_quote), 0)::text AS volume, COALESCE(sum(fees_quote_burned), 0)::text AS burned, - COALESCE(sum(fees_quote_collected - fees_quote_burned), 0)::text AS creators - FROM bb_launches GROUP BY chain_id, quote`, + db<{ chain_id: number; quote: string; launches: bigint; trades: bigint; volume: string; burned: string; creators: string; volume_usd: number; collected_usd: number; burned_usd: number; volume_unpriced: string; collected_unpriced: string; burned_unpriced: string }[]>` + SELECT l.chain_id, l.quote, count(*)::bigint AS launches, COALESCE(sum(l.buys + l.sells), 0)::bigint AS trades, + COALESCE(sum(l.volume_quote), 0)::text AS volume, COALESCE(sum(l.fees_quote_burned), 0)::text AS burned, + COALESCE(sum(l.fees_quote_collected - l.fees_quote_burned), 0)::text AS creators, + -- trade-time USD (trade-time-usd.ts): the stored sums, and the raw amounts with no stored value + COALESCE(sum(l.volume_usd), 0)::float8 AS volume_usd, COALESCE(sum(l.fees_collected_usd), 0)::float8 AS collected_usd, + COALESCE(sum(l.fees_burned_usd), 0)::float8 AS burned_usd, COALESCE(sum(u.uq), 0)::text AS volume_unpriced, + COALESCE(sum(uf.collected), 0)::text AS collected_unpriced, COALESCE(sum(uf.burned), 0)::text AS burned_unpriced + FROM bb_launches l + LEFT JOIN (SELECT chain_id, token, sum(abs(amount0)) AS uq FROM bb_launch_swaps WHERE usd IS NULL GROUP BY chain_id, token) u ON u.chain_id = l.chain_id AND u.token = l.token + LEFT JOIN (SELECT f.chain_id, f.token, sum(f.quote_amount) FILTER (WHERE f.kind = 'collected') AS collected, + sum(f.amount) FILTER (WHERE f.kind = 'burned') AS burned + FROM bb_launch_fee_events f JOIN bb_launches fl ON fl.chain_id = f.chain_id AND fl.token = f.token + WHERE f.usd IS NULL AND (f.kind = 'collected' OR (f.kind = 'burned' AND f.currency = fl.quote)) + GROUP BY f.chain_id, f.token) uf ON uf.chain_id = l.chain_id AND uf.token = l.token + GROUP BY l.chain_id, l.quote`, // boosts are TWIG sent to 0x…dEaD too (boost.ts): they join the TWIG burned figure while the switch is on, and // nothing else reads them here. Only boosts that name a launch count (the lane's own join), so a stray burn whose // last digits name nothing is never called a boost. Display only: a failed read counts none rather than taking @@ -586,15 +607,19 @@ export async function getLaunchTotals(ethUsd: number | null = null): Promise 0n || BigInt(r.burned) > 0n || BigInt(r.creators) > 0n)) t.usd_partial = true; + // trade-time USD: every trade and fee at its own hour (stored), the ones with no stored value (no price history) at + // today's price. Only that last part can be missing: a quote that normally prices but has no price this instant → the + // USD sums undercount; an unlisted ERC-20 (key "other") and a stock without a feed are unpriced by design and are left + // out of the USD figures silently, as always + const priced = hasPriceSource(q); + if (missingNow(r.volume_unpriced, usd, priced) || missingNow(r.collected_unpriced, usd, priced) || missingNow(r.burned_unpriced, usd, priced)) t.usd_partial = true; + const collectedUsd = tradeTimeUsd(r.collected_usd, r.collected_unpriced, q.decimals, usd) ?? 0; + const burnedUsd = tradeTimeUsd(r.burned_usd, r.burned_unpriced, q.decimals, usd) ?? 0; t.launches += Number(r.launches); t.trades += Number(r.trades); - t.volume_usd += units(r.volume, q.decimals) * qu; - t.fees_burned_usd += units(r.burned, q.decimals) * qu; - t.fees_to_creators_usd += units(r.creators, q.decimals) * qu; + t.volume_usd += tradeTimeUsd(r.volume_usd, r.volume_unpriced, q.decimals, usd) ?? 0; + t.fees_burned_usd += burnedUsd; + t.fees_to_creators_usd += collectedUsd - burnedUsd; const bc = t.by_chain[chain]; bc.launches += Number(r.launches); bc.trades += Number(r.trades); diff --git a/app/src/lib/launchpad/trade-time-usd.test.ts b/app/src/lib/launchpad/trade-time-usd.test.ts new file mode 100644 index 00000000..5ca90d49 --- /dev/null +++ b/app/src/lib/launchpad/trade-time-usd.test.ts @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { UNPRICED_SWAPS_SQL, missingNow, tradeTimeUsd } from "./trade-time-usd.ts"; + +const ETH = 10n ** 18n; + +test("trade-time USD = the stored sum, plus what has no stored value at today's price", () => { + // all priced at their own hour: the stored sum, whatever today's price is (the old method would say 2 x 2,000) + assert.equal(tradeTimeUsd(5_300, "0", 18, 2_000), 5_300); + assert.equal(tradeTimeUsd(5_300, 0n, 18, null), 5_300, "a quote with no price right now keeps what was stored"); + // 1 ETH with no stored value counts at today's price + assert.equal(tradeTimeUsd(5_300, ETH.toString(), 18, 2_000), 7_300); + // 6-decimal quotes + assert.equal(tradeTimeUsd(0, "100000000", 6, 1), 100); + // nothing priced it at its hour and nothing prices it now: no USD + assert.equal(tradeTimeUsd(0, ETH, 18, null), null); + assert.equal(tradeTimeUsd(250, ETH, 18, null), 250, "the stored part still counts; the caller flags the rest as partial"); + // no trades at all + assert.equal(tradeTimeUsd(0, "0", 18, 2_000), 0); + assert.equal(tradeTimeUsd(0, "0", 18, null), null, "an unlisted quote with no trades shows no USD, as before"); + assert.equal(tradeTimeUsd(null, null, 18, 3), 0); + assert.equal(tradeTimeUsd(Number.NaN, "0", 18, 3), 0, "a bad stored value is never shown"); +}); + +test("missingNow flags only a part that normally has a price but has none right now", () => { + assert.equal(missingNow(ETH.toString(), null, true), true); + assert.equal(missingNow(ETH.toString(), 2_000, true), false); + assert.equal(missingNow(ETH.toString(), null, false), false, "an unlisted quote is unpriced by design, never 'partial'"); + assert.equal(missingNow("0", null, true), false); + assert.equal(missingNow(null, null, true), false); +}); + +test("the unpriced read only touches rows with no stored value, per launch", () => { + assert.match(UNPRICED_SWAPS_SQL, /FROM bb_launch_swaps WHERE usd IS NULL GROUP BY chain_id, token$/); + assert.match(UNPRICED_SWAPS_SQL, /sum\(abs\(amount0\)\) AS uq/, "buys and sells both count at their size, as volume_quote does"); +}); + +test("the site reads trade-time USD everywhere the all-time figures are shown", () => { + const q = readFileSync(path.join(process.cwd(), "src/lib/launchpad/queries.ts"), "utf8"); + // the list SELECT carries each launch's unpriced volume, joined once + assert.match(q, /COALESCE\(u\.uq, 0\)::text AS volume_quote_unpriced/); + assert.match(q, /LEFT JOIN \(\$\{UNPRICED_SWAPS_SQL\}\) u ON u\.chain_id = l\.chain_id AND u\.token = l\.token/); + // a launch row's all-time volume + assert.match(q, /volume_usd: tradeTimeUsd\(storedVolumeUsd, volume_quote_unpriced, q\.decimals, qu\)/); + assert.doesNotMatch(q, /volume_usd: usd\(r\.volume_quote\)/, "never quote volume at today's price again"); + // the all-time Volume sort ranks by the same figure + assert.match(q, /win === "all"\s*\? db`\(l\.volume_usd \+ COALESCE\(u\.uq, 0\) \/ power\(10, \$\{qd\}\) \* \$\{usdPerUnit\}\)`/); + // the site totals: stored sums plus the unpriced parts, fees to recipients = collected - burned in USD + const totals = q.slice(q.indexOf("export async function getLaunchTotals("), q.indexOf("export async function getLaunchTotals(") + 6000); + assert.match(totals, /COALESCE\(sum\(l\.volume_usd\), 0\)::float8 AS volume_usd/); + assert.ok(totals.includes(`LEFT JOIN (${UNPRICED_SWAPS_SQL}) u ON u.chain_id = l.chain_id AND u.token = l.token`), "the totals inline the same unpriced read as the lists"); + assert.match(totals, /WHERE f\.usd IS NULL AND \(f\.kind = 'collected' OR \(f\.kind = 'burned' AND f\.currency = fl\.quote\)\)/); + assert.match(totals, /t\.volume_usd \+= tradeTimeUsd\(r\.volume_usd, r\.volume_unpriced, q\.decimals, usd\) \?\? 0;/); + assert.match(totals, /t\.fees_to_creators_usd \+= collectedUsd - burnedUsd;/); + assert.doesNotMatch(totals, /units\(r\.volume, q\.decimals\) \* qu/, "the old method is gone from the totals"); +}); diff --git a/app/src/lib/launchpad/trade-time-usd.ts b/app/src/lib/launchpad/trade-time-usd.ts new file mode 100644 index 00000000..9ead228d --- /dev/null +++ b/app/src/lib/launchpad/trade-time-usd.ts @@ -0,0 +1,28 @@ +/** + * Reading trade-time USD (pure; node --test loads it). Every trade and quote-side fee carries its USD value at its own + * hour (bb_launch_swaps.usd, bb_launch_fee_events.usd; usd-history.ts), summed per launch in bb_launches.volume_usd, + * fees_collected_usd and fees_burned_usd. A row with no stored value is one whose quote has no price history (registry + * stocks, community pairs and unlisted tokens traded before 2026-11-10, when the live capture started): it counts at + * the quote's price now, the old method, so the totals stay complete. The site says so where the figures are shown. + */ +import { units } from "./math.ts"; + +/** Swaps with no stored USD value, summed per launch in raw quote units (the partial index bb_launch_swaps_usd_missing_idx keeps it small). */ +export const UNPRICED_SWAPS_SQL = `SELECT chain_id, token, sum(abs(amount0)) AS uq FROM bb_launch_swaps WHERE usd IS NULL GROUP BY chain_id, token`; + +/** + * A USD total: the stored trade-time sum plus the raw quote amount with no stored value at `quoteUsdNow`. + * null = nothing prices any of it (an unlisted quote, or a quote with no price right now and nothing stored). + */ +export function tradeTimeUsd(storedUsd: number | null | undefined, unpricedRaw: string | bigint | null | undefined, decimals: number, quoteUsdNow: number | null): number | null { + const stored = typeof storedUsd === "number" && Number.isFinite(storedUsd) ? storedUsd : 0; + const unpriced = unpricedRaw === null || unpricedRaw === undefined ? 0 : units(unpricedRaw, decimals); + if (unpriced > 0) return quoteUsdNow !== null ? stored + unpriced * quoteUsdNow : stored > 0 ? stored : null; + return stored > 0 || quoteUsdNow !== null ? stored : null; +} + +/** Whether a total undercounts right now: a part with no stored value whose quote normally has a price but has none at the moment. */ +export function missingNow(unpricedRaw: string | bigint | null | undefined, quoteUsdNow: number | null, priced: boolean): boolean { + if (unpricedRaw === null || unpricedRaw === undefined) return false; + return quoteUsdNow === null && priced && BigInt(unpricedRaw) > 0n; +} diff --git a/app/src/lib/launchpad/unlisted-quote.test.ts b/app/src/lib/launchpad/unlisted-quote.test.ts index 79e2ced1..f8731e43 100644 --- a/app/src/lib/launchpad/unlisted-quote.test.ts +++ b/app/src/lib/launchpad/unlisted-quote.test.ts @@ -82,7 +82,7 @@ test("the server resolves unlisted quotes from bb_quote_tokens, fails soft, and assert.match(q, /\/\* fail soft \(table not migrated yet/); assert.match(q, /rankTrending\(rows\.filter\(\(r\) => r\.quote_key !== "other"\)/); assert.match(q, /quote_decimals_known: q\.decimalsKnown !== false/); - assert.match(q, /usd === null && hasPriceSource\(q\)/, "unlisted quotes do not flag the site USD totals as partial (price-source.test.ts runs it)"); + assert.match(q, /const priced = hasPriceSource\(q\);\s*if \(missingNow\(r\.volume_unpriced, usd, priced\)/, "unlisted quotes do not flag the site USD totals as partial (price-source.test.ts runs it)"); }); test("the indexer reads unlisted quotes into bb_quote_tokens with printable text only", () => { diff --git a/app/src/lib/launchpad/volume-window.test.ts b/app/src/lib/launchpad/volume-window.test.ts index 5376aa2a..3e0edf5d 100644 --- a/app/src/lib/launchpad/volume-window.test.ts +++ b/app/src/lib/launchpad/volume-window.test.ts @@ -2,6 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { SELECT, VOLUME_COLUMN_SQL, VOLUME_WINDOWS } from "./queries.ts"; +import { UNPRICED_SWAPS_SQL } from "./trade-time-usd.ts"; /** * The Volume sort orders by a column per window. The window volumes live on the lateral subquery aliased `w` @@ -10,12 +11,13 @@ import { SELECT, VOLUME_COLUMN_SQL, VOLUME_WINDOWS } from "./queries.ts"; const lateral = SELECT.slice(SELECT.indexOf("LEFT JOIN LATERAL ("), SELECT.lastIndexOf(") w ON")); const wColumns = new Set([...lateral.matchAll(/\bAS (\w+)/g)].map((m) => m[1])); // aliases visible to the ORDER BY (the lateral's own inner alias is not): "FROM bb_launches l", "JOIN bb_launch_meta m", "LATERAL (...) w" -const definedAliases = new Set([...SELECT.replace(lateral, "LATERAL (...").matchAll(/\b(?:FROM|JOIN) bb_\w+ (\w+)|\) (\w+) ON/g)].map((m) => m[1] ?? m[2])); +// "LEFT JOIN () u" is the trade-time USD join (trade-time-usd.ts); its inner FROM is not an alias of the outer query +const definedAliases = new Set([...SELECT.replace(lateral, "LATERAL (...").replace(UNPRICED_SWAPS_SQL, "...").matchAll(/\b(?:FROM|JOIN) bb_\w+ (\w+)|\) (\w+) ON/g)].map((m) => m[1] ?? m[2])); test("the lateral subquery is still aliased w and carries the window volumes", () => { assert.ok(SELECT.includes(") w ON true")); assert.ok(wColumns.has("v1") && wColumns.has("v24")); - assert.deepEqual([...definedAliases].sort(), ["l", "m", "w"]); + assert.deepEqual([...definedAliases].sort(), ["l", "m", "u", "w"]); }); test("every volume window orders by columns the query defines", () => {