Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
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}