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: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,18 @@ signed when the keys are present and unsigned when they are not. That is why
there is no Yahoo-style fallback on this path — the primary source degrades to
itself rather than to a second vendor with different provenance.

In the web dashboard this is the **Crypto** tab: a live grid of the majors, a
name-or-symbol picker, and an opt-in 30s auto-refresh that only ticks while that
tab is actually on screen. Prices are fetched when the tab is first opened
In the web dashboard this is the **Crypto** tab: a live grid of the majors with
24h/7d sparklines, a name-or-symbol picker, and an opt-in 30s auto-refresh that
only ticks while that tab is actually on screen.

The sparklines have their own endpoint rather than reusing `/crypto/bars`:
twelve cards do not need thousands of OHLCV objects to draw twelve lines a
couple of hundred pixels wide, and Alpaca'''s multi-symbol bars endpoint
paginates, so one grid load is several upstream requests. The series is
downsampled server-side (24 points for 24h, 56 for 7d) and cached as a unit, so
a whole grid costs one set of requests per minute rather than one per visitor —
14KB on the wire for all twelve. A pair without enough history is drawn without
a line rather than as a flat one, and the summary says how many those were. Prices are fetched when the tab is first opened
rather than on boot, so a visitor who never looks at it costs no upstream calls.

### Pages vs JSON
Expand Down Expand Up @@ -207,6 +216,7 @@ as a bug.
| `GET /crypto/quote?symbol=BTC/USD` | latest trade/quote with spread and spread in basis points |
| `GET /crypto/bars?symbol=&timeframe=&start=&end=&limit=` | historical OHLCV (`1Min`…`1Week`) |
| `GET /crypto/orderbook?symbol=&depth=` | top of book, both sides |
| `GET /crypto/sparklines?symbols=&period=24h|7d` | compact close-price series for the grid cards |
| `GET /crypto/technicals?symbol=&horizon=1\|2` | locally computed indicators + technical score |
| `GET /crypto/report?symbol=` | snapshot + technicals + score in one call |
| `GET /crypto/<PAIR>` | the same report by path, e.g. `/crypto/BTC-USD` |
Expand Down
91 changes: 87 additions & 4 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1493,6 +1493,17 @@ const CRYPTO_GRID_PAIRS = [
];

const CRYPTO_REFRESH_MS = 30_000;

/** Sparkline window shown on the cards. Persisted so it survives a reload. */
let cryptoSparkPeriod = (() => {
try {
const saved = localStorage.getItem("cx-spark-period");
return saved === "7d" || saved === "24h" ? saved : "24h";
} catch {
// Private mode and blocked storage both throw; the default is fine.
return "24h";
}
})();
let cryptoTimer = null;
let cryptoLoading = false;

Expand All @@ -1510,14 +1521,44 @@ function fmtPrice(n) {
return "$" + Number(n).toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp });
}

function cryptoCard(s) {
/**
* Inline SVG sparkline for a card. Returns "" for fewer than two points rather
* than drawing a flat line, which would imply a price we never observed.
*
* viewBox coordinates with preserveAspectRatio="none" so one path stretches to
* whatever width the card ends up — no measuring, no redraw on resize.
*/
function cryptoSparkSvg(points, rising) {
if (!Array.isArray(points) || points.length < 2) return "";
const w = 100;
const h = 28;
const min = Math.min(...points);
const max = Math.max(...points);
// A perfectly flat series has no range to scale against; draw it mid-height.
const span = max - min || 1;
const x = (i) => (i / (points.length - 1)) * w;
const y = (v) => h - 1 - ((v - min) / span) * (h - 2);
const line = points.map((v, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join("");
const stroke = rising ? "var(--pos)" : "var(--neg)";
return `<svg class="cx-spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<path d="${line}L${w},${h}L0,${h}Z" fill="${stroke}" fill-opacity=".12"/>
<path d="${line}" fill="none" stroke="${stroke}" stroke-width="1.5"
vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round"/>
</svg>`;
}

function cryptoCard(s, spark) {
const chg = s.change;
const dir = chg == null ? "" : chg.percent >= 0 ? "positive" : "negative";
const price = s.latestTrade?.price ?? s.dailyBar?.close;
const q = s.latestQuote;
const spreadBps = q && q.askPrice && q.bidPrice
? ((q.askPrice - q.bidPrice) / ((q.askPrice + q.bidPrice) / 2)) * 10000
: null;
// The sparkline is coloured by its OWN period's direction, which is not
// always the session's: a pair can be down today inside a rising week, and
// painting the 7d line red because the day was red would misreport it.
const sparkRising = spark?.changePercent == null ? chg == null || chg.percent >= 0 : spark.changePercent >= 0;
// An anchor, not a button: this has to be shareable, middle-clickable and
// crawlable. The destination renders server-side, so it works before this
// script has run at all.
Expand All @@ -1528,9 +1569,13 @@ function cryptoCard(s) {
<span class="cx-name">${esc(s.name || "")}</span>
</div>
<div class="cx-price">${fmtPrice(price)}</div>
${cryptoSparkSvg(spark?.points, sparkRising)}
<div class="cx-sub">
${chg == null ? '<span class="cx-dim">—</span>'
: `<span class="sig-dir ${dir}">${chg.percent >= 0 ? "+" : ""}${chg.percent.toFixed(2)}%</span>`}
${spark?.changePercent != null
? `<span class="cx-sparkchg ${spark.changePercent >= 0 ? "pos" : "neg"}" title="Change over the selected period">${spark.changePercent >= 0 ? "+" : ""}${spark.changePercent.toFixed(1)}%</span>`
: ""}
${spreadBps != null ? `<span class="cx-spread" title="Bid/ask spread">${spreadBps.toFixed(1)} bps</span>` : ""}
</div>
</a>`;
Expand All @@ -1544,15 +1589,28 @@ async function loadCryptoGrid() {
const summary = $("#cx-summary");
if (!grid) { cryptoLoading = false; return; }
if (!grid.dataset.loaded) grid.innerHTML = `<div class="spinner"></div>`;
const symbols = encodeURIComponent(CRYPTO_GRID_PAIRS.join(","));
try {
const d = await api(`/crypto/snapshot?symbols=${encodeURIComponent(CRYPTO_GRID_PAIRS.join(","))}`);
// Prices are the point of the grid; the sparklines are decoration on top.
// Fetched together, but the chart request is allowed to fail on its own —
// losing the lines is not a reason to lose the prices.
const [d, sparkRes] = await Promise.all([
api(`/crypto/snapshot?symbols=${symbols}`),
api(`/crypto/sparklines?symbols=${symbols}&period=${cryptoSparkPeriod}`).catch(() => null),
]);
const series = sparkRes?.series ?? {};
const rows = (d.snapshots || []).filter((s) => s.latestTrade || s.dailyBar);
grid.innerHTML = rows.length
? rows.map(cryptoCard).join("")
? rows.map((s) => cryptoCard(s, series[s.symbol])).join("")
: `<p class="empty">No crypto prices available right now.</p>`;
grid.dataset.loaded = "1";
if (summary) {
summary.textContent = `${rows.length} pairs · Alpaca US crypto venue · updated ${new Date().toLocaleTimeString()}`;
const charted = rows.filter((s) => series[s.symbol]).length;
summary.textContent =
`${rows.length} pairs · Alpaca US crypto venue · updated ${new Date().toLocaleTimeString()}` +
// Say when the lines are missing rather than leaving bare cards that
// read as a rendering bug.
(charted === rows.length ? "" : ` · ${rows.length - charted} without ${cryptoSparkPeriod} history`);
}
} catch (e) {
// Never blank an already-painted grid on a refresh failure — a transient
Expand Down Expand Up @@ -1591,3 +1649,28 @@ attachLookup(

$("#cx-refresh")?.addEventListener("click", loadCryptoGrid);
$("#cx-auto")?.addEventListener("change", (e) => setCryptoAuto(e.target.checked));

/** Reflect the active sparkline window in the toggle. */
function paintCryptoPeriod() {
$$("#cx-period-group button, .cx-period button").forEach((b) =>
b.classList.toggle("on", b.dataset.period === cryptoSparkPeriod),
);
}

$(".cx-period")?.addEventListener("click", (e) => {
const b = e.target.closest("[data-period]");
if (!b || b.dataset.period === cryptoSparkPeriod) return;
cryptoSparkPeriod = b.dataset.period;
try {
localStorage.setItem("cx-spark-period", cryptoSparkPeriod);
} catch {
/* not worth failing the interaction over */
}
paintCryptoPeriod();
// Force a repaint even though prices have not changed: the cached grid was
// drawn for the other window.
const grid = $("#cx-grid");
if (grid) delete grid.dataset.loaded;
loadCryptoGrid();
});
paintCryptoPeriod();
4 changes: 4 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@
<div class="lookup-results" id="cx-find-results" role="listbox" hidden></div>
</div>
<button id="cx-refresh" class="primary">Refresh</button>
<div class="cx-period" role="group" aria-label="Sparkline period">
<button type="button" data-period="24h" class="on">24h</button>
<button type="button" data-period="7d">7d</button>
</div>
<label class="cx-live" title="Crypto trades 24/7 — refresh the grid every 30s while this tab is open">
<input type="checkbox" id="cx-auto" /> <span>Auto</span>
</label>
Expand Down
19 changes: 19 additions & 0 deletions public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,25 @@ details.evidence .ev { font-size: 12.5px; color: var(--dim); border-left: 2px so
.cx-quote { font-family: var(--mono); font-size: 11px; color: var(--dim); }
.cx-name { color: var(--dim); font-size: 11.5px; margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.cx-price { font-family: var(--mono); font-size: 21px; font-weight: 800; margin: 8px 0 4px; letter-spacing: -.01em; }

/* Sparkline. Stretches to the card's width via preserveAspectRatio="none";
the stroke stays 1.5px because of vector-effect, so the horizontal scaling
does not smear the line. */
.cx-spark { display: block; width: 100%; height: 28px; margin: 2px 0 6px; overflow: visible; }
.cx-sparkchg { font-size: 11px; opacity: .85; }
.cx-sparkchg.pos { color: var(--pos); }
.cx-sparkchg.neg { color: var(--neg); }

/* 24h / 7d toggle */
.cx-period { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); }
.cx-period button {
background: transparent; border: 0; color: var(--dim); cursor: pointer;
font: inherit; font-size: 13px; font-weight: 600;
padding: 5px 12px; border-radius: 999px; transition: background .15s, color .15s;
}
.cx-period button:hover { color: var(--text); }
.cx-period button.on { background: var(--accent); color: var(--bg); }
.cx-period button:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 1px; }
.cx-sub { display: flex; align-items: center; gap: 8px; font-family: var(--mono); font-size: 12px; }
.cx-spread { color: var(--dim); font-size: 11px; margin-left: auto; }
.cx-dim { color: var(--dim); }
Expand Down
46 changes: 46 additions & 0 deletions src/crypto/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ 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 { SPARK_PERIODS, SparklineService, isSparkPeriod } from "./sparkline.ts";
import type { CryptoFundamentalsClient } from "./fundamentals.ts";
import { renderCryptoIndexPage, renderCryptoPage, renderMissingCryptoPage } from "./page.ts";
import { SUPPORTED_PAIRS, getPair, lookupPairs, normalizePair, normalizePairs } from "./pairs.ts";
Expand Down Expand Up @@ -68,6 +69,7 @@ const RESERVED = new Set([
"orderbooks",
"technicals",
"report",
"sparklines",
]);

export interface CryptoRouteDeps {
Expand All @@ -77,6 +79,8 @@ export interface CryptoRouteDeps {
appUrl: string;
/** Market cap / supply. Optional: the pages render without it. */
fundamentals?: CryptoFundamentalsClient;
/** Compact price series for the grid cards. Optional. */
sparklines?: SparklineService;
}

/**
Expand Down Expand Up @@ -120,6 +124,8 @@ export async function handleCryptoRoute(
return await orderbook(url, deps);
case "technicals":
return await technicals(url, deps);
case "sparklines":
return await sparklineRoute(url, deps);
case "report":
return await report(url.searchParams.get("symbol"), url, deps);
}
Expand Down Expand Up @@ -222,6 +228,8 @@ function index(deps: CryptoRouteDeps): Response {
"GET /crypto/bars?symbol=&timeframe=&start=&end=&limit=":
`historical OHLCV; timeframe one of ${VALID_TIMEFRAMES.join(", ")}`,
"GET /crypto/orderbook?symbol=&depth=": "top of book, both sides",
"GET /crypto/sparklines?symbols=&period=24h|7d":
"compact close-price series for drawing sparklines",
"GET /crypto/technicals?symbol=&horizon=1|2":
"locally computed SMA/EMA/RSI/MACD/Bollinger/ATR + technical score",
"GET /crypto/report?symbol=": "snapshot + technicals + score in one call",
Expand Down Expand Up @@ -485,6 +493,44 @@ async function orderbook(url: URL, deps: CryptoRouteDeps): Promise<Response> {
);
}

/**
* Compact close-price series, one per pair, for the grid cards.
*
* Deliberately not a variant of /crypto/bars: that returns full OHLCV and
* paginates, and twelve cards do not need thousands of bar objects to draw
* twelve lines a couple of hundred pixels wide.
*/
async function sparklineRoute(url: URL, deps: CryptoRouteDeps): Promise<Response> {
if (!deps.sparklines) return json({ error: "sparklines unavailable" }, 503);
const b = basket(url);
if ("error" in b) return b.error;

const requested = url.searchParams.get("period") ?? "24h";
if (!isSparkPeriod(requested)) {
return json({ error: `invalid period "${requested}"`, valid: SPARK_PERIODS }, 400);
}

const series = await deps.sparklines.get(b.pairs, requested);
return json(
{
period: requested,
// Only pairs with a drawable line appear. A card omits its chart rather
// than drawing a flat line from a single observation.
series: Object.fromEntries(
b.pairs.flatMap((symbol) => {
const s = series.get(symbol);
return s ? [[symbol, s] as const] : [];
}),
),
...rejectedNote(b.rejected),
disclaimer: CRYPTO_DISCLAIMER,
},
200,
// Matches the service's own cache, so an edge hit and a process hit agree.
requested === "24h" ? 60 : 300,
);
}

async function technicals(url: URL, deps: CryptoRouteDeps): Promise<Response> {
const s = single(url.searchParams.get("symbol"));
if ("error" in s) return s.error;
Expand Down
Loading
Loading