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

9.1 KB · 265 lines · JavaScript Raw History
  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}