const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const chart = @import("../charts/chart.zig"); const tui = @import("../tui.zig"); const framework = @import("tab_framework.zig"); const market = @import("../market.zig"); const App = tui.App; const StyledLine = tui.StyledLine; const glyph = tui.glyph; /// Re-fetch the Quote tab's live quote on re-activation once the held /// quote is older than this many seconds - but only while the market is /// open (outside regular hours the price is frozen). See /// `shouldFetchLiveQuote`. const quote_live_stale_s: i64 = 60; /// Per-symbol chart state for the quote tab. Tracks the active /// timeframe, transmitted Kitty image (when supported), cached /// indicator overlays (SMA/Bollinger/etc), and last-rendered /// data fingerprints used to decide when to re-render. pub const ChartState = struct { timeframe: chart.Timeframe = .@"1Y", image_id: ?u32 = null, // currently transmitted Kitty image ID image_width: u16 = 0, // image width in cells image_height: u16 = 0, // image height in cells // SAFETY: paired with `symbol_len`; only `symbol[0..symbol_len]` is read. symbol: [16]u8 = undefined, // symbol the chart was rendered for symbol_len: usize = 0, timeframe_rendered: ?chart.Timeframe = null, // timeframe the chart was rendered for timeframe_row: ?usize = null, // screen row of the timeframe selector (for mouse clicks) dirty: bool = true, // needs re-render price_min: f64 = 0, price_max: f64 = 0, rsi_latest: ?f64 = null, // Cached indicator data (persists across frames to avoid recomputation) cached_indicators: ?chart.CachedIndicators = null, cache_candle_count: usize = 0, // candle count when cache was computed cache_timeframe: ?chart.Timeframe = null, // timeframe when cache was computed cache_last_close: f64 = 0, // last candle's close when cache was computed /// Free cached indicator memory. pub fn freeCache(self: *ChartState, alloc: std.mem.Allocator) void { if (self.cached_indicators) |*cache| { cache.deinit(alloc); self.cached_indicators = null; } self.cache_candle_count = 0; self.cache_timeframe = null; self.cache_last_close = 0; } /// Check if cache is valid for the given candle data and timeframe. pub fn isCacheValid(self: *const ChartState, candles: []const zfin.Candle, timeframe: chart.Timeframe) bool { if (self.cached_indicators == null) return false; if (self.cache_timeframe == null or self.cache_timeframe.? != timeframe) return false; // Slice candles to timeframe (same logic as renderChart) const max_days = timeframe.tradingDays(); const n = @min(candles.len, max_days); const data = candles[candles.len - n ..]; if (data.len != self.cache_candle_count) return false; if (data.len == 0) return false; // Check if last close changed (detects data refresh) const last_close = data[data.len - 1].close; if (@abs(last_close - self.cache_last_close) > 0.0001) return false; return true; } }; // ── Tab-local action enum ───────────────────────────────────── // // Quote tab cycles the chart timeframe with `[` and `]` (chart- // next / chart-prev). These bindings only fire on the quote tab // today; the surrounding `if (active_tab == .quote)` gate will // disappear when scoped keymaps land in step 3. pub const Action = enum { chart_timeframe_next, chart_timeframe_prev, toggle_live, }; // ── Tab-private state ───────────────────────────────────────── pub const State = struct { /// Stored real-time quote. Fetched on tab activation (staleness- /// gated; see `refreshLiveQuote`) and force-refreshed on r/F5. Null /// until the first successful fetch, in which case the headline /// price falls back to the last candle close. live: ?zfin.Quote = null, /// Unix-epoch seconds for the live-quote fetch - drives the /// "data Xs ago" header readout. timestamp: i64 = 0, /// Pixel-chart state (Kitty graphics + Bollinger bands + /// indicator cache + timeframe selection). Lives here because /// only the quote tab uses it; perf renders its own braille /// chart from `app.symbol_data.candles` directly. chart: ChartState = .{}, /// Opt-in live websocket streaming (default off). Toggled with the /// `toggle_live` action. While true the App polls `tick`, which /// pulls the latest streamed price into `stream_price`. The stream /// itself is owned by `DataService` and torn down on `deactivate` / /// symbol change / `deinit`. streaming: bool = false, /// Latest streamed price for the active symbol, or null until the /// first tick (or when not streaming). When set, it headlines the /// quote as the most-current mark. stream_price: ?f64 = null, }; // ── Tab framework contract ──────────────────────────────────── pub const meta: framework.TabMeta(Action) = .{ .label = "Quote", .default_bindings = &.{ .{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } }, .{ .action = .chart_timeframe_prev, .key = .{ .codepoint = '[' } }, .{ .action = .toggle_live, .key = .{ .codepoint = 'L' } }, }, .action_labels = std.enums.EnumArray(Action, []const u8).init(.{ .chart_timeframe_next = "Chart: next timeframe", .chart_timeframe_prev = "Chart: previous timeframe", .toggle_live = "Toggle live stream", }), .status_hints = &.{ .chart_timeframe_next, .toggle_live, }, }; /// Whether the Quote tab should (re)fetch the live quote on activation. /// Pure so the policy is unit-testable without a DataService. /// /// - No quote held yet (cold open / new symbol): always fetch. /// - Market open: refetch once the held quote is older than the /// staleness window, so re-entering the tab stays current like the /// CLI `quote` command; rapid toggling within the window reuses it. /// - Otherwise (pre/after-hours, weekend, holiday): the last price is /// frozen, so the single cold-open fetch suffices - don't refetch. fn shouldFetchLiveQuote(session: market.MarketSession, has_quote: bool, quote_age_s: i64) bool { if (!has_quote) return true; return session == .open and quote_age_s > quote_live_stale_s; } /// The price to headline for the quote tab, in precedence order: a live /// streamed tick (the most-current mark), then the REST quote's close, /// then the latest candle close. Null only when none are available. /// Pure so the policy is unit-testable. fn headlinePrice(stream_price: ?f64, quote: ?zfin.Quote, candles: []const zfin.Candle) ?f64 { if (stream_price) |p| return p; if (quote) |q| return q.close; if (candles.len > 0) return candles[candles.len - 1].close; return null; } /// Fetch the live quote into `state` when warranted. `force` (r/F5) /// always fetches; otherwise `shouldFetchLiveQuote` gates it. This is /// the same `DataService.getQuote` the CLI `quote` command uses. On /// failure the prior value is left untouched (the tab falls back to the /// last candle close) and the error is debug-logged - a missing live /// quote must never break tab activation. fn refreshLiveQuote(state: *State, app: *App, force: bool) void { if (app.symbol.len == 0) return; // wall-clock required: drives the staleness gate and the // "refreshed Xs ago" header timestamp. const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds(); if (!force and !shouldFetchLiveQuote(market.marketSession(now_s), state.live != null, now_s - state.timestamp)) return; if (app.svc.getQuote(app.symbol, app.fetch_options)) |q| { state.live = q; state.timestamp = now_s; } else |err| std.log.scoped(.quote_tab).debug("{s}: live-quote fetch failed: {t}", .{ app.symbol, err }); } pub const tab = struct { pub const ActionT = Action; pub const StateT = State; pub fn init(state: *State, app: *App) !void { _ = app; state.* = .{}; } pub fn deinit(state: *State, app: *App) void { if (state.streaming) app.svc.stopLiveStream(); state.chart.freeCache(app.allocator); state.* = .{}; } /// Release the transmitted Kitty chart image before vaxis is torn /// down. The App calls this across all tabs at teardown while /// `app.vx_app` is still valid - distinct from `deinit`, which runs /// after vaxis is already gone. pub fn releaseGraphics(state: *State, app: *App) void { if (state.chart.image_id) |id| { if (app.vx_app) |va| va.vx.freeImage(va.tty.writer(), id); state.chart.image_id = null; } } /// On activation the Quote tab loads its shared candle data (by /// delegating to performance, which owns it) and fetches the live /// quote that drives the headline price/change - the same /// `DataService.getQuote` the CLI `quote` command uses. EOD candles /// can't represent the *current* price intraday (today's bar isn't /// available until after the close), so without the live fetch the /// tab would show yesterday's close during market hours. The fetch /// is staleness-gated (see `refreshLiveQuote` / `shouldFetchLiveQuote`) /// so re-entering the tab refetches when stale while rapid toggling /// reuses the held quote. This also covers `zfin i ` startup, /// which routes through `App.loadTabData` -> activate. pub fn activate(state: *State, app: *App) !void { const perf_module = @import("performance_tab.zig"); try perf_module.tab.activate(&app.states.performance, app); refreshLiveQuote(state, app, false); } /// Leaving the tab stops any live stream so it doesn't keep a /// socket + thread alive in the background (and frees the single /// shared `DataService` stream for another tab to use). Re-enabled /// by toggling again on return. pub fn deactivate(state: *State, app: *App) void { stopStream(state, app); } /// Framework poll-tick gate: true only while streaming, so the App /// keeps a 100ms tick armed to pull fresh prices; switching away /// (which stops the stream) lets the poll timer wind down. pub fn wantsPollTick(state: *State, app: *App) bool { _ = app; return state.streaming; } /// Refresh (r/F5): reset the quote-only state, delegate to /// performance.reload (which owns the shared candle/dividend data, /// svc invalidation, and resetting quote's chart cache), then force /// a fresh live quote so the headline price is current as of NOW. pub fn reload(state: *State, app: *App) !void { state.live = null; state.timestamp = 0; const perf_module = @import("performance_tab.zig"); try perf_module.tab.reload(&app.states.performance, app); refreshLiveQuote(state, app, true); } /// Poll hook: while streaming, copy the latest streamed price for /// the active symbol into `state.stream_price`. Runs ~10x/sec (only /// while `wantsPollTick` is true), so the headline price tracks the /// live feed. A symbol with no tick yet leaves `stream_price` as-is. pub fn tick(state: *State, app: *App, frame: u64) void { _ = frame; if (!state.streaming or app.symbol.len == 0) return; var prices = std.StringHashMap(f64).init(app.allocator); defer prices.deinit(); const syms = [_][]const u8{app.symbol}; app.svc.liveStreamSnapshot(&syms, &prices); if (prices.get(app.symbol)) |p| state.stream_price = p; } pub fn handleAction(state: *State, app: *App, action: Action) void { switch (action) { .chart_timeframe_next => { state.chart.timeframe = state.chart.timeframe.next(); state.chart.dirty = true; app.setStatus(state.chart.timeframe.label()); }, .chart_timeframe_prev => { state.chart.timeframe = state.chart.timeframe.prev(); state.chart.dirty = true; app.setStatus(state.chart.timeframe.label()); }, .toggle_live => { if (state.streaming) { stopStream(state, app); app.setStatus("Live stream: off"); } else if (app.symbol.len == 0) { app.setStatus("No symbol to stream"); } else if (startStream(state, app)) { app.setStatus("Live stream: on (Yahoo)"); } else { app.setStatus("Live stream: failed to start"); } }, } } /// Mouse handling: clicks on the timeframe selector row switch /// the chart timeframe. Returns `true` if the click was on a /// timeframe label (consumed); `false` otherwise (unhandled). /// The caller (App's mouse dispatcher) handles wheel scroll, /// tab-bar clicks, and other global mouse semantics before /// routing here. pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { if (mouse.button != .left) return false; if (mouse.type != .press) return false; const tf_row = state.chart.timeframe_row orelse return false; const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset; if (content_row != tf_row) return false; // Layout: " Chart: [3M] 6M YTD 1Y 3Y 5Y ([ ] to change)" // Prefix " Chart: " is 9 chars. Each timeframe label takes // `label_len + 2` (brackets/spaces around the label) + 1 (gap). const col: usize = @intCast(mouse.col); const prefix_len: usize = 9; if (col < prefix_len) return false; const timeframes = [_]chart.Timeframe{ .@"3M", .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" }; var x: usize = prefix_len; for (timeframes) |tf| { const lbl_len = tf.label().len; const slot_width = lbl_len + 2 + 1; if (col >= x and col < x + slot_width) { if (tf != state.chart.timeframe) { state.chart.timeframe = tf; app.setStatus(tf.label()); } return true; } x += slot_width; } return false; } pub fn isDisabled(app: *App) bool { _ = app; return false; } /// Symbol-change reset. Drops the live quote, resets the /// fetch timestamp, marks the chart dirty so the next draw /// re-renders for the new symbol, and frees the indicator /// cache (Bollinger bands etc. are computed per-symbol). /// The candle data lives on `app.symbol_data` and is dropped /// centrally by the App. pub fn onSymbolChange(state: *State, app: *App) void { state.live = null; state.timestamp = 0; state.chart.dirty = true; state.chart.freeCache(app.allocator); // Re-point an active stream at the new symbol (stop + start). if (state.streaming) { stopStream(state, app); if (app.symbol.len > 0) _ = startStream(state, app); } } }; // ── Live-stream helpers ─────────────────────────────────────── /// Start the live stream for the active symbol. Returns true on /// success. On failure the error is debug-logged and `streaming` stays /// false so the tab silently falls back to the REST/close price. fn startStream(state: *State, app: *App) bool { const syms = [_][]const u8{app.symbol}; app.svc.startLiveStream(&syms) catch |err| { std.log.scoped(.quote_tab).debug("{s}: live stream start failed: {t}", .{ app.symbol, err }); state.streaming = false; state.stream_price = null; return false; }; state.streaming = true; state.stream_price = null; return true; } /// Stop the live stream if running and clear the streamed price. /// Idempotent. fn stopStream(state: *State, app: *App) void { if (!state.streaming) return; app.svc.stopLiveStream(); state.streaming = false; state.stream_price = null; } // ── Rendering ───────────────────────────────────────────────── /// Draw the quote tab content. Uses Kitty graphics for the chart when available, /// falling back to braille sparkline otherwise. pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { _ = state; // Determine whether to use Kitty graphics const use_kitty = switch (app.chart_config.mode) { .braille => false, .kitty => true, .auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false, }; if (use_kitty and app.symbol_data.candles != null and app.symbol_data.candles.?.len >= 40) { drawWithKittyChart(app, arena, buf, width, height) catch { // On any failure, fall back to braille try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena)); }; } else { // Fallback to styled lines with braille chart try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena)); } } /// Draw quote tab using Kitty graphics protocol for the chart. fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { const th = app.theme; const c = app.symbol_data.candles orelse return; // Build text header (symbol, price, change) - first few lines var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Symbol (+ resolved name) label, shared by the live and // close-of-day header variants below. const sym_label: []const u8 = if (quoteTabName(app)) |n| try std.fmt.allocPrint(arena, "{s} {s}", .{ app.symbol, n }) else app.symbol; // Symbol + price header. The headline "Change" reflects the // selected chart period (the chart's left edge -> now); the less // relevant 1-day change lives in the detail section below the chart. const cur_tf = app.states.quote.chart.timeframe; const sp = app.states.quote.stream_price; const cur_price: ?f64 = headlinePrice(sp, app.states.quote.live, c); if (sp) |p| { const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (live)", .{ sym_label, p }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() }); } else if (app.states.quote.live) |q| { const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ sym_label, q.close }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() }); } else if (c.len > 0) { const last = c[c.len - 1]; const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ sym_label, last.close }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() }); } if (cur_price) |price| { if (fmt.windowChange(c, cur_tf.tradingDays(), price)) |wc| { var chg_buf: [64]u8 = undefined; var lbl_buf: [24]u8 = undefined; const lbl = std.fmt.bufPrint(&lbl_buf, "Change ({s}):", .{cur_tf.label()}) catch "Change:"; const change_style = if (wc.change >= 0) th.positiveStyle() else th.negativeStyle(); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} {s}", .{ lbl, fmt.fmtPriceChange(&chg_buf, wc.change, wc.pct) }), .style = change_style }); } } // Timeframe selector line { var tf_buf: [80]u8 = undefined; var tf_pos: usize = 0; const prefix = " Chart: "; @memcpy(tf_buf[tf_pos..][0..prefix.len], prefix); tf_pos += prefix.len; const timeframes = [_]chart.Timeframe{ .@"3M", .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" }; for (timeframes) |tf| { const lbl = tf.label(); if (tf == app.states.quote.chart.timeframe) { tf_buf[tf_pos] = '['; tf_pos += 1; @memcpy(tf_buf[tf_pos..][0..lbl.len], lbl); tf_pos += lbl.len; tf_buf[tf_pos] = ']'; tf_pos += 1; } else { tf_buf[tf_pos] = ' '; tf_pos += 1; @memcpy(tf_buf[tf_pos..][0..lbl.len], lbl); tf_pos += lbl.len; tf_buf[tf_pos] = ' '; tf_pos += 1; } tf_buf[tf_pos] = ' '; tf_pos += 1; } const hint = " ([ ] to change)"; @memcpy(tf_buf[tf_pos..][0..hint.len], hint); tf_pos += hint.len; app.states.quote.chart.timeframe_row = lines.items.len; // track which row the timeframe line is on try lines.append(arena, .{ .text = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Draw the text header const header_lines = try lines.toOwnedSlice(arena); try app.drawStyledContent(arena, buf, width, height, header_lines); // Calculate chart area (below the header, leaving room for details below) const header_rows: u16 = @intCast(@min(header_lines.len, height)); const detail_rows: u16 = 10; // reserve rows for quote details below chart const chart_rows = height -| header_rows -| detail_rows; if (chart_rows < 8) return; // not enough space // Compute pixel dimensions from cell size // cell_size may be 0 if terminal hasn't reported pixel dimensions yet const cell_size = app.cellPixelSize(); const cell_w: u32 = cell_size.width; const cell_h: u32 = cell_size.height; const label_cols: u16 = 10; // columns reserved for axis labels on the right const chart_cols = width -| 2 -| label_cols; // 1 col left margin + label area on right if (chart_cols == 0) return; const px_w: u32 = @as(u32, chart_cols) * cell_w; const px_h: u32 = @as(u32, chart_rows) * cell_h; if (px_w < 100 or px_h < 100) return; // Apply resolution cap from chart config const capped_w = @min(px_w, app.chart_config.max_width); const capped_h = @min(px_h, app.chart_config.max_height); // Check if we need to re-render the chart image const symbol_changed = app.states.quote.chart.symbol_len != app.symbol.len or !std.mem.eql(u8, app.states.quote.chart.symbol[0..app.states.quote.chart.symbol_len], app.symbol); const tf_changed = app.states.quote.chart.timeframe_rendered == null or app.states.quote.chart.timeframe_rendered.? != app.states.quote.chart.timeframe; if (app.states.quote.chart.dirty or symbol_changed or tf_changed) { // Free old image if (app.states.quote.chart.image_id) |old_id| { if (app.vx_app) |va| { va.vx.freeImage(va.tty.writer(), old_id); } app.states.quote.chart.image_id = null; } // If symbol changed, invalidate the indicator cache if (symbol_changed) { app.states.quote.chart.freeCache(app.allocator); } // Check if we can reuse cached indicators const cache_valid = app.states.quote.chart.isCacheValid(c, app.states.quote.chart.timeframe); // If cache is invalid, compute new indicators if (!cache_valid) { // Free old cache if it exists app.states.quote.chart.freeCache(app.allocator); // Compute and cache new indicators. Use the warmup variant so // the Bollinger bands / RSI are computed over an extra lookback // beyond the timeframe window and stay valid from the first // *visible* candle - no warm-up gap at the chart's left edge. const new_cache = chart.computeIndicatorsWarmup( app.allocator, c, app.states.quote.chart.timeframe.tradingDays(), 20, ) catch |err| { app.states.quote.chart.dirty = false; var err_buf: [128]u8 = undefined; const msg = std.fmt.bufPrint(&err_buf, "Indicator computation failed: {s}", .{@errorName(err)}) catch "Indicator computation failed"; app.setStatus(msg); return; }; app.states.quote.chart.cached_indicators = new_cache; // Update cache metadata const max_days = app.states.quote.chart.timeframe.tradingDays(); const n = @min(c.len, max_days); const data = c[c.len - n ..]; app.states.quote.chart.cache_candle_count = data.len; app.states.quote.chart.cache_timeframe = app.states.quote.chart.timeframe; app.states.quote.chart.cache_last_close = if (data.len > 0) data[data.len - 1].close else 0; } // Render and transmit - use the app's main allocator, NOT the arena, // because z2d allocates large pixel buffers that would bloat the arena. if (app.vx_app) |va| { // Pass cached indicators to avoid recomputation during rendering const cached_ptr: ?*const chart.CachedIndicators = if (app.states.quote.chart.cached_indicators) |*ci| ci else null; const chart_result = chart.renderChart( app.io, app.allocator, c, app.states.quote.chart.timeframe, capped_w, capped_h, th, cached_ptr, ) catch |err| { app.states.quote.chart.dirty = false; var err_buf: [128]u8 = undefined; const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed"; app.setStatus(msg); return; }; defer app.allocator.free(chart_result.rgb_data); // Base64-encode and transmit raw RGB data directly via Kitty protocol. // This avoids the PNG encode -> file write -> file read -> PNG decode roundtrip. const base64_enc = std.base64.standard.Encoder; const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch { app.states.quote.chart.dirty = false; app.setStatus("Chart: base64 alloc failed"); return; }; defer app.allocator.free(b64_buf); const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data); const img = va.vx.transmitPreEncodedImage( va.tty.writer(), encoded, chart_result.width, chart_result.height, .rgb, ) catch |err| { app.states.quote.chart.dirty = false; var err_buf: [128]u8 = undefined; const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed"; app.setStatus(msg); return; }; app.states.quote.chart.image_id = img.id; app.states.quote.chart.image_width = @intCast(chart_cols); app.states.quote.chart.image_height = chart_rows; // Track what we rendered const sym_len = @min(app.symbol.len, 16); @memcpy(app.states.quote.chart.symbol[0..sym_len], app.symbol[0..sym_len]); app.states.quote.chart.symbol_len = sym_len; app.states.quote.chart.timeframe_rendered = app.states.quote.chart.timeframe; app.states.quote.chart.price_min = chart_result.price_min; app.states.quote.chart.price_max = chart_result.price_max; app.states.quote.chart.rsi_latest = chart_result.rsi_latest; app.states.quote.chart.dirty = false; } } // Place the image in the cell buffer if (app.states.quote.chart.image_id) |img_id| { // Place image at the first cell of the chart area const chart_row_start: usize = header_rows; const chart_col_start: usize = 1; // 1 col left margin const buf_idx = chart_row_start * @as(usize, width) + chart_col_start; if (buf_idx < buf.len) { buf[buf_idx] = .{ .char = .{ .grapheme = " " }, .style = th.contentStyle(), .image = .{ .img_id = img_id, .options = .{ .size = .{ .rows = app.states.quote.chart.image_height, .cols = app.states.quote.chart.image_width, }, .scale = .contain, }, }, }; } // ── Axis labels (terminal text in the right margin) ─────────── // The chart image uses layout fractions: price=72%, gap=8%, RSI=20% // Map these to terminal rows to position labels. const img_rows = app.states.quote.chart.image_height; const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.states.quote.chart.image_width) + 1; const label_style = th.mutedStyle(); if (label_col + 8 <= width and img_rows >= 4 and app.states.quote.chart.price_max > app.states.quote.chart.price_min) { // Price axis labels - evenly spaced across the price panel (top 72%) const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72; const n_price_labels: usize = 5; for (0..n_price_labels) |i| { const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1)); const price_val = app.states.quote.chart.price_max - frac * (app.states.quote.chart.price_max - app.states.quote.chart.price_min); const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows; const row: usize = @intFromFloat(@round(row_f)); if (row >= height) continue; var lbl_buf: [16]u8 = undefined; const lbl = std.fmt.bufPrint(&lbl_buf, "{f}", .{Money.from(price_val)}) catch "$?"; const start_idx = row * @as(usize, width) + label_col; for (lbl, 0..) |ch, ci| { const idx = start_idx + ci; if (idx < buf.len and label_col + ci < width) { buf[idx] = .{ .char = .{ .grapheme = glyph(ch) }, .style = label_style, }; } } } // RSI axis labels - positioned within the RSI panel (bottom 20%, after 80% offset) const rsi_panel_start_f = @as(f64, @floatFromInt(img_rows)) * 0.80; const rsi_panel_h = @as(f64, @floatFromInt(img_rows)) * 0.20; const rsi_labels = [_]struct { val: f64, label: []const u8 }{ .{ .val = 70, .label = "70" }, .{ .val = 50, .label = "50" }, .{ .val = 30, .label = "30" }, }; for (rsi_labels) |rl| { // RSI maps 0-100 top-to-bottom within the RSI panel const rsi_frac = 1.0 - (rl.val / 100.0); const row_f = @as(f64, @floatFromInt(chart_row_start)) + rsi_panel_start_f + rsi_frac * rsi_panel_h; const row: usize = @intFromFloat(@round(row_f)); if (row >= height) continue; const start_idx = row * @as(usize, width) + label_col; for (rl.label, 0..) |ch, ci| { const idx = start_idx + ci; if (idx < buf.len and label_col + ci < width) { buf[idx] = .{ .char = .{ .grapheme = glyph(ch) }, .style = label_style, }; } } } } // Render quote details below the chart image as styled text const detail_start_row = header_rows + app.states.quote.chart.image_height; if (detail_start_row + 8 < height) { var detail_lines: std.ArrayList(StyledLine) = .empty; try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const latest = c[c.len - 1]; const quote_data = app.states.quote.live; const price = headlinePrice(app.states.quote.stream_price, quote_data, c) orelse latest.close; const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0); // `cur_tf` (the selected timeframe) is computed once at the // top of this function; the window change reuses it. const period = fmt.windowChange(c, cur_tf.tradingDays(), price); try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close, period, cur_tf.label()); // Write detail lines into the buffer below the image const detail_buf_start = detail_start_row * @as(usize, width); const remaining_height = height - @as(u16, @intCast(detail_start_row)); const detail_slice = try detail_lines.toOwnedSlice(arena); if (detail_buf_start < buf.len) { try app.drawStyledContent(arena, buf[detail_buf_start..], width, remaining_height, detail_slice); } } } } /// Source-of-data hint shown in the quote tab's header line. /// Determines which sub-form of the header is rendered. pub const QuoteHeaderSource = union(enum) { /// Live quote with a "refreshed Xs ago" suffix. live: []const u8, /// Close-of-day data with a date. close: zfin.Date, /// No timing info - just the symbol. none, /// Real-time websocket stream (NOT the ~15-min-delayed REST quote). /// Carries the latest streamed price so the headline shows a live, /// changing number even for symbols with no cached candles. streaming: f64, }; /// Format the quote tab's header line. Pure function over /// (arena, symbol, name, source). The three branches mirror the /// live / close-of-day / no-data paths in the live builder. When /// `name` is non-null and non-empty, it's rendered between the /// symbol and the timing suffix (e.g. "AAPL Apple Inc. (live ...)"). pub fn formatQuoteHeader( arena: std.mem.Allocator, symbol: []const u8, name: ?[]const u8, source: QuoteHeaderSource, ) ![]const u8 { const name_part: []const u8 = if (name) |n| (if (n.len > 0) try std.fmt.allocPrint(arena, " {s}", .{n}) else "") else ""; return switch (source) { .live => |ago| std.fmt.allocPrint(arena, " {s}{s} (live, ~15 min delay, refreshed {s})", .{ symbol, name_part, ago }), .close => |date| std.fmt.allocPrint(arena, " {s}{s} (as of close on {f})", .{ symbol, name_part, date }), .none => std.fmt.allocPrint(arena, " {s}{s}", .{ symbol, name_part }), .streaming => |price| std.fmt.allocPrint(arena, " {s}{s} ${d:.2} (live)", .{ symbol, name_part, price }), }; } /// Resolve the active symbol's display name using the shared policy /// (`resolveSecurityName`): the curated `metadata.srf` `name::` field /// first, then the live quote name (Yahoo `longName`, the same source /// as the price), then the ETF profile's fund name. Mirrors the CLI /// `quote` command so the two quote surfaces always agree. The 'K' /// overlay uses the same helper but, lacking a per-symbol live quote, /// resolves metadata -> ETF name only. fn quoteTabName(app: *App) ?[]const u8 { const cm = app.portfolio.classificationMap(); const live: ?[]const u8 = if (app.states.quote.live) |*q| q.name() else null; const etf: ?[]const u8 = if (app.symbol_data.etf_profile) |p| p.name else null; return zfin.classification.resolveSecurityName(app.symbol, cm, .{ .live_quote = live, .etf_profile = etf }); } fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { const th = app.theme; var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); if (app.symbol.len == 0) { try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } var ago_buf: [16]u8 = undefined; const name = quoteTabName(app); if (app.states.quote.streaming and app.states.quote.stream_price != null) { try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .{ .streaming = app.states.quote.stream_price.? }), .style = th.headerStyle() }); } else if (app.states.quote.live != null and app.states.quote.timestamp > 0) { // wall-clock required: per-frame "now" for the data-age readout. const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds(); const ago_str = fmt.fmtTimeAgo(&ago_buf, app.states.quote.timestamp, now_s); try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .{ .live = ago_str }), .style = th.headerStyle() }); } else if (app.symbol_data.candleLastDate()) |d| { try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .{ .close = d }), .style = th.headerStyle() }); } else { try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .none), .style = th.headerStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Use stored real-time quote if available (fetched on manual refresh) const quote_data = app.states.quote.live; const c = app.symbol_data.candles orelse { // Streaming with no cached candle history (e.g. BTC-USD): the // header already carries the live price, so just note the // absence of chart/history rather than the misleading "No data". if (app.states.quote.streaming and app.states.quote.stream_price != null) { try lines.append(arena, .{ .text = " Live price above. No chart history cached for this symbol.", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } if (quote_data) |q| { // No candle data but have a quote - show it try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(q.close)}), .style = th.contentStyle() }); if (q.previous_close > 0) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Prev: ${d:.2}", .{q.previous_close}), .style = th.mutedStyle() }); } { var chg_buf: [64]u8 = undefined; const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle(); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change (1D): {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style }); } return lines.toOwnedSlice(arena); } try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); }; if (c.len == 0) { try lines.append(arena, .{ .text = " No candle data.", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } // Use real-time quote price if available, otherwise latest candle const price = headlinePrice(app.states.quote.stream_price, quote_data, c) orelse c[c.len - 1].close; const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0); const latest = c[c.len - 1]; // The braille fallback charts the last 60 candles; the window // change measures against that same span. const period = fmt.windowChange(c, 60, price); try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close, period, "60D"); // Braille sparkline chart of recent 60 trading days try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const chart_days: usize = @min(c.len, 60); const chart_data = c[c.len - chart_days ..]; try tui.renderBrailleToStyledLines(arena, &lines, chart_data, th); // Recent history table try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Recent History:", .style = th.headerStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}", .{ "Date", "Open", "High", "Low", "Close", "Volume" }), .style = th.mutedStyle() }); const start_idx = if (c.len > 20) c.len - 20 else 0; for (c[start_idx..]) |candle| { var row_buf: [128]u8 = undefined; const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle(); try lines.append(arena, .{ .text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)), .style = day_change }); } return lines.toOwnedSlice(arena); } // ── Quote detail columns (price/OHLCV | ETF stats | sectors | holdings) ── const Column = struct { texts: std.ArrayList([]const u8), styles: std.ArrayList(vaxis.Style), width: usize, // fixed column width for padding fn init() Column { return .{ .texts = .empty, .styles = .empty, .width = 0, }; } fn add(app: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void { try app.texts.append(arena, text); try app.styles.append(arena, style); } fn len(app: *const Column) usize { return app.texts.items.len; } }; fn buildDetailColumns( app: *App, arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), latest: zfin.Candle, quote_data: ?zfin.Quote, price: f64, prev_close: f64, period: ?fmt.PctChange, period_label: []const u8, ) !void { const th = app.theme; var vol_buf: [32]u8 = undefined; // Column 1: Price/OHLCV var col1 = Column.init(); col1.width = 36; try col1.add(arena, try std.fmt.allocPrint(arena, " Date: {f}", .{latest.date}), th.contentStyle()); try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(price)}), th.contentStyle()); try col1.add(arena, try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), th.mutedStyle()); try col1.add(arena, try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle()); try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle()); try col1.add(arena, try std.fmt.allocPrint(arena, " Volume: {s}", .{fmt.fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), th.mutedStyle()); if (prev_close > 0) { try col1.add(arena, try std.fmt.allocPrint(arena, " Prev: ${d:.2}", .{prev_close}), th.mutedStyle()); } if (fmt.pctChange(price, prev_close)) |dc| { var chg_buf: [64]u8 = undefined; const change_style = if (dc.change >= 0) th.positiveStyle() else th.negativeStyle(); try col1.add(arena, try std.fmt.allocPrint(arena, " {s:<14}{s}", .{ "Change (1D):", fmt.fmtPriceChange(&chg_buf, dc.change, dc.pct) }), change_style); } // Change over the displayed chart window (the chart's left edge -> // now), labeled with the window span so it reads distinctly from the // 1-day change above. Null when there's no meaningful window. if (period) |pc| { var wchg_buf: [64]u8 = undefined; var lbl_buf: [24]u8 = undefined; const lbl = std.fmt.bufPrint(&lbl_buf, "Change ({s}):", .{period_label}) catch "Change:"; const wstyle = if (pc.change >= 0) th.positiveStyle() else th.negativeStyle(); try col1.add(arena, try std.fmt.allocPrint(arena, " {s:<14}{s}", .{ lbl, fmt.fmtPriceChange(&wchg_buf, pc.change, pc.pct) }), wstyle); } // Columns 2-4: ETF profile (only for actual ETFs) var col2 = Column.init(); // ETF stats col2.width = 22; var col3 = Column.init(); // Sectors col3.width = 26; var col4 = Column.init(); // Top holdings col4.width = 30; if (app.symbol_data.etf_profile) |profile| { // Col 2: ETF key stats try col2.add(arena, "ETF Profile", th.headerStyle()); if (profile.expense_ratio) |er| { try col2.add(arena, try std.fmt.allocPrint(arena, " Expense: {d:.2}%", .{er * 100.0}), th.contentStyle()); } if (profile.net_assets) |na| { try col2.add(arena, try std.fmt.allocPrint(arena, " Assets: ${s}", .{std.mem.trimEnd(u8, &fmt.fmtLargeNum(na), &.{' '})}), th.contentStyle()); } if (profile.dividend_yield) |dy| { try col2.add(arena, try std.fmt.allocPrint(arena, " Yield: {d:.2}%", .{dy * 100.0}), th.contentStyle()); } if (profile.total_holdings) |th_val| { try col2.add(arena, try std.fmt.allocPrint(arena, " Holdings: {d}", .{th_val}), th.mutedStyle()); } // Col 3: Sector allocation if (profile.sectors) |sectors| { if (sectors.len > 0) { try col3.add(arena, "Sectors", th.headerStyle()); const show = @min(sectors.len, 7); for (sectors[0..show]) |sec| { var title_buf: [64]u8 = undefined; const title_name = fmt.toTitleCase(&title_buf, sec.name); const name = if (title_name.len > 20) title_name[0..20] else title_name; try col3.add(arena, try std.fmt.allocPrint(arena, " {d:>5.1}% {s}", .{ sec.weight * 100.0, name }), th.contentStyle()); } } } // Col 4: Top holdings if (profile.holdings) |holdings| { if (holdings.len > 0) { try col4.add(arena, "Top Holdings", th.headerStyle()); const show = @min(holdings.len, 7); for (holdings[0..show]) |h| { const sym_str = h.symbol orelse "--"; try col4.add(arena, try std.fmt.allocPrint(arena, " {s:>6} {d:>5.1}%", .{ sym_str, h.weight * 100.0 }), th.contentStyle()); } } } } // Merge all columns into grapheme-based StyledLines const gap: usize = 3; const bg_style = vaxis.Style{ .fg = theme.Theme.vcolor(th.text), .bg = theme.Theme.vcolor(th.bg) }; const cols = [_]*const Column{ &col1, &col2, &col3, &col4 }; var max_rows: usize = 0; for (cols) |col| max_rows = @max(max_rows, col.len()); // Total max width for allocation const max_width = col1.width + gap + col2.width + gap + col3.width + gap + col4.width + 4; for (0..max_rows) |ri| { const graphemes = try arena.alloc([]const u8, max_width); const col_styles = try arena.alloc(vaxis.Style, max_width); var pos: usize = 0; for (cols, 0..) |col, ci| { if (ci > 0 and col.len() == 0) continue; // skip empty columns entirely if (ci > 0) { // Gap between columns for (0..gap) |_| { if (pos < max_width) { graphemes[pos] = " "; col_styles[pos] = bg_style; pos += 1; } } } if (ri < col.len()) { const text = col.texts.items[ri]; const style = col.styles.items[ri]; // Write text characters for (0..@min(text.len, col.width)) |ci2| { if (pos < max_width) { graphemes[pos] = glyph(text[ci2]); col_styles[pos] = style; pos += 1; } } // Pad to column width if (text.len < col.width) { for (0..col.width - text.len) |_| { if (pos < max_width) { graphemes[pos] = " "; col_styles[pos] = bg_style; pos += 1; } } } } else { // Empty row in this column - pad full width for (0..col.width) |_| { if (pos < max_width) { graphemes[pos] = " "; col_styles[pos] = bg_style; pos += 1; } } } } try lines.append(arena, .{ .text = "", .style = bg_style, .graphemes = graphemes[0..pos], .cell_styles = col_styles[0..pos], }); } } // ── Tests ───────────────────────────────────────────────────── const testing = std.testing; const Date = zfin.Date; test "formatQuoteHeader: live source includes refreshed-ago string" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const text = try formatQuoteHeader(arena, "AAPL", null, .{ .live = "5s ago" }); try testing.expectEqualStrings(" AAPL (live, ~15 min delay, refreshed 5s ago)", text); } test "formatQuoteHeader: close source includes ISO date" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const text = try formatQuoteHeader(arena, "VTI", null, .{ .close = Date.fromYmd(2024, 3, 15) }); try testing.expectEqualStrings(" VTI (as of close on 2024-03-15)", text); } test "formatQuoteHeader: none source renders just the symbol" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const text = try formatQuoteHeader(arena, "BRK.B", null, .none); try testing.expectEqualStrings(" BRK.B", text); } test "formatQuoteHeader: name renders between symbol and suffix" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); try testing.expectEqualStrings( " AAPL Apple Inc. (live, ~15 min delay, refreshed 5s ago)", try formatQuoteHeader(arena, "AAPL", "Apple Inc.", .{ .live = "5s ago" }), ); try testing.expectEqualStrings( " VTI Vanguard Total Stock Market ETF (as of close on 2024-03-15)", try formatQuoteHeader(arena, "VTI", "Vanguard Total Stock Market ETF", .{ .close = Date.fromYmd(2024, 3, 15) }), ); try testing.expectEqualStrings( " BRK.B Berkshire Hathaway", try formatQuoteHeader(arena, "BRK.B", "Berkshire Hathaway", .none), ); } test "formatQuoteHeader: empty name is omitted" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const text = try formatQuoteHeader(arena, "AAPL", "", .none); try testing.expectEqualStrings(" AAPL", text); } test "shouldFetchLiveQuote: with no held quote, always fetches" { try testing.expect(shouldFetchLiveQuote(.open, false, 0)); try testing.expect(shouldFetchLiveQuote(.closed, false, 0)); try testing.expect(shouldFetchLiveQuote(.premarket, false, 99_999)); try testing.expect(shouldFetchLiveQuote(.afterhours, false, 99_999)); } test "shouldFetchLiveQuote: market open refetches only once past the staleness window" { try testing.expect(!shouldFetchLiveQuote(.open, true, 0)); // Boundary: equal-to-window is not yet "older than", so no refetch. try testing.expect(!shouldFetchLiveQuote(.open, true, quote_live_stale_s)); try testing.expect(shouldFetchLiveQuote(.open, true, quote_live_stale_s + 1)); } test "shouldFetchLiveQuote: outside regular hours a held quote is never refetched" { // Price is frozen pre/after-hours and on weekends/holidays, so even a // very stale held quote should not trigger a refetch. try testing.expect(!shouldFetchLiveQuote(.premarket, true, 100_000)); try testing.expect(!shouldFetchLiveQuote(.afterhours, true, 100_000)); try testing.expect(!shouldFetchLiveQuote(.closed, true, 100_000)); } /// A `Quote` with only `close` meaningful; other fields are zeroed. /// Enough to exercise the price-precedence helpers. fn testQuote(close: f64) zfin.Quote { return .{ .symbol = "X", .exchange = "", .datetime = "", .close = close, .open = 0, .high = 0, .low = 0, .volume = 0, .previous_close = 0, .change = 0, .percent_change = 0, .average_volume = 0, .fifty_two_week_low = 0, .fifty_two_week_high = 0, }; } test "headlinePrice: a streamed tick wins over the quote and candle close" { const candles = [_]zfin.Candle{ .{ .date = .{ .days = 20000 }, .open = 0, .high = 0, .low = 0, .close = 50.0, .adj_close = 50.0, .volume = 0 }, }; try testing.expectEqual(@as(?f64, 282.5), headlinePrice(282.5, testQuote(100.0), &candles)); } test "headlinePrice: falls back quote -> candle close -> null" { const candles = [_]zfin.Candle{ .{ .date = .{ .days = 20000 }, .open = 0, .high = 0, .low = 0, .close = 50.0, .adj_close = 50.0, .volume = 0 }, }; // No stream -> REST quote close. try testing.expectEqual(@as(?f64, 100.0), headlinePrice(null, testQuote(100.0), &candles)); // No stream, no quote -> latest candle close. try testing.expectEqual(@as(?f64, 50.0), headlinePrice(null, null, &candles)); // Nothing available -> null. const empty = [_]zfin.Candle{}; try testing.expect(headlinePrice(null, null, &empty) == null); } test "formatQuoteHeader: streaming source shows a real-time price and (live) tag" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); try testing.expectEqualStrings(" AAPL $282.01 (live)", try formatQuoteHeader(arena, "AAPL", null, .{ .streaming = 282.01 })); try testing.expectEqualStrings( " AAPL Apple Inc. $282.01 (live)", try formatQuoteHeader(arena, "AAPL", "Apple Inc.", .{ .streaming = 282.01 }), ); }