Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand Down
124 changes: 0 additions & 124 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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) =>
`<div class="ob-row ${side}"><span class="ob-p">${fmtPrice(lvl.price)}</span><span class="ob-s">${fmtNum(lvl.size, 4)}</span></div>`;
const bookHtml = book && (book.bids?.length || book.asks?.length)
? `<div class="dl-section"><h3>Order book</h3>
<div class="obgrid">
<div><div class="ob-head">Bids</div>${(book.bids || []).slice(0, 8).map((l) => depthRow(l, "bid")).join("")}</div>
<div><div class="ob-head">Asks</div>${(book.asks || []).slice(0, 8).map((l) => depthRow(l, "ask")).join("")}</div>
</div>
<p class="src-note">Top of book at ${esc(String(book.timestamp || "").slice(11, 19))} UTC. Never cached — a stale book is worse than none.</p>
</div>`
: "";

$("#detail-panel").innerHTML = `
<button class="close-x" data-close aria-label="Close">×</button>
<div class="dl-head">
<div>
<span class="tkr">${esc(d.symbol)}</span>
<span class="badge conservative">crypto</span>
<div class="cname">${esc(d.name || "")} · Alpaca US crypto venue</div>
</div>
<div class="dl-price">
<div class="p">${fmtPrice(price)}</div>
<div class="sub">${chg != null ? `<span class="${chg.percent >= 0 ? "sig-dir positive" : "sig-dir negative"}">${chg.percent >= 0 ? "+" : ""}${chg.percent.toFixed(2)}%</span> · ` : ""}live · 24/7</div>
</div>
</div>

<div class="chartbox">
<div id="lwc-price" class="lwchart"></div>
<div class="legend"><span><i style="background:#22c55e"></i>Candles</span><span><i style="background:#4c8dff"></i>SMA20</span><span><i style="background:#ffb454"></i>SMA50</span><span><i style="background:rgba(150,160,190,.8)"></i>Bollinger 20/2</span><span><i style="background:rgba(34,197,94,.55)"></i>Support</span><span><i style="background:rgba(248,113,113,.55)"></i>Resistance</span></div>
</div>
<div class="chartbox">
<div id="lwc-rsi" class="lwchart rsi"></div>
<div class="legend"><span><i style="background:#c48dff"></i>RSI(14)</span><span>oversold 30 · overbought 70</span></div>
</div>
<div class="chartbox">
<div id="lwc-macd" class="lwchart macd"></div>
<div class="legend"><span><i style="background:#4c8dff"></i>MACD</span><span><i style="background:#ffb454"></i>Signal</span><span>12 / 26 / 9</span></div>
</div>

<div class="dl-section"><h3>Market</h3>
<div class="grid2">
${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))}
</div>
</div>

<div class="dl-section"><h3>Technical</h3>
<div class="grid2">
${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 || "—"))}
</div>
</div>

${bookHtml}

${(d.caveats || []).length
? `<div class="dl-section"><h3>How to read this</h3>
<ul class="cx-caveats">${d.caveats.map((c) => `<li>${esc(c)}</li>`).join("")}</ul>
</div>`
: ""}

<p class="src-note">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.</p>
<div class="disclaimer">${esc(d.disclaimer || "")}</div>`;

mountCharts(bars);
}

async function openCryptoPair(pair) {
if (!pair) return;
const modal = $("#detail");
modal.classList.remove("hidden");
$("#detail-panel").innerHTML = `<button class="close-x" data-close>×</button><div class="spinner"></div><p class="empty">Loading ${esc(pair)}…</p>`;
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 = `<button class="close-x" data-close>×</button><div class="empty">Failed to load ${esc(pair)} (${esc(e.message)}).</div>`;
}
}

/* 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. */
Expand Down
58 changes: 56 additions & 2 deletions src/crypto/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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. */
Expand All @@ -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 `<section class="rp-section">
<h2>Performance</h2>
<dl class="rp-grid">${cells}</dl>
<dl class="rp-grid">
${kv("52-week high", `${cryptoMoney(p.high52)}${p.high52At ? ` <span class="rp-when">${e(p.high52At)}</span>` : ""}`)}
${kv("52-week low", `${cryptoMoney(p.low52)}${p.low52At ? ` <span class="rp-when">${e(p.low52At)}</span>` : ""}`)}
${kv(`Session volume (${e(quote)})`, p.volumeQuote == null ? "—" : cryptoMoney(p.volumeQuote))}
${kv("Daily bars", String(p.barCount))}
</dl>
${thin ? `<p class="rp-note">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.</p>` : ""}
<p class="rp-note">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.</p>
</section>`;
}

/** 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) => `<div class="ob-row ${cls}"><span class="ob-p">${cryptoMoney(l.price)}</span><span class="ob-s">${num(l.size, 4)}</span></div>`)
.join("");
return `<section class="rp-section">
<h2>Order book</h2>
<div class="obgrid">
<div><div class="ob-head">Bids</div>${side(ob.bids ?? [], "bid")}</div>
<div><div class="ob-head">Asks</div>${side(ob.asks ?? [], "ask")}</div>
</div>
<p class="rp-note">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.</p>
</section>`;
}

function analysisSection(a: CryptoAnalysis | null): string {
if (!a) {
// An empty Analysis heading reads as a broken feature; saying why it is
Expand Down Expand Up @@ -122,7 +172,7 @@ export function renderCryptoPage(data: CryptoPageData, opts: CryptoPageOptions):
Fetched <strong>${e(absoluteTime(data.fetchedAt))}</strong>.
Rendered live on request — crypto has no market close, so there is no daily
snapshot to store.
<a class="rp-open" href="/?pair=${e(pair.slug)}">Open the interactive chart ↗</a>
<a class="rp-open" href="/#crypto">Back to the crypto grid ↗</a>
</p>

${data.marketError ? `<p class="rp-note">Market data was unavailable for part of this page (${e(data.marketError)}).</p>` : ""}
Expand All @@ -144,8 +194,12 @@ export function renderCryptoPage(data: CryptoPageData, opts: CryptoPageOptions):
</dl>
</section>

${performanceSection(data.performance, pair.quote)}

${analysisSection(analysis)}

${orderbookSection(data.orderbook)}

<section class="rp-section">
<h2>Technical</h2>
<dl class="rp-grid">
Expand Down
93 changes: 93 additions & 0 deletions src/crypto/performance.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading