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"); pub const ParsedArgs = struct { symbol: []const u8, }; 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 \\ \\Show the latest real-time quote for a symbol (Yahoo / TwelveData) \\plus a braille price chart of the last 60 candles and a table \\of the last 20 trading days. \\ \\If real-time fetch fails, falls back to the cached close. The \\Yahoo path is free and unauthenticated; TwelveData requires \\TWELVEDATA_API_KEY. \\ \\Examples: \\ zfin quote AAPL \\ zfin quote spy # symbols are case-insensitive \\ , }; comptime { framework.validateCommandModule(@This()); } /// 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 { if (cmd_args.len < 1) { try cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n"); return error.MissingSymbol; } if (cmd_args.len > 1) { try cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n"); return error.UnexpectedArg; } return .{ .symbol = cmd_args[0] }; } pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; // Fetch candle data for chart and history const candle_result = svc.getCandles(parsed.symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n"); return; }, else => { try cli.stderrPrint(ctx.io, "Error fetching candle data.\n"); return; }, }; defer candle_result.deinit(); const candles = candle_result.data; // Fetch real-time quote via DataService var quote: ?QuoteData = null; if (svc.getQuote(parsed.symbol)) |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, }; } else |_| {} try display(ctx.allocator, candles, quote, parsed.symbol, ctx.today, ctx.color, ctx.out); } pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { const has_quote = quote != null; // Header try cli.setBold(out, color); if (quote) |q| { try out.print("\n{s} {f}\n", .{ symbol, Money.from(q.price) }); } else if (candles.len > 0) { try out.print("\n{s} {f} (close)\n", .{ symbol, Money.from(candles[candles.len - 1].close) }); } else { try out.print("\n{s}\n", .{symbol}); } 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) { const change = price - prev_close; const pct = (change / prev_close) * 100.0; var chg_buf: [64]u8 = undefined; try cli.printGainLoss(out, color, change, " Change: {s}\n", .{fmt.fmtPriceChange(&chg_buf, change, pct)}); } } // Braille chart (60 columns, 10 rows) if (candles.len >= 2) { try out.print("\n", .{}); const chart_days: usize = @min(candles.len, 60); const chart_data = candles[candles.len - chart_days ..]; var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null; if (chart) |*ch| { defer ch.deinit(allocator); try fmt.writeBrailleAnsi(out, ch, color, cli.CLR_MUTED, false); } } // 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", }); const start_idx = if (candles.len > 20) candles.len - 20 else 0; for (candles[start_idx..]) |candle| { var row_buf: [128]u8 = undefined; const day_gain = candle.close >= candle.open; try cli.printGainLoss(out, color, if (day_gain) 1.0 else -1.0, "{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 "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", zfin.Date.fromYmd(2026, 5, 8), false, &w); 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 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", zfin.Date.fromYmd(2026, 5, 8), false, &w); 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 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", zfin.Date.fromYmd(2026, 5, 8), false, &w); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); }