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) =>
- `
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.
`;
- 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 `
+
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)}).
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");
- });
-});