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