repos
/ finance-rust master

finance-rust

mirror archived upstream

Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.

axumdockerfinancerustself-hostedsqlitestocksvite

22.2 KB · 613 lines · JavaScript Raw History
  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}