diff --git a/docs/reference/cli/quote.md b/docs/reference/cli/quote.md index dd7774b..2f23101 100644 --- a/docs/reference/cli/quote.md +++ b/docs/reference/cli/quote.md @@ -46,11 +46,18 @@ SPY $746.74 (close) High: $748.23 Low: $743.86 Volume: 80,875,657 - Change: +$5.78 (+0.78%) + Change (1D): +$5.78 (+0.78%) + Change (3M): +$31.40 (+4.39%) ... (price chart over the selected window -- inline Kitty image, or braille) ``` +Two change rows are shown: `Change (1D)` is the day-over-day move, +and `Change ()` is the move across the chart window (from the +first visible candle's close to the current price). The span label +reflects `--since` (`3M` by default; `1Y`, `YTD`, or a date when set). +In the TUI it's the selected chart timeframe instead (e.g. `Change (1Y)`). + ## See also - [`perf`](perf.md) -- trailing returns instead of a spot price. diff --git a/src/commands/quote.zig b/src/commands/quote.zig index 385f369..b3cb114 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -21,6 +21,10 @@ pub const ParsedArgs = struct { /// means the default window (last ~3 months). Accepts the same /// grammar as `history --since`: `YYYY-MM-DD`, `N[WMQY]`, or `ytd`. since: ?zfin.Date = null, + /// The raw `--since` token as the user typed it (e.g. "1Y", "ytd", + /// "2025-01-15"), kept only to label the "Change ()" detail + /// row. Null when `--since` was omitted. + since_raw: ?[]const u8 = null, }; pub const meta: framework.Meta = .{ @@ -79,6 +83,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr var symbol: ?[]const u8 = null; var export_chart: ?[]const u8 = null; var since: ?zfin.Date = null; + var since_raw: ?[]const u8 = null; var i: usize = 0; while (i < cmd_args.len) : (i += 1) { @@ -88,6 +93,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr } else if (std.mem.eql(u8, a, "--since")) { const value = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); since = cli.parseRequiredDateOrStderr(ctx.io, value, ctx.today, "--since") catch return error.InvalidDate; + since_raw = value; } else if (a.len > 0 and a[0] == '-') { // Reject ANY leading-dash token we don't recognize, // including single-dash ones like `-x`. Previously only @@ -110,7 +116,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n"); return error.MissingSymbol; } - return .{ .symbol = symbol.?, .export_chart = export_chart, .since = since }; + return .{ .symbol = symbol.?, .export_chart = export_chart, .since = since, .since_raw = since_raw }; } pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { @@ -201,7 +207,17 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { .kitty => .{ .kitty = k }, .auto => if (ctx.graphics_caps.kitty) .{ .kitty = k } else .braille, }; - try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out, display_count, chart_render); + + // Short label for the "Change ()" row: echo the --since token + // (upper-cased so "1y" -> "1Y", "ytd" -> "YTD") or "3M" for the + // default window. Backed by a stack buffer that outlives `display`. + var wl_buf: [16]u8 = undefined; + const window_label: []const u8 = if (parsed.since_raw) |raw| blk: { + const len = @min(raw.len, wl_buf.len); + break :blk std.ascii.upperString(wl_buf[0..len], raw[0..len]); + } else "3M"; + + try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out, display_count, window_label, chart_render); } /// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the @@ -287,7 +303,7 @@ fn emitQuoteKitty(allocator: std.mem.Allocator, out: *std.Io.Writer, candles: [] try term_graphics.placeInline(out, allocator, rgb, dims.width, dims.height, cols, rows); } -pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer, display_count: usize, chart_render: ChartRender) !void { +pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer, display_count: usize, window_label: []const u8, chart_render: ChartRender) !void { const has_quote = quote != null; // Header. The security name (when resolved) renders between the @@ -325,11 +341,20 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote try out.print(" Low: ${d:.2}\n", .{low_val}); try out.print(" Volume: {s}\n", .{fmt.fmtIntCommas(&vol_buf, vol_val)}); - if (prev_close > 0) { - const change = price - prev_close; - const pct = (change / prev_close) * 100.0; + if (fmt.pctChange(price, prev_close)) |dc| { var chg_buf: [64]u8 = undefined; - try cli.printGainLoss(out, color, change, " Change: {s}\n", .{fmt.fmtPriceChange(&chg_buf, change, pct)}); + try cli.printGainLoss(out, color, dc.change, " {s:<14} {s}\n", .{ "Change (1D):", fmt.fmtPriceChange(&chg_buf, dc.change, dc.pct) }); + } + + // Change over the chart window: the first visible candle's close + // (the chart's left edge) vs the current price. The `()` + // mirrors the --since window, disambiguating it from the 1-day + // change above. + if (fmt.windowChange(candles, display_count, price)) |wc| { + var wbuf: [64]u8 = undefined; + var lbl_buf: [32]u8 = undefined; + const lbl = std.fmt.bufPrint(&lbl_buf, "Change ({s}):", .{window_label}) catch "Change:"; + try cli.printGainLoss(out, color, wc.change, " {s:<14} {s}\n", .{ lbl, fmt.fmtPriceChange(&wbuf, wc.change, wc.pct) }); } } @@ -470,7 +495,7 @@ test "display with candles only" { .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 }, .{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 }, }; - try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .braille); + try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null); @@ -478,6 +503,24 @@ test "display with candles only" { try std.testing.expect(std.mem.indexOf(u8, out, "2 trading days shown") != null); } +test "display renders both the 1D and window change rows" { + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const candles = [_]zfin.Candle{ + .{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 100.0, .adj_close = 100.0, .volume = 1_000_000 }, + .{ .date = .{ .days = 20001 }, .open = 100.0, .high = 112.0, .low = 100.0, .close = 110.0, .adj_close = 110.0, .volume = 1_200_000 }, + .{ .date = .{ .days = 20002 }, .open = 110.0, .high = 130.0, .low = 108.0, .close = 125.0, .adj_close = 125.0, .volume = 1_500_000 }, + }; + try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "1Y", .braille); + const out = w.buffered(); + // 1-day change is the last close (125) vs the prior close (110). + try std.testing.expect(std.mem.indexOf(u8, out, "Change (1D):") != null); + // Window change carries the supplied span label and measures from + // the first visible candle's close (100) to the current price (125). + try std.testing.expect(std.mem.indexOf(u8, out, "Change (1Y):") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "+$25.00 (+25.00%)") != null); +} + test "display with quote data" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); @@ -491,7 +534,7 @@ test "display with quote data" { .prev_close = 172.00, .date = .{ .days = 20001 }, }; - try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .braille); + try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null); @@ -505,7 +548,7 @@ test "display renders the security name when provided" { const candles = [_]zfin.Candle{ .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 }, }; - try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .braille); + try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille); const out = w.buffered(); // Name appears between the symbol and the price. try std.testing.expect(std.mem.indexOf(u8, out, "AAPL Apple Inc.") != null); @@ -517,7 +560,7 @@ test "display omits an empty name" { const candles = [_]zfin.Candle{ .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 }, }; - try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .braille); + try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille); const out = w.buffered(); // No double-space orphan where the name would have gone. try std.testing.expect(std.mem.indexOf(u8, out, "AAPL $") != null); @@ -529,7 +572,7 @@ test "display no ANSI without color" { const candles = [_]zfin.Candle{ .{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 }, }; - try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .braille); + try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); } diff --git a/src/format.zig b/src/format.zig index bf4f4aa..b49ced0 100644 --- a/src/format.zig +++ b/src/format.zig @@ -512,6 +512,37 @@ pub fn filterCandlesFrom(candles: []const Candle, from: Date) []const Candle { return candles[lo..]; } +/// An absolute and percentage price change. +pub const PctChange = struct { + /// `price - base`, in price units. + change: f64, + /// `(price - base) / base * 100`, a percentage. + pct: f64, +}; + +/// Absolute and percentage change from `base` to `price`. Returns null +/// when `base <= 0`, where a percentage isn't meaningful (e.g. a missing +/// previous close). The single source of truth for the day-over-day and +/// chart-window change figures shared by the `quote` CLI command and the +/// TUI quote tab. +pub fn pctChange(price: f64, base: f64) ?PctChange { + if (base <= 0) return null; + const change = price - base; + return .{ .change = change, .pct = (change / base) * 100.0 }; +} + +/// Change from the first candle of the most-recent `window_count` candles +/// (the chart's left edge) to `price` (the current price). Used to label +/// the "Change ()" row in both the `quote` CLI command and the TUI +/// quote tab. `window_count` is clamped to the available candles. Returns +/// null when the window holds fewer than 2 candles or its first close is +/// non-positive - i.e. there's no meaningful window to measure. +pub fn windowChange(candles: []const Candle, window_count: usize, price: f64) ?PctChange { + const win_n = @min(candles.len, window_count); + if (win_n < 2) return null; + return pctChange(price, candles[candles.len - win_n].close); +} + // ── Options helpers ────────────────────────────────────────── /// Filter options contracts to +/- N strikes from ATM. @@ -1305,6 +1336,64 @@ test "filterCandlesFrom" { try std.testing.expectEqual(@as(usize, 0), from_empty.len); } +fn testCandle(close: f64) Candle { + return .{ .date = Date.fromYmd(2024, 1, 2), .open = close, .high = close, .low = close, .close = close, .adj_close = close, .volume = 1000 }; +} + +test "pctChange: up and down moves" { + const up = pctChange(110.0, 100.0).?; + try std.testing.expectApproxEqAbs(@as(f64, 10.0), up.change, 1e-9); + try std.testing.expectApproxEqAbs(@as(f64, 10.0), up.pct, 1e-9); + + const down = pctChange(90.0, 100.0).?; + try std.testing.expectApproxEqAbs(@as(f64, -10.0), down.change, 1e-9); + try std.testing.expectApproxEqAbs(@as(f64, -10.0), down.pct, 1e-9); +} + +test "pctChange: non-positive base returns null" { + try std.testing.expect(pctChange(100.0, 0.0) == null); + try std.testing.expect(pctChange(100.0, -5.0) == null); +} + +test "windowChange: measures from the first candle in the window" { + const candles = [_]Candle{ testCandle(100.0), testCandle(110.0), testCandle(125.0) }; + // Full window: base = first close (100), price 125 -> +25 (+25%). + const full = windowChange(&candles, 3, 125.0).?; + try std.testing.expectApproxEqAbs(@as(f64, 25.0), full.change, 1e-9); + try std.testing.expectApproxEqAbs(@as(f64, 25.0), full.pct, 1e-9); +} + +test "windowChange: window_count clamps to available candles" { + const candles = [_]Candle{ testCandle(100.0), testCandle(110.0) }; + // Asking for 60 but only 2 exist -> base is the earliest (100). + const wc = windowChange(&candles, 60, 121.0).?; + try std.testing.expectApproxEqAbs(@as(f64, 21.0), wc.change, 1e-9); + try std.testing.expectApproxEqAbs(@as(f64, 21.0), wc.pct, 1e-9); +} + +test "windowChange: sub-window picks candles[len-window_count] as base" { + const candles = [_]Candle{ testCandle(50.0), testCandle(100.0), testCandle(120.0) }; + // window_count=2 -> base is candles[len-2] = 100, not 50. + const wc = windowChange(&candles, 2, 120.0).?; + try std.testing.expectApproxEqAbs(@as(f64, 20.0), wc.change, 1e-9); + try std.testing.expectApproxEqAbs(@as(f64, 20.0), wc.pct, 1e-9); +} + +test "windowChange: fewer than 2 candles in the window returns null" { + const one = [_]Candle{testCandle(100.0)}; + try std.testing.expect(windowChange(&one, 60, 110.0) == null); + const empty: []const Candle = &.{}; + try std.testing.expect(windowChange(empty, 60, 110.0) == null); + // window_count < 2 yields null even with enough candles. + const two = [_]Candle{ testCandle(100.0), testCandle(110.0) }; + try std.testing.expect(windowChange(&two, 1, 110.0) == null); +} + +test "windowChange: non-positive base close returns null" { + const candles = [_]Candle{ testCandle(0.0), testCandle(110.0) }; + try std.testing.expect(windowChange(&candles, 2, 110.0) == null); +} + test "filterNearMoney" { const exp = Date.fromYmd(2024, 3, 15); const contracts = [_]OptionContract{ diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index a167601..55a70b0 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -189,14 +189,14 @@ pub const tab = struct { 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)" + // 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{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" }; + const timeframes = [_]chart.Timeframe{ .@"3M", .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" }; var x: usize = prefix_len; for (timeframes) |tf| { const lbl_len = tf.label().len; @@ -273,28 +273,28 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi else app.symbol; - // Symbol + price header + // 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 cur_price: ?f64 = if (app.states.quote.live) |q| q.close else if (c.len > 0) c[c.len - 1].close else null; + 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() }); - 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)", .{ sym_label, 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; + } + + if (cur_price) |price| { + if (fmt.windowChange(c, cur_tf.tradingDays(), price)) |wc| { 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 }); + 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 }); } } @@ -305,7 +305,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi 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" }; + const timeframes = [_]chart.Timeframe{ .@"3M", .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" }; for (timeframes) |tf| { const lbl = tf.label(); if (tf == app.states.quote.chart.timeframe) { @@ -388,11 +388,15 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi // Free old cache if it exists app.states.quote.chart.freeCache(app.allocator); - // Compute and cache new indicators - const new_cache = chart.computeIndicators( + // 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, + app.states.quote.chart.timeframe.tradingDays(), + 20, ) catch |err| { app.states.quote.chart.dirty = false; var err_buf: [128]u8 = undefined; @@ -570,7 +574,10 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi 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); + // `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); @@ -664,7 +671,7 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { { 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 }); + 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); } @@ -681,7 +688,11 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { 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); + // 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() }); @@ -737,25 +748,35 @@ fn buildDetailColumns( 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 = 30; + 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) { - const change = price - prev_close; - const pct = (change / prev_close) * 100.0; + if (fmt.pctChange(price, prev_close)) |dc| { 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); + 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)