add --since flag to quote

This commit is contained in:
Emil Lerch 2026-06-26 07:35:53 -07:00
parent 3f48a5b38c
commit 3e45393d93
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 220 additions and 90 deletions

View file

@ -4,12 +4,13 @@ Show the latest quote for a symbol, with a price chart and recent
history.
```
Usage: zfin quote <SYMBOL>
Usage: zfin quote <SYMBOL> [--since <WHEN>] [--export-chart <PATH>]
```
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 <WHEN>` 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 <PATH>` 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

View file

@ -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),
);
}

View file

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

View file

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

View file

@ -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 <SYMBOL> [--export-chart <PATH>]
\\Usage: zfin quote <SYMBOL> [--since <WHEN>] [--export-chart <PATH>]
\\
\\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 <WHEN> 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 <PATH> 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);
}