From 3e45393d933375b0be489b05c5df28f830808074 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 26 Jun 2026 07:35:53 -0700 Subject: [PATCH] add --since flag to quote --- docs/reference/cli/quote.md | 18 +++- src/chart_export.zig | 26 ++++-- src/charts/chart.zig | 84 ++++++++++++++++-- src/commands/common.zig | 12 ++- src/commands/quote.zig | 170 +++++++++++++++++++++--------------- 5 files changed, 220 insertions(+), 90 deletions(-) diff --git a/docs/reference/cli/quote.md b/docs/reference/cli/quote.md index 9893a03..dd7774b 100644 --- a/docs/reference/cli/quote.md +++ b/docs/reference/cli/quote.md @@ -4,12 +4,13 @@ Show the latest quote for a symbol, with a price chart and recent history. ``` -Usage: zfin quote +Usage: zfin quote [--since ] [--export-chart ] ``` Prints the last price, the day's open/high/low, volume, and the -day-over-day change, followed by a price chart of the last 60 candles -and a table of the last 20 trading days. Quotes come from Yahoo +day-over-day change, followed by a price chart over a recent window +(the last ~3 months by default) and a table of the last 20 trading +days. Quotes come from Yahoo (TwelveData fallback) and are **never cached** -- so this command needs network access and does nothing useful in `--refresh-data=never` mode. @@ -18,6 +19,12 @@ volume + RSI) when your terminal supports it, falling back to a braille price line otherwise. Force a mode with the global [`--chart`](index.md) flag (`auto` / `braille` / `WxH`). +Use `--since ` to change how far back the chart reaches. `WHEN` +accepts an absolute `YYYY-MM-DD`, a relative shortcut (`1W`, `1M`, +`1Q`, `1Y`), or `ytd`. It governs both the inline chart and the +`--export-chart` PNG; the 20-day history table is always the last 20 +trading days regardless. + Supports `--export-chart ` to render the chart as a 1920x1080 PNG instead of text (see [export charts](../../guides/offline-and-refresh.md) and the projections page). @@ -26,6 +33,9 @@ and the projections page). ```bash ZFIN_HOME=examples/pre-retirement-both zfin quote SPY + +# A one-year chart window instead of the default ~3 months: +ZFIN_HOME=examples/pre-retirement-both zfin quote SPY --since 1Y ``` ``` @@ -38,7 +48,7 @@ SPY $746.74 (close) Volume: 80,875,657 Change: +$5.78 (+0.78%) - ... (60-candle price chart -- inline Kitty image, or braille) + ... (price chart over the selected window -- inline Kitty image, or braille) ``` ## See also diff --git a/src/chart_export.zig b/src/chart_export.zig index de275ab..7011334 100644 --- a/src/chart_export.zig +++ b/src/chart_export.zig @@ -38,24 +38,34 @@ const theme = @import("tui/theme.zig"); pub const default_width: u32 = 1920; pub const default_height: u32 = 1080; -/// Export a price+Bollinger+RSI chart for a single symbol. -/// Wraps `chart.renderToSurface` + `writeToPNGFile`. +/// Export a price+Bollinger+RSI chart for a single symbol, showing the +/// most recent `display_count` candles. The overlays are computed with a +/// warmup lookback (via `chart.computeIndicatorsWarmup`) so they're valid +/// from the first displayed candle. Wraps `renderToSurface` + `writeToPNGFile`. pub fn exportSymbolChart( io: std.Io, alloc: std.mem.Allocator, candles: []const zfin.Candle, - timeframe: chart.Timeframe, + display_count: usize, path: []const u8, ) !void { + var cached = chart.computeIndicatorsWarmup(alloc, candles, display_count, 20) catch |err| switch (err) { + error.InsufficientData => return error.InsufficientData, + else => return err, + }; + defer cached.deinit(alloc); + const n = @min(candles.len, display_count); + const display = candles[candles.len - n ..]; + var rendered = chart.renderToSurface( io, alloc, - candles, - timeframe, + display, + null, default_width, default_height, theme.default_theme, - null, + &cached, true, ) catch |err| switch (err) { error.InsufficientData => return error.InsufficientData, @@ -151,7 +161,7 @@ test "exportSymbolChart writes a non-empty PNG file" { const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol.png" }); defer alloc.free(path); - try exportSymbolChart(io, alloc, &candles, .@"6M", path); + try exportSymbolChart(io, alloc, &candles, 60, path); // Verify the file exists, starts with the PNG magic, and is // big enough to plausibly contain a chart (not just headers). @@ -194,7 +204,7 @@ test "exportSymbolChart returns InsufficientData on too-few candles" { try std.testing.expectError( error.InsufficientData, - exportSymbolChart(io, alloc, &candles, .@"6M", path), + exportSymbolChart(io, alloc, &candles, 60, path), ); } diff --git a/src/charts/chart.zig b/src/charts/chart.zig index 2abf02f..bd40c3f 100644 --- a/src/charts/chart.zig +++ b/src/charts/chart.zig @@ -51,6 +51,7 @@ const Pixel = z2d.Pixel; /// Chart timeframe selection. pub const Timeframe = enum { + @"3M", @"6M", ytd, @"1Y", @@ -59,6 +60,7 @@ pub const Timeframe = enum { pub fn label(self: Timeframe) []const u8 { return switch (self) { + .@"3M" => "3M", .@"6M" => "6M", .ytd => "YTD", .@"1Y" => "1Y", @@ -69,6 +71,7 @@ pub const Timeframe = enum { pub fn tradingDays(self: Timeframe) usize { return switch (self) { + .@"3M" => 63, .@"6M" => 126, .ytd => 252, // approximation, we'll clamp .@"1Y" => 252, @@ -79,17 +82,19 @@ pub const Timeframe = enum { pub fn next(self: Timeframe) Timeframe { return switch (self) { + .@"3M" => .@"6M", .@"6M" => .ytd, .ytd => .@"1Y", .@"1Y" => .@"3Y", .@"3Y" => .@"5Y", - .@"5Y" => .@"6M", + .@"5Y" => .@"3M", }; } pub fn prev(self: Timeframe) Timeframe { return switch (self) { - .@"6M" => .@"5Y", + .@"3M" => .@"5Y", + .@"6M" => .@"3M", .ytd => .@"6M", .@"1Y" => .ytd, .@"3Y" => .@"1Y", @@ -175,6 +180,51 @@ pub fn computeIndicators( }; } +/// Like `computeIndicators`, but for a fixed *display count* of the most +/// recent candles, computing the Bollinger/RSI series over an extra +/// `warmup` candles of lookback so the overlays are valid from the first +/// displayed candle (no warm-up gap). Returns owned arrays of length +/// `min(candles.len, display_count)`, aligned with the last that many +/// candles. Pair with `renderToSurface(candles[len-n..], null, ...)`. +pub fn computeIndicatorsWarmup( + alloc: std.mem.Allocator, + candles: []const zfin.Candle, + display_count: usize, + warmup: usize, +) !CachedIndicators { + if (candles.len < 20) return error.InsufficientData; + const n = @min(candles.len, display_count); + const m = @min(candles.len, n + warmup); + const window = candles[candles.len - m ..]; + + const closes_w = try zfin.indicators.closePrices(alloc, window); + defer alloc.free(closes_w); + const vols_w = try zfin.indicators.volumes(alloc, window); + defer alloc.free(vols_w); + const bb_w = try zfin.indicators.bollingerBands(alloc, closes_w, 20, 2.0); + defer alloc.free(bb_w); + const rsi_w = try zfin.indicators.rsi(alloc, closes_w, 14); + defer alloc.free(rsi_w); + + // Keep only the last `n` of each (the displayed candles); the leading + // `off` were lookback for the overlays. Dupe into clean owned arrays + // so the result has a normal `deinit` (no views into freed buffers). + const off = m - n; + const closes = try alloc.dupe(f64, closes_w[off..]); + errdefer alloc.free(closes); + const vols = try alloc.dupe(f64, vols_w[off..]); + errdefer alloc.free(vols); + const bb = try alloc.dupe(?zfin.indicators.BollingerBand, bb_w[off..]); + errdefer alloc.free(bb); + const rsi_vals = try alloc.dupe(?f64, rsi_w[off..]); + return .{ + .closes = closes, + .volumes = vols, + .bb = bb, + .rsi_vals = rsi_vals, + }; +} + /// Render a complete financial chart to raw RGB pixel data. /// The returned rgb_data is allocated with `alloc` and must be freed by caller. /// If `cached` is provided, uses pre-computed indicators instead of recomputing. @@ -214,7 +264,7 @@ pub fn renderToSurface( io: std.Io, alloc: std.mem.Allocator, candles: []const zfin.Candle, - timeframe: Timeframe, + timeframe: ?Timeframe, width_px: u32, height_px: u32, th: theme.Theme, @@ -223,9 +273,9 @@ pub fn renderToSurface( ) !RenderedChart { if (candles.len < 20) return error.InsufficientData; - // Slice candles to timeframe - const max_days = timeframe.tradingDays(); - const n = @min(candles.len, max_days); + // Render the timeframe's slice, or all of `candles` when the caller + // already sliced (timeframe == null - the CLI's date-based path). + const n = if (timeframe) |tf| @min(candles.len, tf.tradingDays()) else candles.len; const data = candles[candles.len - n ..]; // Use cached indicators or compute fresh ones @@ -626,22 +676,38 @@ test "ChartConfig.parse" { } test "Timeframe next/prev cycle" { - // next cycles through all values + // next cycles: 3M -> 6M -> ytd -> 1Y -> 3Y -> 5Y -> 3M + try std.testing.expectEqual(Timeframe.@"6M", Timeframe.@"3M".next()); try std.testing.expectEqual(Timeframe.ytd, Timeframe.@"6M".next()); try std.testing.expectEqual(Timeframe.@"1Y", Timeframe.ytd.next()); - try std.testing.expectEqual(Timeframe.@"6M", Timeframe.@"5Y".next()); // wraps + try std.testing.expectEqual(Timeframe.@"3M", Timeframe.@"5Y".next()); // wraps // prev is the reverse - try std.testing.expectEqual(Timeframe.@"5Y", Timeframe.@"6M".prev()); // wraps + try std.testing.expectEqual(Timeframe.@"5Y", Timeframe.@"3M".prev()); // wraps + try std.testing.expectEqual(Timeframe.@"3M", Timeframe.@"6M".prev()); try std.testing.expectEqual(Timeframe.@"6M", Timeframe.ytd.prev()); } test "Timeframe tradingDays" { + try std.testing.expectEqual(@as(usize, 63), Timeframe.@"3M".tradingDays()); try std.testing.expectEqual(@as(usize, 126), Timeframe.@"6M".tradingDays()); try std.testing.expectEqual(@as(usize, 252), Timeframe.@"1Y".tradingDays()); try std.testing.expectEqual(@as(usize, 1260), Timeframe.@"5Y".tradingDays()); } +test "computeIndicatorsWarmup: overlays valid from the first displayed candle" { + var candles: [80]zfin.Candle = undefined; + buildLinearCandles(&candles, 100.0); + var cached = try computeIndicatorsWarmup(test_alloc, &candles, 60, 20); + defer cached.deinit(test_alloc); + // Displays the last 60; the 20-candle lookback means the first + // displayed point already carries a Bollinger band and an RSI value. + try std.testing.expectEqual(@as(usize, 60), cached.closes.len); + try std.testing.expectEqual(@as(usize, 60), cached.bb.len); + try std.testing.expect(cached.bb[0] != null); + try std.testing.expect(cached.rsi_vals[0] != null); +} + // ── renderToSurface tests ───────────────────────────────────────────── // // These exercise the actual chart rendering pipeline (z2d surface + diff --git a/src/commands/common.zig b/src/commands/common.zig index d538072..94678fa 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -443,6 +443,10 @@ pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.D if (std.ascii.eqlIgnoreCase(s, "live") or std.ascii.eqlIgnoreCase(s, "now")) { return null; } + // Year-to-date: Jan 1 of the reference year. + if (std.ascii.eqlIgnoreCase(s, "ytd")) { + return zfin.Date.fromYmd(as_of.year(), 1, 1); + } // Explicit YYYY-MM-DD. if (s.len == 10 and s[4] == '-' and s[7] == '-') { @@ -477,7 +481,7 @@ pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.D /// caller is responsible for formatting the surrounding message. pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 { return switch (err) { - error.InvalidFormat => std.fmt.bufPrint(buf, "Invalid as-of value: {s}. Expected YYYY-MM-DD, N[WMQY] (e.g. 1M, 3Q, 2Y), or 'live'.", .{input}) catch input, + error.InvalidFormat => std.fmt.bufPrint(buf, "Invalid as-of value: {s}. Expected YYYY-MM-DD, N[WMQY] (e.g. 1M, 3Q, 2Y), 'ytd', or 'live'.", .{input}) catch input, error.EmptyUnit => std.fmt.bufPrint(buf, "As-of value {s} is missing a unit. Expected one of W, M, Q, Y.", .{input}) catch input, error.UnknownUnit => std.fmt.bufPrint(buf, "As-of value {s} has an unknown unit. Expected one of W (weeks), M (months), Q (quarters), Y (years).", .{input}) catch input, error.ZeroQuantity => std.fmt.bufPrint(buf, "As-of quantity must be at least 1 (got {s}).", .{input}) catch input, @@ -1032,6 +1036,12 @@ test "parseAsOfDate: literal 'live' and 'now' (case-insensitive)" { try std.testing.expect((try parseAsOfDate("Now", today)) == null); } +test "parseAsOfDate: 'ytd' is Jan 1 of the reference year (case-insensitive)" { + const today = zfin.Date.fromYmd(2026, 4, 2); + try std.testing.expect((try parseAsOfDate("ytd", today)).?.eql(zfin.Date.fromYmd(2026, 1, 1))); + try std.testing.expect((try parseAsOfDate("YTD", today)).?.eql(zfin.Date.fromYmd(2026, 1, 1))); +} + test "parseAsOfDate: explicit YYYY-MM-DD" { const today = zfin.Date.fromYmd(2026, 4, 2); const r = try parseAsOfDate("2026-03-13", today); diff --git a/src/commands/quote.zig b/src/commands/quote.zig index a9153ca..385f369 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -17,6 +17,10 @@ pub const ParsedArgs = struct { /// z2d-rendered pixel buffers for the TUI; this flag just lands /// the same pixels in a file via z2d's PNG exporter. export_chart: ?[]const u8 = null, + /// Chart start date from `--since` (resolved at parse time). Null + /// 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, }; pub const meta: framework.Meta = .{ @@ -25,18 +29,25 @@ pub const meta: framework.Meta = .{ .synopsis = "Show latest quote with chart and 20-day history", .uppercase_first_arg = true, .help = - \\Usage: zfin quote [--export-chart ] + \\Usage: zfin quote [--since ] [--export-chart ] \\ \\Show the latest real-time quote for a symbol (Yahoo / TwelveData) - \\plus a price chart of the last 60 candles (an inline Kitty image + \\plus a price chart over a recent window (an inline Kitty image \\when the terminal supports it, braille otherwise) and a table - \\of the last 20 trading days. + \\of the last 20 trading days. The chart spans the last ~3 months + \\by default; use --since to widen or narrow it. \\ \\If real-time fetch fails, falls back to the cached close. The \\Yahoo path is free and unauthenticated; TwelveData requires \\TWELVEDATA_API_KEY. \\ \\Options: + \\ --since Start the price chart at WHEN instead of + \\ the default last ~3 months. Applies to + \\ both the inline chart and --export-chart. + \\ Accepts YYYY-MM-DD, a relative shortcut + \\ (1W/1M/1Q/1Y), or 'ytd'. The 20-day + \\ history table is unaffected. \\ --export-chart Render the price+Bollinger+RSI chart \\ to a PNG file at the given path \\ (1920x1080) and exit. No text output @@ -46,10 +57,11 @@ pub const meta: framework.Meta = .{ \\Examples: \\ zfin quote AAPL \\ zfin quote spy # symbols are case-insensitive + \\ zfin quote AAPL --since 1Y \\ zfin quote AAPL --export-chart aapl.png \\ , - .user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue }, + .user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue, InvalidDate }, }; /// Quote data extracted from the real-time API (or synthesized from candles). @@ -66,12 +78,16 @@ pub const QuoteData = struct { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { var symbol: ?[]const u8 = null; var export_chart: ?[]const u8 = null; + var since: ?zfin.Date = null; var i: usize = 0; while (i < cmd_args.len) : (i += 1) { const a = cmd_args[i]; if (std.mem.eql(u8, a, "--export-chart")) { export_chart = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); + } 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; } 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 @@ -94,7 +110,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 }; + return .{ .symbol = symbol.?, .export_chart = export_chart, .since = since }; } pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { @@ -114,13 +130,15 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer candle_result.deinit(); const candles = candle_result.data; - // PNG export short-circuits all text rendering. Use the - // longest timeframe the candle history can support - falling - // back to shorter ones until one fits - so the user gets the - // most chart context without having to think about it. + // Chart window: candles on/after the `--since` date (default: the + // last ~3 months). `display_count` is how many recent candles get + // drawn; the overlays warm up over extra lookback (see emitQuoteKitty). + const since_date = parsed.since orelse ctx.today.subtractMonths(3); + const display_count = fmt.filterCandlesFrom(candles, since_date).len; + + // PNG export short-circuits all text rendering. if (parsed.export_chart) |path| { - const tf: tui_chart.Timeframe = pickTimeframe(candles); - chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, tf, path) catch |err| switch (err) { + chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, display_count, path) catch |err| switch (err) { error.InsufficientData => { cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n"); return; @@ -183,7 +201,7 @@ 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, chart_render); + try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out, display_count, chart_render); } /// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the @@ -237,66 +255,39 @@ const KittyChart = struct { caps: term_query.Caps, }; -/// Pick the longest timeframe the candle history can fill, falling back -/// to 6M. Shared by `--export-chart` and the inline kitty chart. -fn pickTimeframe(candles: []const zfin.Candle) tui_chart.Timeframe { - const candidates = [_]tui_chart.Timeframe{ .@"5Y", .@"3Y", .@"1Y", .@"6M" }; - for (candidates) |c| { - if (candles.len >= c.tradingDays()) return c; - } - return .@"6M"; -} - -/// Braille price chart of the last 60 candles (the fallback path). -fn renderBrailleCandles(allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, candles: []const zfin.Candle) !void { - const chart_days: usize = @min(candles.len, 60); - const chart_data = candles[candles.len - chart_days ..]; - var ch = fmt.computeBrailleChart(allocator, chart_data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return; +/// Braille price chart of the most recent `display_count` candles (the +/// fallback path, used when kitty graphics aren't available). +fn renderBrailleCandles(allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, candles: []const zfin.Candle, display_count: usize) !void { + const n = @min(candles.len, display_count); + const data = candles[candles.len - n ..]; + var ch = fmt.computeBrailleChart(allocator, data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return; defer ch.deinit(allocator); try fmt.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false); } -/// Render the price+Bollinger+volume+RSI chart as kitty graphics at -/// `term_graphics.quote_cols` wide and emit it inline. Displays the same -/// last-60-candle window as the braille fallback, but computes the -/// Bollinger/RSI overlays over an extra `warmup` candles of lookback and -/// slices them to the window - so the bands are valid from the first -/// *displayed* candle instead of warming up a third of the way in. The -/// standalone `--export-chart` PNG still shows the longest history. -/// Returns `error.InsufficientData` when there's too little history -/// (< 20 candles) so the caller can fall back to braille. -fn emitQuoteKitty(allocator: std.mem.Allocator, out: *std.Io.Writer, candles: []const zfin.Candle, k: KittyChart) !void { - const display_n: usize = @min(candles.len, 60); - const warmup: usize = 20; // >= the BB(20) / RSI(14) warmup periods - const window_n: usize = @min(candles.len, display_n + warmup); - const window = candles[candles.len - window_n ..]; - - // Compute indicators over the (display + warmup) window, then view - // the last `display_n` of each so they align with the displayed - // candles and carry no warmup gap. `full` owns the backing arrays; - // `cached` is a non-owning slice into them. - var full = try tui_chart.computeIndicators(allocator, window, .@"6M"); - defer full.deinit(allocator); - const off = window_n - display_n; - const cached: tui_chart.CachedIndicators = .{ - .closes = full.closes[off..], - .volumes = full.volumes[off..], - .bb = full.bb[off..], - .rsi_vals = full.rsi_vals[off..], - }; - - const display_data = candles[candles.len - display_n ..]; +/// Render the price+Bollinger+volume+RSI chart for the most recent +/// `display_count` candles as kitty graphics at `term_graphics.quote_cols` +/// wide and emit it inline. The overlays are computed with a warmup +/// lookback (`chart.computeIndicatorsWarmup`) so they're valid from the +/// first displayed candle at any window size. Returns +/// `error.InsufficientData` when there's too little history (< 20 candles) +/// so the caller can fall back to braille. +fn emitQuoteKitty(allocator: std.mem.Allocator, out: *std.Io.Writer, candles: []const zfin.Candle, display_count: usize, k: KittyChart) !void { + var cached = try tui_chart.computeIndicatorsWarmup(allocator, candles, display_count, 20); + defer cached.deinit(allocator); + const n = @min(candles.len, display_count); + const display_data = candles[candles.len - n ..]; const cols = term_graphics.quote_cols; const rows = term_graphics.rowsForWidth(cols, k.caps.cell_w, k.caps.cell_h); const dims = term_graphics.pixelDims(cols, rows, k.caps.cell_w, k.caps.cell_h); - var rendered = try tui_chart.renderToSurface(k.io, allocator, display_data, .@"6M", dims.width, dims.height, theme.default_theme, &cached, true); + var rendered = try tui_chart.renderToSurface(k.io, allocator, display_data, null, dims.width, dims.height, theme.default_theme, &cached, true); defer rendered.deinit(allocator); const rgb = try rendered.extractRgb(allocator); defer allocator.free(rgb); 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, 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, chart_render: ChartRender) !void { const has_quote = quote != null; // Header. The security name (when resolved) renders between the @@ -343,13 +334,13 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote } // Chart: inline kitty graphics when supported, else a braille price - // chart of the last 60 candles. + // chart over the selected window (`display_count` recent candles). if (candles.len >= 2) { try out.print("\n", .{}); switch (chart_render) { - .braille => try renderBrailleCandles(allocator, out, color, candles), - .kitty => |k| emitQuoteKitty(allocator, out, candles, k) catch |err| switch (err) { - error.InsufficientData => try renderBrailleCandles(allocator, out, color, candles), + .braille => try renderBrailleCandles(allocator, out, color, candles, display_count), + .kitty => |k| emitQuoteKitty(allocator, out, candles, display_count, k) catch |err| switch (err) { + error.InsufficientData => try renderBrailleCandles(allocator, out, color, candles, display_count), else => return err, }, } @@ -429,6 +420,49 @@ test "parseArgs: --export-chart followed by a flag does not swallow the flag" { try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args)); } +test "parseArgs: --since accepts an explicit YYYY-MM-DD" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.today = zfin.Date.fromYmd(2026, 5, 8); + const args = [_][]const u8{ "AAPL", "--since", "2025-01-15" }; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expect(parsed.since.?.eql(zfin.Date.fromYmd(2025, 1, 15))); +} + +test "parseArgs: --since accepts a relative shortcut" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.today = zfin.Date.fromYmd(2026, 5, 8); + const args = [_][]const u8{ "AAPL", "--since", "1Y" }; + const parsed = try parseArgs(&ctx, &args); + // 1Y back from 2026-05-08 is 2025-05-08 (calendar-year subtraction). + try std.testing.expect(parsed.since.?.eql(zfin.Date.fromYmd(2025, 5, 8))); +} + +test "parseArgs: --since defaults to null when omitted" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"AAPL"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expect(parsed.since == null); +} + +test "parseArgs: --since without a value is rejected" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.today = zfin.Date.fromYmd(2026, 5, 8); + const args = [_][]const u8{ "AAPL", "--since" }; + try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args)); +} + +test "parseArgs: --since with an invalid value is rejected" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.today = zfin.Date.fromYmd(2026, 5, 8); + const args = [_][]const u8{ "AAPL", "--since", "garbage" }; + try std.testing.expectError(error.InvalidDate, parseArgs(&ctx, &args)); +} + test "display with candles only" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); @@ -436,7 +470,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, .braille); + try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .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); @@ -457,7 +491,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, .braille); + try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .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); @@ -471,7 +505,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, .braille); + try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .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); @@ -483,7 +517,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, .braille); + try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .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); @@ -495,7 +529,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, .braille); + try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .braille); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); }