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// Live market stream client.
2//
3// Opens one EventSource to /stream, declaring the tickers the current page
4// shows so the server only polls Yahoo for symbols actually on screen. `quote`
5// events patch the data-field nodes in place; `market` events drive the status
6// pill; a `health` nudge is re-broadcast as a `finance:health` window event
7// for the data-health page. Pages are server-rendered with the freshest known
8// figures, so the initial snapshot lands silently and only genuine moves flash.
9
10// These mirror the server-side minijinja filters (templates.rs) so a value
11// patched here is identical to one the server rendered.
12const DASH = "·";
13
14// Dashboard sparkline viewBox: 0 0 100 36, line drawn within y ∈ [3, 33].
15// Mirrors the SPARK_* constants in compute.rs.
16const SPARK_TOP = 3;
17const SPARK_BOTTOM = 33;
18
19function fmtMoney(n) {
20 if (n == null || Number.isNaN(n)) return DASH;
21 return "$" + n.toLocaleString("en-US", {
22 minimumFractionDigits: 2,
23 maximumFractionDigits: 2,
24 });
25}
26
27function fmtSigned(n) {
28 if (n == null || Number.isNaN(n)) return DASH;
29 return n.toLocaleString("en-US", {
30 minimumFractionDigits: 2,
31 maximumFractionDigits: 2,
32 signDisplay: "exceptZero",
33 });
34}
35
36function fmtPct(n) {
37 if (n == null || Number.isNaN(n)) return DASH;
38 return (
39 n.toLocaleString("en-US", {
40 minimumFractionDigits: 2,
41 maximumFractionDigits: 2,
42 signDisplay: "exceptZero",
43 }) + "%"
44 );
45}
46
47// The current time as the server-side `asof` filter renders it — a market-
48// clock time of day like "3:42pm" (lowercase, no space). Used to refresh the
49// dashboard's live section freshness captions (Phase 22).
50function fmtClock(d) {
51 return d
52 .toLocaleTimeString("en-US", {
53 timeZone: "America/New_York",
54 hour: "numeric",
55 minute: "2-digit",
56 })
57 .replace(/\s/g, "")
58 .toLowerCase();
59}
60
61// Set the semantic move class (green/red/flat) from a percentage change.
62function setMove(el, pct) {
63 el.classList.remove("is-up", "is-down", "is-flat");
64 if (pct == null || Number.isNaN(pct)) el.classList.add("is-flat");
65 else el.classList.add(pct >= 0 ? "is-up" : "is-down");
66}
67
68// Nudge a dashboard card's sparkline from a live quote: recolour the card and
69// move the line's trailing point onto the new price, keeping the same value→y
70// mapping the server used (compute::sparkline). data-lo/data-hi carry the
71// y-scale; a price outside it is clamped to the box.
72function paintSparkline(root, q) {
73 root.classList.toggle("is-up-card", q.change_pct >= 0);
74 root.classList.toggle("is-down-card", q.change_pct < 0);
75
76 const svg = root.querySelector("svg.spark");
77 if (!svg || q.price == null) return;
78 const lo = parseFloat(svg.dataset.lo);
79 const hi = parseFloat(svg.dataset.hi);
80 if (!(hi > lo)) return;
81
82 const t = Math.min(1, Math.max(0, (q.price - lo) / (hi - lo)));
83 const y = (SPARK_BOTTOM - t * (SPARK_BOTTOM - SPARK_TOP)).toFixed(2);
84
85 const line = svg.querySelector(".spark__line");
86 if (line) {
87 const pts = line.getAttribute("points").trim().split(/\s+/);
88 const x = pts[pts.length - 1].split(",")[0];
89 pts[pts.length - 1] = `${x},${y}`;
90 line.setAttribute("points", pts.join(" "));
91 }
92 // The area fill's points are [x0,bottom …line… xN,bottom], so the line's
93 // final point is the second-to-last token.
94 const area = svg.querySelector(".spark__area");
95 if (area) {
96 const pts = area.getAttribute("points").trim().split(/\s+/);
97 if (pts.length >= 3) {
98 const x = pts[pts.length - 2].split(",")[0];
99 pts[pts.length - 2] = `${x},${y}`;
100 area.setAttribute("points", pts.join(" "));
101 }
102 }
103}
104
105// Last price seen per ticker, so a card flashes in the right direction.
106const lastPrice = new Map();
107
108function applyQuote(q) {
109 const prev = lastPrice.get(q.ticker);
110 lastPrice.set(q.ticker, q.price);
111 const dir = prev === undefined ? 0 : Math.sign(q.price - prev);
112
113 // Whether this quote landed on a dashboard sparkline card — if so, the live
114 // section freshness caption is refreshed once the patches are applied.
115 let hitSparkCard = false;
116
117 document.querySelectorAll(`[data-ticker="${q.ticker}"]`).forEach((root) => {
118 const price = root.querySelector('[data-field="price"]');
119 if (price) price.textContent = fmtMoney(q.price);
120
121 // Compact form on the dashboard cards: the day's % move alone.
122 const pct = root.querySelector('[data-field="change_pct"]');
123 if (pct) {
124 pct.textContent = fmtPct(q.change_pct);
125 setMove(pct, q.change_pct);
126 }
127 // Full form in the symbol header: absolute and % together.
128 const chg = root.querySelector('[data-field="change"]');
129 if (chg && q.change_pct != null) {
130 chg.textContent = `${fmtSigned(q.change_abs)} (${fmtPct(q.change_pct)})`;
131 setMove(chg, q.change_pct);
132 }
133
134 // The header's freshness caption (Phase 22): a fresh quote just landed,
135 // so reset its age to "just now" rather than letting it drift stale while
136 // the price ticks. Mirrors the server-side `ago` filter's < 5s branch.
137 const quoted = root.querySelector('[data-field="quoted"]');
138 if (quoted) quoted.textContent = "quoted just now";
139
140 // Dashboard sparkline cards track the live quote: recolour and move the
141 // line tip. (The price/change nodes above are already patched.)
142 if (root.classList.contains("spark-card")) {
143 hitSparkCard = true;
144 if (q.change_pct != null) paintSparkline(root, q);
145 }
146
147 const isCard =
148 root.classList.contains("ticker-card") ||
149 root.classList.contains("spark-card");
150 if (dir !== 0 && isCard) {
151 root.classList.remove("flash-up", "flash-down");
152 void root.offsetWidth; // reflow, so the animation re-triggers
153 root.classList.add(dir > 0 ? "flash-up" : "flash-down");
154 }
155 });
156
157 // Re-broadcast the quote as a window event (Phase 6) so the symbol chart can
158 // live-tick its last intraday bar without opening a second EventSource. The
159 // chart filters by ticker and only reacts while an intraday range is shown.
160 window.dispatchEvent(new CustomEvent("finance:quote", { detail: q }));
161
162 // A dashboard sparkline card just took a live quote, so its section's
163 // freshness caption ("prices as of …") is now current (Phase 22). Both live
164 // sections poll together each cycle, so refreshing them as one is honest.
165 if (hitSparkCard) {
166 const now = fmtClock(new Date());
167 document
168 .querySelectorAll('[data-field="spark-asof"]')
169 .forEach((el) => (el.textContent = now));
170 }
171}
172
173// Status-pill appearance per market session. The dot color reuses the
174// existing data-state styles; the label is the human session name.
175const SESSIONS = {
176 regular: { state: "ok", label: "Market open" },
177 pre: { state: "ok", label: "Pre-market" },
178 post: { state: "ok", label: "After hours" },
179 closed: { state: "idle", label: "Market closed" },
180};
181
182export function initStream() {
183 const pill = document.querySelector('[data-role="status-pill"]');
184 const label = document.querySelector('[data-role="status-label"]');
185
186 let connected = false;
187 let session = "closed";
188
189 function paintPill() {
190 if (!pill) return;
191 if (!connected) {
192 pill.dataset.state = "stale";
193 if (label) label.textContent = "Reconnecting…";
194 return;
195 }
196 const s = SESSIONS[session] || SESSIONS.closed;
197 pill.dataset.state = s.state;
198 if (label) label.textContent = s.label;
199 }
200
201 const tickers = [
202 ...new Set(
203 [...document.querySelectorAll("[data-ticker]")]
204 .map((el) => el.dataset.ticker)
205 .filter(Boolean),
206 ),
207 ];
208 const query = tickers.length
209 ? "?symbols=" + tickers.map(encodeURIComponent).join(",")
210 : "";
211
212 let es = null;
213
214 function connect() {
215 es = new EventSource("/stream" + query);
216
217 es.addEventListener("open", () => {
218 connected = true;
219 paintPill();
220 });
221 es.addEventListener("error", () => {
222 // EventSource reconnects on its own; just reflect the gap in the pill.
223 connected = false;
224 paintPill();
225 });
226 es.addEventListener("quote", (e) => {
227 try {
228 applyQuote(JSON.parse(e.data));
229 } catch {
230 /* ignore a malformed frame */
231 }
232 });
233 es.addEventListener("market", (e) => {
234 try {
235 session = JSON.parse(e.data).session;
236 paintPill();
237 } catch {
238 /* ignore a malformed frame */
239 }
240 });
241 es.addEventListener("health", () => {
242 // Re-broadcast as a window event so the data-health page can react
243 // without opening its own EventSource. The frame carries no payload —
244 // it is purely a nudge to re-pull /api/health.
245 window.dispatchEvent(new Event("finance:health"));
246 });
247 }
248
249 connect();
250
251 // Close the stream cleanly before the page goes away. Without this the
252 // browser aborts an in-flight chunked response on every navigation, which
253 // Chrome logs as ERR_INCOMPLETE_CHUNKED_ENCODING. If the page is later
254 // restored from the back/forward cache, reconnect so it stays live.
255 window.addEventListener("pagehide", () => {
256 if (es) es.close();
257 });
258 window.addEventListener("pageshow", (e) => {
259 if (e.persisted && (!es || es.readyState === EventSource.CLOSED)) {
260 connected = false;
261 connect();
262 }
263 });
264}