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 ()" 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 [--since ] [--export-chart ] [--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 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 \\ 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 ()" 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 `()` // 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); }