From 926c585780f194d8e4019e11991b71f6a5e25ff3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 17 Aug 2026 11:26:36 +0000 Subject: [PATCH] Make the watchlist a place, and a dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The saved watchlist was a fragment (/#watchlist) holding a list of links. A fragment cannot be linked to from an email, crawled, or reloaded into, and a list of tickers answers "what did I save" but not "what has any of it done" — you had to open every row to find out. Two changes. **/watchlist is a path.** Every tab is one now (/discover, /watchlist, /search, /signals, /about), pushed through the History API, with Back and Forward moving between them. The older /#watchlist form is rewritten to the path on arrival, so existing links still land. The server already answers an unknown path with the app shell, so this needed no new route. **The tab is a dashboard.** Six summary tiles, one line chart of the watchlist equal-weighted and rebased to 100 against SPY on the same base, and a sortable, filterable table with a sparkline per row. Sort, filter, risk class and window live in the URL as well as in localStorage: the address bar makes a configured table shareable, storage makes it the way you left it. Backed by GET /api/watchlist/overview, which is separate from /api/watchlist because it costs a market fetch and must be allowed to fail on its own — losing prices never costs the list of what is saved. Three rules shape it: - one upstream fetch for the whole list, cached ten minutes and shared across viewers, so 200 tickers is not 200 round trips per load; adding a ticker fetches that ticker. AlpacaClient.getBars now uses the multi-symbol endpoint when asked for more than one, which the digest gets for free; - nothing is invented: a ticker with no bars stays on the list, is reported unpriced and named in stats.missing rather than borrowing its stored report price, and a period longer than the available history is null; - the freshness is part of the answer: the payload carries the date of the last bar it used, and the equal-weight line names both the tickers it covers and the ones left out for want of history over the window. computePerformance and the sparkline series maths move to src/market/, since they were never crypto-specific; the crypto modules re-export them so their callers and tests read as they did. Chart colours were validated rather than picked: both series pass the lightness, chroma, contrast and colour-vision-separation checks against the chart surface, and are differentiated by dash pattern and direct end labels as well as hue. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 60 ++- public/app.js | 675 +++++++++++++++++++++++++++++-- public/index.html | 31 +- public/styles.css | 138 ++++++- src/auth/watchlist.ts | 32 +- src/crypto/performance.ts | 95 +---- src/crypto/sparkline.ts | 52 +-- src/market/performance.ts | 108 +++++ src/market/series.ts | 54 +++ src/providers/alpaca.ts | 51 ++- src/server.ts | 9 +- src/watchlist/overview.ts | 559 +++++++++++++++++++++++++ test/dashboard-watchlist.test.ts | 476 ++++++++++++++++++++++ test/watchlist-overview.test.ts | 369 +++++++++++++++++ 14 files changed, 2535 insertions(+), 174 deletions(-) create mode 100644 src/market/performance.ts create mode 100644 src/market/series.ts create mode 100644 src/watchlist/overview.ts create mode 100644 test/dashboard-watchlist.test.ts create mode 100644 test/watchlist-overview.test.ts diff --git a/README.md b/README.md index 39ab535..070259a 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ CLI binary: **`transcripts`**. ## Live -- **Web dashboard + PWA:** https://advis0r.up.railway.app (Watchlist / Search / - Signals / About; installable). API root at `/api`. +- **Web dashboard + PWA:** https://advis0r.up.railway.app (Discover / Watchlist + / Search / Signals / Crypto / About; installable). Every tab is a real path — + `/watchlist`, `/search` — so it can be linked to. API root at `/api`. - Deployed on Railway (Bun, `src/server.ts`), backed by the same Turso database the CLI uses. @@ -402,13 +403,60 @@ The pages need no JavaScript — the price history is inline SVG — so they wor a crawler, a link preview, or a text browser. The interactive candlestick view stays in the app's modal, one click away. -In the app, watchlist rows link to `/ticker/` (so middle-click and -"open in new tab" work) but open the modal on click. The modal shows the -snapshot age, a permalink, and — for watchlist tickers — a **↻ Regenerate** -button. Regenerating refreshes the report's *data* and is free; re-running the +In the app, watchlist rows link straight to `/stocks/`, so a ticker is +somewhere you can send someone rather than a modal that leaves the URL alone. +Discover cards still open the modal, which shows the snapshot age, a permalink, +and — for watchlist tickers — a **↻ Regenerate** button. Regenerating refreshes the report's *data* and is free; re-running the LLM is the separate, credit-metered **Re-run AI** button, so a free action never silently spends a credit. +## The watchlist dashboard + +The saved watchlist lives at **`/watchlist`** — a path, not a fragment, so it +can be linked to, bookmarked, crawled and reloaded. Every tab is a path now +(`/discover`, `/watchlist`, `/search`, `/signals`, `/about`); the older +`/#watchlist` form is rewritten to the path on arrival, so existing links keep +working. The server already answers an unknown path with the app shell, so the +routing needed no new server route. + +The tab is a dashboard rather than a list of links: + +| Layer | What it shows | +|---|---| +| Six summary tiles | Count and how many are priced · last session's average move with the up/down split · equal-weight change over the window against SPY · best and worst mover · average score | +| One line chart | The watchlist, equal-weight and rebased to 100, drawn against SPY on the same base. Hovering reports both lines at that session | +| A sortable table | Ticker, company, note, price, 1D/1W/1M/window change, a sparkline, score, distance from the 52-week high, and the date it was saved | + +Sort, filter, risk-class and window live in the URL as well as in +`localStorage`: the address bar makes a configured table shareable, storage +makes it the way you left it. `/watchlist?sort=range&dir=desc&q=ai&range=1Y` +opens already arranged. + +``` +GET /api/watchlist/overview?range=1M|3M|6M|1Y (signed in; 401 otherwise) + -> { range, asOf, source, items[], stats, index } +``` + +Three rules shape the payload, and they are the reason it is a separate +endpoint from `/api/watchlist`: + +- **One upstream fetch for the whole list.** Bars for every saved ticker plus + the benchmark come back in a single batched request, cached for ten minutes + and shared across viewers, so a 200-ticker watchlist is not 200 round trips + per load. Adding a ticker fetches that ticker, not the list. +- **Nothing is invented.** A ticker the provider has no bars for stays on the + list, is reported as unpriced and is named in `stats.missing` — it is never + filled in from its stored report price. A period longer than the history + available is `null`, not extrapolated. +- **The freshness is part of the answer.** Daily bars are end-of-session data, + so the payload carries the date of the last bar it used, and the page prints + it. The equal-weight line names the tickers it covers and the ones it left + out for want of history over the window. + +Losing the overview never costs the list: it is fetched alongside the +membership rather than instead of it, and the tab falls back to the plain rows +when market data is unavailable. + ## Email digests Signed-in users get a market summary of the tickers on their saved watchlist, diff --git a/public/app.js b/public/app.js index 1283b27..0b589b4 100644 --- a/public/app.js +++ b/public/app.js @@ -11,19 +11,50 @@ async function api(path) { return res.json(); } -/* ---- Tab routing ---- */ -function showView(name) { +/* ---- Tab routing ---- + Each tab is a path — /watchlist, /discover — not a fragment. A fragment is + invisible to the server, so it cannot be linked to from an email, cannot be + crawled, and is dropped by anything that rewrites URLs. The server already + answers an unknown path with the app shell, so the only thing needed here is + to keep the address bar and the history stack honest. + + Fragments still work: /#watchlist is upgraded to /watchlist on arrival, so + older links keep landing in the right place. */ +const VIEWS = ["discover", "watchlist", "search", "signals", "crypto", "about"]; + +/** The view a URL asks for, by path first and then by legacy fragment. */ +function viewFromLocation() { + const seg = location.pathname.replace(/^\/+|\/+$/g, ""); + if (VIEWS.includes(seg)) return seg; + const hash = (location.hash || "").replace(/^#/, ""); + return VIEWS.includes(hash) ? hash : null; +} + +function showView(name, opts = {}) { $$("#tabs button").forEach((b) => b.classList.toggle("active", b.dataset.view === name)); $$(".view").forEach((v) => v.classList.toggle("active", v.dataset.view === name)); - location.hash = name; + if (opts.history !== false) { + // The query string travels with the view: it carries the watchlist's sort, + // filter and range, which is what makes a configured table shareable. + const target = `/${name}${location.search}`; + const current = location.pathname + location.search; + try { + if (current !== target) history[opts.replace ? "replaceState" : "pushState"]({ view: name }, "", target); + } catch { /* a sandboxed frame cannot write history; the view still switches */ } + } // Crypto prices are only fetched once the tab is actually opened — loading // them on boot would spend upstream calls for every visitor who never looks. if (name === "crypto") loadCryptoGrid(); + if (name === "watchlist") openWatchlistTab(); } $("#tabs").addEventListener("click", (e) => { const b = e.target.closest("button"); if (b) showView(b.dataset.view); }); +// Back and forward move between tabs instead of leaving the app. +window.addEventListener("popstate", () => { + showView(viewFromLocation() ?? "discover", { history: false }); +}); /* ---- Health + about stats ---- */ async function loadHealthAndStats() { @@ -323,6 +354,7 @@ async function exportDiscover() { const res = await wlApi("POST", { csv: discoverCsv() }); // Re-renders the Watchlist tab and flips these cards' buttons to ✓ Watching. renderMyWatchlist(res.items || []); + loadWatchlistOverview(); $("#wl-summary").textContent = `${importSummary(res)} See the Watchlist tab.`; } catch (e) { if (e.authRequired) { openAuth("login"); return; } @@ -398,10 +430,11 @@ async function boot() { loadTopics(); const disc = "This output is generated from public information and automated analysis. It is a research aid, not a guarantee, personalized recommendation, or substitute for professional financial advice. Small-cap and low-priced stocks may be highly volatile, illiquid, subject to dilution, manipulation, delisting, and total loss."; $("#disclaimer").textContent = disc; - // "discover" was missing from this list after the view rename, so a link to - // /#discover landed on the per-user Watchlist tab instead. - const start = (location.hash || "#discover").slice(1); - showView(["discover", "watchlist", "search", "signals", "crypto", "about"].includes(start) ? start : "discover"); + // Restore the tab from the URL. `replace` rather than push, so arriving at + // /#watchlist rewrites the address bar to /watchlist without leaving a + // fragment entry behind for Back to land on. + restoreWatchlistPrefs(); + showView(viewFromLocation() ?? "discover", { replace: true }); runWatchlist(); // Deep link from a digest email: /?ticker=NVDA opens that stock's detail. const params = new URL(location.href).searchParams; @@ -1118,26 +1151,509 @@ async function wlApi(method, body) { return data; } -function renderMyWatchlist(items) { - myTickers = new Set(items.map((i) => i.ticker)); +/* ---- Watchlist dashboard state ---- + Two payloads back this tab. `/api/watchlist` is the membership list — it is + what add and remove return, so it is the source of truth for what is saved. + `/api/watchlist/overview` is the same rows priced, scored and charted; it + costs a market fetch, so it is loaded alongside rather than instead, and the + table degrades to the plain list when it is missing. + + Sort, filter and range live in the URL as well as in localStorage: the + address bar makes a configured table shareable, storage makes it the way you + left it on the next visit. */ + +const WL_STORE_KEY = "wl-view"; +const WL_RANGES = ["1M", "3M", "6M", "1Y"]; +const WL_DEFAULTS = { range: "3M", sort: "range", dir: "desc", q: "", cls: "all" }; + +let wlView = { ...WL_DEFAULTS }; +let wlItems = []; // saved rows: {ticker, note, createdAt} +let wlOverview = null; // the priced payload, or null before it lands +let wlLoadingOverview = false; +/** A one-off message (an import result, an error) shown ahead of the prices. */ +let wlNotice = ""; + +function restoreWatchlistPrefs() { + try { + const saved = JSON.parse(localStorage.getItem(WL_STORE_KEY) || "{}"); + for (const k of Object.keys(WL_DEFAULTS)) { + if (typeof saved[k] === "string") wlView[k] = saved[k]; + } + } catch { /* blocked storage: the defaults are fine */ } + // A link wins over what this browser last did — that is the point of putting + // it in the URL. + const params = new URL(location.href).searchParams; + for (const k of Object.keys(WL_DEFAULTS)) { + const v = params.get(k === "q" ? "q" : k); + if (v != null) wlView[k] = v; + } + if (!WL_RANGES.includes(wlView.range)) wlView.range = WL_DEFAULTS.range; + if (wlView.dir !== "asc" && wlView.dir !== "desc") wlView.dir = WL_DEFAULTS.dir; +} + +function persistWatchlistPrefs() { + try { localStorage.setItem(WL_STORE_KEY, JSON.stringify(wlView)); } catch { /* ignore */ } + // Only rewrite the URL while the watchlist is the visible tab, so switching + // to Search does not leave the table's state stuck to a different path. + if (!document.querySelector('.view[data-view="watchlist"]')?.classList.contains("active")) return; + const url = new URL(location.href); + for (const [k, def] of Object.entries(WL_DEFAULTS)) { + if (wlView[k] && wlView[k] !== def) url.searchParams.set(k, wlView[k]); + else url.searchParams.delete(k); + } + try { history.replaceState(history.state, "", `${url.pathname}${url.search}`); } catch { /* ignore */ } +} + +/* ---- Formatting ---- */ + +const wlPct = (n, dp = 2) => (n == null || !isFinite(n) ? "—" : `${n >= 0 ? "+" : ""}${n.toFixed(dp)}%`); +const wlSign = (n) => (n == null || !isFinite(n) ? "" : n > 0 ? "pos" : n < 0 ? "neg" : ""); +const wlMoney = (n) => (n == null || !isFinite(n) ? "—" : `$${Number(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`); +const wlDate = (iso) => (iso ? String(iso).slice(0, 10) : "—"); +const changeOf = (item, label) => item.changes?.find((c) => c.label === label)?.percent ?? null; + +/* ---- Summary tiles ---- */ + +function wlStatTile(label, value, sub, cls = "") { + return `
+ ${esc(label)} + ${value} + ${sub} +
`; +} + +function renderWlStats() { + const el = document.getElementById("wl-stats"); + if (!el || !wlOverview) return; + const s = wlOverview.stats; + const range = wlOverview.range; + const mover = (m) => (m ? `${esc(m.ticker)} ${wlPct(m.percent, 1)}` : "—"); + + el.innerHTML = [ + wlStatTile( + "Tickers", + String(s.count), + `${s.priced} priced${s.missing.length ? ` · ${s.missing.length} without data` : ""}`, + ), + wlStatTile( + "Last session", + wlPct(s.avgDayPercent, 2), + `${s.gainers} up · ${s.losers} down`, + wlSign(s.avgDayPercent), + ), + wlStatTile( + `${range} equal-weight`, + wlPct(s.rangePercent, 1), + s.benchmarkPercent != null ? `SPY ${wlPct(s.benchmarkPercent, 1)}` : "no benchmark data", + wlSign(s.rangePercent), + ), + wlStatTile("Best", mover(s.best), `over ${range}`, s.best ? wlSign(s.best.percent) : ""), + wlStatTile("Worst", mover(s.worst), `over ${range}`, s.worst ? wlSign(s.worst.percent) : ""), + wlStatTile( + "Avg score", + s.avgScore != null ? `${Math.round(s.avgScore)}/100` : "—", + `${s.scored} of ${s.count} scored`, + ), + ].join(""); +} + +/* ---- The index chart ---- + Both lines are rebased to 100 at the start of the window, so one axis carries + both: an equal-weight watchlist and a broad-market ETF have nothing in common + in dollars, and drawing them against two scales would let any pair of lines + be made to tell any story. Percentages from a shared base is the honest form. + + Drawn as inline SVG at real pixel coordinates rather than through the chart + vendor: two rebased lines need no candles, no panes and no time-scale sync, + and this way the crosshair, the dashed benchmark and the end labels are + exactly what they look like. */ + +const WL_CHART_H = 230; +/** Room on the right for the end labels; dropped when the chart is narrow. */ +const WL_LABEL_W = 116; +const WL_CHART_PAD = { top: 16, right: WL_LABEL_W, bottom: 24, left: 46 }; +/** Below this the end labels would take more room than the lines. */ +const WL_LABEL_MIN_W = 520; + +let wlChartGeom = null; + +/** + * Gridline values on a 1 / 2 / 2.5 / 5 ladder rather than the raw domain split + * evenly — "15%, 10%, 4%, -1%" is a scale nobody reads twice. + */ +function niceTicks(lo, hi, count = 5) { + const raw = (hi - lo) / Math.max(1, count - 1); + if (!(raw > 0)) return [lo]; + const mag = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag; + const out = []; + for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) out.push(Number(v.toFixed(6))); + return out.length ? out : [lo, hi]; +} + +function wlChartPaths(index, width) { + const dates = [...new Set([...index.points, ...index.benchmark].map((p) => p.t))].sort(); + if (dates.length < 2) return null; + const xAt = new Map(dates.map((d, i) => [d, i])); + // On a narrow chart the end labels are dropped, not shrunk: the legend below + // already names both lines, and a squeezed plot reads worse than no label. + const labelled = width >= WL_LABEL_MIN_W; + const padRight = labelled ? WL_CHART_PAD.right : 16; + const plotW = Math.max(60, width - WL_CHART_PAD.left - padRight); + const plotH = WL_CHART_H - WL_CHART_PAD.top - WL_CHART_PAD.bottom; + + const values = [...index.points, ...index.benchmark].map((p) => p.value).concat(100); + const min = Math.min(...values); + const max = Math.max(...values); + const pad = (max - min) * 0.08 || 1; + const lo = min - pad; + const hi = max + pad; + + const x = (t) => WL_CHART_PAD.left + (xAt.get(t) / (dates.length - 1)) * plotW; + const y = (v) => WL_CHART_PAD.top + (1 - (v - lo) / (hi - lo)) * plotH; + const project = (pts) => pts.filter((p) => xAt.has(p.t)).map((p) => ({ ...p, x: x(p.t), y: y(p.value) })); + + return { + dates, lo, hi, plotW, plotH, x, y, labelled, + series: [ + { key: "watchlist", label: "Watchlist", color: "var(--chart-1)", dashed: false, points: project(index.points) }, + { key: "benchmark", label: index.benchmarkSymbol, color: "var(--chart-2)", dashed: true, points: project(index.benchmark) }, + ].filter((s) => s.points.length >= 2), + }; +} + +const wlPathD = (points) => points.map((p, i) => `${i ? "L" : "M"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(""); + +function renderWlChart() { + const box = document.getElementById("wl-chart"); + const legend = document.getElementById("wl-chart-legend"); + const note = document.getElementById("wl-chart-note"); + if (!box) return; + const index = wlOverview?.index; + if (!index || index.points.length < 2) { + wlChartGeom = null; + box.innerHTML = `

Not enough shared price history to chart this watchlist yet.

`; + if (legend) legend.innerHTML = ""; + if (note) note.textContent = ""; + return; + } + + const width = box.clientWidth || 720; + const geom = wlChartPaths(index, width); + if (!geom) return; + wlChartGeom = geom; + + // Recessive gridlines with their own value labels. A rebased chart reads in + // percent, so they are labelled that way rather than as index points. + const ticks = niceTicks(geom.lo, geom.hi); + const dp = Math.abs((ticks[1] ?? 100) - (ticks[0] ?? 0)) < 1 ? 1 : 0; + const grid = ticks + .map((v) => ` + ${(v - 100).toFixed(dp)}%`) + .join(""); + + const lines = geom.series + .map((s) => ``) + .join(""); + + // Direct end labels: identity never rests on colour alone. + const ends = geom.series + .map((s) => { + const last = s.points.at(-1); + const pct = last.value - 100; + const label = geom.labelled + ? `${esc(s.label)} ${wlPct(pct, 1)}` + : ""; + return `${label}`; + }) + .join(""); + + const first = geom.dates[0]; + const last = geom.dates.at(-1); + const axis = `${esc(first)} + ${esc(last)}`; + + box.innerHTML = ` + ${grid} + + ${lines}${ends}${axis} + + `; + + if (legend) { + legend.innerHTML = geom.series + .map((s) => ` + + ${esc(s.label)} ${wlPct(s.points.at(-1).value - 100, 1)} + `) + .join(""); + } + if (note) { + const excluded = index.excluded.length + ? ` ${index.excluded.length} ticker${index.excluded.length === 1 ? "" : "s"} left out for want of history over the window (${index.excluded.slice(0, 6).map(esc).join(", ")}${index.excluded.length > 6 ? "…" : ""}).` + : ""; + note.textContent = `Equal-weight across ${index.members.length} ticker${index.members.length === 1 ? "" : "s"}, rebased to 100 at ${first}.${excluded}`; + } + + attachWlCrosshair(box.querySelector("svg"), legend); +} + +/** Hover anywhere on the plot: both lines report their value at that session. */ +function attachWlCrosshair(svg, legend) { + if (!svg || !wlChartGeom) return; + const cross = svg.querySelector(".wl-cross"); + const geom = wlChartGeom; + + const move = (clientX) => { + const rect = svg.getBoundingClientRect(); + // A zero-width rect means the element is not laid out (or is off-screen); + // there is nothing meaningful to point at. + if (!rect.width) return; + const px = ((clientX - rect.left) / rect.width) * (svg.viewBox?.baseVal?.width || rect.width); + let best = null; + for (const s of geom.series) { + for (const p of s.points) { + const d = Math.abs(p.x - px); + if (!best || d < best.d) best = { d, t: p.t }; + } + } + if (!best) return; + cross?.removeAttribute("hidden"); + const lineEl = cross?.querySelector(".wl-cross-line"); + let lineX = null; + for (const s of geom.series) { + const point = s.points.find((p) => p.t === best.t) ?? null; + const dot = cross?.querySelector(`[data-series="${s.key}"]`); + if (dot) { + if (point) { dot.setAttribute("cx", point.x); dot.setAttribute("cy", point.y); dot.removeAttribute("hidden"); } + else dot.setAttribute("hidden", ""); + } + if (point) lineX = point.x; + const val = legend?.querySelector(`[data-series="${s.key}"] .wl-legend-val`); + if (val) val.textContent = point ? wlPct(point.value - 100, 1) : "—"; + } + if (lineEl && lineX != null) { lineEl.setAttribute("x1", lineX); lineEl.setAttribute("x2", lineX); } + const cap = document.getElementById("wl-chart-cap"); + if (cap) cap.dataset.hover = best.t; + const dateEl = legend?.querySelector(".wl-legend-date"); + if (dateEl) dateEl.textContent = best.t; + }; + + const leave = () => { + cross?.setAttribute("hidden", ""); + for (const s of geom.series) { + const val = legend?.querySelector(`[data-series="${s.key}"] .wl-legend-val`); + if (val) val.textContent = wlPct(s.points.at(-1).value - 100, 1); + } + const dateEl = legend?.querySelector(".wl-legend-date"); + if (dateEl) dateEl.textContent = geom.dates.at(-1); + }; + + if (legend && !legend.querySelector(".wl-legend-date")) { + legend.insertAdjacentHTML("beforeend", `${esc(geom.dates.at(-1))}`); + } + svg.addEventListener("mousemove", (e) => move(e.clientX)); + svg.addEventListener("mouseleave", leave); + svg.addEventListener("touchmove", (e) => { const t = e.touches[0]; if (t) move(t.clientX); }, { passive: true }); + svg.addEventListener("touchend", leave); +} + +/* ---- Filtering + sorting ---- */ + +const WL_COLUMNS = [ + { key: "ticker", label: "Ticker", type: "text" }, + { key: "company", label: "Company", type: "text" }, + { key: "price", label: "Price", type: "num" }, + { key: "d1", label: "1D", type: "num" }, + { key: "w1", label: "1W", type: "num", optional: true }, + { key: "m1", label: "1M", type: "num", optional: true }, + { key: "range", label: "", type: "num" }, // label follows the range + { key: "spark", label: "Trend", type: "none" }, + { key: "score", label: "Score", type: "num" }, + { key: "fromHigh", label: "From high", type: "num", optional: true }, + { key: "added", label: "Added", type: "text", optional: true }, + { key: "act", label: "", type: "none" }, +]; + +const WL_VALUE = { + ticker: (i) => i.ticker, + company: (i) => (i.companyName || "").toLowerCase(), + price: (i) => i.price, + d1: (i) => changeOf(i, "1D"), + w1: (i) => changeOf(i, "1W"), + m1: (i) => changeOf(i, "1M"), + range: (i) => i.rangePercent, + score: (i) => (i.overallScore == null ? null : i.overallScore), + fromHigh: (i) => i.fromHigh52, + added: (i) => i.createdAt || "", +}; + +function wlVisibleRows() { + const items = wlOverview?.items ?? []; + const q = wlView.q.trim().toLowerCase(); + const cls = wlView.cls; + const filtered = items.filter((i) => { + if (cls !== "all" && (i.classification || "unclassified") !== cls) return false; + if (!q) return true; + return `${i.ticker} ${i.companyName || ""} ${i.note || ""}`.toLowerCase().includes(q); + }); + + const pick = WL_VALUE[wlView.sort] ?? WL_VALUE.range; + const dir = wlView.dir === "asc" ? 1 : -1; + return filtered.sort((a, b) => { + const va = pick(a); + const vb = pick(b); + // Missing data sorts last in both directions: a ticker with no price is not + // "the smallest mover", it is unknown, and floating it to the top of an + // ascending sort would read as a fact. + if (va == null && vb == null) return a.ticker.localeCompare(b.ticker); + if (va == null) return 1; + if (vb == null) return -1; + if (typeof va === "string" || typeof vb === "string") return String(va).localeCompare(String(vb)) * dir; + return (va - vb) * dir; + }); +} + +function wlSortHeader(col) { + const label = col.key === "range" ? wlView.range : col.label; + if (col.type === "none") return `${esc(label)}`; + const on = wlView.sort === col.key; + const arrow = on ? (wlView.dir === "asc" ? "▲" : "▼") : ""; + return ` + + `; +} + +function wlRow(i) { + const rangeCls = wlSign(i.rangePercent); + const stale = wlOverview?.asOf && i.priceAsOf && i.priceAsOf < wlOverview.asOf; + return ` + ${esc(i.ticker)} + ${i.hasReport ? "" : '·'} + ${esc(i.companyName || "—")} + ${i.note ? `${esc(i.note)}` : ""} + ${wlMoney(i.price)}${ + stale ? `·` : "" + } + ${wlPct(changeOf(i, "1D"))} + ${wlPct(changeOf(i, "1W"))} + ${wlPct(changeOf(i, "1M"))} + ${wlPct(i.rangePercent, 1)} + + ${sparkSvg(i.spark, (i.rangePercent ?? 0) >= 0, "wl-spark", false)} + ${ + i.overallScore == null ? "—" : `${Math.round(i.overallScore)}` + } + ${i.fromHigh52 == null ? "—" : wlPct(i.fromHigh52, 1)} + ${wlDate(i.createdAt)} + + `; +} + +function renderWlTable() { + const list = document.getElementById("my-list"); + if (!list) return; + const rows = wlVisibleRows(); + const count = document.getElementById("wl-count"); + if (count) { + const total = wlOverview?.items.length ?? 0; + count.textContent = rows.length === total ? `${total} ticker${total === 1 ? "" : "s"}` : `${rows.length} of ${total}`; + } + if (!rows.length) { + list.innerHTML = `
No rows match that filter.
`; + return; + } + list.innerHTML = `
+ ${WL_COLUMNS.map(wlSortHeader).join("")} + ${rows.map(wlRow).join("")} +
`; +} + +/** The risk-class filter, built from the classes actually present. */ +function renderWlClassFilter() { + const el = document.getElementById("wl-classes"); + if (!el || !wlOverview) return; + const present = [...new Set(wlOverview.items.map((i) => i.classification).filter(Boolean))]; + if (!present.includes(wlView.cls) && wlView.cls !== "all") wlView.cls = "all"; + el.innerHTML = ["all", ...present] + .map((c) => ``) + .join(""); +} + +function renderWlRanges() { + for (const b of $$("#wl-ranges button")) b.classList.toggle("on", b.dataset.range === wlView.range); + const filter = document.getElementById("wl-filter"); + if (filter && filter.value !== wlView.q) filter.value = wlView.q; +} + +/* ---- Rendering the tab ---- */ + +function renderWatchlist() { const list = document.getElementById("my-list"); + const dash = document.getElementById("wl-dash"); const summary = document.getElementById("my-summary"); if (!list) return; - if (summary) summary.textContent = items.length - ? `${items.length} ticker${items.length === 1 ? "" : "s"} saved` - : ""; - list.innerHTML = items.length - ? items.map((i) => `
-
- - ${esc(i.ticker)} - ${i.note ? `${esc(i.note)}` : ""} -
+ + if (!wlItems.length) { + if (dash) dash.hidden = true; + if (summary) summary.textContent = ""; + list.innerHTML = `
Nothing saved yet. Add a ticker above, or use “+ Watchlist” on any Discover result.
`; + return; + } + + if (summary) { + const s = wlOverview?.stats; + const line = wlOverview + ? `${wlItems.length} saved · prices from ${wlOverview.source}${wlOverview.asOf ? ` through ${wlOverview.asOf}` : ""}${ + s?.missing.length ? ` · no data for ${s.missing.join(", ")}` : "" + }${wlOverview.marketError ? ` · market data unavailable (${wlOverview.marketError})` : ""}` + : `${wlItems.length} ticker${wlItems.length === 1 ? "" : "s"} saved · pricing…`; + summary.textContent = wlNotice ? `${wlNotice} · ${line}` : line; + } + + // Before the overview lands (or when market data is down) the plain list is + // still useful, and is what every add/remove renders instantly. + if (!wlOverview) { + if (dash) dash.hidden = true; + list.innerHTML = wlItems + .map((i) => `
+ ${esc(i.ticker)} + ${i.note ? `${esc(i.note)}` : ""} -
`).join("") - : `
Nothing saved yet. Add a ticker above, or use “+ Watchlist” on any Discover result.
`; +
`) + .join(""); + return; + } + + if (dash) dash.hidden = false; + renderWlStats(); + renderWlChart(); + renderWlRanges(); + renderWlClassFilter(); + renderWlTable(); +} + +function renderMyWatchlist(items) { + wlItems = items; + myTickers = new Set(items.map((i) => i.ticker)); + // Keep the priced view consistent with membership straight away: a removed + // row should leave the table on click, not on the next fetch. + if (wlOverview) { + wlOverview.items = wlOverview.items.filter((i) => myTickers.has(i.ticker)); + if (!wlOverview.items.length && items.length) wlOverview = null; + } + renderWatchlist(); // Keep Discover buttons in sync with what is now saved. document.querySelectorAll("[data-watch]").forEach(syncWatchButton); // Adding the open ticker to the watchlist is what unlocks its Regenerate @@ -1159,24 +1675,115 @@ function syncWatchButton(btn) { async function loadMyWatchlist() { const list = document.getElementById("my-list"); const summary = document.getElementById("my-summary"); + const dash = document.getElementById("wl-dash"); if (!list) return; try { const { items } = await wlApi("GET"); renderMyWatchlist(items || []); } catch (e) { myTickers = new Set(); + wlItems = []; + wlOverview = null; if (summary) summary.textContent = ""; + if (dash) dash.hidden = true; list.innerHTML = e.authRequired ? `
Your watchlist is private to your account.
` : `
Could not load your watchlist (${esc(e.message)}).
`; } } +/** + * The priced view. Deliberately separate from the membership fetch: it costs a + * market request, it can fail on its own, and losing it must never cost the + * list of what is saved. + */ +async function loadWatchlistOverview() { + if (wlLoadingOverview) return; + wlLoadingOverview = true; + try { + const res = await fetch(`/api/watchlist/overview?range=${encodeURIComponent(wlView.range)}`, { + credentials: "same-origin", + }); + if (!res.ok) throw new Error(String(res.status)); + const data = await res.json(); + if (!data || !Array.isArray(data.items)) return; + wlOverview = data; + renderWatchlist(); + } catch { + // Keep whatever is already on screen; the summary line says what is known. + } finally { + wlLoadingOverview = false; + } +} + +/** Everything the tab needs, in the order that puts something on screen first. */ +function openWatchlistTab() { + wlNotice = ""; + loadMyWatchlist(); + loadDigestPrefs(); + loadWatchlistOverview(); +} + +/* ---- Watchlist controls ---- */ + +document.addEventListener("click", (e) => { + const sort = e.target.closest("[data-sort]"); + if (sort) { + e.preventDefault(); + const key = sort.dataset.sort; + // Clicking the active column flips it; a new column starts on the reading + // most people want — biggest first for numbers, A-Z for text. + if (wlView.sort === key) wlView.dir = wlView.dir === "asc" ? "desc" : "asc"; + else { + wlView.sort = key; + wlView.dir = WL_COLUMNS.find((c) => c.key === key)?.type === "text" ? "asc" : "desc"; + } + persistWatchlistPrefs(); + renderWlTable(); + return; + } + const range = e.target.closest("#wl-ranges [data-range]"); + if (range) { + e.preventDefault(); + if (range.dataset.range === wlView.range) return; + wlView.range = range.dataset.range; + persistWatchlistPrefs(); + renderWlRanges(); + loadWatchlistOverview(); + return; + } + const cls = e.target.closest("#wl-classes [data-class]"); + if (cls) { + e.preventDefault(); + wlView.cls = cls.dataset.class; + persistWatchlistPrefs(); + renderWlClassFilter(); + renderWlTable(); + } +}); + +document.getElementById("wl-filter")?.addEventListener("input", (e) => { + wlView.q = e.target.value; + persistWatchlistPrefs(); + renderWlTable(); +}); + +// The chart is drawn at pixel coordinates, so a resized window needs a redraw. +let wlResizeTimer = null; +window.addEventListener("resize", () => { + if (!wlOverview) return; + clearTimeout(wlResizeTimer); + wlResizeTimer = setTimeout(renderWlChart, 150); +}); + async function toggleWatch(ticker) { const on = myTickers.has(ticker); try { const { items } = await wlApi(on ? "DELETE" : "POST", { ticker }); renderMyWatchlist(items || []); + // A newly saved ticker has no price yet. The bars for everything else are + // already cached server-side, so this re-fetch is one symbol's worth. + if (!on) loadWatchlistOverview(); } catch (e) { if (e.authRequired) { openAuth("login"); return; } alert(e.message); @@ -1277,6 +1884,7 @@ async function toggleWatchAdd(ticker) { try { const { items } = await wlApi("POST", { ticker }); renderMyWatchlist(items || []); + loadWatchlistOverview(); } catch (e) { if (e.authRequired) { openAuth("login"); return; } alert(e.message); @@ -1300,6 +1908,10 @@ document.getElementById("my-add")?.addEventListener("keydown", (e) => { the file's text and lets the server parse it. */ function setMySummary(text) { + // Held rather than written straight to the element: the tab re-renders its + // summary line whenever prices land, which would otherwise wipe an import + // result a second after showing it. + wlNotice = text; const summary = document.getElementById("my-summary"); if (summary) summary.textContent = text; } @@ -1320,6 +1932,7 @@ async function importWatchlistFile(file) { try { const res = await wlApi("POST", { csv: text }); renderMyWatchlist(res.items || []); + loadWatchlistOverview(); setMySummary(importSummary(res)); } catch (e) { if (e.authRequired) { openAuth("login"); return; } @@ -1438,11 +2051,9 @@ document.addEventListener("click", (e) => { if (opt) { e.preventDefault(); setDigestFrequency(opt.dataset.digest); } }); -// Load on first visit to the tab, and refresh after any auth change. -document.addEventListener("click", (e) => { - if (e.target.closest('#tabs button[data-view="watchlist"]')) { loadMyWatchlist(); loadDigestPrefs(); } -}); -window.addEventListener("advis0r:auth-changed", () => { loadMyWatchlist(); loadDigestPrefs(); }); +// Opening the tab loads it (see showView); an auth change reloads it, because +// signing in is what turns the prompt into somebody's actual watchlist. +window.addEventListener("advis0r:auth-changed", () => { openWatchlistTab(); }); /* ---- Sign-in promo for the AI analysis paths ---- @@ -1528,7 +2139,7 @@ function fmtPrice(n) { * 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) { +function sparkSvg(points, rising, className = "cx-spark", filled = true) { if (!Array.isArray(points) || points.length < 2) return ""; const w = 100; const h = 28; @@ -1540,8 +2151,8 @@ function cryptoSparkSvg(points, rising) { 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 `${esc(s.name || "")}
${fmtPrice(price)}
- ${cryptoSparkSvg(spark?.points, sparkRising)} + ${sparkSvg(spark?.points, sparkRising)}
${chg == null ? '' : `${chg.percent >= 0 ? "+" : ""}${chg.percent.toFixed(2)}%`} diff --git a/public/index.html b/public/index.html index 7915402..e52eb22 100644 --- a/public/index.html +++ b/public/index.html @@ -70,10 +70,39 @@ valid ticker is. -->
-

Your saved tickers. Private to your account and available on any device you sign in from.

+

Your saved tickers, priced. Private to your account and available on any device you sign in from.

+ + + +
diff --git a/public/styles.css b/public/styles.css index c4d73a0..f1a1be1 100644 --- a/public/styles.css +++ b/public/styles.css @@ -10,6 +10,12 @@ --pos: #35d07f; --neg: #ff6b6b; --warn: #ffb454; + /* Chart series. Two hues, checked against the chart surface for lightness, + chroma, contrast and colour-vision separation — not eyeballed. The lines + are also differentiated by dash pattern and direct labels, so identity + never rests on hue alone. */ + --chart-1: #12a884; + --chart-2: #4c8dff; --radius: 14px; --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; --sans: system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif; @@ -266,7 +272,10 @@ details.evidence .ev { font-size: 12.5px; color: var(--dim); border-left: 2px so .dl-actions { display: flex; gap: .5rem; margin-top: .6rem; } .dl-watch { font-size: .8rem; padding: .32rem .7rem; border-radius: 8px; } .dl-watch-promo { max-width: 460px; margin-top: .9rem; } -.wl-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .75rem .9rem; } +/* The unpriced fallback row, shown until the overview lands. */ +.wl-plain { display: flex; align-items: center; gap: 1rem; padding: .75rem .9rem; } +.wl-plain .wl-note { margin-right: auto; } +.wl-plain .wl-remove { margin-left: auto; } .wl-main { display: flex; align-items: baseline; gap: .7rem; min-width: 0; } .wl-tick { font-weight: 600; color: var(--fg, #d7dee8); text-decoration: none; font-size: .95rem; } .wl-tick:hover { text-decoration: underline; } @@ -557,3 +566,130 @@ details.evidence .ev { font-size: 12.5px; color: var(--dim); border-left: 2px so .cx-price { font-size: 18px; } .cx-name { display: none; } } + +/* ==== Watchlist dashboard ================================================== + Summary tiles, one line chart, and a sortable table. The table is the data + view the chart is read against: every number drawn is also printed. */ + +.wl-dash[hidden] { display: none; } + +/* The watchlist is a table twelve columns wide; the reading column the rest of + the app is set in cannot hold it without a horizontal scroll on every visit. */ +main:has(.view[data-view="watchlist"].active) { max-width: 1340px; } + +.wl-stats { + display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; margin: 4px 0 16px; +} +.wl-stat { + background: var(--panel); border: 1px solid var(--line); border-radius: 12px; + padding: 11px 13px; display: flex; flex-direction: column; gap: 3px; min-width: 0; +} +.wl-stat-lab { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; } +.wl-stat-val { font-family: var(--mono); font-size: 21px; font-weight: 600; line-height: 1.15; } +.wl-stat-val.pos { color: var(--pos); } +.wl-stat-val.neg { color: var(--neg); } +.wl-stat-of { color: var(--dim); font-size: 13px; font-weight: 400; } +.wl-stat-tick { font-size: 15px; letter-spacing: .01em; } +.wl-stat-sub { color: var(--dim); font-size: 11.5px; font-family: var(--mono); } + +/* ---- The index chart ---- */ +.wl-chartbox { + margin: 0 0 16px; padding: 12px 14px 10px; + background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--radius); +} +.wl-chartbox figcaption { color: var(--dim); font-size: 12px; margin-bottom: 6px; } +.wl-chart { width: 100%; min-height: 230px; } +.wl-svg { display: block; width: 100%; height: auto; } +.wl-grid { stroke: rgba(140, 155, 175, .16); stroke-width: 1; } +.wl-base { stroke: rgba(140, 155, 175, .5); stroke-width: 1; stroke-dasharray: 2 4; } +.wl-axis { fill: var(--dim); font-size: 10px; font-family: var(--mono); } +.wl-endlab { font-size: 11px; font-family: var(--mono); font-weight: 600; } +.wl-cross-line { stroke: rgba(200, 212, 228, .45); stroke-width: 1; stroke-dasharray: 2 3; } +/* The `hidden` attribute does not hide SVG content on its own — without these + the crosshair drew itself at the origin before anyone had hovered. */ +.wl-cross[hidden], .wl-cross-dot[hidden] { display: none; } +.wl-chart-empty { padding: 60px 10px; } +.wl-legend { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; margin-top: 6px; font-size: 12px; color: var(--dim); } +.wl-legend-item { display: inline-flex; align-items: center; gap: 6px; color: var(--text); } +.wl-legend-val { font-family: var(--mono); font-weight: 600; } +.wl-legend-date { margin-left: auto; font-family: var(--mono); } +.wl-swatch { width: 14px; height: 3px; border-radius: 2px; background: var(--sw); display: inline-block; } +.wl-swatch.dashed { + background: repeating-linear-gradient(90deg, var(--sw) 0 4px, transparent 4px 7px); +} +.wl-chart-note { color: var(--dim); font-size: 11.5px; margin: 8px 0 0; line-height: 1.5; } + +/* ---- Toolbar ---- */ +.wl-toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 12px; } +.wl-toolbar input[type="search"] { + flex: 1 1 220px; min-width: 0; + background: var(--panel); border: 1px solid var(--line); color: var(--text); + padding: 9px 12px; border-radius: 10px; font-size: 14px; font-family: inherit; +} +.wl-toolbar input[type="search"]:focus { outline: 2px solid var(--accent-2); border-color: transparent; } +.wl-segment { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); } +.wl-segment button { + background: none; border: 0; color: var(--dim); font-family: inherit; font-size: 12px; + padding: .3rem .68rem; border-radius: 999px; cursor: pointer; white-space: nowrap; +} +.wl-segment button:hover { color: var(--text); } +.wl-segment button.on { background: var(--accent); color: var(--bg); font-weight: 600; } +.wl-segment button:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 1px; } +.wl-segment:empty { display: none; } +.wl-count { color: var(--dim); font-size: 12px; font-family: var(--mono); margin-left: auto; padding-right: 2px; white-space: nowrap; } + +/* ---- Table ---- */ +.wl-tablewrap { + border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); + overflow: auto; max-height: 68vh; +} +.wl-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.wl-table th, .wl-table td { padding: 8px 10px; text-align: left; white-space: nowrap; } +.wl-table thead th { + position: sticky; top: 0; z-index: 1; + background: var(--panel-2); border-bottom: 1px solid var(--line); + color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; font-weight: 600; +} +.wl-table th.num, .wl-table td.num { text-align: right; font-family: var(--mono); } +.wl-table tbody tr { border-top: 1px solid rgba(255,255,255,.04); } +.wl-table tbody tr:hover { background: rgba(255,255,255,.03); } +.wl-sort { + background: none; border: 0; padding: 0; margin: 0; cursor: pointer; font: inherit; + color: inherit; text-transform: inherit; letter-spacing: inherit; display: inline-flex; gap: 4px; align-items: center; +} +.wl-sort:hover { color: var(--text); } +.wl-sort.on { color: var(--accent); } +.wl-arrow { font-size: 9px; } +.wl-table td.pos { color: var(--pos); } +.wl-table td.neg { color: var(--neg); } +.wl-c-company { max-width: 260px; } +.wl-c-company .wl-name { display: block; overflow: hidden; text-overflow: ellipsis; max-width: 260px; } +.wl-c-company .wl-note { display: block; font-size: 11px; max-width: 260px; } +.wl-c-price { color: var(--text); } +.wl-spark { display: block; width: 92px; height: 26px; overflow: visible; } +.wl-c-spark { width: 100px; } +.wl-score { + display: inline-block; min-width: 28px; text-align: center; padding: 1px 6px; border-radius: 6px; + border: 1px solid var(--line); font-family: var(--mono); font-size: 12px; +} +.wl-score.conservative { border-color: rgba(53,208,127,.4); color: var(--pos); } +.wl-score.speculative { border-color: rgba(255,180,84,.4); color: var(--warn); } +.wl-score.high { border-color: rgba(255,107,107,.45); color: var(--neg); } +.wl-c-added { color: var(--dim); font-family: var(--mono); font-size: 11.5px; } +.wl-noreport, .wl-stale { color: var(--warn); margin-left: 3px; cursor: help; } +.wl-table .wl-remove { padding: .12rem .45rem; font-size: .8rem; line-height: 1.2; } + +@media (max-width: 820px) { + .wl-opt { display: none; } + .wl-c-company, .wl-c-company .wl-name, .wl-c-company .wl-note { max-width: 150px; } + .wl-stat-val { font-size: 18px; } +} +@media (max-width: 560px) { + /* Price and the changes are what a phone is for; the line and most of the + company name give way so the range column lands on screen. */ + .wl-c-spark, .wl-th-spark { display: none; } + .wl-c-company, .wl-c-company .wl-name, .wl-c-company .wl-note { max-width: 96px; } + .wl-table th, .wl-table td { padding: 8px 7px; } + .wl-toolbar .wl-count { margin-left: 0; } +} diff --git a/src/auth/watchlist.ts b/src/auth/watchlist.ts index 48df9ff..1da6ee5 100644 --- a/src/auth/watchlist.ts +++ b/src/auth/watchlist.ts @@ -17,6 +17,8 @@ import { SESSION_COOKIE, readCookie } from "./routes.ts"; import { userForSession, type PublicUser } from "./service.ts"; import { reportPrices } from "../reports/store.ts"; import { WATCHLIST_CSV_FILENAME, formatWatchlistCsv, parseWatchlistCsv } from "./watchlist-csv.ts"; +import { DEFAULT_RANGE, buildWatchlistOverview, isRangeKey } from "../watchlist/overview.ts"; +import type { AlpacaMarketDataClient } from "../providers/interfaces.ts"; /** Tickers per user. Generous, but bounded so one account cannot fill the table. */ export const MAX_WATCHLIST_ITEMS = 200; @@ -152,6 +154,13 @@ const json = (body: unknown, status = 200) => headers: { "content-type": "application/json", "cache-control": "no-store" }, }); +export interface WatchlistDeps { + db: Client; + /** Prices the overview. Absent in tests that only exercise the CRUD paths. */ + market?: AlpacaMarketDataClient; + marketSource?: string; +} + /** * Handle a /api/watchlist request. Returns null when the path does not match, * so the caller can continue routing. @@ -162,15 +171,34 @@ const json = (body: unknown, status = 200) => export async function handleWatchlistRoute( req: Request, path: string, - db: Client, + deps: WatchlistDeps | Client, ): Promise { - if (path !== "/api/watchlist") return null; + // Accepting a bare client keeps the older two-argument call sites working. + const resolved: WatchlistDeps = "execute" in deps ? { db: deps as Client } : (deps as WatchlistDeps); + const db = resolved.db; + const isOverview = path === "/api/watchlist/overview"; + if (path !== "/api/watchlist" && !isOverview) return null; const user: PublicUser | null = await userForSession(db, readCookie(req, SESSION_COOKIE)); if (!user) { return json({ error: "Sign in to use your watchlist.", authRequired: true }, 401); } + // The priced, charted view of the same rows. Read-only, so it is GET-only. + if (isOverview) { + if (req.method !== "GET") return json({ error: "method not allowed" }, 405); + if (!resolved.market) { + return json({ error: "Market data is not configured on this server." }, 503); + } + const raw = new URL(req.url).searchParams.get("range"); + const overview = await buildWatchlistOverview( + { db, market: resolved.market, marketSource: resolved.marketSource }, + await listWatchlist(db, user.id), + { range: isRangeKey(raw) ? raw : DEFAULT_RANGE }, + ); + return json(overview); + } + if (req.method === "GET") { const items = await listWatchlist(db, user.id); // ?format=csv is a download, not an API shape — the browser gets a file. diff --git a/src/crypto/performance.ts b/src/crypto/performance.ts index 6677e43..e7e6696 100644 --- a/src/crypto/performance.ts +++ b/src/crypto/performance.ts @@ -1,93 +1,18 @@ /** - * Multi-period price performance, computed from the daily bars already fetched - * for the indicators — no extra upstream call, no second vendor. + * Crypto's view of the shared performance calculator. * - * 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. + * The implementation moved to `src/market/performance.ts` when the equity + * watchlist needed the same per-period changes and 52-week extremes. This file + * stays as the crypto-facing name so the pair page and its tests keep reading + * the way they did. */ +import { CRYPTO_PERIODS, computePerformance as compute, type PricePerformance } from "../market/performance.ts"; 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 }, -]; +export type { PeriodChange } from "../market/performance.ts"; +export type CryptoPerformance = PricePerformance; -/** - * `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. - */ +/** `bars` must be chronological. See the shared implementation for the rules. */ 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, - }; + return compute(bars, CRYPTO_PERIODS); } diff --git a/src/crypto/sparkline.ts b/src/crypto/sparkline.ts index a8d7bb3..7785ed8 100644 --- a/src/crypto/sparkline.ts +++ b/src/crypto/sparkline.ts @@ -11,6 +11,13 @@ */ import type { AlpacaCryptoClient } from "./client.ts"; import type { MarketBar } from "../types.ts"; +import { downsample, toSeries, type SparkSeries } from "../market/series.ts"; + +// The series maths is shared with the equity watchlist, which draws the same +// line per row. Re-exported here so the crypto surfaces (and their tests) keep +// importing it from the module they always did. +export { downsample, toSeries }; +export type { SparkSeries }; export type SparkPeriod = "24h" | "7d"; @@ -29,51 +36,6 @@ const SPECS: Record = { "7d": { hours: 24 * 7, maxPoints: 56, cacheTtlMs: 5 * 60_000 }, }; -export interface SparkSeries { - symbol: string; - /** Closing prices, oldest first. */ - points: number[]; - first: number | null; - last: number | null; - changePercent: number | null; - start: string | null; - end: string | null; -} - -/** - * Keep at most `max` points, evenly spaced, always retaining the first and - * last. Dropping the last point would move the line's endpoint away from the - * current price and make the card disagree with the number printed beside it. - */ -export function downsample(values: number[], max: number): number[] { - if (max <= 0) return []; - if (values.length <= max) return [...values]; - if (max === 1) return [values.at(-1)!]; - const step = (values.length - 1) / (max - 1); - const out: number[] = []; - for (let i = 0; i < max; i++) out.push(values[Math.round(i * step)]!); - return out; -} - -/** Bars for one symbol -> the series a card draws. */ -export function toSeries(symbol: string, bars: MarketBar[], maxPoints: number): SparkSeries { - const usable = bars.filter((b) => Number.isFinite(b.close)); - const points = downsample(usable.map((b) => b.close), maxPoints); - const first = points[0] ?? null; - const last = points.at(-1) ?? null; - return { - symbol, - points, - first, - last, - // Measured across the window actually returned, not the window requested — - // a pair with only six hours of history reports its six-hour change. - changePercent: first != null && last != null && first !== 0 ? ((last - first) / first) * 100 : null, - start: usable[0]?.timestamp ?? null, - end: usable.at(-1)?.timestamp ?? null, - }; -} - export interface SparklineOptions { now?: () => number; } diff --git a/src/market/performance.ts b/src/market/performance.ts new file mode 100644 index 0000000..bf7a7c0 --- /dev/null +++ b/src/market/performance.ts @@ -0,0 +1,108 @@ +/** + * Multi-period price performance, computed from daily bars that were already + * fetched for something else — no extra upstream call, no second vendor. + * + * Written for the crypto pair page, moved here when the watchlist needed the + * same numbers per row. The only thing that differs between the two callers is + * which periods they name, so that is the parameter. + * + * Deliberately NOT here: market capitalisation, shares outstanding and + * all-time high. A market-data API does not carry them, and deriving them would + * mean either inventing a figure or mixing in a vendor with its own provenance. + * An absent field is better than a wrong one. + */ +import type { MarketBar } from "../types.ts"; + +export interface PeriodSpec { + label: string; + /** Calendar days back. */ + days: number; +} + +export interface PeriodChange extends PeriodSpec { + percent: number | null; + /** The close this was measured against, so the number is checkable. */ + from: number | null; +} + +export interface PricePerformance { + changes: PeriodChange[]; + high52: number | null; + low52: number | null; + high52At: string | null; + low52At: string | null; + /** 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; +} + +/** What a 24/7 asset is asked for. */ +export const CRYPTO_PERIODS: readonly PeriodSpec[] = [ + { label: "24h", days: 1 }, + { label: "7d", days: 7 }, + { label: "30d", days: 30 }, + { label: "90d", days: 90 }, + { label: "1y", days: 365 }, +]; + +/** What a watchlist row is asked for — same windows, market convention. */ +export const EQUITY_PERIODS: readonly PeriodSpec[] = [ + { label: "1D", days: 1 }, + { label: "1W", days: 7 }, + { label: "1M", days: 30 }, + { label: "3M", 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[], + periods: readonly PeriodSpec[] = CRYPTO_PERIODS, +): PricePerformance { + 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; an equity has no + // weekend bars and a feed gap would skew it either way. 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/market/series.ts b/src/market/series.ts new file mode 100644 index 0000000..419ed15 --- /dev/null +++ b/src/market/series.ts @@ -0,0 +1,54 @@ +/** + * Compact price series for anything that draws a sparkline. + * + * Lived in `src/crypto/sparkline.ts` until the watchlist grew a line per row + * and needed exactly the same thing for equities. Nothing here is crypto- or + * equity-specific: bars in, a short array of closes out, so the wire format + * stays a bare number list instead of a few thousand OHLCV objects. + */ +import type { MarketBar } from "../types.ts"; + +export interface SparkSeries { + symbol: string; + /** Closing prices, oldest first. */ + points: number[]; + first: number | null; + last: number | null; + changePercent: number | null; + start: string | null; + end: string | null; +} + +/** + * Keep at most `max` points, evenly spaced, always retaining the first and + * last. Dropping the last point would move the line's endpoint away from the + * current price and make the card disagree with the number printed beside it. + */ +export function downsample(values: number[], max: number): number[] { + if (max <= 0) return []; + if (values.length <= max) return [...values]; + if (max === 1) return [values.at(-1)!]; + const step = (values.length - 1) / (max - 1); + const out: number[] = []; + for (let i = 0; i < max; i++) out.push(values[Math.round(i * step)]!); + return out; +} + +/** Bars for one symbol -> the series a card draws. */ +export function toSeries(symbol: string, bars: MarketBar[], maxPoints: number): SparkSeries { + const usable = bars.filter((b) => Number.isFinite(b.close)); + const points = downsample(usable.map((b) => b.close), maxPoints); + const first = points[0] ?? null; + const last = points.at(-1) ?? null; + return { + symbol, + points, + first, + last, + // Measured across the window actually returned, not the window requested — + // a pair with only six hours of history reports its six-hour change. + changePercent: first != null && last != null && first !== 0 ? ((last - first) / first) * 100 : null, + start: usable[0]?.timestamp ?? null, + end: usable.at(-1)?.timestamp ?? null, + }; +} diff --git a/src/providers/alpaca.ts b/src/providers/alpaca.ts index 5ef11b8..02f6fa8 100644 --- a/src/providers/alpaca.ts +++ b/src/providers/alpaca.ts @@ -179,9 +179,16 @@ export class AlpacaClient implements AlpacaMarketDataClient { } async getBars(request: BarsRequest): Promise { - const out: MarketBar[] = []; const feed = request.feed ?? this.feed; const adjustment = request.adjustment ?? this.adjustment; + if (request.symbols.length === 0) return []; + // Several symbols at once is one request, not one per symbol. The watchlist + // overview asks for every saved ticker on every load, and the digest asks + // for the union of everyone's — serialised, that is minutes of round trips + // for data the API is happy to return in a single page. + if (request.symbols.length > 1) return this.getBarsBatched(request, feed, adjustment); + + const out: MarketBar[] = []; for (const symbol of request.symbols) { let pageToken: string | undefined; do { @@ -207,6 +214,48 @@ export class AlpacaClient implements AlpacaMarketDataClient { return out; } + /** + * The multi-symbol bars endpoint: `{ bars: { AAPL: [...], MSFT: [...] } }`, + * paginated across the whole set rather than per symbol. + * + * Chunked because the symbol list travels in the query string, and capped by + * page count so a mis-specified window cannot loop forever on a feed that + * keeps handing back a token. + */ + private async getBarsBatched( + request: BarsRequest, + feed: AlpacaFeed, + adjustment: string, + ): Promise { + const out: MarketBar[] = []; + const CHUNK = 100; + const MAX_PAGES = 50; + for (let i = 0; i < request.symbols.length; i += CHUNK) { + const chunk = request.symbols.slice(i, i + CHUNK); + let pageToken: string | undefined; + let pages = 0; + do { + const { body }: { body: any } = await this.request(this.dataUrl, "/v2/stocks/bars", { + symbols: chunk.join(","), + timeframe: request.timeframe, + start: request.start, + end: request.end, + limit: request.limit ?? 10000, + adjustment, + feed, + page_token: pageToken, + }); + for (const [symbol, rows] of Object.entries(body.bars ?? {})) { + for (const b of (rows as any[]) ?? []) { + out.push(toBar(symbol, b, request.timeframe, adjustment)); + } + } + pageToken = body.next_page_token ?? undefined; + } while (pageToken && ++pages < MAX_PAGES); + } + return out; + } + async getAssets(symbols?: string[]): Promise { // Asset metadata lives on the trading API, not the data API. const map = (a: any): AlpacaAsset => ({ diff --git a/src/server.ts b/src/server.ts index 6b07143..ede5ae8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -435,6 +435,9 @@ const server = Bun.serve({ "POST /api/report/regenerate": "rebuild one snapshot (watchlist members only)", "GET /api/discover?topic=&provider=offline&horizon=2&limit=": "ranked watchlist", "GET /api/lookup?q=&limit=": "find a ticker by company name (e.g. q=rivian -> RIVN)", + "GET /api/watchlist": "your saved tickers (requires sign-in); ?format=csv downloads them", + "GET /api/watchlist/overview?range=1M|3M|6M|1Y": + "the same tickers priced: per-row changes, sparkline and score, summary statistics, and an equal-weight index against SPY", "GET /api/digest": "your watchlist email frequency (requires sign-in)", "POST /api/digest": "set frequency: daily | weekly | off", "GET /crypto": "crypto market data index — every crypto route is namespaced under /crypto/**", @@ -802,7 +805,11 @@ const server = Bun.serve({ // The saved watchlist is the one authenticated feature — everything else // stays public. - const watchlistResponse = await handleWatchlistRoute(req, p, db); + const watchlistResponse = await handleWatchlistRoute(req, p, { + db, + market: registry.alpaca, + marketSource: registry.marketSource, + }); if (watchlistResponse) return watchlistResponse; const creditsResponse = await handleCreditsRoute(req, p, { db, coinpay, appUrl: config.appUrl }); diff --git a/src/watchlist/overview.ts b/src/watchlist/overview.ts new file mode 100644 index 0000000..b0f0bb6 --- /dev/null +++ b/src/watchlist/overview.ts @@ -0,0 +1,559 @@ +/** + * The saved watchlist, priced. + * + * `/api/watchlist` answers what is on the list: a ticker and a note. That is + * enough to render a list of links and nothing else, which is what the page did + * — you had to open every row to learn whether anything had moved. + * + * This assembles the at-a-glance view instead: per row a price, the changes + * over five windows, 52-week context, the stored report's score, and a short + * close series for the row's sparkline; across the whole list a set of summary + * statistics and an equal-weight index measured against a broad-market + * benchmark. + * + * Three rules shape the implementation: + * + * - **One upstream fetch for the whole list.** Bars come back for every saved + * ticker plus the benchmark in a single batched request (see + * `AlpacaClient.getBarsBatched`), cached for a few minutes and shared by + * every viewer, so a 200-ticker watchlist is not 200 round trips per load. + * - **Nothing is invented.** A ticker the provider has no bars for is + * reported as unpriced and named in `missing`, never filled in from its + * stored report price and never dropped silently. Every period longer than + * the history available yields null. + * - **The freshness is part of the answer.** Daily bars are end-of-session + * data, so the payload carries the date of the last bar it used. A stale + * price labelled with its date is a snapshot; unlabelled it is a bug. + */ +import type { Client } from "@libsql/client"; +import type { AlpacaMarketDataClient } from "../providers/interfaces.ts"; +import type { MarketBar } from "../types.ts"; +import { EQUITY_PERIODS, computePerformance } from "../market/performance.ts"; +import { downsample } from "../market/series.ts"; + +/** Windows the range control offers, in calendar days. */ +export const OVERVIEW_RANGES = { "1M": 30, "3M": 90, "6M": 180, "1Y": 365 } as const; +export type RangeKey = keyof typeof OVERVIEW_RANGES; +export const DEFAULT_RANGE: RangeKey = "3M"; + +export function isRangeKey(v: unknown): v is RangeKey { + return typeof v === "string" && v in OVERVIEW_RANGES; +} + +/** Broad-market line every watchlist is drawn against. */ +export const BENCHMARK_SYMBOL = "SPY"; +export const BENCHMARK_LABEL = "S&P 500 (SPY)"; + +/** Enough history for the 1Y change and the 52-week extremes to be real. */ +const HISTORY_DAYS = 400; + +/** How long a fetched set of bars is reused across requests and users. */ +const BARS_TTL_MS = 10 * 60_000; + +/** Points per row sparkline. A cell is ~110px wide; more is invisible. */ +const SPARK_POINTS = 40; + +/** Points in the index chart. Enough for a year of sessions to read smoothly. */ +const INDEX_POINTS = 160; + +/** Sessions averaged for the relative-volume figure. */ +const AVG_VOLUME_SESSIONS = 20; + +export interface SavedItem { + ticker: string; + note?: string; + createdAt: string; +} + +export interface OverviewChange { + label: string; + percent: number | null; +} + +export interface OverviewItem extends SavedItem { + companyName?: string; + classification?: string; + overallScore?: number; + confidence?: number; + signalCount: number; + sourceCount: number; + /** When the stored report snapshot was taken, if there is one. */ + reportGeneratedAt?: string; + hasReport: boolean; + /** Last close from the bars actually fetched. Absent when there are none. */ + price: number | null; + /** Session date of that close. */ + priceAsOf: string | null; + /** Keyed by period label: 1D, 1W, 1M, 3M, 1Y. */ + changes: OverviewChange[]; + /** Change across the selected range — what the table sorts and colours by. */ + rangePercent: number | null; + high52: number | null; + low52: number | null; + /** Percent below the 52-week high; 0 means it is at it. */ + fromHigh52: number | null; + volume: number | null; + avgVolume: number | null; + relativeVolume: number | null; + /** Closes across the range, oldest first — the row's sparkline. */ + spark: number[]; + barCount: number; +} + +export interface OverviewStats { + count: number; + priced: number; + /** Saved tickers the provider returned nothing for. */ + missing: string[]; + gainers: number; + losers: number; + unchanged: number; + avgDayPercent: number | null; + medianDayPercent: number | null; + best: { ticker: string; percent: number } | null; + worst: { ticker: string; percent: number } | null; + bestDay: { ticker: string; percent: number } | null; + worstDay: { ticker: string; percent: number } | null; + avgScore: number | null; + scored: number; + withReports: number; + /** Equal-weight change across the range, and the benchmark's for contrast. */ + rangePercent: number | null; + benchmarkPercent: number | null; +} + +export interface IndexPoint { + t: string; + value: number; +} + +export interface OverviewIndex { + /** Equal-weight, rebased to 100 at the start of the range. */ + points: IndexPoint[]; + benchmark: IndexPoint[]; + benchmarkSymbol: string; + benchmarkLabel: string; + /** Tickers that had history for the whole range and so are in the line. */ + members: string[]; + /** Saved tickers left out, because their history starts inside the range. */ + excluded: string[]; +} + +export interface WatchlistOverview { + range: RangeKey; + rangeDays: number; + /** Session date of the newest bar used anywhere in this payload. */ + asOf: string | null; + /** Where the prices came from. */ + source: string; + /** Set when the market fetch failed outright; items are then unpriced. */ + marketError?: string; + items: OverviewItem[]; + stats: OverviewStats; + index: OverviewIndex | null; +} + +export interface OverviewDeps { + db: Client; + market: AlpacaMarketDataClient; + marketSource?: string; + now?: () => number; +} + +/* ---- Bars cache ---------------------------------------------------------- */ + +interface CacheEntry { + at: number; + bars: MarketBar[]; +} + +/** + * Daily bars per symbol, shared process-wide. + * + * Two users watching NVDA cost one fetch, and a user reloading the tab costs + * none. Only symbols that are missing or stale are re-requested, so adding one + * ticker to a long list is a one-symbol fetch rather than a full refresh. + */ +export class BarsCache { + private cache = new Map(); + + constructor( + private readonly market: AlpacaMarketDataClient, + private readonly ttlMs = BARS_TTL_MS, + private readonly now: () => number = Date.now, + ) {} + + async get(symbols: string[]): Promise<{ bars: Map; error?: string }> { + const wanted = [...new Set(symbols)]; + const t = this.now(); + const stale = wanted.filter((s) => { + const hit = this.cache.get(s); + return !hit || t - hit.at >= this.ttlMs; + }); + + let error: string | undefined; + if (stale.length) { + try { + const fetched = await this.market.getBars({ + symbols: stale, + timeframe: "1Day", + start: new Date(t - HISTORY_DAYS * 86_400_000).toISOString(), + end: new Date(t).toISOString(), + }); + const grouped = new Map(); + for (const b of fetched) { + const sym = b.symbol.toUpperCase(); + (grouped.get(sym) ?? grouped.set(sym, []).get(sym)!).push(b); + } + // Symbols the provider answered nothing for are cached as empty too: + // a delisted or unknown ticker should not be re-requested every load. + for (const sym of stale) { + const rows = (grouped.get(sym) ?? []).sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + this.cache.set(sym, { at: t, bars: rows }); + } + } catch (err) { + error = String(err).slice(0, 300); + } + } + + const out = new Map(); + for (const s of wanted) { + const hit = this.cache.get(s); + if (hit?.bars.length) out.set(s, hit.bars); + } + return { bars: out, error }; + } +} + +/* ---- Report columns ------------------------------------------------------ */ + +interface ReportRow { + companyName?: string; + classification?: string; + overallScore?: number; + confidence?: number; + signalCount: number; + sourceCount: number; + generatedAt: string; +} + +const num = (v: unknown): number | undefined => { + const n = Number(v); + return v == null || Number.isNaN(n) ? undefined : n; +}; + +/** The denormalized report columns for a set of tickers — no payload parsing. */ +async function reportRows(db: Client, tickers: string[]): Promise> { + const out = new Map(); + if (!tickers.length) return out; + for (let i = 0; i < tickers.length; i += 100) { + const chunk = tickers.slice(i, i + 100); + const rs = await db.execute({ + sql: `SELECT ticker, company_name, last_price, overall_score, confidence, classification, + signal_count, source_count, generated_at + FROM reports WHERE ticker IN (${chunk.map(() => "?").join(",")})`, + args: chunk, + }); + for (const r of rs.rows) { + out.set(String(r.ticker), { + companyName: r.company_name == null ? undefined : String(r.company_name), + classification: r.classification == null ? undefined : String(r.classification), + overallScore: num(r.overall_score), + confidence: num(r.confidence), + signalCount: Number(r.signal_count ?? 0), + sourceCount: Number(r.source_count ?? 0), + generatedAt: String(r.generated_at), + }); + } + } + return out; +} + +/* ---- Per-row maths ------------------------------------------------------- */ + +/** Bars at or after `since`, chronological. */ +function withinRange(bars: MarketBar[], since: number): MarketBar[] { + return bars.filter((b) => Date.parse(b.timestamp) >= since); +} + +/** + * Change from the first close at or after `since` to the last close. + * + * Measured against a bar we actually have rather than the calendar date asked + * for: over a 3M window the first session inside it is the honest baseline. + * Null when the window holds fewer than two closes. + */ +function rangeChange(bars: MarketBar[], since: number): number | null { + const rows = withinRange(bars, since).filter((b) => Number.isFinite(b.close)); + if (rows.length < 2) return null; + const first = rows[0]!.close; + const last = rows.at(-1)!.close; + return first ? ((last - first) / first) * 100 : null; +} + +function averageVolume(bars: MarketBar[], sessions: number): number | null { + const vols = bars.slice(-sessions).map((b) => b.volume).filter((v): v is number => Number.isFinite(v as number)); + if (!vols.length) return null; + return vols.reduce((a, b) => a + b, 0) / vols.length; +} + +function median(values: number[]): number | null { + if (!values.length) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2; +} + +const mean = (values: number[]): number | null => + values.length ? values.reduce((a, b) => a + b, 0) / values.length : null; + +/* ---- The equal-weight index --------------------------------------------- */ + +/** Session date (YYYY-MM-DD) -> close, for one symbol. */ +function closesByDate(bars: MarketBar[]): Map { + const out = new Map(); + for (const b of bars) { + if (Number.isFinite(b.close)) out.set(b.timestamp.slice(0, 10), b.close); + } + return out; +} + +/** + * Rebase every member to 100 at the start of the range and average them. + * + * Equal weight, because a watchlist is a list of things being watched, not a + * portfolio with position sizes — weighting by price or market cap would state + * a holding nobody entered. + * + * Only tickers with a close at or before the first session are members: a + * ticker whose history starts halfway through the range would otherwise join + * the average at 100 and flatten it. The ones left out are named, not hidden. + */ +export function buildIndex( + seriesByTicker: Map, + benchmarkBars: MarketBar[] | undefined, + since: number, + tickers: string[], +): OverviewIndex | null { + const memberDates = new Map>(); + for (const ticker of tickers) { + const bars = seriesByTicker.get(ticker); + if (bars?.length) memberDates.set(ticker, closesByDate(bars)); + } + const benchDates = benchmarkBars?.length ? closesByDate(benchmarkBars) : null; + + // The benchmark trades every session, so it is the cleanest calendar. Without + // it, fall back to every date any member has. + const calendar = [ + ...new Set( + benchDates + ? [...benchDates.keys()] + : [...memberDates.values()].flatMap((m) => [...m.keys()]), + ), + ] + .sort() + .filter((d) => Date.parse(`${d}T00:00:00Z`) >= since); + if (calendar.length < 2) return null; + + const first = calendar[0]!; + const members: string[] = []; + const excluded: string[] = []; + const baselines = new Map(); + for (const [ticker, dates] of memberDates) { + // Its own first close inside the window, and only if that is the window's + // opening session — otherwise the ticker's history starts too late. + const own = [...dates.keys()].sort().find((d) => d >= first); + const base = own ? dates.get(own) : undefined; + if (own && base && own <= calendar[Math.min(2, calendar.length - 1)]!) { + members.push(ticker); + baselines.set(ticker, base); + } else { + excluded.push(ticker); + } + } + if (!members.length) return null; + + const lastSeen = new Map(); + const points: IndexPoint[] = []; + for (const date of calendar) { + const ratios: number[] = []; + for (const ticker of members) { + const close = memberDates.get(ticker)!.get(date); + // Carry the last known close through a session a symbol has no bar for — + // a single gap must not drop a member out of the average and step the line. + if (close != null) lastSeen.set(ticker, close); + const value = close ?? lastSeen.get(ticker); + const base = baselines.get(ticker)!; + if (value != null && base) ratios.push(value / base); + } + const avg = mean(ratios); + if (avg != null) points.push({ t: date, value: avg * 100 }); + } + if (points.length < 2) return null; + + const benchmark: IndexPoint[] = []; + if (benchDates) { + const firstBench = calendar.find((d) => benchDates.has(d)); + const base = firstBench ? benchDates.get(firstBench)! : null; + if (base) { + for (const date of calendar) { + const v = benchDates.get(date); + if (v != null) benchmark.push({ t: date, value: (v / base) * 100 }); + } + } + } + + return { + points: downsamplePoints(points, INDEX_POINTS), + benchmark: downsamplePoints(benchmark, INDEX_POINTS), + benchmarkSymbol: BENCHMARK_SYMBOL, + benchmarkLabel: BENCHMARK_LABEL, + members: members.sort(), + excluded: excluded.sort(), + }; +} + +/** Same rule as `downsample`, applied to dated points. */ +function downsamplePoints(points: IndexPoint[], max: number): IndexPoint[] { + if (points.length <= max) return points; + const step = (points.length - 1) / (max - 1); + const out: IndexPoint[] = []; + for (let i = 0; i < max; i++) out.push(points[Math.round(i * step)]!); + return out; +} + +/* ---- The build ----------------------------------------------------------- */ + +/** + * One `BarsCache` per process. It is keyed by symbol and holds a few hundred + * daily bars each, so even a busy instance is a small map. + */ +let sharedCache: BarsCache | null = null; +let sharedFor: AlpacaMarketDataClient | null = null; + +function cacheFor(market: AlpacaMarketDataClient, now: () => number): BarsCache { + if (!sharedCache || sharedFor !== market) { + sharedCache = new BarsCache(market, BARS_TTL_MS, now); + sharedFor = market; + } + return sharedCache; +} + +export async function buildWatchlistOverview( + deps: OverviewDeps, + saved: SavedItem[], + opts: { range?: RangeKey; cache?: BarsCache } = {}, +): Promise { + const now = deps.now ?? Date.now; + const range = opts.range ?? DEFAULT_RANGE; + const rangeDays = OVERVIEW_RANGES[range]; + // Aligned to the start of the day, for two reasons: the rows compare bar + // timestamps while the index compares session dates, and an unaligned cutoff + // makes those two disagree about the first session in the window; and a + // window that shifts with the time of day would make the same "3M" mean + // something slightly different every load. + const since = Date.parse(`${new Date(now() - rangeDays * 86_400_000).toISOString().slice(0, 10)}T00:00:00Z`); + const tickers = saved.map((i) => i.ticker); + + const cache = opts.cache ?? cacheFor(deps.market, now); + // An empty watchlist has nothing to price: fetching the benchmark alone would + // spend a request to draw a line with no watchlist on it. + const [{ bars, error }, reports] = tickers.length + ? await Promise.all([cache.get([...tickers, BENCHMARK_SYMBOL]), reportRows(deps.db, tickers)]) + : [{ bars: new Map(), error: undefined }, new Map()]; + + const items: OverviewItem[] = saved.map((entry) => { + const rows = bars.get(entry.ticker) ?? []; + const report = reports.get(entry.ticker); + const perf = computePerformance(rows, EQUITY_PERIODS); + const last = rows.at(-1); + const inRange = withinRange(rows, since); + const avgVolume = averageVolume(rows, AVG_VOLUME_SESSIONS); + const volume = last?.volume ?? null; + + return { + ...entry, + companyName: report?.companyName, + classification: report?.classification, + overallScore: report?.overallScore, + confidence: report?.confidence, + signalCount: report?.signalCount ?? 0, + sourceCount: report?.sourceCount ?? 0, + reportGeneratedAt: report?.generatedAt, + hasReport: Boolean(report), + price: last?.close ?? null, + priceAsOf: last?.timestamp.slice(0, 10) ?? null, + changes: perf.changes.map((c) => ({ label: c.label, percent: c.percent })), + rangePercent: rangeChange(rows, since), + high52: perf.high52, + low52: perf.low52, + fromHigh52: + perf.high52 && last?.close != null ? ((last.close - perf.high52) / perf.high52) * 100 : null, + volume, + avgVolume, + relativeVolume: volume != null && avgVolume ? volume / avgVolume : null, + spark: downsample(inRange.map((b) => b.close).filter((c) => Number.isFinite(c)), SPARK_POINTS), + barCount: perf.barCount, + }; + }); + + const dayOf = (i: OverviewItem) => i.changes.find((c) => c.label === "1D")?.percent ?? null; + const priced = items.filter((i) => i.price != null); + const dayMoves = priced.map(dayOf).filter((p): p is number => p != null); + const ranked = priced.filter((i) => i.rangePercent != null); + const byRange = [...ranked].sort((a, b) => b.rangePercent! - a.rangePercent!); + const byDay = priced.filter((i) => dayOf(i) != null).sort((a, b) => dayOf(b)! - dayOf(a)!); + const scores = items.map((i) => i.overallScore).filter((s): s is number => s != null); + + const index = buildIndex(bars, bars.get(BENCHMARK_SYMBOL), since, tickers); + const benchPoints = index?.benchmark ?? []; + const benchmarkPercent = + benchPoints.length >= 2 ? benchPoints.at(-1)!.value - benchPoints[0]!.value : null; + const indexPoints = index?.points ?? []; + const rangePercent = indexPoints.length >= 2 ? indexPoints.at(-1)!.value - indexPoints[0]!.value : null; + + const asOf = items + .map((i) => i.priceAsOf) + .filter((d): d is string => d != null) + .sort() + .at(-1) ?? null; + + const top = (list: OverviewItem[], pick: (i: OverviewItem) => number | null) => { + const head = list[0]; + const percent = head ? pick(head) : null; + return head && percent != null ? { ticker: head.ticker, percent } : null; + }; + + const stats: OverviewStats = { + count: items.length, + priced: priced.length, + missing: items.filter((i) => i.price == null).map((i) => i.ticker), + gainers: dayMoves.filter((p) => p > 0).length, + losers: dayMoves.filter((p) => p < 0).length, + unchanged: dayMoves.filter((p) => p === 0).length, + avgDayPercent: mean(dayMoves), + medianDayPercent: median(dayMoves), + best: top(byRange, (i) => i.rangePercent), + worst: top([...byRange].reverse(), (i) => i.rangePercent), + bestDay: top(byDay, dayOf), + worstDay: top([...byDay].reverse(), dayOf), + avgScore: mean(scores), + scored: scores.length, + withReports: items.filter((i) => i.hasReport).length, + rangePercent, + benchmarkPercent, + }; + + return { + range, + rangeDays, + asOf, + source: deps.marketSource ?? "market data", + // Only a real failure is reported. An empty watchlist has no prices to + // fetch and is not an error. + marketError: error, + items, + stats, + index, + }; +} diff --git a/test/dashboard-watchlist.test.ts b/test/dashboard-watchlist.test.ts new file mode 100644 index 0000000..9cbbdd2 --- /dev/null +++ b/test/dashboard-watchlist.test.ts @@ -0,0 +1,476 @@ +/** + * The watchlist tab — the real public/index.html and public/app.js, in a real DOM. + * + * Two things are being locked down. The first is that the tab is a *place*: + * /watchlist is a path the server can answer and a link someone can send, and + * the older /#watchlist form still lands there. The second is that the tab is a + * dashboard rather than a list of links — summary tiles, one chart, and a table + * whose sort, filter and range survive a reload, because they live in the URL + * and in storage rather than in a variable that dies with the page. + * + * Hermetic like its crypto sibling: every request is answered from the fixtures + * below, so the suite needs no server, no database and no market data, and + * cannot go red because a price moved. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { JSDOM, VirtualConsole } from "jsdom"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const PUBLIC_DIR = join(import.meta.dir, "..", "public"); +const read = (f: string) => readFileSync(join(PUBLIC_DIR, f), "utf8"); + +/* ---- Fixtures ---------------------------------------------------------- */ + +const ITEMS = [ + { ticker: "NVDA", note: "Discover “AI infrastructure” · #1", createdAt: "2026-06-01T00:00:00.000Z" }, + { ticker: "RIVN", createdAt: "2026-07-15T00:00:00.000Z" }, + { ticker: "ZZZZ", note: "no market data", createdAt: "2026-08-01T00:00:00.000Z" }, +]; + +const spark = (from: number, to: number, n = 12) => + Array.from({ length: n }, (_, i) => Number((from + ((to - from) * i) / (n - 1)).toFixed(2))); + +const overviewItem = ( + ticker: string, + opts: Partial> = {}, +) => ({ + ticker, + note: ITEMS.find((i) => i.ticker === ticker)?.note, + createdAt: ITEMS.find((i) => i.ticker === ticker)?.createdAt, + companyName: null, + classification: null, + overallScore: null, + confidence: null, + signalCount: 0, + sourceCount: 0, + hasReport: false, + price: null, + priceAsOf: null, + changes: [ + { label: "1D", percent: null }, + { label: "1W", percent: null }, + { label: "1M", percent: null }, + { label: "3M", percent: null }, + { label: "1Y", percent: null }, + ], + rangePercent: null, + high52: null, + low52: null, + fromHigh52: null, + volume: null, + avgVolume: null, + relativeVolume: null, + spark: [], + barCount: 0, + ...opts, +}); + +/** The 3M payload; the 1Y one differs so a range switch is observable. */ +function overview(range: string) { + const long = range === "1Y"; + return { + range, + rangeDays: long ? 365 : 90, + asOf: "2026-08-14", + source: "iex", + items: [ + overviewItem("NVDA", { + companyName: "NVIDIA Corporation", + classification: "speculative", + overallScore: 71.4, + confidence: 62, + signalCount: 12, + sourceCount: 4, + hasReport: true, + reportGeneratedAt: "2026-08-14T12:00:00.000Z", + price: 178.24, + priceAsOf: "2026-08-14", + changes: [ + { label: "1D", percent: 1.25 }, + { label: "1W", percent: 3.4 }, + { label: "1M", percent: 9.1 }, + { label: "3M", percent: 22.5 }, + { label: "1Y", percent: 140 }, + ], + rangePercent: long ? 140 : 22.5, + high52: 190, + low52: 90, + fromHigh52: -6.19, + spark: spark(150, 178), + barCount: 250, + }), + overviewItem("RIVN", { + companyName: "Rivian Automotive, Inc.", + classification: "high-risk speculative", + overallScore: 44, + confidence: 51, + signalCount: 3, + sourceCount: 1, + hasReport: true, + reportGeneratedAt: "2026-08-10T12:00:00.000Z", + price: 12.06, + priceAsOf: "2026-08-14", + changes: [ + { label: "1D", percent: -2.4 }, + { label: "1W", percent: -5.1 }, + { label: "1M", percent: -8 }, + { label: "3M", percent: -14.75 }, + { label: "1Y", percent: -30 }, + ], + rangePercent: long ? -30 : -14.75, + high52: 20, + low52: 10, + fromHigh52: -39.7, + spark: spark(15, 12), + barCount: 250, + }), + overviewItem("ZZZZ"), + ], + stats: { + count: 3, + priced: 2, + missing: ["ZZZZ"], + gainers: 1, + losers: 1, + unchanged: 0, + avgDayPercent: -0.575, + medianDayPercent: -0.575, + best: { ticker: "NVDA", percent: long ? 140 : 22.5 }, + worst: { ticker: "RIVN", percent: long ? -30 : -14.75 }, + bestDay: { ticker: "NVDA", percent: 1.25 }, + worstDay: { ticker: "RIVN", percent: -2.4 }, + avgScore: 57.7, + scored: 2, + withReports: 2, + rangePercent: long ? 55 : 3.9, + benchmarkPercent: long ? 18 : 2.1, + }, + index: { + points: [ + { t: "2026-05-16", value: 100 }, + { t: "2026-06-16", value: 104.2 }, + { t: "2026-07-16", value: 99.5 }, + { t: "2026-08-14", value: long ? 155 : 103.9 }, + ], + benchmark: [ + { t: "2026-05-16", value: 100 }, + { t: "2026-06-16", value: 101.1 }, + { t: "2026-07-16", value: 100.4 }, + { t: "2026-08-14", value: long ? 118 : 102.1 }, + ], + benchmarkSymbol: "SPY", + benchmarkLabel: "S&P 500 (SPY)", + members: ["NVDA", "RIVN"], + excluded: ["ZZZZ"], + }, + }; +} + +let watchlistRequests: Array<{ method: string; url: string; body?: string }> = []; + +function respond(rawUrl: string): unknown { + const url = new URL(rawUrl, "http://localhost"); + const p = url.pathname; + if (p === "/health") return { ok: true }; + if (p === "/api/stats") return { documents: 1, signals: 2, transcripts: 3, analyses: 4 }; + if (p === "/api/topics") return { topics: ["AI infrastructure"] }; + if (p === "/api/discover") return { candidates: [], disclaimer: "" }; + if (p === "/api/auth/me") return { user: { id: "u1", email: "a@b.com", emailVerified: true } }; + if (p === "/api/credits") return { balance: 100, monthlyFree: 100 }; + if (p === "/api/digest") return { frequency: "daily", nextSendAt: "2026-08-18T08:00:00.000Z" }; + if (p === "/api/watchlist/overview") return overview(url.searchParams.get("range") ?? "3M"); + if (p === "/api/watchlist") return { items: ITEMS }; + return {}; +} + +/* ---- Harness ------------------------------------------------------------ */ + +let dom: JSDOM; +let win: any; +let pageErrors: string[] = []; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const $ = (sel: string) => win.document.querySelector(sel); +const $$ = (sel: string) => [...win.document.querySelectorAll(sel)]; +const text = (sel: string) => $(sel)?.textContent ?? ""; +const click = (el: any) => el?.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); +const rows = () => $$(".wl-table tbody tr").map((r: any) => r.dataset.ticker); +const cell = (ticker: string, nth: number) => + $$(`.wl-table tbody tr`).find((r: any) => r.dataset.ticker === ticker)?.children[nth]?.textContent?.trim(); + +async function loadPage(where = "/watchlist") { + pageErrors = []; + watchlistRequests = []; + const vc = new VirtualConsole(); + vc.on("jsdomError", (e: Error) => pageErrors.push(e.message)); + vc.on("error", (...a: unknown[]) => pageErrors.push(a.join(" "))); + + dom = new JSDOM(read("index.html"), { + url: `http://localhost${where}`, + runScripts: "outside-only", + pretendToBeVisual: true, + virtualConsole: vc, + }); + win = dom.window as any; + win.LightweightCharts = null; + win.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }; + win.alert = () => {}; + win.fetch = async (input: any, init: any = {}) => { + const u = String(input?.url ?? input); + const method = String(init.method ?? "GET"); + if (u.includes("/api/watchlist")) watchlistRequests.push({ method, url: u, body: init.body }); + return { + ok: true, status: 200, + json: async () => respond(u), + text: async () => JSON.stringify(respond(u)), + }; + }; + + win.eval([read("app.js"), read("auth.js")].join("\n;\n")); + await sleep(200); +} + +beforeEach(async () => { await loadPage(); }); +afterEach(() => { try { win?.close(); } catch {} }); + +/* ---- Tests -------------------------------------------------------------- */ + +describe("routing", () => { + test("the page evaluates without errors", () => { + expect(pageErrors).toEqual([]); + }); + + test("/watchlist opens the watchlist tab", () => { + expect($('.view[data-view="watchlist"]').classList.contains("active")).toBe(true); + const tab = $$("#tabs button").find((b: any) => b.dataset.view === "watchlist"); + expect(tab.classList.contains("active")).toBe(true); + }); + + test("the older /#watchlist link still lands there, and drops the fragment", async () => { + await loadPage("/#watchlist"); + expect($('.view[data-view="watchlist"]').classList.contains("active")).toBe(true); + expect(win.location.pathname).toBe("/watchlist"); + }); + + test("an unknown path falls back to Discover", async () => { + await loadPage("/not-a-tab"); + expect($('.view[data-view="discover"]').classList.contains("active")).toBe(true); + }); + + test("switching tabs writes the path, and Back returns", async () => { + click($$("#tabs button").find((b: any) => b.dataset.view === "search")); + expect(win.location.pathname).toBe("/search"); + win.history.back(); + await sleep(60); + expect(win.location.pathname).toBe("/watchlist"); + expect($('.view[data-view="watchlist"]').classList.contains("active")).toBe(true); + }); +}); + +describe("summary statistics", () => { + test("renders a tile per headline number", () => { + const tiles = $$(".wl-stat"); + expect(tiles.length).toBe(6); + const labels = tiles.map((t: any) => t.querySelector(".wl-stat-lab").textContent); + expect(labels).toContain("Tickers"); + expect(labels).toContain("Best"); + expect(labels).toContain("3M equal-weight"); + }); + + test("signs the numbers and says what they are measured against", () => { + const tile = (label: string) => + $$(".wl-stat").find((t: any) => t.querySelector(".wl-stat-lab").textContent === label); + // -0.575 is not exactly representable, so it rounds down at two places. + expect(tile("Last session").querySelector(".wl-stat-val").textContent).toBe("-0.57%"); + expect(tile("Last session").querySelector(".wl-stat-val").classList.contains("neg")).toBe(true); + expect(tile("Last session").querySelector(".wl-stat-sub").textContent).toBe("1 up · 1 down"); + expect(tile("3M equal-weight").querySelector(".wl-stat-sub").textContent).toContain("SPY +2.1%"); + expect(tile("Best").textContent).toContain("NVDA"); + expect(tile("Tickers").querySelector(".wl-stat-sub").textContent).toContain("without data"); + }); +}); + +describe("the index chart", () => { + test("draws both series, rebased, with a legend and direct labels", () => { + const paths = $$("#wl-chart .wl-line"); + expect(paths.length).toBe(2); + // Identity never rests on colour alone: the benchmark is dashed as well. + expect(paths[1].getAttribute("stroke-dasharray")).toBeTruthy(); + const labels = $$("#wl-chart .wl-endlab").map((t: any) => t.textContent); + expect(labels[0]).toContain("Watchlist"); + expect(labels[1]).toContain("SPY"); + const legend = text("#wl-chart-legend"); + expect(legend).toContain("Watchlist"); + expect(legend).toContain("SPY"); + expect(legend).toContain("+3.9%"); + }); + + test("says how many tickers are in the line and which were left out", () => { + const note = text("#wl-chart-note"); + expect(note).toContain("2 tickers"); + expect(note).toContain("ZZZZ"); + }); + + test("a baseline marks the rebasing point", () => { + expect($("#wl-chart .wl-base")).toBeTruthy(); + }); +}); + +describe("the table", () => { + test("renders a row per saved ticker, including the unpriced one", () => { + expect(rows()).toHaveLength(3); + expect(rows()).toContain("ZZZZ"); + }); + + test("shows price, changes and company for a priced row", () => { + const row = $$(".wl-table tbody tr").find((r: any) => r.dataset.ticker === "NVDA"); + expect(row.textContent).toContain("NVIDIA Corporation"); + expect(row.textContent).toContain("$178.24"); + expect(row.textContent).toContain("+1.25%"); + expect(row.querySelector(".wl-spark")).toBeTruthy(); + expect(row.querySelector(".wl-score").textContent).toBe("71"); + }); + + test("an unpriced row shows gaps rather than invented numbers", () => { + const row = $$(".wl-table tbody tr").find((r: any) => r.dataset.ticker === "ZZZZ"); + expect(row.textContent).toContain("—"); + expect(row.textContent).not.toContain("$"); + expect(row.querySelector(".wl-spark")).toBeNull(); + }); + + test("the ticker is a link to its shareable report page", () => { + const link = $$(".wl-table .wl-tick").find((a: any) => a.textContent === "RIVN"); + expect(link.getAttribute("href")).toBe("/stocks/RIVN"); + }); + + test("sorts by the range column, biggest first, by default", () => { + expect(rows()).toEqual(["NVDA", "RIVN", "ZZZZ"]); + }); + + test("clicking a header sorts by it, and clicking again reverses", () => { + click($$(".wl-sort").find((b: any) => b.dataset.sort === "d1")); + expect(rows()).toEqual(["NVDA", "RIVN", "ZZZZ"]); + click($$(".wl-sort").find((b: any) => b.dataset.sort === "d1")); + // Ascending puts the worst first — but the row with no data stays last, + // because "unknown" is not "smallest". + expect(rows()).toEqual(["RIVN", "NVDA", "ZZZZ"]); + }); + + test("text columns sort A-Z on first click", () => { + click($$(".wl-sort").find((b: any) => b.dataset.sort === "ticker")); + expect(rows()).toEqual(["NVDA", "RIVN", "ZZZZ"]); + click($$(".wl-sort").find((b: any) => b.dataset.sort === "ticker")); + expect(rows()).toEqual(["ZZZZ", "RIVN", "NVDA"]); + }); + + test("the sorted column is marked for assistive tech", () => { + click($$(".wl-sort").find((b: any) => b.dataset.sort === "price")); + const th = $$(".wl-table thead th").find((h: any) => h.querySelector('[data-sort="price"]')); + expect(th.getAttribute("aria-sort")).toBe("descending"); + }); +}); + +describe("filtering", () => { + test("the filter box matches ticker, company and note", async () => { + const box = $("#wl-filter"); + box.value = "rivian"; + box.dispatchEvent(new win.Event("input", { bubbles: true })); + expect(rows()).toEqual(["RIVN"]); + + box.value = "AI infrastructure"; + box.dispatchEvent(new win.Event("input", { bubbles: true })); + expect(rows()).toEqual(["NVDA"]); + expect(text("#wl-count")).toBe("1 of 3"); + }); + + test("the risk-class chips are built from the classes actually present", () => { + const chips = $$("#wl-classes button").map((b: any) => b.dataset.class); + expect(chips).toEqual(["all", "speculative", "high-risk speculative"]); + click($$("#wl-classes button").find((b: any) => b.dataset.class === "speculative")); + expect(rows()).toEqual(["NVDA"]); + }); + + test("a filter that matches nothing says so instead of rendering an empty table", () => { + const box = $("#wl-filter"); + box.value = "nothing matches this"; + box.dispatchEvent(new win.Event("input", { bubbles: true })); + expect($(".wl-table")).toBeNull(); + expect(text("#my-list")).toContain("No rows match"); + }); +}); + +describe("the range control", () => { + test("defaults to 3M and labels the column with it", () => { + expect($$("#wl-ranges button").find((b: any) => b.classList.contains("on")).dataset.range).toBe("3M"); + expect($$(".wl-sort").find((b: any) => b.dataset.sort === "range").textContent).toContain("3M"); + }); + + test("switching to 1Y refetches and redraws from the longer window", async () => { + click($$("#wl-ranges button").find((b: any) => b.dataset.range === "1Y")); + await sleep(120); + expect(watchlistRequests.some((r) => r.url.includes("range=1Y"))).toBe(true); + expect(cell("NVDA", 6)).toBe("+140.0%"); + expect($$(".wl-sort").find((b: any) => b.dataset.sort === "range").textContent).toContain("1Y"); + }); +}); + +describe("state that survives a reload", () => { + test("sort, filter and range are written to the URL", async () => { + click($$(".wl-sort").find((b: any) => b.dataset.sort === "ticker")); + const box = $("#wl-filter"); + box.value = "riv"; + box.dispatchEvent(new win.Event("input", { bubbles: true })); + click($$("#wl-ranges button").find((b: any) => b.dataset.range === "6M")); + await sleep(60); + const params = new URLSearchParams(win.location.search); + expect(win.location.pathname).toBe("/watchlist"); + expect(params.get("sort")).toBe("ticker"); + expect(params.get("dir")).toBe("asc"); + expect(params.get("q")).toBe("riv"); + expect(params.get("range")).toBe("6M"); + }); + + test("a link carrying that state opens the table already configured", async () => { + await loadPage("/watchlist?sort=ticker&dir=desc&q=riv&range=1Y"); + expect(rows()).toEqual(["RIVN"]); + expect($$("#wl-ranges button").find((b: any) => b.classList.contains("on")).dataset.range).toBe("1Y"); + expect(watchlistRequests.some((r) => r.url.includes("range=1Y"))).toBe(true); + }); +}); + +describe("membership", () => { + test("Remove deletes through the API and takes the row with it", async () => { + const row = $$(".wl-table tbody tr").find((r: any) => r.dataset.ticker === "RIVN"); + click(row.querySelector(".wl-remove")); + await sleep(80); + const del = watchlistRequests.find((r) => r.method === "DELETE"); + expect(del).toBeTruthy(); + expect(JSON.parse(del!.body!)).toEqual({ ticker: "RIVN" }); + }); + + test("the summary line names its source and its date", () => { + const summary = text("#my-summary"); + expect(summary).toContain("3 saved"); + expect(summary).toContain("iex"); + expect(summary).toContain("2026-08-14"); + expect(summary).toContain("ZZZZ"); + }); + + test("the dashboard is hidden when nothing is saved", async () => { + // Same page, an empty list: the prompt should be all there is. + const original = win.fetch; + win.fetch = async (input: any, init: any = {}) => { + const u = String(input?.url ?? input); + if (u.includes("/api/watchlist/overview")) { + return { ok: true, status: 200, json: async () => ({ ...overview("3M"), items: [], index: null }) }; + } + if (u.includes("/api/watchlist")) return { ok: true, status: 200, json: async () => ({ items: [] }) }; + return original(input, init); + }; + win.dispatchEvent(new win.CustomEvent("advis0r:auth-changed")); + await sleep(120); + expect($("#wl-dash").hasAttribute("hidden")).toBe(true); + expect(text("#my-list")).toContain("Nothing saved yet"); + }); +}); diff --git a/test/watchlist-overview.test.ts b/test/watchlist-overview.test.ts new file mode 100644 index 0000000..34a1e3c --- /dev/null +++ b/test/watchlist-overview.test.ts @@ -0,0 +1,369 @@ +/** + * The priced watchlist — /api/watchlist/overview. + * + * What is worth locking down here is not the arithmetic so much as the + * honesty rules around it: a ticker the provider has no bars for must stay on + * the list and be named as unpriced rather than quietly disappear or borrow a + * price from its stored report; a period longer than the history available must + * come back null; and the equal-weight line must leave out a ticker whose + * history starts inside the window instead of joining it at par and flattening + * the curve. + * + * The upstream cost is asserted too. One fetch for a whole watchlist, reused + * across viewers, is the difference between this being a page you can leave + * open and one that spends a request per row per load. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { createClient, type Client } from "@libsql/client"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { migrate } from "../src/db/index.ts"; +import { newId } from "../src/auth/crypto.ts"; +import { handleWatchlistRoute } from "../src/auth/watchlist.ts"; +import { saveReport } from "../src/reports/store.ts"; +import { + BENCHMARK_SYMBOL, + BarsCache, + buildWatchlistOverview, + isRangeKey, + type SavedItem, +} from "../src/watchlist/overview.ts"; +import type { AlpacaMarketDataClient } from "../src/providers/interfaces.ts"; +import type { MarketBar } from "../src/types.ts"; + +const dir = mkdtempSync(join(tmpdir(), "advis0r-wl-overview-")); +let db: Client; + +const DAY = 86_400_000; +/** Fixed clock: every window in these tests is measured back from here. */ +const NOW = Date.parse("2026-08-17T20:00:00Z"); +const now = () => NOW; + +/** + * One bar per calendar day, closing on a straight line from `from` to `to`. + * A daily series with no weekend gaps is enough for every rule under test and + * makes each expected number obvious by hand. + */ +function ramp(symbol: string, days: number, from: number, to: number): MarketBar[] { + return Array.from({ length: days }, (_, i) => { + const close = Number((from + ((to - from) * i) / (days - 1)).toFixed(4)); + return { + symbol, + timestamp: new Date(NOW - (days - 1 - i) * DAY).toISOString(), + open: close, high: close * 1.01, low: close * 0.99, close, + volume: 1_000_000 + i, + timeframe: "1Day" as const, + adjustment: "all" as const, + }; + }); +} + +/** 400 days of history for the majors; SHORT only started ten days ago. */ +const SERIES: Record = { + UP: ramp("UP", 400, 50, 100), // doubles over the full history + DOWN: ramp("DOWN", 400, 200, 100), // halves + SHORT: ramp("SHORT", 10, 10, 11), + [BENCHMARK_SYMBOL]: ramp(BENCHMARK_SYMBOL, 400, 400, 500), +}; + +/** Counts what the page actually costs upstream. */ +class StubMarket implements AlpacaMarketDataClient { + calls: string[][] = []; + async getBars(request: { symbols: string[] }): Promise { + this.calls.push([...request.symbols]); + return request.symbols.flatMap((s) => SERIES[s] ?? []); + } + async getSnapshots() { return []; } + async getLatestTrades() { return []; } + async getLatestQuotes() { return []; } + async getAssets() { return []; } + async getCalendar() { return []; } +} + +/** + * What a symbol's line should end at, rebased to 100 over `days`. + * + * Computed from the same fixture rather than written as a constant: the + * windows are calendar-based, so "1Y" starts 365 days back into a 400-day + * series, not at its first bar — hard-coding the endpoint would encode that + * off-by-35-sessions mistake as the expectation. + */ +function rebased(symbol: string, days: number): number { + const since = Date.parse(`${new Date(NOW - days * DAY).toISOString().slice(0, 10)}T00:00:00Z`); + const win = SERIES[symbol]!.filter((b) => Date.parse(b.timestamp) >= since); + return (win.at(-1)!.close / win[0]!.close) * 100; +} + +const saved = (ticker: string, note?: string): SavedItem => ({ + ticker, + note, + createdAt: new Date(NOW - 30 * DAY).toISOString(), +}); + +function build(market: StubMarket, items: SavedItem[], range?: "1M" | "3M" | "6M" | "1Y", cache?: BarsCache) { + return buildWatchlistOverview( + { db, market, marketSource: "iex", now }, + items, + { range, cache: cache ?? new BarsCache(market, 10 * 60_000, now) }, + ); +} + +beforeAll(async () => { + db = createClient({ url: `file:${join(dir, "test.sqlite")}` }); + await migrate(db); + await saveReport(db, "UP", { + companyName: "Upward Industries", + lastPrice: 99, + overallScore: 71, + confidence: 60, + classification: "speculative", + sources: [{}, {}], + signals: [{}, {}, {}], + }); + await saveReport(db, "DOWN", { + companyName: "Downward Corp", + lastPrice: 101, + overallScore: 41, + confidence: 55, + classification: "high-risk speculative", + sources: [], + signals: [{}], + }); +}); + +afterAll(() => { + db?.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe("per-row pricing", () => { + let market: StubMarket; + let overview: Awaited>; + + beforeEach(async () => { + market = new StubMarket(); + overview = await build(market, [saved("UP", "from Discover"), saved("DOWN"), saved("NODATA")], "3M"); + }); + + test("prices every row from the bars actually fetched", () => { + const up = overview.items.find((i) => i.ticker === "UP")!; + expect(up.price).toBe(100); + expect(up.priceAsOf).toBe(new Date(NOW).toISOString().slice(0, 10)); + expect(up.barCount).toBe(400); + }); + + test("reports the changes for each window", () => { + const up = overview.items.find((i) => i.ticker === "UP")!; + const pct = (label: string) => up.changes.find((c) => c.label === label)!.percent!; + // The ramp gains 50/399 ≈ 0.1253 per session off a base near 100. + expect(pct("1D")).toBeCloseTo(0.1255, 3); + expect(pct("1W")).toBeGreaterThan(pct("1D")); + // A year back into a 400-day ramp, not the start of it. + expect(pct("1Y")).toBeCloseTo(rebased("UP", 365) - 100, 3); + expect(overview.items.find((i) => i.ticker === "DOWN")!.changes.find((c) => c.label === "1Y")!.percent) + .toBeCloseTo(rebased("DOWN", 365) - 100, 3); + }); + + test("a period longer than the history is null, not extrapolated", async () => { + const short = (await build(new StubMarket(), [saved("SHORT")], "1M")).items[0]!; + expect(short.changes.find((c) => c.label === "1D")!.percent).not.toBeNull(); + expect(short.changes.find((c) => c.label === "3M")!.percent).toBeNull(); + expect(short.changes.find((c) => c.label === "1Y")!.percent).toBeNull(); + }); + + test("a ticker with no bars stays on the list, unpriced and named", () => { + const none = overview.items.find((i) => i.ticker === "NODATA")!; + expect(none.price).toBeNull(); + expect(none.spark).toEqual([]); + expect(none.rangePercent).toBeNull(); + expect(overview.stats.missing).toEqual(["NODATA"]); + // Still three rows: dropping it would look like it was never saved. + expect(overview.items).toHaveLength(3); + }); + + test("the stored report's columns ride along without parsing payloads", () => { + const up = overview.items.find((i) => i.ticker === "UP")!; + expect(up.companyName).toBe("Upward Industries"); + expect(up.overallScore).toBe(71); + expect(up.classification).toBe("speculative"); + expect(up.signalCount).toBe(3); + expect(up.sourceCount).toBe(2); + expect(up.hasReport).toBe(true); + expect(overview.items.find((i) => i.ticker === "NODATA")!.hasReport).toBe(false); + }); + + test("the note and the date it was saved survive", () => { + expect(overview.items.find((i) => i.ticker === "UP")!.note).toBe("from Discover"); + expect(overview.items[0]!.createdAt).toBeString(); + }); + + test("the sparkline covers the selected range and ends on the last close", () => { + const up = overview.items.find((i) => i.ticker === "UP")!; + expect(up.spark.length).toBeGreaterThan(2); + expect(up.spark.at(-1)).toBe(up.price!); + // Downsampled rather than 90 raw closes on the wire. + expect(up.spark.length).toBeLessThanOrEqual(40); + }); + + test("52-week context is measured, and distance from the high is signed", () => { + const up = overview.items.find((i) => i.ticker === "UP")!; + expect(up.high52).toBeCloseTo(101, 0); + expect(up.fromHigh52!).toBeLessThanOrEqual(0); + const down = overview.items.find((i) => i.ticker === "DOWN")!; + // A year into a downtrend, today is well below the 52-week high. + expect(down.fromHigh52!).toBeLessThan(-20); + }); +}); + +describe("summary statistics", () => { + test("counts movers, names the extremes and averages the scores", async () => { + const o = await build(new StubMarket(), [saved("UP"), saved("DOWN"), saved("NODATA")], "3M"); + expect(o.stats.count).toBe(3); + expect(o.stats.priced).toBe(2); + expect(o.stats.gainers).toBe(1); + expect(o.stats.losers).toBe(1); + expect(o.stats.best!.ticker).toBe("UP"); + expect(o.stats.worst!.ticker).toBe("DOWN"); + expect(o.stats.bestDay!.ticker).toBe("UP"); + expect(o.stats.avgScore).toBe(56); // (71 + 41) / 2 + expect(o.stats.scored).toBe(2); + expect(o.stats.withReports).toBe(2); + }); + + test("an empty watchlist reports nothing and costs no upstream call", async () => { + const market = new StubMarket(); + const o = await build(market, [], "3M"); + expect(o.items).toEqual([]); + expect(o.stats.count).toBe(0); + expect(o.stats.avgDayPercent).toBeNull(); + expect(o.index).toBeNull(); + expect(market.calls).toEqual([]); + }); + + test("the range drives the window", async () => { + const market = new StubMarket(); + const cache = new BarsCache(market, 10 * 60_000, now); + const month = await build(market, [saved("UP")], "1M", cache); + const year = await build(market, [saved("UP")], "1Y", cache); + expect(month.rangeDays).toBe(30); + expect(year.rangeDays).toBe(365); + expect(year.items[0]!.rangePercent!).toBeGreaterThan(month.items[0]!.rangePercent!); + }); +}); + +describe("the equal-weight index", () => { + test("rebases every member to 100 and averages them", async () => { + const o = await build(new StubMarket(), [saved("UP"), saved("DOWN")], "1Y"); + const idx = o.index!; + expect(idx.points[0]!.value).toBeCloseTo(100, 6); + expect(idx.members).toEqual(["DOWN", "UP"]); + // UP rises and DOWN falls across the window; equal weight is their mean. + const expected = (rebased("UP", 365) + rebased("DOWN", 365)) / 2; + expect(idx.points.at(-1)!.value).toBeCloseTo(expected, 1); + expect(o.stats.rangePercent!).toBeCloseTo(expected - 100, 1); + }); + + test("draws the benchmark on the same base", async () => { + const o = await build(new StubMarket(), [saved("UP")], "1Y"); + const idx = o.index!; + expect(idx.benchmarkSymbol).toBe("SPY"); + expect(idx.benchmark[0]!.value).toBeCloseTo(100, 6); + expect(idx.benchmark.at(-1)!.value).toBeCloseTo(rebased(BENCHMARK_SYMBOL, 365), 1); + expect(o.stats.benchmarkPercent!).toBeCloseTo(rebased(BENCHMARK_SYMBOL, 365) - 100, 1); + }); + + test("leaves out a ticker whose history starts inside the window, and says so", async () => { + const o = await build(new StubMarket(), [saved("UP"), saved("SHORT")], "1Y"); + const idx = o.index!; + expect(idx.members).toEqual(["UP"]); + expect(idx.excluded).toEqual(["SHORT"]); + // Had SHORT been included at par it would have dragged the line toward 100. + expect(idx.points.at(-1)!.value).toBeCloseTo(rebased("UP", 365), 1); + }); + + test("both lines are downsampled to a drawable number of points", async () => { + const o = await build(new StubMarket(), [saved("UP")], "1Y"); + expect(o.index!.points.length).toBeLessThanOrEqual(160); + expect(o.index!.points.length).toBeGreaterThan(50); + }); +}); + +describe("upstream cost", () => { + test("one fetch covers the whole list, benchmark included", async () => { + const market = new StubMarket(); + await build(market, [saved("UP"), saved("DOWN")], "3M"); + expect(market.calls).toHaveLength(1); + expect(market.calls[0]).toEqual(["UP", "DOWN", "SPY"]); + }); + + test("a second load inside the TTL costs nothing", async () => { + const market = new StubMarket(); + const cache = new BarsCache(market, 10 * 60_000, now); + await build(market, [saved("UP")], "3M", cache); + await build(market, [saved("UP")], "1Y", cache); + expect(market.calls).toHaveLength(1); + }); + + test("adding a ticker fetches only the ticker that was added", async () => { + const market = new StubMarket(); + const cache = new BarsCache(market, 10 * 60_000, now); + await build(market, [saved("UP")], "3M", cache); + await build(market, [saved("UP"), saved("DOWN")], "3M", cache); + expect(market.calls).toHaveLength(2); + expect(market.calls[1]).toEqual(["DOWN"]); + }); + + test("a provider failure degrades to an unpriced list with the reason", async () => { + const broken: AlpacaMarketDataClient = { + async getBars() { throw new Error("Alpaca 403: forbidden"); }, + async getSnapshots() { return []; }, + async getLatestTrades() { return []; }, + async getLatestQuotes() { return []; }, + async getAssets() { return []; }, + async getCalendar() { return []; }, + }; + const o = await buildWatchlistOverview( + { db, market: broken, marketSource: "iex", now }, + [saved("UP")], + { cache: new BarsCache(broken, 10 * 60_000, now) }, + ); + expect(o.marketError).toContain("403"); + expect(o.items[0]!.price).toBeNull(); + // The row still carries everything that does not need a price. + expect(o.items[0]!.companyName).toBe("Upward Industries"); + expect(o.index).toBeNull(); + }); +}); + +describe("the route", () => { + const url = "http://localhost/api/watchlist/overview"; + + test("an anonymous request is 401 with the sign-in marker", async () => { + const res = (await handleWatchlistRoute(new Request(url), "/api/watchlist/overview", { db }))!; + expect(res.status).toBe(401); + expect((await res.json()).authRequired).toBe(true); + }); + + test("it is not a write endpoint", async () => { + const res = (await handleWatchlistRoute( + new Request(url, { method: "POST" }), + "/api/watchlist/overview", + { db }, + ))!; + // Unauthenticated first: the method check must never leak whether a + // watchlist exists. + expect(res.status).toBe(401); + }); + + test("an unrelated path is still passed through", async () => { + expect(await handleWatchlistRoute(new Request(url), "/api/stats", { db })).toBeNull(); + }); + + test("only the documented ranges are accepted", () => { + expect(isRangeKey("3M")).toBe(true); + expect(isRangeKey("1Y")).toBe(true); + expect(isRangeKey("10Y")).toBe(false); + expect(isRangeKey(null)).toBe(false); + }); +});