Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
1import {
2 createChart,
3 CandlestickSeries,
4 LineSeries,
5 HistogramSeries,
6 ColorType,
7 createSeriesMarkers,
8} from "lightweight-charts";
9
10// Paper Ledger theme: ink figures and hairline rules on the warm paper
11// surface. autoSize wires an internal ResizeObserver so the chart tracks
12// its container on every viewport.
13//
14// handleScroll / handleScale are OFF: the range buttons are the only way to
15// change what's shown, so the viewport can never pan or zoom into empty space
16// beyond the loaded data. Drag is freed up for the measure tool below.
17// Pane resizing is off for the same reason — the RSI pane keeps a fixed size.
18const chartOptions = {
19 autoSize: true,
20 handleScroll: false,
21 handleScale: false,
22 layout: {
23 background: { type: ColorType.Solid, color: "transparent" },
24 textColor: "#6b6456",
25 fontFamily: "'JetBrains Mono', monospace",
26 attributionLogo: false,
27 panes: { enableResize: false, separatorColor: "rgba(33,31,26,0.16)" },
28 },
29 grid: {
30 vertLines: { color: "rgba(33,31,26,0.07)" },
31 horzLines: { color: "rgba(33,31,26,0.07)" },
32 },
33 rightPriceScale: { borderColor: "rgba(33,31,26,0.16)" },
34 timeScale: { borderColor: "rgba(33,31,26,0.16)", rightOffset: 0 },
35 crosshair: { mode: 1 },
36};
37
38// Overlay indicator inks. The candlesticks own semantic green/red, and the
39// rest of the app reserves green/amber/red for good/ok/bad, so the moving
40// averages get their own muted, non-semantic palette — wayfinding lines, not
41// value judgments — desaturated to sit inside the warm Paper Ledger world.
42const OVERLAY_INK = {
43 sma50: "#3f6f9c",
44 sma200: "#9c6b3f",
45 ema21: "#6f5b86",
46};
47const RSI_INK = "#3f6f9c";
48// Phase 28 benchmark overlay: a fourth wayfinding ink, dashed so it reads as
49// "what would have happened in the benchmark" rather than blending with the
50// SMAs. Anchored to the fund's first visible close, so the two lines start
51// together and the divergence is the relative performance the eye should
52// follow.
53const BENCH_INK = "#7a5237";
54// Supertrend is the deliberate green/red exception (a user call): the band's
55// whole point is its trend colour, and up=green / down=red matches the app's
56// price-move semantics. It reuses the candle green/red exactly so the overlay
57// reads as part of the price, not a separate wayfinding ink.
58const SUPERTREND_UP = "#2f7d4f";
59const SUPERTREND_DOWN = "#b23b32";
60const VOLUME_UP = "rgba(47,125,79,0.38)";
61const VOLUME_DOWN = "rgba(178,59,50,0.38)";
62// Phase 25: earnings-date markers. A small ink dot above each candle that
63// matches a past 8-K item-2.02 date. Same warm-paper ink-faint as the rest
64// of the Paper Ledger palette so it reads as wayfinding, not a value verdict
65// (the candles still own green/red and the indicator inks own the other
66// non-semantic palette).
67const EARNINGS_INK = "rgba(33,31,26,0.55)";
68
69/** `12.4` -> `+$12.40`, `-3` -> `-$3.00`. */
70function fmtMoney(n) {
71 const sign = n > 0 ? "+" : n < 0 ? "-" : "";
72 return `${sign}$${Math.abs(n).toFixed(2)}`;
73}
74
75// A bar's `time` is a `YYYY-MM-DD` string on the daily ranges and a UNIX-
76// seconds number on the intraday ranges (1D / 1W). `barMs` returns epoch-ms
77// for either, and `fmtBarTime` a human label — a plain date for daily bars, a
78// New-York date+time for intraday ones (so the measure readout reads sensibly
79// in both worlds).
80function barMs(t) {
81 return typeof t === "number" ? t * 1000 : Date.parse(t);
82}
83function fmtBarTime(t) {
84 if (typeof t !== "number") return t;
85 return new Date(t * 1000).toLocaleString("en-US", {
86 timeZone: "America/New_York",
87 month: "short",
88 day: "numeric",
89 hour: "numeric",
90 minute: "2-digit",
91 });
92}
93
94/**
95 * A human caption for a visible span, e.g. "over 6 months", "over 8 years".
96 * Derived from the actual bars shown rather than the range button, so a deep
97 * MAX history clamped to what fits is described honestly. Handles both the
98 * daily date strings and the intraday UNIX-seconds times, scaling its unit
99 * from hours (an intraday session) up to years.
100 */
101function spanLabel(from, to) {
102 const ms = barMs(to) - barMs(from);
103 const hours = ms / 3.6e6;
104 if (hours < 20) {
105 const h = Math.max(1, Math.round(hours));
106 return `over ${h} hour${h === 1 ? "" : "s"}`;
107 }
108 const days = ms / 8.64e7;
109 if (days < 11) {
110 const d = Math.round(days);
111 return `over ${d} day${d === 1 ? "" : "s"}`;
112 }
113 const months = ms / 2.6298e9; // 30.44d
114 if (months < 1.6) return "over 1 month";
115 if (months < 11.5) return `over ${Math.round(months)} months`;
116 const years = months / 12;
117 return years < 1.5 ? "over 1 year" : `over ${Math.round(years)} years`;
118}
119
120export function initChart() {
121 const el = document.getElementById("chart");
122 if (!el) return;
123 const ticker = el.dataset.ticker;
124
125 const chart = createChart(el, chartOptions);
126
127 // Volume first so the candlesticks draw over it in the shared bottom strip;
128 // its own price scale, pinned low and unlabelled, keeps it off the axis.
129 const volumeSeries = chart.addSeries(HistogramSeries, {
130 priceScaleId: "volume",
131 priceFormat: { type: "volume" },
132 lastValueVisible: false,
133 priceLineVisible: false,
134 });
135 chart.priceScale("volume").applyOptions({
136 scaleMargins: { top: 0.82, bottom: 0 },
137 });
138
139 const series = chart.addSeries(CandlestickSeries, {
140 upColor: "#2f7d4f",
141 downColor: "#b23b32",
142 wickUpColor: "#2f7d4f",
143 wickDownColor: "#b23b32",
144 borderVisible: false,
145 });
146
147 // Moving-average overlays on the price pane. Created up front and shown or
148 // hidden by the toggle row; sma200 first so the faster lines sit on top.
149 const overlay = (key, dashed) =>
150 chart.addSeries(LineSeries, {
151 color: OVERLAY_INK[key],
152 lineWidth: 2,
153 lineStyle: dashed ? 2 : 0,
154 priceLineVisible: false,
155 lastValueVisible: false,
156 crosshairMarkerVisible: false,
157 });
158 const overlays = {
159 sma200: overlay("sma200", false),
160 sma50: overlay("sma50", false),
161 ema21: overlay("ema21", true),
162 };
163
164 // Benchmark line (Phase 28). Created up front and shown only when the
165 // history payload carries `benchmark`; the toggle row gets a swatch so
166 // the user can hide it the same way as the SMAs.
167 const benchmarkSeries = chart.addSeries(LineSeries, {
168 color: BENCH_INK,
169 lineWidth: 2,
170 lineStyle: 2,
171 priceLineVisible: false,
172 lastValueVisible: false,
173 crosshairMarkerVisible: false,
174 visible: false,
175 });
176
177 // Supertrend overlay: a single line whose colour is set per point — green
178 // while the band trails below price (uptrend), red while it rides above
179 // (downtrend). One line means one value and one colour per bar, so the two
180 // trends can never draw at the same time; the band simply jumps to the other
181 // side at a flip. (Two whitespace-gapped series were tried first but the line
182 // connected straight across the gaps, drawing both colours at once.)
183 const supertrendSeries = chart.addSeries(LineSeries, {
184 color: SUPERTREND_UP,
185 lineWidth: 2,
186 priceLineVisible: false,
187 lastValueVisible: false,
188 crosshairMarkerVisible: false,
189 });
190
191 // Earnings-date markers (Phase 25). Stocks only; the payload carries an
192 // `earnings` array of `YYYY-MM-DD` past dates that match candle times.
193 // Each draws a small ink dot above the matching bar. v5's
194 // createSeriesMarkers attaches to the candle series and is replaced
195 // wholesale on each setMarkers call.
196 const earningsMarkers = createSeriesMarkers(series, []);
197
198 let bars = []; // loaded candles, ascending by time
199 let latest = null; // last loaded payload, kept so RSI can attach on demand
200
201 // Prior-close reference line (Phase 6). The intraday ranges draw the previous
202 // daily close as a dashed guide so the session's move is legible at a glance;
203 // the daily ranges carry no `prev_close`, so the line is cleared on those.
204 let prevCloseLine = null;
205 function setPrevCloseLine(price) {
206 if (prevCloseLine) {
207 series.removePriceLine(prevCloseLine);
208 prevCloseLine = null;
209 }
210 if (price == null) return;
211 prevCloseLine = series.createPriceLine({
212 price,
213 color: "rgba(33,31,26,0.42)",
214 lineWidth: 1,
215 lineStyle: 2,
216 axisLabelVisible: true,
217 title: "prev close",
218 });
219 }
220
221 // The moving-average, RSI and benchmark overlays are all derived from the
222 // daily series, so they are meaningless on the intraday ranges. Their toggle
223 // buttons hide there (benchmark hides on its own when the payload has none).
224 const DAILY_ONLY_INDS = ["sma50", "sma200", "ema21", "rsi", "supertrend"];
225
226 // RSI lives in its own pane below the price pane and is created only while
227 // toggled on, so an empty second pane never lingers when it is off.
228 let rsiSeries = null;
229 function buildRsi() {
230 if (rsiSeries) return;
231 rsiSeries = chart.addSeries(
232 LineSeries,
233 {
234 color: RSI_INK,
235 lineWidth: 2,
236 priceLineVisible: false,
237 lastValueVisible: false,
238 crosshairMarkerVisible: false,
239 // Pin the pane to 0..100 so the 30/70 guides are always in view.
240 autoscaleInfoProvider: () => ({
241 priceRange: { minValue: 0, maxValue: 100 },
242 }),
243 },
244 1,
245 );
246 for (const level of [70, 30]) {
247 rsiSeries.createPriceLine({
248 price: level,
249 color: "rgba(33,31,26,0.30)",
250 lineWidth: 1,
251 lineStyle: 2,
252 axisLabelVisible: true,
253 title: String(level),
254 });
255 }
256 // Tight margins so the pinned 0..100 range fills the pane without the
257 // axis padding out to stray values like 120.
258 rsiSeries.priceScale().applyOptions({
259 scaleMargins: { top: 0.12, bottom: 0.12 },
260 });
261 const panes = chart.panes();
262 if (panes.length > 1) panes[1].setHeight(116);
263 if (latest) rsiSeries.setData(latest.rsi14);
264 }
265 function destroyRsi() {
266 if (!rsiSeries) return;
267 chart.removeSeries(rsiSeries);
268 rsiSeries = null;
269 // removeSeries leaves the now-empty pane behind; drop it explicitly.
270 if (chart.panes().length > 1) chart.removePane(1);
271 }
272
273 // Measure-tool overlay: a shaded band plus a readout chip, drawn by
274 // click-dragging across the chart to compare two points (the Google
275 // Finance gesture). Both are pointer-transparent so the drag stays on #chart.
276 const band = document.createElement("div");
277 band.className = "chart-band";
278 band.hidden = true;
279 const readout = document.createElement("div");
280 readout.className = "chart-readout";
281 readout.hidden = true;
282 el.append(band, readout);
283
284 const ts = chart.timeScale();
285 let anchorIdx = null; // bar index where the drag began
286 let curIdx = null; // bar index under the pointer now
287 let dragging = false;
288
289 // Map a viewport x to the nearest loaded bar index.
290 function barIndexAt(clientX) {
291 const x = clientX - el.getBoundingClientRect().left;
292 const logical = ts.coordinateToLogical(x);
293 if (logical === null) return null;
294 return Math.min(bars.length - 1, Math.max(0, Math.round(logical)));
295 }
296
297 function clearSelection() {
298 anchorIdx = null;
299 curIdx = null;
300 band.hidden = true;
301 readout.hidden = true;
302 }
303
304 // Position the band between the two selected bars and fill the readout
305 // with the % / absolute change over the interval. Called on drag and on
306 // every chart relayout so the band stays glued to the data.
307 function renderSelection() {
308 if (anchorIdx === null || curIdx === null || anchorIdx === curIdx) {
309 band.hidden = true;
310 readout.hidden = true;
311 return;
312 }
313 const a = Math.min(anchorIdx, curIdx);
314 const b = Math.max(anchorIdx, curIdx);
315 const xa = ts.logicalToCoordinate(a);
316 const xb = ts.logicalToCoordinate(b);
317 if (xa === null || xb === null) return;
318
319 const left = Math.min(xa, xb);
320 const width = Math.abs(xb - xa);
321 band.style.left = `${left}px`;
322 band.style.width = `${width}px`;
323 band.hidden = false;
324
325 const startClose = bars[a].close;
326 const endClose = bars[b].close;
327 const absChange = endClose - startClose;
328 const pct = startClose !== 0 ? (absChange / startClose) * 100 : 0;
329 const up = absChange >= 0;
330 readout.dataset.dir = up ? "up" : "down";
331 readout.innerHTML =
332 `<span class="chart-readout__pct">${up ? "▲" : "▼"} ` +
333 `${up ? "+" : ""}${pct.toFixed(2)}%</span>` +
334 `<span class="chart-readout__sub">${fmtMoney(absChange)} · ` +
335 `${fmtBarTime(bars[a].time)} → ${fmtBarTime(bars[b].time)}</span>`;
336 readout.hidden = false;
337
338 // Center the readout over the band, clamped to the chart's width.
339 const mid = left + width / 2;
340 const rw = readout.offsetWidth;
341 const max = el.clientWidth - rw - 4;
342 readout.style.left = `${Math.min(max, Math.max(4, mid - rw / 2))}px`;
343 }
344
345 el.addEventListener("pointerdown", (e) => {
346 if (bars.length < 2) return;
347 const idx = barIndexAt(e.clientX);
348 if (idx === null) return;
349 dragging = true;
350 anchorIdx = idx;
351 curIdx = idx;
352 el.setPointerCapture(e.pointerId);
353 renderSelection();
354 });
355 el.addEventListener("pointermove", (e) => {
356 if (!dragging) return;
357 const idx = barIndexAt(e.clientX);
358 if (idx === null) return;
359 curIdx = idx;
360 renderSelection();
361 });
362 function endDrag(e) {
363 if (!dragging) return;
364 dragging = false;
365 try {
366 el.releasePointerCapture(e.pointerId);
367 } catch {
368 /* pointer already released */
369 }
370 // A click with no drag clears any existing selection.
371 if (anchorIdx === curIdx) clearSelection();
372 }
373 el.addEventListener("pointerup", endDrag);
374 el.addEventListener("pointercancel", endDrag);
375
376 // The change chip beside the range buttons: % and absolute move across the
377 // bars actually visible on the chart, so the headline figure always agrees
378 // with what is drawn — a deep MAX history is clamped to what legibly fits,
379 // and the chip then reports only that visible span, not the full dataset.
380 function renderRangeSummary() {
381 const summary = document.getElementById("range-summary");
382 if (!summary) return;
383 let lo = 0;
384 let hi = bars.length - 1;
385 const lr = ts.getVisibleLogicalRange();
386 if (lr) {
387 lo = Math.max(lo, Math.ceil(lr.from));
388 hi = Math.min(hi, Math.floor(lr.to));
389 }
390 if (hi <= lo) {
391 summary.hidden = true;
392 return;
393 }
394 const start = bars[lo].close;
395 const end = bars[hi].close;
396 const abs = end - start;
397 const pct = start !== 0 ? (abs / start) * 100 : 0;
398 const up = abs >= 0;
399 summary.dataset.dir = up ? "up" : "down";
400 summary.innerHTML =
401 `<span class="range-summary__chg num">${up ? "▲" : "▼"} ` +
402 `${up ? "+" : ""}${pct.toFixed(2)}%` +
403 `<span class="range-summary__abs">${fmtMoney(abs)}</span></span>` +
404 `<span class="range-summary__cap">${spanLabel(bars[lo].time, bars[hi].time)}</span>`;
405 summary.hidden = false;
406 }
407
408 // Keep the band and the range chip glued to the data when the chart relays
409 // out — a range change (fitContent), a resize, anything that moves the view.
410 ts.subscribeVisibleLogicalRangeChange(() => {
411 if (anchorIdx !== null && curIdx !== null) renderSelection();
412 renderRangeSummary();
413 });
414
415 // Apply a freshly fetched payload to every series at once.
416 function applyData(d) {
417 latest = d;
418 bars = d.candles;
419 series.setData(d.candles);
420 volumeSeries.setData(
421 d.candles.map((c) => ({
422 time: c.time,
423 value: c.volume,
424 color: c.close >= c.open ? VOLUME_UP : VOLUME_DOWN,
425 })),
426 );
427 overlays.sma50.setData(d.sma50);
428 overlays.sma200.setData(d.sma200);
429 overlays.ema21.setData(d.ema21);
430 if (rsiSeries) rsiSeries.setData(d.rsi14);
431
432 // Supertrend: one line, coloured per bar by its trend side.
433 supertrendSeries.setData(
434 (d.supertrend || []).map((p) => ({
435 time: p.time,
436 value: p.value,
437 color: p.up ? SUPERTREND_UP : SUPERTREND_DOWN,
438 })),
439 );
440 // Phase 28: benchmark overlay rides on the price pane when present.
441 const bench = d.benchmark || [];
442 benchmarkSeries.setData(bench);
443 // Only show the benchmark series — and the toggle for it — when the
444 // payload actually has one; its visibility then follows the toggle's
445 // is-active state (off by default).
446 const benchBtn = document.querySelector('[data-ind="benchmark"]');
447 if (benchBtn) benchBtn.hidden = bench.length === 0;
448 const benchOn = bench.length > 0 && (!benchBtn || benchBtn.classList.contains("is-active"));
449 benchmarkSeries.applyOptions({ visible: benchOn });
450
451 // Phase 25: earnings-date pips. Filter to dates inside the visible
452 // candle window — lightweight-charts ignores markers whose time
453 // does not match a candle, but trimming first keeps the payload small
454 // and the markers sorted ascending (the API requires it).
455 const earnings = d.earnings || [];
456 const candleTimes = new Set(d.candles.map((c) => c.time));
457 const markers = earnings
458 .filter((e) => candleTimes.has(e.time))
459 .map((e) => ({
460 time: e.time,
461 position: "aboveBar",
462 color: EARNINGS_INK,
463 shape: "circle",
464 }))
465 .sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0));
466 earningsMarkers.setMarkers(markers);
467
468 // Phase 6: intraday ranges (1D / 1W) draw the prior close and drop the
469 // daily-only overlays + their toggles. Returning to a daily range restores
470 // them (RSI rebuilds its pane only if its toggle is still on).
471 setPrevCloseLine(d.intraday ? (d.prev_close ?? null) : null);
472 DAILY_ONLY_INDS.forEach((key) => {
473 const btn = document.querySelector(`[data-ind="${key}"]`);
474 if (btn) btn.hidden = !!d.intraday;
475 });
476 if (d.intraday) {
477 destroyRsi();
478 } else if (document.querySelector('[data-ind="rsi"]')?.classList.contains("is-active")) {
479 buildRsi();
480 }
481 }
482
483 // ── indicator toggles ──────────────────────────────────────────────────
484 function applyIndicator(key, on) {
485 if (key === "rsi") {
486 if (on) buildRsi();
487 else destroyRsi();
488 } else if (key === "volume") {
489 volumeSeries.applyOptions({ visible: on });
490 } else if (key === "benchmark") {
491 benchmarkSeries.applyOptions({ visible: on });
492 } else if (key === "supertrend") {
493 supertrendSeries.applyOptions({ visible: on });
494 } else if (overlays[key]) {
495 overlays[key].applyOptions({ visible: on });
496 }
497 }
498
499 document.querySelectorAll(".ind-btn").forEach((btn) => {
500 const key = btn.dataset.ind;
501 // Paint the swatch from the JS palette so the inks live in one place.
502 const dot = btn.querySelector(".ind-btn__dot");
503 if (dot) {
504 // Supertrend's swatch is split green/red to telegraph its two-tone line;
505 // the rest take their single ink from the palette.
506 dot.style.background =
507 key === "supertrend"
508 ? `linear-gradient(90deg, ${SUPERTREND_UP} 50%, ${SUPERTREND_DOWN} 50%)`
509 : key === "rsi"
510 ? RSI_INK
511 : key === "benchmark"
512 ? BENCH_INK
513 : OVERLAY_INK[key];
514 }
515 // The template's is-active class is the initial visibility.
516 applyIndicator(key, btn.classList.contains("is-active"));
517 btn.addEventListener("click", () => {
518 const on = !btn.classList.contains("is-active");
519 btn.classList.toggle("is-active", on);
520 btn.setAttribute("aria-pressed", on ? "true" : "false");
521 applyIndicator(key, on);
522 });
523 });
524
525 // ── range buttons ──────────────────────────────────────────────────────
526 const isIntraday = (range) => range === "1D" || range === "1W";
527
528 // Fetch a range and paint it. `quiet` is the 60s intraday refresh: it skips
529 // the loading dim and keeps any measure selection, since it is just folding
530 // in newly-stored bars rather than answering a click.
531 async function reload(range, quiet) {
532 if (!quiet) el.classList.add("is-loading");
533 try {
534 const res = await fetch(
535 `/api/symbols/${encodeURIComponent(ticker)}/history?range=${range}`,
536 );
537 if (!res.ok) throw new Error(`history ${res.status}`);
538 applyData(await res.json());
539 chart.timeScale().fitContent();
540 renderRangeSummary();
541 } catch (err) {
542 if (!quiet) loaded = null;
543 console.error("chart load failed", err);
544 } finally {
545 if (!quiet) el.classList.remove("is-loading");
546 }
547 }
548
549 // While an intraday range is shown, re-pull every 60s so freshly-stored 15m
550 // bars appear without a click. The fetch only touches the local DB (no Yahoo
551 // call), and the live quote stream keeps the trailing bar moving in between.
552 let refreshTimer = null;
553 function stopRefresh() {
554 if (refreshTimer) {
555 clearInterval(refreshTimer);
556 refreshTimer = null;
557 }
558 }
559
560 let loaded = null;
561 async function load(range) {
562 if (loaded === range) return;
563 loaded = range;
564 clearSelection();
565 stopRefresh();
566 await reload(range, false);
567 if (isIntraday(range)) {
568 refreshTimer = setInterval(() => reload(range, true), 60000);
569 }
570 }
571
572 // Live-tick the trailing intraday bar from the shared quote stream (Phase 6):
573 // stream.js re-broadcasts each quote as a `finance:quote` event, so the chart
574 // moves the last bar's close/high/low in place without a second EventSource.
575 window.addEventListener("finance:quote", (e) => {
576 const q = e.detail;
577 if (!q || q.ticker !== ticker || q.price == null) return;
578 if (!latest || !latest.intraday || !bars.length) return;
579 const last = bars[bars.length - 1];
580 last.close = q.price;
581 if (q.price > last.high) last.high = q.price;
582 if (q.price < last.low) last.low = q.price;
583 series.update({
584 time: last.time,
585 open: last.open,
586 high: last.high,
587 low: last.low,
588 close: last.close,
589 });
590 volumeSeries.update({
591 time: last.time,
592 value: last.volume,
593 color: last.close >= last.open ? VOLUME_UP : VOLUME_DOWN,
594 });
595 renderRangeSummary();
596 if (anchorIdx !== null && curIdx !== null) renderSelection();
597 });
598
599 window.addEventListener("pagehide", stopRefresh);
600
601 const buttons = Array.from(document.querySelectorAll("[data-range]"));
602 buttons.forEach((btn) => {
603 btn.addEventListener("click", () => {
604 buttons.forEach((b) => b.classList.remove("is-active"));
605 btn.classList.add("is-active");
606 load(btn.dataset.range);
607 });
608 });
609
610 const active = buttons.find((b) => b.classList.contains("is-active"));
611 load(active ? active.dataset.range : "1Y");
612}