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", () => {