add the period change to quote display on cli/tui

This commit is contained in:
Emil Lerch 2026-06-26 08:11:59 -07:00
parent 3e45393d93
commit 8e0288d437
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 202 additions and 42 deletions

View file

@ -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 (<span>)` 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.

View file

@ -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 (<span>)" 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 (<span>)" 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 `(<span>)`
// 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);
}

View file

@ -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 (<span>)" 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{

View file

@ -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)