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

3.6 KB · 113 lines · JavaScript Raw History
  1// Growth-of-$10,000 chart (Phase 28). A small area chart on the ETF symbol
  2// page that scales the fund's daily closes so $10,000 invested at the
  3// series' first bar reads as that, then runs forward to today. When a
  4// benchmark is configured, a dashed line of the same $10,000 in the
  5// benchmark runs alongside so the relative path is read at a glance.
  6//
  7// Driven by GET /api/symbols/{ticker}/growth, which returns
  8// `{ fund: [{date, value}, ...], benchmark: [...], benchmark_ticker }`.
  9
 10import {
 11  createChart,
 12  AreaSeries,
 13  LineSeries,
 14  ColorType,
 15} from "lightweight-charts";
 16
 17// Paper Ledger palette: ink fill (very translucent), the same warm-ink line
 18// the symbol price chart uses, and the dashed benchmark in the chart's
 19// non-semantic wayfinding-ink palette.
 20const FUND_LINE = "#2f7d4f";
 21const FUND_FILL_TOP = "rgba(47, 125, 79, 0.20)";
 22const FUND_FILL_BTM = "rgba(47, 125, 79, 0.00)";
 23const BENCH_LINE = "#7a5237";
 24
 25const chartOptions = {
 26  autoSize: true,
 27  handleScroll: false,
 28  handleScale: false,
 29  layout: {
 30    background: { type: ColorType.Solid, color: "transparent" },
 31    textColor: "#6b6456",
 32    fontFamily: "'JetBrains Mono', monospace",
 33    attributionLogo: false,
 34  },
 35  grid: {
 36    vertLines: { color: "rgba(33,31,26,0.07)" },
 37    horzLines: { color: "rgba(33,31,26,0.07)" },
 38  },
 39  rightPriceScale: {
 40    borderColor: "rgba(33,31,26,0.16)",
 41    // Format the y-axis in compact dollars (e.g. $12.4K, $58K) so the
 42    // growth path reads cleanly without a bare 50000 number.
 43    mode: 0,
 44  },
 45  timeScale: { borderColor: "rgba(33,31,26,0.16)", rightOffset: 0 },
 46  crosshair: { mode: 1 },
 47};
 48
 49export function initGrowth() {
 50  const el = document.getElementById("growth-chart");
 51  if (!el) return;
 52  const ticker = el.dataset.ticker;
 53
 54  fetch(`/api/symbols/${encodeURIComponent(ticker)}/growth`)
 55    .then((res) => {
 56      if (!res.ok) throw new Error(`growth ${res.status}`);
 57      return res.json();
 58    })
 59    .then((d) => {
 60      if (!d.fund || d.fund.length < 2) {
 61        el.innerHTML = '<p class="growth-empty">Not enough history to draw the growth path.</p>';
 62        return;
 63      }
 64      const chart = createChart(el, chartOptions);
 65      const fund = chart.addSeries(AreaSeries, {
 66        lineColor: FUND_LINE,
 67        topColor: FUND_FILL_TOP,
 68        bottomColor: FUND_FILL_BTM,
 69        lineWidth: 2,
 70        priceFormat: {
 71          type: "custom",
 72          // Compact dollar formatter for the y-axis and crosshair: $42.1K,
 73          // $1.2M etc., which keeps the labels short across the 4-orders-of-
 74          // magnitude growth a multi-decade fund accrues.
 75          formatter: fmtCompactUsd,
 76          minMove: 1,
 77        },
 78      });
 79      fund.setData(
 80        d.fund.map((p) => ({ time: p.date, value: p.value })),
 81      );
 82      if (d.benchmark && d.benchmark.length >= 2) {
 83        const bench = chart.addSeries(LineSeries, {
 84          color: BENCH_LINE,
 85          lineWidth: 2,
 86          lineStyle: 2,
 87          priceFormat: {
 88            type: "custom",
 89            formatter: fmtCompactUsd,
 90            minMove: 1,
 91          },
 92          priceLineVisible: false,
 93        });
 94        bench.setData(
 95          d.benchmark.map((p) => ({ time: p.date, value: p.value })),
 96        );
 97      }
 98      chart.timeScale().fitContent();
 99    })
100    .catch((err) => {
101      console.error("growth load failed", err);
102    });
103}
104
105/** $1,234,567 -> "$1.2M", $42_100 -> "$42.1K". */
106function fmtCompactUsd(n) {
107  const abs = Math.abs(n);
108  if (abs >= 1e9) return `$${(n / 1e9).toFixed(1)}B`;
109  if (abs >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
110  if (abs >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;
111  return `$${n.toFixed(0)}`;
112}