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("chart.zig"); const tui = @import("../tui.zig"); const framework = @import("tab_framework.zig"); const App = tui.App; const StyledLine = tui.StyledLine; const glyph = tui.glyph; /// 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 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, }; // ── Tab-private state ───────────────────────────────────────── pub const State = struct { /// Stored real-time quote (only fetched on manual refresh; not /// auto-refetched on every redraw). 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 = .{}, }; // ── Tab framework contract ──────────────────────────────────── pub const tab = struct { pub const ActionT = Action; pub const StateT = State; /// Display name for the tab bar. pub const label: []const u8 = "Quote"; pub const default_bindings: []const framework.TabBinding(Action) = &.{ .{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } }, .{ .action = .chart_timeframe_prev, .key = .{ .codepoint = '[' } }, }; pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{ .chart_timeframe_next = "Chart: next timeframe", .chart_timeframe_prev = "Chart: previous timeframe", }); pub const status_hints: []const Action = &.{ .chart_timeframe_next, }; pub fn init(state: *State, app: *App) !void { _ = app; state.* = .{}; } pub fn deinit(state: *State, app: *App) void { state.chart.freeCache(app.allocator); state.* = .{}; } /// Quote loads its own data on activation (the live-quote /// fetch path lives in tui.zig after the tab switches because /// it depends on App.svc); no-op here. Chart redraws are /// triggered by the dirty flag on `state.chart`. /// Quote and performance share `app.symbol_data` (candles + /// dividends). Performance owns the loader; quote piggybacks /// by delegating its activate to performance's. This keeps /// `loadTabData`'s dispatch uniform — every tab activates its /// own state — while preserving the historical "switching to /// quote populates shared candle data" behavior. pub fn activate(state: *State, app: *App) !void { _ = state; const perf_module = @import("performance_tab.zig"); try perf_module.tab.activate(&app.states.performance, app); } pub const deactivate = framework.noopDeactivate(State); /// Refresh: delegate to performance.reload, which owns the /// shared candle/dividend data and svc invalidation. Quote's /// chart-state (dirty + freeCache) is also reset by /// performance.reload — see the comment there for why. /// Quote-only state (live quote + timestamp) is reset here /// because performance doesn't know about it. 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); } pub const tick = framework.noopTick(State); 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()); }, } } /// 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: [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 = @as(usize, @intCast(mouse.col)); const prefix_len: usize = 9; if (col < prefix_len) return false; const timeframes = [_]chart.Timeframe{ .@"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); } }; // ── 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 + price header if (app.states.quote.live) |q| { const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ app.symbol, q.close }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() }); if (q.previous_close > 0) { const change = q.close - q.previous_close; const pct = (change / q.previous_close) * 100.0; var chg_buf: [64]u8 = undefined; const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle(); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style }); } } else if (c.len > 0) { const last = c[c.len - 1]; const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ app.symbol, last.close }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() }); if (c.len >= 2) { const prev_close = c[c.len - 2].close; const change = last.close - prev_close; const pct = (change / prev_close) * 100.0; var chg_buf: [64]u8 = undefined; const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle(); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, 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{ .@"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 const new_cache = chart.computeIndicators( app.allocator, c, app.states.quote.chart.timeframe, ) 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 = if (quote_data) |q| q.close else 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); try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close); // 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, }; /// Format the quote tab's header line. Pure function over /// (arena, symbol, source). The three branches mirror the live / /// close-of-day / no-data paths in the live builder. pub fn formatQuoteHeader( arena: std.mem.Allocator, symbol: []const u8, source: QuoteHeaderSource, ) ![]const u8 { return switch (source) { .live => |ago| std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ symbol, ago }), .close => |date| std.fmt.allocPrint(arena, " {s} (as of close on {f})", .{ symbol, date }), .none => std.fmt.allocPrint(arena, " {s}", .{symbol}), }; } 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; 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, .{ .live = ago_str }), .style = th.headerStyle() }); } else if (app.symbol_data.candleLastDate()) |d| { try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, .{ .close = d }), .style = th.headerStyle() }); } else { try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, .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 { 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() }); { 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: {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 = if (quote_data) |q| q.close else 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]; try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close); // 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, ) !void { const th = app.theme; var vol_buf: [32]u8 = undefined; // Column 1: Price/OHLCV var col1 = Column.init(); col1.width = 30; 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) { const change = price - prev_close; const pct = (change / prev_close) * 100.0; var chg_buf: [64]u8 = undefined; const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle(); try col1.add(arena, try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), change_style); } // 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", .{ .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", .{ .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", .none); try testing.expectEqualStrings(" BRK.B", text); }