zfin/src/commands/quote.zig
Emil Lerch 6bd063d51f
All checks were successful
Generic zig build / build (push) Successful in 5m23s
Generic zig build / publish-macos (push) Successful in 13s
Generic zig build / deploy (push) Successful in 20s
correctly color history when open == close (mutual funds)
2026-09-01 15:15:08 -07:00

732 lines
34 KiB
Zig

const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const chart_export = @import("../chart_export.zig");
const tui_chart = @import("../charts/chart.zig");
const braille = @import("../charts/braille.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
pub const ParsedArgs = struct {
symbol: []const u8,
/// When set, render the chart as a PNG to this path instead of
/// emitting any text output. The chart code already produces
/// 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,
/// 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,
/// When set (`--live`), stream live prices for the symbol
/// continuously (Yahoo websocket) instead of printing a one-shot
/// quote. Runs until interrupted (Ctrl-C) or killed (e.g.
/// `timeout`). Ignores the chart / --since / --export-chart options.
live: bool = false,
};
pub const meta: framework.Meta = .{
.name = "quote",
.group = .symbol_lookup,
.synopsis = "Show latest quote with chart and 20-day history",
.uppercase_first_arg = true,
.help =
\\Usage: zfin quote <SYMBOL> [--since <WHEN>] [--export-chart <PATH>] [--live]
\\
\\Show the latest real-time quote for a symbol (Yahoo / TwelveData)
\\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. 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
\\ is emitted. Uses the TUI's default
\\ theme.
\\ --live Stream live prices for SYMBOL continuously
\\ (free Yahoo websocket) instead of a
\\ one-shot quote. Prints the latest price
\\ once per second until interrupted (Ctrl-C)
\\ or killed. Ignores the chart options above.
\\ Outside market hours, US equities stream a
\\ closing snapshot then go quiet; 24/7
\\ symbols (e.g. BTC-USD) keep ticking.
\\
\\Examples:
\\ zfin quote AAPL
\\ zfin quote spy # symbols are case-insensitive
\\ zfin quote AAPL --since 1Y
\\ zfin quote AAPL --export-chart aapl.png
\\ timeout 12 zfin quote --live BTC-USD # watch live ticks for 12s
\\
,
.user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue, InvalidDate },
};
/// Quote data extracted from the real-time API (or synthesized from candles).
pub const QuoteData = struct {
price: f64,
open: f64,
high: f64,
low: f64,
volume: u64,
prev_close: f64,
date: zfin.Date,
};
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 since_raw: ?[]const u8 = null;
var live: bool = false;
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;
since_raw = value;
} else if (std.mem.eql(u8, a, "--live")) {
live = true;
} 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
// `--`-prefixed flags were caught, so `-x` slipped through
// and became the symbol.
cli.stderrPrint(ctx.io, "Error: 'quote': unexpected flag ");
cli.stderrPrint(ctx.io, a);
cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg;
} else {
if (symbol != null) {
cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n");
return error.UnexpectedArg;
}
symbol = a;
}
}
if (symbol == null) {
cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n");
return error.MissingSymbol;
}
return .{ .symbol = symbol.?, .export_chart = export_chart, .since = since, .since_raw = since_raw, .live = live };
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
// `--live` streams continuously and shares none of the one-shot
// quote's candle/chart/name machinery, so branch before any of it.
if (parsed.live) return runLive(ctx, svc, parsed.symbol);
const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy);
// Fetch candle data for chart and history
const candle_result = svc.getCandles(parsed.symbol, opts) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n");
return;
},
else => {
cli.stderrPrint(ctx.io, "Error fetching candle data.\n");
return;
},
};
defer candle_result.deinit();
const candles = candle_result.data;
// 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| {
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, display_count, ctx.chart_theme, 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;
},
else => {
cli.stderrPrint(ctx.io, "Error: failed to write PNG.\n");
return err;
},
};
return;
}
// Fetch real-time quote via DataService
var quote: ?QuoteData = null;
// Live security name (Yahoo `longName`) captured out of the
// transient Quote into a function-scope buffer. Shares the price's
// source, so the displayed name and price always describe the same
// security - even after a ticker is recycled.
var live_name_buf: [256]u8 = undefined;
var live_name: ?[]const u8 = null;
if (svc.getQuote(parsed.symbol, opts)) |q| {
quote = .{
.price = q.close,
.open = q.open,
.high = q.high,
.low = q.low,
.volume = q.volume,
.prev_close = q.previous_close,
.date = if (candles.len > 0) candles[candles.len - 1].date else ctx.today,
};
if (q.name().len > 0) live_name = clampName(&live_name_buf, q.name());
} else |_| {}
// Resolve a human-readable security name the same way the TUI
// 'K' overlay and quote tab do, via the shared policy: the curated
// `metadata.srf` `name::` field first, then the live quote name,
// then the ETF profile's fund name. The result is copied into
// `name_buf` so its lifetime is independent of the (transient)
// classification map / ETF fetch result.
var name_buf: [256]u8 = undefined;
var name: ?[]const u8 = null;
{
var cm_opt = loadClassificationMap(ctx);
defer if (cm_opt) |*cm| cm.deinit();
const cm_ptr: ?*const zfin.classification.ClassificationMap = if (cm_opt) |*cm| cm else null;
// metadata > live quote name. Both are already in hand, so no
// EDGAR round-trip happens unless these come up empty.
if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, .{ .live_quote = live_name })) |nm| {
name = clampName(&name_buf, nm);
} else {
// Fallback: the ETF profile's fund name, gated on isEtf()
// so it matches the TUI (which only retains fund-shaped
// profiles in symbol_data). Plain stocks not in metadata
// and without a live name render symbol-only, exactly like
// the 'K' overlay. Only fetched when the cheap sources
// yielded nothing, so already-named holdings don't pay for
// an EDGAR round-trip.
if (svc.getEtfProfile(parsed.symbol, opts)) |etf_result| {
defer etf_result.deinit();
if (etf_result.data.isEtf()) {
if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, .{ .live_quote = live_name, .etf_profile = etf_result.data.name })) |nm| {
name = clampName(&name_buf, nm);
}
}
} else |_| {}
}
}
const k: KittyChart = .{ .io = ctx.io, .caps = ctx.graphics_caps, .theme = ctx.chart_theme };
const chart_render: ChartRender = switch (ctx.globals.chart_config.mode) {
.braille => .braille,
.kitty => .{ .kitty = k },
.auto => if (ctx.graphics_caps.kitty) .{ .kitty = k } else .braille,
};
// 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);
}
/// `--live`: open the live-price websocket stream for `symbol` and
/// print the latest price once per second until the process is
/// interrupted (Ctrl-C) or killed (e.g. `timeout 12 zfin quote --live
/// SPY`). Each line is flushed immediately so output appears in real
/// time and a SIGTERM mid-loop doesn't swallow buffered lines.
///
/// This is the streaming counterpart to the one-shot quote above and
/// the simplest end-to-end exercise of the Yahoo stream transport.
/// Note: outside market/extended hours, US equities stream their
/// closing snapshot once and then go quiet; 24/7 symbols (e.g.
/// `BTC-USD`) keep ticking.
fn runLive(ctx: *framework.RunCtx, svc: *zfin.DataService, symbol: []const u8) !void {
const syms = [_][]const u8{symbol};
svc.startLiveStream(&syms) catch |err| {
cli.stderrPrint(ctx.io, "Error: failed to start live stream for ");
cli.stderrPrint(ctx.io, symbol);
cli.stderrPrint(ctx.io, ": ");
cli.stderrPrint(ctx.io, @errorName(err));
cli.stderrPrint(ctx.io, "\n");
return;
};
defer svc.stopLiveStream();
try ctx.out.print("Streaming live quotes for {s} - Ctrl-C (or timeout) to stop.\n", .{symbol});
try ctx.out.flush();
var prices = std.StringHashMap(f64).init(ctx.allocator);
defer prices.deinit();
var elapsed: usize = 0;
while (true) {
prices.clearRetainingCapacity();
svc.liveStreamSnapshot(&syms, &prices);
if (prices.get(symbol)) |p| {
try ctx.out.print(" [{d:>4}s] {s} {f}\n", .{ elapsed, symbol, Money.from(p) });
} else {
try ctx.out.print(" [{d:>4}s] {s} (waiting for first tick...)\n", .{ elapsed, symbol });
}
try ctx.out.flush();
// Cancelable; on cancel, break so `defer stopLiveStream` runs.
ctx.io.sleep(.fromSeconds(1), .real) catch break;
elapsed += 1;
}
}
/// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the
/// written slice. Fund/security names fit easily in 256 bytes.
fn clampName(buf: []u8, s: []const u8) []const u8 {
const n = @min(s.len, buf.len);
@memcpy(buf[0..n], s[0..n]);
return buf[0..n];
}
/// Quietly load the `metadata.srf` classification map that sits
/// beside the resolved portfolio anchor. Best-effort: returns null
/// (printing nothing) when there's no portfolio, no `metadata.srf`,
/// or it fails to parse. Unlike `cli.loadPortfolio` this never emits
/// "no portfolio" noise - `quote` works fine without one, and the
/// map is only used to enrich the header with a name. Caller owns
/// the returned map and must `deinit()` it.
fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.ClassificationMap {
var resolved = framework.resolvePatterns(
ctx.io,
ctx.allocator,
ctx.config,
ctx.globals.portfolio_patterns,
) catch return null;
defer resolved.deinit();
if (resolved.paths.len == 0) return null;
// metadata.srf lives in the same directory as the portfolio
// anchor (see AGENTS.md "Portfolio auto-detection").
const anchor_path = resolved.paths[0];
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(ctx.allocator, "{s}metadata.srf", .{anchor_path[0..dir_end]}) catch return null;
defer ctx.allocator.free(meta_path);
const meta_data = std.Io.Dir.cwd().readFileAlloc(ctx.io, meta_path, ctx.allocator, .limited(1024 * 1024)) catch return null;
defer ctx.allocator.free(meta_data);
return zfin.classification.parseClassificationFile(ctx.allocator, meta_data) catch null;
}
/// How `display` draws the price chart.
const ChartRender = union(enum) {
/// Braille price line - the universal fallback.
braille,
/// Inline kitty graphics (price + Bollinger + volume + RSI).
kitty: KittyChart,
};
const KittyChart = struct {
io: std.Io,
caps: term_query.Caps,
theme: theme.Theme,
};
/// 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 = braille.computeBrailleChart(allocator, data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
defer ch.deinit(allocator);
try braille.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false);
}
/// 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, null, dims.width, dims.height, k.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, 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
// symbol and the price, matching the TUI quote tab.
try cli.setBold(out, color);
try out.print("\n{s}", .{symbol});
if (name) |nm| {
if (nm.len > 0) try out.print(" {s}", .{nm});
}
if (quote) |q| {
try out.print(" {f}\n", .{Money.from(q.price)});
} else if (candles.len > 0) {
try out.print(" {f} (close)\n", .{Money.from(candles[candles.len - 1].close)});
} else {
try out.print("\n", .{});
}
try cli.reset(out, color);
try out.print("======================================================================\n", .{});
// Quote details
const price = if (quote) |q| q.price else if (candles.len > 0) candles[candles.len - 1].close else @as(f64, 0);
const prev_close = if (quote) |q| q.prev_close else if (candles.len >= 2) candles[candles.len - 2].close else @as(f64, 0);
if (candles.len > 0 or has_quote) {
const latest_date = if (quote) |q| q.date else if (candles.len > 0) candles[candles.len - 1].date else as_of;
const open_val = if (quote) |q| q.open else if (candles.len > 0) candles[candles.len - 1].open else @as(f64, 0);
const high_val = if (quote) |q| q.high else if (candles.len > 0) candles[candles.len - 1].high else @as(f64, 0);
const low_val = if (quote) |q| q.low else if (candles.len > 0) candles[candles.len - 1].low else @as(f64, 0);
const vol_val = if (quote) |q| q.volume else if (candles.len > 0) candles[candles.len - 1].volume else @as(u64, 0);
var vol_buf: [32]u8 = undefined;
try out.print(" Date: {f}\n", .{latest_date});
try out.print(" Open: ${d:.2}\n", .{open_val});
try out.print(" High: ${d:.2}\n", .{high_val});
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) {
try out.print(" Prev Close: ${d:.2}\n", .{prev_close});
}
if (fmt.pctChange(price, prev_close)) |dc| {
var chg_buf: [64]u8 = undefined;
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) });
}
}
// Chart: inline kitty graphics when supported, else a braille price
// 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, 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,
},
}
}
// Recent history table (last 20 candles)
if (candles.len > 0) {
try out.print("\n", .{});
try cli.printBold(out, color, " Recent History:\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}\n", .{
"Date", "Open", "High", "Low", "Close", "Volume",
});
// The window is the last 20 candles, but the direction of its
// first row is measured against the candle *before* the window
// (via the absolute index `i`), so the top row gets a real prior
// close instead of defaulting to a gain.
const start_idx = if (candles.len > 20) candles.len - 20 else 0;
for (candles[start_idx..], start_idx..) |candle, i| {
var row_buf: [128]u8 = undefined;
const prev: ?zfin.Candle = if (i > 0) candles[i - 1] else null;
try cli.printDirection(out, color, candle.direction(prev), "{s}\n", .{fmt.fmtCandleRow(&row_buf, candle)});
}
try out.print("\n {d} trading days shown\n", .{candles[start_idx..].len});
}
try out.print("\n", .{});
}
// ── Tests ────────────────────────────────────────────────────
test "parseArgs: accepts a single symbol" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"AAPL"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("AAPL", parsed.symbol);
}
test "parseArgs: missing symbol errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args));
}
test "parseArgs: extra args error" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "AAPL", "extra" };
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: --export-chart captures the path" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "AAPL", "--export-chart", "aapl.png" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("AAPL", parsed.symbol);
try std.testing.expectEqualStrings("aapl.png", parsed.export_chart.?);
}
test "parseArgs: single-dash unknown flag is rejected (not swallowed as symbol)" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"-x"};
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: --export-chart without a value is rejected" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "AAPL", "--export-chart" };
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}
test "parseArgs: --export-chart followed by a flag does not swallow the flag" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "AAPL", "--export-chart", "--bogus" };
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 "parseArgs: --live sets the live flag" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "SPY", "--live" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("SPY", parsed.symbol);
try std.testing.expect(parsed.live);
}
test "parseArgs: live defaults to false when --live is omitted" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"SPY"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(!parsed.live);
}
test "display with candles only" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
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 },
.{ .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, "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);
try std.testing.expect(std.mem.indexOf(u8, out, "Recent History") != null);
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);
const candles = [_]zfin.Candle{};
const quote: QuoteData = .{
.price = 175.50,
.open = 174.00,
.high = 176.00,
.low = 173.50,
.volume = 60_000_000,
.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, "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);
// Should NOT have "(close)" since we have a real quote
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") == null);
}
test "display renders the security name when provided" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
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, "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);
}
test "display omits an empty name" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
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, "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);
}
test "display no ANSI without color" {
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 = 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, "3M", .braille);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
test "display: a NAV-priced fund's down day is coloured as a loss" {
// Regression: open == high == low == close for once-daily-priced
// instruments (mutual funds, unitized trusts), so the old
// `close >= open` test rendered every row in the Recent History
// table green - even a run of consecutive down days.
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 9.55, .high = 9.55, .low = 9.55, .close = 9.55, .adj_close = 9.55, .volume = 0 },
.{ .date = .{ .days = 20001 }, .open = 9.54, .high = 9.54, .low = 9.54, .close = 9.54, .adj_close = 9.54, .volume = 0 },
.{ .date = .{ .days = 20002 }, .open = 9.49, .high = 9.49, .low = 9.49, .close = 9.49, .adj_close = 9.49, .volume = 0 },
};
try display(std.testing.allocator, &candles, null, "NAVFUND", null, zfin.Date.fromYmd(2026, 5, 8), true, &w, 60, "3M", .braille);
const out = w.buffered();
var neg_buf: [32]u8 = undefined;
const negative = try std.fmt.bufPrint(&neg_buf, "\x1b[38;2;{d};{d};{d}m", .{
cli.CLR_NEGATIVE[0], cli.CLR_NEGATIVE[1], cli.CLR_NEGATIVE[2],
});
try std.testing.expect(std.mem.indexOf(u8, out, negative) != null);
}
test "display: a real green candle inside a downtrend stays green" {
// Guards against "simplifying" the rule to uniform day-over-day,
// which would paint this row red while its own Open and Close
// columns say it rose.
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 157.0, .high = 158.0, .low = 154.0, .close = 155.0, .adj_close = 155.0, .volume = 1_000_000 },
.{ .date = .{ .days = 20001 }, .open = 150.0, .high = 154.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 1_000_000 },
};
try display(std.testing.allocator, &candles, null, "STOCK", null, zfin.Date.fromYmd(2026, 5, 8), true, &w, 60, "3M", .braille);
const out = w.buffered();
var neg_buf: [32]u8 = undefined;
const negative = try std.fmt.bufPrint(&neg_buf, "\x1b[38;2;{d};{d};{d}m", .{
cli.CLR_NEGATIVE[0], cli.CLR_NEGATIVE[1], cli.CLR_NEGATIVE[2],
});
// Exactly one red row in the table: the first candle has no
// predecessor and closed below its own open (157 -> 155). The
// 150 -> 153 row must stay green despite closing below 155.
const rows_start = std.mem.indexOf(u8, out, "Recent History") orelse 0;
var reds: usize = 0;
var it = std.mem.splitScalar(u8, out[rows_start..], '\n');
while (it.next()) |line| {
if (std.mem.indexOf(u8, line, negative) != null) reds += 1;
}
try std.testing.expectEqual(@as(usize, 1), reds);
}