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

- -
` - : ""} - -

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"); - }); -});