From dfda1c9a49ed466e8521fe1ea5b47e0483813550 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 6 Aug 2026 23:25:53 +0000 Subject: [PATCH] One surface per crypto pair, with fuller pricing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported symptom was that /?pair=SOL-USD lacks pricing. It does have pricing — bid, ask, mid, spread, day high/low, previous close, all populated. What that URL lacks is the analysis, because it is the old in-app modal, not the page. Two views of the same pair had drifted apart: modal: Market, Technical, Order book (no analysis) page: Market, Technical, Analysis (no order book) So this collapses them instead of patching the modal. /?pair=X now permanently redirects to /crypto/X, the crypto modal is deleted, and the page gains the one section it was missing. The page also gains what neither had, which is the part that most deserves the name "pricing info" on a 24/7 asset — what it has been doing, not just what it costs right now: - change over 24h / 7d / 30d / 90d / 1y, each against a dated close - 52-week high and low, with the dates they occurred - session volume in the quote currency Computed from the daily bars already fetched for the indicators, so no extra upstream call and no second vendor. A period with less history than it needs reports "—" and says why: measuring 1y from the oldest of four months of bars would be a claim about time we cannot see. Market capitalisation, circulating supply and all-time high are absent for the same reason — Alpaca does not carry them, and deriving them means inventing a supply figure. The page names those gaps rather than leaving them silent. The deep-link redirect lives in src/crypto/routes.ts rather than inline in the server so it is testable and the pair grammar stays in one place; it accepts every spelling the other routes do, and falls through rather than 301-ing an unknown pair into a 404. 453 tests pass (12 new), tsc clean, verified live: the redirect, the new sections, and every period figure. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 16 ++++- public/app.js | 124 -------------------------------- src/crypto/page.ts | 58 ++++++++++++++- src/crypto/performance.ts | 93 ++++++++++++++++++++++++ src/crypto/routes.ts | 34 +++++++++ src/server.ts | 5 +- test/crypto-page.test.ts | 50 +++++++++++++ test/crypto-performance.test.ts | 101 ++++++++++++++++++++++++++ test/crypto.test.ts | 29 +++++++- test/dashboard-crypto.test.ts | 76 ++------------------ 10 files changed, 386 insertions(+), 200 deletions(-) create mode 100644 src/crypto/performance.ts create mode 100644 test/crypto-performance.test.ts diff --git a/README.md b/README.md index b59a0ad..4b50614 100644 --- a/README.md +++ b/README.md @@ -159,8 +159,20 @@ in sitemaps, digest emails, and anywhere a report was already shared, so they keep resolving. Pair paths canonicalize too: `/crypto/btc` → `/crypto/BTC-USD`. The named data endpoints below answer JSON under **either** prefix; only the -directory and a pair have a page form. The interactive candlestick view remains -in the app, linked from each page (`/?pair=BTC-USD`). +directory and a pair have a page form. + +**One surface per pair.** `/?pair=BTC-USD` used to open an in-app modal — a +second, weaker view of the same pair, with no analysis and a URL nobody could +share. It permanently redirects to `/crypto/BTC-USD` now, and the modal is +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. | Endpoint | Returns | | --- | --- | diff --git a/public/app.js b/public/app.js index 047a9a9..2972233 100644 --- a/public/app.js +++ b/public/app.js @@ -411,12 +411,6 @@ async function boot() { } // Deep link from a "no report for that symbol" page: /?lookup=rivian lands on // the watchlist with the picker already showing what they meant. - // Deep link to one pair: /?pair=BTC-USD opens it on the Crypto tab. - const pair = params.get("pair"); - if (pair && /^[A-Za-z0-9]{2,6}-[A-Za-z]{3,4}$/.test(pair)) { - showView("crypto"); - openCryptoPair(pair.toUpperCase().replace("-", "/")); - } const lookup = params.get("lookup"); if (lookup) { showView("watchlist"); @@ -1516,13 +1510,6 @@ function fmtPrice(n) { return "$" + Number(n).toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp }); } -/** Alpaca bar -> the {t,o,h,l,c,v} shape the chart code already speaks. */ -const toChartBars = (rows) => - (rows || []).map((b) => ({ - t: String(b.timestamp).slice(0, 10), - o: b.open, h: b.high, l: b.low, c: b.close, v: b.volume, - })); - function cryptoCard(s) { const chg = s.change; const dir = chg == null ? "" : chg.percent >= 0 ? "positive" : "negative"; @@ -1589,117 +1576,6 @@ function setCryptoAuto(on) { }, CRYPTO_REFRESH_MS); } -function renderCryptoDetail(d, bars, book) { - const t = d.technical || {}; - const s = d.snapshot || {}; - const chg = s.change; - const q = s.latestQuote; - const price = s.latestTrade?.price ?? s.dailyBar?.close; - const spread = q ? q.askPrice - q.bidPrice : null; - const mid = q ? (q.askPrice + q.bidPrice) / 2 : null; - - const depthRow = (lvl, side) => - `
${fmtPrice(lvl.price)}${fmtNum(lvl.size, 4)}
`; - const bookHtml = book && (book.bids?.length || book.asks?.length) - ? `

Order book

-
-
Bids
${(book.bids || []).slice(0, 8).map((l) => depthRow(l, "bid")).join("")}
-
Asks
${(book.asks || []).slice(0, 8).map((l) => depthRow(l, "ask")).join("")}
-
-

Top of book at ${esc(String(book.timestamp || "").slice(11, 19))} UTC. Never cached — a stale book is worse than none.

-
` - : ""; - - $("#detail-panel").innerHTML = ` - -
-
- ${esc(d.symbol)} - crypto -
${esc(d.name || "")} · Alpaca US crypto venue
-
-
-
${fmtPrice(price)}
-
${chg != null ? `${chg.percent >= 0 ? "+" : ""}${chg.percent.toFixed(2)}% · ` : ""}live · 24/7
-
-
- -
-
-
CandlesSMA20SMA50Bollinger 20/2SupportResistance
-
-
-
-
RSI(14)oversold 30 · overbought 70
-
-
-
-
MACDSignal12 / 26 / 9
-
- -

Market

-
- ${kv("Bid", q ? fmtPrice(q.bidPrice) : "—")} - ${kv("Ask", q ? fmtPrice(q.askPrice) : "—")} - ${kv("Mid", mid != null ? fmtPrice(mid) : "—")} - ${kv("Spread", spread != null && mid ? `${((spread / mid) * 10000).toFixed(1)} bps` : "—")} - ${kv("Day high", s.dailyBar ? fmtPrice(s.dailyBar.high) : "—")} - ${kv("Day low", s.dailyBar ? fmtPrice(s.dailyBar.low) : "—")} - ${kv("Prev close", s.prevDailyBar ? fmtPrice(s.prevDailyBar.close) : "—")} - ${kv("Venue volume", fmtNum(s.dailyBar?.volume, 4))} -
-
- -

Technical

-
- ${kv("Trend", esc(t.trend || "—"), t.trend === "bullish" ? "pos" : t.trend === "bearish" ? "neg" : "")} - ${kv("Tech score", d.technicalScore?.score != null ? d.technicalScore.score + "/100" : "—")} - ${kv("RSI(14)", fmtNum(t.rsi14, 1))} - ${kv("SMA 20/50/200", `${fmtNum(t.sma?.[20])} / ${fmtNum(t.sma?.[50])} / ${fmtNum(t.sma?.[200])}`)} - ${kv("MACD", fmtNum(t.macd?.macd, 3))} - ${kv("ATR(14)", fmtNum(t.atr14, 3))} - ${kv("Mom 20/60/120d", `${fmtNum(t.momentum?.[20], 1)}% / ${fmtNum(t.momentum?.[60], 1)}% / ${fmtNum(t.momentum?.[120], 1)}%`)} - ${kv("From 52w high", t.distanceFrom52WeekHigh != null ? fmtNum(t.distanceFrom52WeekHigh, 1) + "%" : "—")} - ${kv("Golden cross", t.goldenCross ? "yes" : "no", t.goldenCross ? "pos" : "")} - ${kv("Volatility", esc(t.volatilityRegime || "—"))} -
-
- - ${bookHtml} - - ${(d.caveats || []).length - ? `

How to read this

-
    ${d.caveats.map((c) => `
  • ${esc(c)}
  • `).join("")}
-
` - : ""} - -

Prices: Alpaca US crypto venue (real-time, 24/7). Indicators computed locally from daily bars. A digital asset has no issuer filings, so there is no SEC section here.

-
${esc(d.disclaimer || "")}
`; - - mountCharts(bars); -} - -async function openCryptoPair(pair) { - if (!pair) return; - const modal = $("#detail"); - modal.classList.remove("hidden"); - $("#detail-panel").innerHTML = `

Loading ${esc(pair)}…

`; - document.body.style.overflow = "hidden"; - try { - // Report and bars are separate calls; the order book is allowed to fail on - // its own without taking the whole panel down with it. - const [d, barsRes, book] = await Promise.all([ - api(`/crypto/report?symbol=${encodeURIComponent(pair)}`), - api(`/crypto/bars?symbol=${encodeURIComponent(pair)}&timeframe=1Day&limit=400`), - api(`/crypto/orderbook?symbol=${encodeURIComponent(pair)}&depth=8`).catch(() => null), - ]); - const rows = (barsRes.bars || {})[d.symbol] || []; - renderCryptoDetail(d, toChartBars(rows), book?.orderbooks?.[0] || null); - } catch (e) { - $("#detail-panel").innerHTML = `
Failed to load ${esc(pair)} (${esc(e.message)}).
`; - } -} - /* Same widget as the ticker boxes, pointed at the crypto directory. A bare "BTC" is NOT treated as already-a-symbol: it goes through lookup so the dropdown can offer BTC/USD, BTC/USDT and BTC/USDC rather than guessing. */ diff --git a/src/crypto/page.ts b/src/crypto/page.ts index 4ea7e0a..96a5395 100644 --- a/src/crypto/page.ts +++ b/src/crypto/page.ts @@ -15,7 +15,8 @@ import { CRYPTO_DISCLAIMER } from "../compliance.ts"; 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 { CryptoSnapshot } from "./client.ts"; +import type { CryptoOrderbook, CryptoSnapshot } from "./client.ts"; +import type { CryptoPerformance } from "./performance.ts"; import type { CryptoPair } from "./pairs.ts"; import type { MarketBar, TechnicalIndicatorSet, TechnicalScore } from "../types.ts"; @@ -48,6 +49,9 @@ export interface CryptoPageData { technical?: TechnicalIndicatorSet; technicalScore?: TechnicalScore; analysis: CryptoAnalysis | null; + performance?: CryptoPerformance; + /** Top of book, when the upstream returned one. */ + orderbook?: CryptoOrderbook; caveats: readonly string[]; fetchedAt: string; /** Set when market data could not be reached, so the page can say so. */ @@ -59,6 +63,52 @@ export interface CryptoPageOptions { now?: Date; } +const signed = (n: number, dp = 2) => `${n >= 0 ? "+" : ""}${n.toFixed(dp)}%`; + +/** Multi-period performance — what the pair has been doing, not just its spread. */ +function performanceSection(p: CryptoPerformance | undefined, quote: string): string { + if (!p) return ""; + const cells = p.changes + .map((c) => + kv( + c.label, + c.percent == null ? "—" : signed(c.percent), + c.percent == null ? "" : c.percent >= 0 ? "pos" : "neg", + ), + ) + .join(""); + const thin = p.changes.some((c) => c.percent == null); + return `
+

Performance

+
${cells}
+
+ ${kv("52-week high", `${cryptoMoney(p.high52)}${p.high52At ? ` ${e(p.high52At)}` : ""}`)} + ${kv("52-week low", `${cryptoMoney(p.low52)}${p.low52At ? ` ${e(p.low52At)}` : ""}`)} + ${kv(`Session volume (${e(quote)})`, p.volumeQuote == null ? "—" : cryptoMoney(p.volumeQuote))} + ${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.

+
`; +} + +/** Top of book, the one thing the old in-app modal had that the page did not. */ +function orderbookSection(ob: CryptoOrderbook | undefined): string { + if (!ob || (!ob.bids?.length && !ob.asks?.length)) return ""; + const side = (levels: Array<{ price: number; size: number }>, cls: string) => + levels.slice(0, 8) + .map((l) => `
${cryptoMoney(l.price)}${num(l.size, 4)}
`) + .join(""); + return `
+

Order book

+
+
Bids
${side(ob.bids ?? [], "bid")}
+
Asks
${side(ob.asks ?? [], "ask")}
+
+

Top of book as of ${e(String(ob.timestamp ?? "").slice(11, 19))} UTC. A book moves continuously — this one is as of page load, not live.

+
`; +} + function analysisSection(a: CryptoAnalysis | null): string { if (!a) { // An empty Analysis heading reads as a broken feature; saying why it is @@ -122,7 +172,7 @@ export function renderCryptoPage(data: CryptoPageData, opts: CryptoPageOptions): Fetched ${e(absoluteTime(data.fetchedAt))}. Rendered live on request — crypto has no market close, so there is no daily snapshot to store. - Open the interactive chart ↗ + Back to the crypto grid ↗

${data.marketError ? `

Market data was unavailable for part of this page (${e(data.marketError)}).

` : ""} @@ -144,8 +194,12 @@ export function renderCryptoPage(data: CryptoPageData, opts: CryptoPageOptions): + ${performanceSection(data.performance, pair.quote)} + ${analysisSection(analysis)} + ${orderbookSection(data.orderbook)} +

Technical

diff --git a/src/crypto/performance.ts b/src/crypto/performance.ts new file mode 100644 index 0000000..6677e43 --- /dev/null +++ b/src/crypto/performance.ts @@ -0,0 +1,93 @@ +/** + * Multi-period price performance, computed from the daily bars already fetched + * for the indicators — no extra upstream call, no second vendor. + * + * The pair page previously showed only the session's numbers (bid, ask, day + * high/low, previous close). That answers "what is it now" but not "what has it + * been doing", which is most of what someone means by pricing information on a + * 24/7 asset. + * + * Deliberately NOT here: market capitalisation, circulating supply and + * all-time high. Alpaca's market-data API does not carry them, and deriving + * them would mean either inventing a supply figure or adding a second vendor + * with its own provenance. An absent field is better than a wrong one. + */ +import type { MarketBar } from "../types.ts"; + +export interface PeriodChange { + label: string; + /** Calendar days back. */ + days: number; + percent: number | null; + /** The close this was measured against, so the number is checkable. */ + from: number | null; +} + +export interface CryptoPerformance { + changes: PeriodChange[]; + high52: number | null; + low52: number | null; + high52At: string | null; + low52At: string | null; + /** Venue volume over the last session, in quote currency. */ + volumeQuote: number | null; + /** How many daily bars backed this, so a thin history is visible. */ + barCount: number; +} + +const PERIODS: Array<{ label: string; days: number }> = [ + { label: "24h", days: 1 }, + { label: "7d", days: 7 }, + { label: "30d", days: 30 }, + { label: "90d", days: 90 }, + { label: "1y", days: 365 }, +]; + +/** + * `bars` must be chronological. A period longer than the available history + * yields null rather than silently measuring from the oldest bar — "+400% + * over 1y" computed from four months of data is a fabrication. + */ +export function computePerformance(bars: MarketBar[]): CryptoPerformance { + const usable = bars.filter((b) => Number.isFinite(b.close)); + const last = usable.at(-1); + if (!last) { + return { + changes: PERIODS.map((p) => ({ ...p, percent: null, from: null })), + high52: null, low52: null, high52At: null, low52At: null, + volumeQuote: null, barCount: 0, + }; + } + + const changes = PERIODS.map(({ label, days }) => { + // Index arithmetic would assume one bar per calendar day; crypto has no + // market close, but a gap in the feed would still skew it. Seek by date. + const cutoff = Date.parse(last.timestamp) - days * 86_400_000; + const prior = [...usable].reverse().find((b) => Date.parse(b.timestamp) <= cutoff); + if (!prior || !prior.close) return { label, days, percent: null, from: null }; + return { + label, + days, + percent: ((last.close - prior.close) / prior.close) * 100, + from: prior.close, + }; + }); + + const window52 = usable.slice(-365); + let high: MarketBar | undefined; + let low: MarketBar | undefined; + for (const b of window52) { + if (!high || b.high > high.high) high = b; + if (!low || b.low < low.low) low = b; + } + + return { + changes, + high52: high?.high ?? null, + low52: low?.low ?? null, + high52At: high?.timestamp?.slice(0, 10) ?? null, + low52At: low?.timestamp?.slice(0, 10) ?? null, + volumeQuote: last.volume != null && last.close != null ? last.volume * last.close : null, + barCount: usable.length, + }; +} diff --git a/src/crypto/routes.ts b/src/crypto/routes.ts index 0ced475..bededbf 100644 --- a/src/crypto/routes.ts +++ b/src/crypto/routes.ts @@ -24,6 +24,7 @@ import { calculateIndicators, scoreTechnicalSetup } from "../technical/indicator 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 { renderCryptoIndexPage, renderCryptoPage, renderMissingCryptoPage } from "./page.ts"; import { SUPPORTED_PAIRS, getPair, lookupPairs, normalizePair, normalizePairs } from "./pairs.ts"; @@ -148,6 +149,27 @@ export async function handleCryptoRoute( * Tolerant of a trailing slash. Order matters: "/api/crypto" must be tested * first, or it would be mistaken for a pair named "crypto" under "/api". */ +/** + * `/?pair=BTC-USD` used to open an in-app modal — a second, weaker view of the + * same pair, with no analysis and a URL nobody could share. There is one + * surface now, so those links redirect to it. + * + * Lives here rather than inline in the server so it can be tested, and so the + * pair grammar stays in one place. Returns null when the path is not the app + * root or the parameter is not a pair we serve. + */ +export function cryptoDeepLinkRedirect( + path: string, + url: URL, + appUrl: string, +): Response | null { + if (path !== "/" && path !== "") return null; + if (!url.searchParams.has("pair")) return null; + const symbol = normalizePair(url.searchParams.get("pair")); + if (!symbol) return null; + return Response.redirect(`${appUrl.replace(/\/$/, "")}/crypto/${getPair(symbol)!.slug}`, 301); +} + /** * Does this route render HTML? Only the directory and a pair, and only under * the bare prefix — every named endpoint answers JSON under either prefix. @@ -561,9 +583,12 @@ async function pairPage(raw: string, deps: CryptoRouteDeps): Promise { return Response.redirect(`${deps.appUrl.replace(/\/$/, "")}/crypto/${pair.slug}`, 301); } + // 400 days covers the technical windows; a year of history also backs the + // 1-year performance figure. const start = new Date(Date.now() - TECHNICAL_LOOKBACK_DAYS * 86_400_000).toISOString(); let snapshot: Awaited>[number] | undefined; let bars: MarketBar[] = []; + let orderbook: Awaited>[number] | undefined; let marketError: string | undefined; try { const [snaps, rows] = await Promise.all([ @@ -576,6 +601,13 @@ 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 */ + } const technical = bars.length >= 2 ? calculateIndicators(bars, deps.indicators) : undefined; const technicalScore = technical ? scoreTechnicalSetup(technical, 2) : undefined; @@ -589,6 +621,8 @@ async function pairPage(raw: string, deps: CryptoRouteDeps): Promise { technical, technicalScore, analysis: analyzeCrypto(pair.symbol, pair.name, technical, technicalScore), + performance: computePerformance(bars), + orderbook, caveats: SCORE_CAVEATS, fetchedAt: new Date().toISOString(), marketError, diff --git a/src/server.ts b/src/server.ts index 43029f4..4a6a4d5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -37,7 +37,7 @@ import { startDigestScheduler } from "./digest/run.ts"; import { handleReportRoute } from "./reports/routes.ts"; import { loadReport, normalizeSymbol, saveReport } from "./reports/store.ts"; import { handleLookupRoute } from "./symbols/routes.ts"; -import { handleCryptoRoute } from "./crypto/routes.ts"; +import { cryptoDeepLinkRedirect, handleCryptoRoute } from "./crypto/routes.ts"; import { cryptoSitemapEntries } from "./crypto/page.ts"; import { SUPPORTED_PAIRS } from "./crypto/pairs.ts"; import { resolveOne } from "./symbols/lookup.ts"; @@ -415,6 +415,9 @@ const server = Bun.serve({ try { if (p === "/health") return json({ ok: true }); + const pairRedirect = cryptoDeepLinkRedirect(p, url, config.appUrl); + if (pairRedirect) return pairRedirect; + if (p === "/api" || p === "/api/") { return json({ name: "advis0r.com API", diff --git a/test/crypto-page.test.ts b/test/crypto-page.test.ts index 6af673e..a27e49b 100644 --- a/test/crypto-page.test.ts +++ b/test/crypto-page.test.ts @@ -179,6 +179,56 @@ describe("crypto page", () => { expect(render()).toContain('href="/api/crypto/BTC-USD"'); }); + test("shows multi-period performance, which is what the modal never had", () => { + const page = render({ + performance: { + changes: [ + { label: "24h", days: 1, percent: 1.5, from: 63300 }, + { label: "7d", days: 7, percent: -4.25, from: 67000 }, + { label: "1y", days: 365, percent: null, from: null }, + ], + high52: 124720.32, low52: 58531.14, + high52At: "2026-01-04", low52At: "2026-06-02", + volumeQuote: 987654.3, barCount: 120, + }, + }); + expect(page).toContain("Performance"); + expect(page).toContain("+1.50%"); + expect(page).toContain("-4.25%"); + expect(page).toContain("$124,720.32"); + 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"); + }); + + test("shows the order book, the other thing only the modal had", () => { + const page = render({ + orderbook: { + symbol: "BTC/USD", timestamp: "2026-08-06T13:00:00Z", + bids: Array.from({ length: 12 }, (_, i) => ({ price: 64200 - i, size: 0.5 })), + asks: Array.from({ length: 12 }, (_, i) => ({ price: 64300 + i, size: 0.5 })), + }, + }); + expect(page).toContain("Order book"); + expect(page).toContain("$64,200.00"); + // Capped at 8 a side, as the modal was. + expect((page.match(/ob-row bid/g) ?? []).length).toBe(8); + expect((page.match(/ob-row ask/g) ?? []).length).toBe(8); + expect(page).toContain("as of page load, not live"); + }); + + test("omits the order book entirely when the upstream gave none", () => { + // An empty two-column grid reads as "no liquidity", which is a claim. + expect(render({ orderbook: undefined })).not.toContain("Order book"); + }); + + 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="); + }); + test("a market failure degrades the page instead of replacing it", () => { const page = render({ marketError: "alpaca timeout" }); expect(page).toContain("alpaca timeout"); diff --git a/test/crypto-performance.test.ts b/test/crypto-performance.test.ts new file mode 100644 index 0000000..396b48e --- /dev/null +++ b/test/crypto-performance.test.ts @@ -0,0 +1,101 @@ +/** + * Multi-period performance. + * + * The interesting cases are all about refusing to answer. A 1-year change + * computed from four months of bars, or a 24h change measured from the oldest + * bar available because nothing newer matched, are both fabrications that look + * exactly like real figures. + */ +import { describe, expect, test } from "bun:test"; +import { computePerformance } from "../src/crypto/performance.ts"; +import type { MarketBar } from "../src/types.ts"; + +/** `n` daily bars ending today, closing at `f(i)`. */ +function series(n: number, f: (i: number) => number): MarketBar[] { + const end = Date.UTC(2026, 7, 6); + return Array.from({ length: n }, (_, i) => { + const close = f(i); + return { + symbol: "BTC/USD", + timestamp: new Date(end - (n - 1 - i) * 86_400_000).toISOString(), + open: close, high: close + 1, low: close - 1, close, + volume: 10, vwap: close, + timeframe: "1Day" as const, adjustment: "raw" as const, + }; + }); +} + +const pick = (p: ReturnType, label: string) => + p.changes.find((c) => c.label === label)!; + +describe("period changes", () => { + test("computes a change against the close that many days back", () => { + // Flat 100 for a year, then today at 110 => +10% over every window. + const bars = series(400, (i) => (i === 399 ? 110 : 100)); + const p = computePerformance(bars); + for (const label of ["24h", "7d", "30d", "90d", "1y"]) { + expect(pick(p, label).percent).toBeCloseTo(10, 6); + expect(pick(p, label).from).toBe(100); + } + }); + + test("a period longer than the history is null, not measured from the oldest bar", () => { + // 100 days only: "+x% over 1y" would be a claim about time we cannot see. + const p = computePerformance(series(100, (i) => 100 + i)); + expect(pick(p, "30d").percent).not.toBeNull(); + expect(pick(p, "1y").percent).toBeNull(); + expect(pick(p, "1y").from).toBeNull(); + }); + + test("a falling series reports negative changes", () => { + const p = computePerformance(series(400, (i) => 500 - i)); + expect(pick(p, "24h").percent).toBeLessThan(0); + expect(pick(p, "1y").percent).toBeLessThan(0); + }); + + test("no bars at all yields nulls rather than zeros", () => { + // Zero would read as "unchanged", which is a different claim from "unknown". + const p = computePerformance([]); + expect(p.barCount).toBe(0); + expect(p.changes.every((c) => c.percent === null)).toBe(true); + expect(p.high52).toBeNull(); + expect(p.volumeQuote).toBeNull(); + }); + + test("a gap in the feed does not shift the window", () => { + // Index arithmetic would treat 30 bars back as 30 days; these are 2 apart. + const end = Date.UTC(2026, 7, 6); + const bars: MarketBar[] = Array.from({ length: 40 }, (_, i) => { + const close = 100 + i; + return { + symbol: "BTC/USD", + timestamp: new Date(end - (39 - i) * 2 * 86_400_000).toISOString(), + open: close, high: close, low: close, close, + volume: 1, timeframe: "1Day" as const, adjustment: "raw" as const, + }; + }); + const p = computePerformance(bars); + // 7 days back is ~3.5 bars, so it must not resolve to 7 bars back (=132). + expect(pick(p, "7d").from).toBeGreaterThan(132); + }); +}); + +describe("52-week range and volume", () => { + test("reports the extremes with the dates they occurred", () => { + const bars = series(300, (i) => (i === 10 ? 10 : i === 200 ? 900 : 100)); + const p = computePerformance(bars); + expect(p.high52).toBe(901); // high = close + 1 + expect(p.low52).toBe(9); // low = close - 1 + expect(p.high52At).toBe(bars[200]!.timestamp.slice(0, 10)); + expect(p.low52At).toBe(bars[10]!.timestamp.slice(0, 10)); + }); + + test("session volume is converted to the quote currency", () => { + const p = computePerformance(series(5, () => 200)); + expect(p.volumeQuote).toBe(10 * 200); + }); + + test("the bar count is reported so thin history is visible", () => { + expect(computePerformance(series(12, () => 100)).barCount).toBe(12); + }); +}); diff --git a/test/crypto.test.ts b/test/crypto.test.ts index a0b6e7a..636f97f 100644 --- a/test/crypto.test.ts +++ b/test/crypto.test.ts @@ -12,7 +12,7 @@ * The client is faked throughout — these tests never touch the network. */ import { describe, expect, test } from "bun:test"; -import { handleCryptoRoute, resetAssetsCache } from "../src/crypto/routes.ts"; +import { cryptoDeepLinkRedirect, handleCryptoRoute, resetAssetsCache } from "../src/crypto/routes.ts"; import { DEFAULT_QUOTE, SUPPORTED_PAIRS, @@ -371,6 +371,33 @@ describe("errors", () => { }); }); +describe("the old in-app deep link", () => { + const redirect = (path: string, query = "") => + cryptoDeepLinkRedirect(path, new URL(`https://advis0r.com${path}${query}`), "https://advis0r.com"); + + test("/?pair=SOL-USD lands on the pair's page", async () => { + // This URL opened a modal with no analysis and nothing to share. It is a + // permanent redirect now, so links already sent to people still work. + const res = redirect("/", "?pair=SOL-USD"); + expect(res!.status).toBe(301); + expect(res!.headers.get("location")).toBe("https://advis0r.com/crypto/SOL-USD"); + }); + + test("it accepts the same spellings every other route does", async () => { + for (const q of ["?pair=btc", "?pair=BTC-USD", "?pair=BTC/USD", "?pair=btcusd"]) { + expect(redirect("/", q)!.headers.get("location")).toBe("https://advis0r.com/crypto/BTC-USD"); + } + }); + + test("it leaves everything else alone", async () => { + // An unknown pair must fall through to the app rather than 301 into a 404. + expect(redirect("/", "?pair=nonsense")).toBeNull(); + expect(redirect("/", "?ticker=NVDA")).toBeNull(); + expect(redirect("/")).toBeNull(); + expect(redirect("/crypto", "?pair=BTC-USD")).toBeNull(); + }); +}); + describe("compliance", () => { test("every JSON response carries the crypto disclaimer", async () => { for (const path of [ diff --git a/test/dashboard-crypto.test.ts b/test/dashboard-crypto.test.ts index f98ff06..ccb83c2 100644 --- a/test/dashboard-crypto.test.ts +++ b/test/dashboard-crypto.test.ts @@ -4,10 +4,12 @@ * * This is the project's first frontend test, and it exists because the crypto * tab reuses the stock side's machinery rather than copying it: `attachLookup` - * now serves both the ticker boxes and the crypto picker, and the crypto modal - * mounts the same charts. That reuse is the right call, but it means a change - * made for one surface can silently break the other — so the equity lookup is - * asserted here too, not just the crypto path. + * now serves both the ticker boxes and the crypto picker. That reuse is the + * right call, but it means a change made for one surface can silently break the + * other — so the equity lookup is asserted here too, not just the crypto path. + * + * There is no crypto modal any more: a pair is a page, so the grid's job is to + * link to it correctly. * * Hermetic on purpose. Every request is answered from the fixtures below, so * the suite never needs a server, a database, or Alpaca credentials, and it @@ -254,48 +256,6 @@ describe("crypto tab", () => { }); }); -/** - * The modal is no longer how you reach a pair — cards link to pages now. It - * survives as the in-app interactive chart, reached from the page via - * "Open the interactive chart" (/?pair=BTC-USD), so it is still worth testing. - */ -describe("crypto interactive view", () => { - beforeEach(async () => { - // Reached the way a person reaches it: the link on the rendered page. - await loadPage("?pair=BTC-USD"); - await sleep(350); - }); - - test("opens and finishes loading", () => { - expect($("#detail").classList.contains("hidden")).toBe(false); - expect(text("#detail-panel")).not.toContain("Loading BTC/USD"); - expect(text("#detail-panel")).toContain("BTC/USD"); - expect(text("#detail-panel")).toContain("Bitcoin"); - }); - - test("shows market, technicals and the order book", () => { - const panel = text("#detail-panel"); - expect(panel).toContain("Spread"); - expect(panel).toContain("RSI(14)"); - expect(panel).toContain("Order book"); - // depth=8 is requested and enforced in the render, so a 12-deep book trims. - expect($$("#detail-panel .ob-row.bid").length).toBe(8); - expect($$("#detail-panel .ob-row.ask").length).toBe(8); - }); - - test("carries the venue-volume caveat and the crypto disclaimer", () => { - const panel = text("#detail-panel"); - // Publishing a liquidity score without this note invites it to be read as - // illiquidity, when it only reflects Alpaca's own venue. - expect(panel).toContain("US crypto venue alone"); - expect(panel).toContain("circuit breakers"); - }); - - test("omits the SEC block, which cannot exist for a digital asset", () => { - expect(text("#detail-panel")).not.toContain("Fundamentals (SEC)"); - }); -}); - describe("crypto lookup", () => { test("typing a name offers pairs", async () => { type($("#cx-find"), "bitcoin"); @@ -360,27 +320,3 @@ describe("equity surfaces still work after the shared-lookup refactor", () => { }); }); -describe("deep links", () => { - test("?pair=BTC-USD opens that pair on the crypto tab", async () => { - win?.close(); - const prev = respond; - dom = new JSDOM(read("index.html"), { - url: "http://localhost/?pair=BTC-USD", - runScripts: "outside-only", - pretendToBeVisual: true, - }); - win = dom.window as any; - win.LightweightCharts = null; - win.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }; - win.alert = () => {}; - win.fetch = async (input: any) => ({ - ok: true, status: 200, - json: async () => prev(String(input?.url ?? input)), - text: async () => JSON.stringify(prev(String(input?.url ?? input))), - }); - win.eval([read("app.js"), read("auth.js")].join("\n;\n")); - await sleep(400); - expect($('.view[data-view="crypto"]').classList.contains("active")).toBe(true); - expect(text("#detail-panel")).toContain("BTC/USD"); - }); -});