const std = @import("std"); const zfin = @import("zfin"); const fmt = zfin.format; const tui = @import("tui"); const usage = \\Usage: zfin [options] \\ \\Commands: \\ interactive [opts] Launch interactive TUI \\ perf Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style) \\ quote Show latest quote with chart and history \\ history Show recent price history \\ divs Show dividend history \\ splits Show split history \\ options Show options chain (all expirations) \\ earnings Show earnings history and upcoming \\ etf Show ETF profile (holdings, sectors, expense ratio) \\ portfolio Load and analyze a portfolio (.srf file) \\ lookup Look up CUSIP to ticker via OpenFIGI \\ cache stats Show cache statistics \\ cache clear Clear all cached data \\ \\Global options: \\ --no-color Disable colored output \\ \\Interactive mode options: \\ -p, --portfolio Portfolio file (.srf) \\ -w, --watchlist Watchlist file (default: watchlist.srf) \\ -s, --symbol Initial symbol (default: VTI) \\ --chart Chart graphics: auto, braille, or WxH (e.g. 1920x1080) \\ --default-keys Print default keybindings \\ --default-theme Print default theme \\ \\Options command options: \\ --ntm Show +/- N strikes near the money (default: 8) \\ \\Portfolio command options: \\ -w, --watchlist Watchlist file \\ --refresh Force refresh (ignore cache, re-fetch all prices) \\ \\Environment Variables: \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) \\ POLYGON_API_KEY Polygon.io API key (dividends, splits) \\ FINNHUB_API_KEY Finnhub API key (earnings) \\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles) \\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional) \\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin) \\ NO_COLOR Disable colored output (https://no-color.org) \\ ; // ── Default CLI colors (match TUI default theme) ───────────── const CLR_GREEN = [3]u8{ 166, 227, 161 }; // positive const CLR_RED = [3]u8{ 243, 139, 168 }; // negative const CLR_MUTED = [3]u8{ 128, 128, 128 }; // muted/dim const CLR_HEADER = [3]u8{ 205, 214, 244 }; // headers const CLR_ACCENT = [3]u8{ 137, 180, 250 }; // info/accent const CLR_YELLOW = [3]u8{ 249, 226, 175 }; // stale/manual price indicator pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); const allocator = gpa.allocator(); const args = try std.process.argsAlloc(allocator); defer std.process.argsFree(allocator, args); if (args.len < 2) { try stdout_print(usage); return; } // Scan for global --no-color flag var no_color_flag = false; for (args[1..]) |arg| { if (std.mem.eql(u8, arg, "--no-color")) no_color_flag = true; } const color = fmt.shouldUseColor(no_color_flag); var config = zfin.Config.fromEnv(allocator); defer config.deinit(); const command = args[1]; if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "--help") or std.mem.eql(u8, command, "-h")) { try stdout_print(usage); return; } // Interactive TUI -- delegates to the TUI module (owns its own DataService) if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) { try tui.run(allocator, config, args); return; } var svc = zfin.DataService.init(allocator, config); defer svc.deinit(); if (std.mem.eql(u8, command, "perf")) { if (args.len < 3) return try stderr_print("Error: 'perf' requires a symbol argument\n"); try cmdPerf(allocator, &svc, args[2], color); } else if (std.mem.eql(u8, command, "quote")) { if (args.len < 3) return try stderr_print("Error: 'quote' requires a symbol argument\n"); try cmdQuote(allocator, config, &svc, args[2], color); } else if (std.mem.eql(u8, command, "history")) { if (args.len < 3) return try stderr_print("Error: 'history' requires a symbol argument\n"); try cmdHistory(allocator, &svc, args[2], color); } else if (std.mem.eql(u8, command, "divs")) { if (args.len < 3) return try stderr_print("Error: 'divs' requires a symbol argument\n"); try cmdDivs(allocator, &svc, config, args[2], color); } else if (std.mem.eql(u8, command, "splits")) { if (args.len < 3) return try stderr_print("Error: 'splits' requires a symbol argument\n"); try cmdSplits(allocator, &svc, args[2], color); } else if (std.mem.eql(u8, command, "options")) { if (args.len < 3) return try stderr_print("Error: 'options' requires a symbol argument\n"); // Parse --ntm flag var ntm: usize = 8; var ai: usize = 3; while (ai < args.len) : (ai += 1) { if (std.mem.eql(u8, args[ai], "--ntm") and ai + 1 < args.len) { ai += 1; ntm = std.fmt.parseInt(usize, args[ai], 10) catch 8; } } try cmdOptions(allocator, &svc, args[2], ntm, color); } else if (std.mem.eql(u8, command, "earnings")) { if (args.len < 3) return try stderr_print("Error: 'earnings' requires a symbol argument\n"); try cmdEarnings(allocator, &svc, args[2], color); } else if (std.mem.eql(u8, command, "etf")) { if (args.len < 3) return try stderr_print("Error: 'etf' requires a symbol argument\n"); try cmdEtf(allocator, &svc, args[2], color); } else if (std.mem.eql(u8, command, "portfolio")) { if (args.len < 3) return try stderr_print("Error: 'portfolio' requires a file path argument\n"); // Parse -w/--watchlist and --refresh flags var watchlist_path: ?[]const u8 = null; var force_refresh = false; var pi: usize = 3; while (pi < args.len) : (pi += 1) { if ((std.mem.eql(u8, args[pi], "--watchlist") or std.mem.eql(u8, args[pi], "-w")) and pi + 1 < args.len) { pi += 1; watchlist_path = args[pi]; } else if (std.mem.eql(u8, args[pi], "--refresh")) { force_refresh = true; } } try cmdPortfolio(allocator, config, &svc, args[2], watchlist_path, force_refresh, color); } else if (std.mem.eql(u8, command, "lookup")) { if (args.len < 3) return try stderr_print("Error: 'lookup' requires a CUSIP argument\n"); try cmdLookup(allocator, &svc, args[2], color); } else if (std.mem.eql(u8, command, "cache")) { if (args.len < 3) return try stderr_print("Error: 'cache' requires a subcommand (stats, clear)\n"); try cmdCache(allocator, config, args[2]); } else { try stderr_print("Unknown command. Run 'zfin help' for usage.\n"); } } // ── ANSI color helpers ─────────────────────────────────────── fn setFg(out: anytype, c: bool, rgb: [3]u8) !void { if (c) try fmt.ansiSetFg(out, rgb[0], rgb[1], rgb[2]); } fn setBold(out: anytype, c: bool) !void { if (c) try fmt.ansiBold(out); } fn reset(out: anytype, c: bool) !void { if (c) try fmt.ansiReset(out); } fn setGainLoss(out: anytype, c: bool, value: f64) !void { if (c) { if (value >= 0) try fmt.ansiSetFg(out, CLR_GREEN[0], CLR_GREEN[1], CLR_GREEN[2]) else try fmt.ansiSetFg(out, CLR_RED[0], CLR_RED[1], CLR_RED[2]); } } // ── Commands ───────────────────────────────────────────────── fn cmdPerf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { const result = svc.getTrailingReturns(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try stderr_print("Error: TWELVEDATA_API_KEY not set. Get a free key at https://twelvedata.com\n"); return; }, else => { try stderr_print("Error fetching data.\n"); return; }, }; defer allocator.free(result.candles); defer if (result.dividends) |d| allocator.free(d); if (result.source == .cached) try stderr_print("(using cached data)\n"); const c = result.candles; const end_date = c[c.len - 1].date; const today = fmt.todayDate(); const month_end = today.lastDayOfPriorMonth(); var buf: [8192]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; try setBold(out, color); try out.print("\nTrailing Returns for {s}\n", .{symbol}); try reset(out, color); try out.print("========================================\n", .{}); try setFg(out, color, CLR_MUTED); try out.print("Data points: {d} (", .{c.len}); { var db: [10]u8 = undefined; try out.print("{s}", .{c[0].date.format(&db)}); } try out.print(" to ", .{}); { var db: [10]u8 = undefined; try out.print("{s}", .{end_date.format(&db)}); } try reset(out, color); var close_buf: [24]u8 = undefined; try out.print(")\nLatest close: {s}\n", .{fmt.fmtMoney(&close_buf, c[c.len - 1].close)}); const has_divs = result.asof_total != null; // -- As-of-date returns -- { var db: [10]u8 = undefined; try setBold(out, color); try out.print("\nAs-of {s}:\n", .{end_date.format(&db)}); try reset(out, color); } try printReturnsTable(out, result.asof_price, if (has_divs) result.asof_total else null, color); // -- Month-end returns -- { var db: [10]u8 = undefined; try setBold(out, color); try out.print("\nMonth-end ({s}):\n", .{month_end.format(&db)}); try reset(out, color); } try printReturnsTable(out, result.me_price, if (has_divs) result.me_total else null, color); if (!has_divs) { try setFg(out, color, CLR_MUTED); try out.print("\nSet POLYGON_API_KEY for total returns with dividend reinvestment.\n", .{}); try reset(out, color); } try out.print("\n", .{}); try out.flush(); } fn printReturnsTable( out: anytype, price: zfin.performance.TrailingReturns, total: ?zfin.performance.TrailingReturns, color: bool, ) !void { const has_total = total != null; try setFg(out, color, CLR_MUTED); if (has_total) { try out.print("{s:>22} {s:>14} {s:>14}\n", .{ "", "Price Only", "Total Return" }); try out.print("{s:->22} {s:->14} {s:->14}\n", .{ "", "", "" }); } else { try out.print("{s:>22} {s:>14}\n", .{ "", "Price Only" }); try out.print("{s:->22} {s:->14}\n", .{ "", "" }); } try reset(out, color); const periods = [_]struct { label: []const u8, years: u16 }{ .{ .label = "1-Year Return:", .years = 1 }, .{ .label = "3-Year Return:", .years = 3 }, .{ .label = "5-Year Return:", .years = 5 }, .{ .label = "10-Year Return:", .years = 10 }, }; const price_arr = [_]?zfin.performance.PerformanceResult{ price.one_year, price.three_year, price.five_year, price.ten_year, }; const total_arr: [4]?zfin.performance.PerformanceResult = if (total) |t| .{ t.one_year, t.three_year, t.five_year, t.ten_year } else .{ null, null, null, null }; for (periods, 0..) |period, i| { try out.print(" {s:<20}", .{period.label}); if (price_arr[i]) |r| { var rb: [32]u8 = undefined; const val = if (period.years > 1) r.annualized_return orelse r.total_return else r.total_return; try setGainLoss(out, color, val); try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)}); try reset(out, color); } else { try setFg(out, color, CLR_MUTED); try out.print(" {s:>13}", .{"N/A"}); try reset(out, color); } if (has_total) { if (total_arr[i]) |r| { var rb: [32]u8 = undefined; const val = if (period.years > 1) r.annualized_return orelse r.total_return else r.total_return; try setGainLoss(out, color, val); try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)}); try reset(out, color); } else { try setFg(out, color, CLR_MUTED); try out.print(" {s:>13}", .{"N/A"}); try reset(out, color); } } if (period.years > 1) { try setFg(out, color, CLR_MUTED); try out.print(" ann.", .{}); try reset(out, color); } try out.print("\n", .{}); } } fn cmdQuote(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { // Fetch candle data for chart and history const candle_result = svc.getCandles(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try stderr_print("Error: TWELVEDATA_API_KEY not set.\n"); return; }, else => { try stderr_print("Error fetching candle data.\n"); return; }, }; defer allocator.free(candle_result.data); const candles = candle_result.data; // Fetch real-time quote var q_close: f64 = 0; var q_open: f64 = 0; var q_high: f64 = 0; var q_low: f64 = 0; var q_volume: u64 = 0; var q_prev_close: f64 = 0; var has_quote = false; if (config.twelvedata_key) |key| { var td = zfin.TwelveData.init(allocator, key); defer td.deinit(); if (td.fetchQuote(allocator, symbol)) |qr_val| { var qr = qr_val; defer qr.deinit(); if (qr.parse(allocator)) |q_val| { var q = q_val; defer q.deinit(); q_close = q.close(); q_open = q.open(); q_high = q.high(); q_low = q.low(); q_volume = q.volume(); q_prev_close = q.previous_close(); has_quote = true; } else |_| {} } else |_| {} } var buf: [16384]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; // Header try setBold(out, color); if (has_quote) { var price_buf: [24]u8 = undefined; try out.print("\n{s} {s}\n", .{ symbol, fmt.fmtMoney(&price_buf, q_close) }); } else if (candles.len > 0) { var price_buf: [24]u8 = undefined; try out.print("\n{s} {s} (close)\n", .{ symbol, fmt.fmtMoney(&price_buf, candles[candles.len - 1].close) }); } else { try out.print("\n{s}\n", .{symbol}); } try reset(out, color); try out.print("========================================\n", .{}); // Quote details const price = if (has_quote) q_close else if (candles.len > 0) candles[candles.len - 1].close else @as(f64, 0); const prev_close = if (has_quote) 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 (candles.len > 0) candles[candles.len - 1].date else fmt.todayDate(); const open_val = if (has_quote) q_open else if (candles.len > 0) candles[candles.len - 1].open else @as(f64, 0); const high_val = if (has_quote) q_high else if (candles.len > 0) candles[candles.len - 1].high else @as(f64, 0); const low_val = if (has_quote) q_low else if (candles.len > 0) candles[candles.len - 1].low else @as(f64, 0); const vol_val = if (has_quote) q_volume else if (candles.len > 0) candles[candles.len - 1].volume else @as(u64, 0); var date_buf: [10]u8 = undefined; var vol_buf: [32]u8 = undefined; try out.print(" Date: {s}\n", .{latest_date.format(&date_buf)}); 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; try setGainLoss(out, color, change); if (change >= 0) { try out.print(" Change: +${d:.2} (+{d:.2}%)\n", .{ change, pct }); } else { try out.print(" Change: -${d:.2} ({d:.2}%)\n", .{ -change, pct }); } try reset(out, color); } } // 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, CLR_GREEN, CLR_RED) catch null; if (chart) |*ch| { defer ch.deinit(allocator); try fmt.writeBrailleAnsi(out, ch, color, CLR_MUTED); } } // Recent history table (last 20 candles) if (candles.len > 0) { try out.print("\n", .{}); try setBold(out, color); try out.print(" Recent History:\n", .{}); try reset(out, color); try setFg(out, color, CLR_MUTED); try out.print(" {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}\n", .{ "Date", "Open", "High", "Low", "Close", "Volume", }); try reset(out, color); const start_idx = if (candles.len > 20) candles.len - 20 else 0; for (candles[start_idx..]) |candle| { var db: [10]u8 = undefined; var vb: [32]u8 = undefined; const day_gain = candle.close >= candle.open; try setGainLoss(out, color, if (day_gain) @as(f64, 1) else @as(f64, -1)); try out.print(" {s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{ candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume), }); try reset(out, color); } try out.print("\n {d} trading days shown\n", .{candles[start_idx..].len}); } try out.print("\n", .{}); try out.flush(); } fn cmdHistory(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { const result = svc.getCandles(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try stderr_print("Error: TWELVEDATA_API_KEY not set.\n"); return; }, else => { try stderr_print("Error fetching data.\n"); return; }, }; defer allocator.free(result.data); if (result.source == .cached) try stderr_print("(using cached data)\n"); const all = result.data; if (all.len == 0) return try stderr_print("No data available.\n"); const today = fmt.todayDate(); const one_month_ago = today.addDays(-30); const c = fmt.filterCandlesFrom(all, one_month_ago); if (c.len == 0) return try stderr_print("No data available.\n"); var buf: [8192]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; try setBold(out, color); try out.print("\nPrice History for {s} (last 30 days)\n", .{symbol}); try reset(out, color); try out.print("========================================\n", .{}); try setFg(out, color, CLR_MUTED); try out.print("{s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}\n", .{ "Date", "Open", "High", "Low", "Close", "Volume", }); try out.print("{s:->12} {s:->10} {s:->10} {s:->10} {s:->10} {s:->12}\n", .{ "", "", "", "", "", "", }); try reset(out, color); for (c) |candle| { var db: [10]u8 = undefined; var vb: [32]u8 = undefined; try setGainLoss(out, color, if (candle.close >= candle.open) @as(f64, 1) else @as(f64, -1)); try out.print("{s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{ candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume), }); try reset(out, color); } try out.print("\n{d} trading days\n\n", .{c.len}); try out.flush(); } fn cmdDivs(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8, color: bool) !void { const result = svc.getDividends(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try stderr_print("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); return; }, else => { try stderr_print("Error fetching dividend data.\n"); return; }, }; defer allocator.free(result.data); if (result.source == .cached) try stderr_print("(using cached dividend data)\n"); const d = result.data; // Fetch current price for yield calculation var current_price: ?f64 = null; if (config.twelvedata_key) |td_key| { var td = zfin.TwelveData.init(allocator, td_key); defer td.deinit(); if (td.fetchQuote(allocator, symbol)) |qr_val| { var qr = qr_val; defer qr.deinit(); if (qr.parse(allocator)) |q_val| { var q = q_val; defer q.deinit(); current_price = q.close(); } else |_| {} } else |_| {} } var buf: [8192]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; try setBold(out, color); try out.print("\nDividend History for {s}\n", .{symbol}); try reset(out, color); try out.print("========================================\n", .{}); if (d.len == 0) { try setFg(out, color, CLR_MUTED); try out.print(" No dividends found.\n\n", .{}); try reset(out, color); try out.flush(); return; } try setFg(out, color, CLR_MUTED); try out.print("{s:>12} {s:>10} {s:>12} {s:>6} {s:>10}\n", .{ "Ex-Date", "Amount", "Pay Date", "Freq", "Type", }); try out.print("{s:->12} {s:->10} {s:->12} {s:->6} {s:->10}\n", .{ "", "", "", "", "", }); try reset(out, color); const today = fmt.todayDate(); const one_year_ago = today.subtractYears(1); var total: f64 = 0; var ttm: f64 = 0; for (d) |div| { var ex_buf: [10]u8 = undefined; try out.print("{s:>12} {d:>10.4}", .{ div.ex_date.format(&ex_buf), div.amount }); if (div.pay_date) |pd| { var pay_buf: [10]u8 = undefined; try out.print(" {s:>12}", .{pd.format(&pay_buf)}); } else { try out.print(" {s:>12}", .{"--"}); } if (div.frequency) |f| { try out.print(" {d:>6}", .{f}); } else { try out.print(" {s:>6}", .{"--"}); } try out.print(" {s:>10}\n", .{@tagName(div.distribution_type)}); total += div.amount; if (!div.ex_date.lessThan(one_year_ago)) ttm += div.amount; } try out.print("\n{d} dividends, total: ${d:.4}\n", .{ d.len, total }); try setFg(out, color, CLR_ACCENT); try out.print("TTM dividends: ${d:.4}", .{ttm}); if (current_price) |cp| { if (cp > 0) { const yield = (ttm / cp) * 100.0; try out.print(" (yield: {d:.2}% at ${d:.2})", .{ yield, cp }); } } try reset(out, color); try out.print("\n\n", .{}); try out.flush(); } fn cmdSplits(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { const result = svc.getSplits(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try stderr_print("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); return; }, else => { try stderr_print("Error fetching split data.\n"); return; }, }; defer allocator.free(result.data); if (result.source == .cached) try stderr_print("(using cached split data)\n"); const sp = result.data; var buf: [4096]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; try setBold(out, color); try out.print("\nSplit History for {s}\n", .{symbol}); try reset(out, color); try out.print("========================================\n", .{}); if (sp.len == 0) { try setFg(out, color, CLR_MUTED); try out.print(" No splits found.\n\n", .{}); try reset(out, color); try out.flush(); return; } try setFg(out, color, CLR_MUTED); try out.print("{s:>12} {s:>10}\n", .{ "Date", "Ratio" }); try out.print("{s:->12} {s:->10}\n", .{ "", "" }); try reset(out, color); for (sp) |s| { var db: [10]u8 = undefined; try out.print("{s:>12} {d:.0}:{d:.0}\n", .{ s.date.format(&db), s.numerator, s.denominator }); } try out.print("\n{d} split(s)\n\n", .{sp.len}); try out.flush(); } fn cmdOptions(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool) !void { const result = svc.getOptions(symbol) catch |err| switch (err) { zfin.DataError.FetchFailed => { try stderr_print("Error fetching options data from CBOE.\n"); return; }, else => { try stderr_print("Error loading options data.\n"); return; }, }; const ch = result.data; defer { for (ch) |chain| { allocator.free(chain.underlying_symbol); allocator.free(chain.calls); allocator.free(chain.puts); } allocator.free(ch); } if (result.source == .cached) try stderr_print("(using cached options data)\n"); if (ch.len == 0) { try stderr_print("No options data found.\n"); return; } var buf: [32768]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; try setBold(out, color); try out.print("\nOptions Chain for {s}\n", .{symbol}); try reset(out, color); try out.print("========================================\n", .{}); if (ch[0].underlying_price) |price| { var price_buf: [24]u8 = undefined; try out.print("Underlying: {s} {d} expiration(s) +/- {d} strikes NTM\n", .{ fmt.fmtMoney(&price_buf, price), ch.len, ntm }); } else { try out.print("{d} expiration(s) available\n", .{ch.len}); } // Find nearest monthly expiration to auto-expand var auto_expand_idx: ?usize = null; for (ch, 0..) |chain, ci| { if (fmt.isMonthlyExpiration(chain.expiration)) { auto_expand_idx = ci; break; } } // If no monthly found, expand the first one if (auto_expand_idx == null and ch.len > 0) auto_expand_idx = 0; const atm_price = if (ch[0].underlying_price) |p| p else @as(f64, 0); // List all expirations, expanding the nearest monthly for (ch, 0..) |chain, ci| { var db: [10]u8 = undefined; const is_monthly = fmt.isMonthlyExpiration(chain.expiration); const is_expanded = auto_expand_idx != null and ci == auto_expand_idx.?; try out.print("\n", .{}); if (is_expanded) { try setBold(out, color); try out.print("{s} ({d} calls, {d} puts)", .{ chain.expiration.format(&db), chain.calls.len, chain.puts.len, }); if (is_monthly) try out.print(" [monthly]", .{}); try reset(out, color); try out.print("\n", .{}); // Print calls try printOptionsSection(out, allocator, "CALLS", chain.calls, atm_price, ntm, true, color); try out.print("\n", .{}); // Print puts try printOptionsSection(out, allocator, "PUTS", chain.puts, atm_price, ntm, false, color); } else { try setFg(out, color, if (is_monthly) CLR_HEADER else CLR_MUTED); try out.print("{s} ({d} calls, {d} puts)", .{ chain.expiration.format(&db), chain.calls.len, chain.puts.len, }); if (is_monthly) try out.print(" [monthly]", .{}); try reset(out, color); try out.print("\n", .{}); } } try out.print("\n", .{}); try out.flush(); } fn printOptionsSection( out: anytype, allocator: std.mem.Allocator, label: []const u8, contracts: []const zfin.OptionContract, atm_price: f64, ntm: usize, is_calls: bool, color: bool, ) !void { try setBold(out, color); try out.print(" {s}\n", .{label}); try reset(out, color); try setFg(out, color, CLR_MUTED); try out.print(" {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}\n", .{ "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV", }); try out.print(" {s:->10} {s:->10} {s:->10} {s:->10} {s:->10} {s:->8} {s:->8}\n", .{ "", "", "", "", "", "", "", }); try reset(out, color); const filtered = fmt.filterNearMoney(contracts, atm_price, ntm); for (filtered) |c| { const itm = if (is_calls) c.strike <= atm_price else c.strike >= atm_price; const prefix: []const u8 = if (itm) " |" else " "; const line = try fmt.fmtContractLine(allocator, prefix, c); defer allocator.free(line); try out.print("{s}\n", .{line}); } } fn cmdEarnings(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { const result = svc.getEarnings(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try stderr_print("Error: FINNHUB_API_KEY not set. Get a free key at https://finnhub.io\n"); return; }, else => { try stderr_print("Error fetching earnings data.\n"); return; }, }; defer allocator.free(result.data); if (result.source == .cached) try stderr_print("(using cached earnings data)\n"); const ev = result.data; var buf: [8192]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; try setBold(out, color); try out.print("\nEarnings History for {s}\n", .{symbol}); try reset(out, color); try out.print("========================================\n", .{}); if (ev.len == 0) { try setFg(out, color, CLR_MUTED); try out.print(" No earnings data found.\n\n", .{}); try reset(out, color); try out.flush(); return; } try setFg(out, color, CLR_MUTED); try out.print("{s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10} {s:>5}\n", .{ "Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %", "When", }); try out.print("{s:->12} {s:->4} {s:->12} {s:->12} {s:->12} {s:->10} {s:->5}\n", .{ "", "", "", "", "", "", "", }); try reset(out, color); for (ev) |e| { var db: [10]u8 = undefined; const is_future = e.isFuture(); const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true; if (is_future) { try setFg(out, color, CLR_MUTED); } else if (surprise_positive) { try setFg(out, color, CLR_GREEN); } else { try setFg(out, color, CLR_RED); } try out.print("{s:>12}", .{e.date.format(&db)}); if (e.quarter) |q| try out.print(" Q{d}", .{q}) else try out.print(" {s:>4}", .{"--"}); if (e.estimate) |est| try out.print(" {s:>12}", .{fmtEps(est)}) else try out.print(" {s:>12}", .{"--"}); if (e.actual) |act| try out.print(" {s:>12}", .{fmtEps(act)}) else try out.print(" {s:>12}", .{"--"}); if (e.surpriseAmount()) |s| { var surp_buf: [12]u8 = undefined; const surp_str = if (s >= 0) std.fmt.bufPrint(&surp_buf, "+${d:.4}", .{s}) catch "?" else std.fmt.bufPrint(&surp_buf, "-${d:.4}", .{-s}) catch "?"; try out.print(" {s:>12}", .{surp_str}); } else { try out.print(" {s:>12}", .{"--"}); } if (e.surprisePct()) |sp| { var pct_buf: [12]u8 = undefined; const pct_str = if (sp >= 0) std.fmt.bufPrint(&pct_buf, "+{d:.1}%", .{sp}) catch "?" else std.fmt.bufPrint(&pct_buf, "{d:.1}%", .{sp}) catch "?"; try out.print(" {s:>10}", .{pct_str}); } else { try out.print(" {s:>10}", .{"--"}); } try out.print(" {s:>5}", .{@tagName(e.report_time)}); try reset(out, color); try out.print("\n", .{}); } try out.print("\n{d} earnings event(s)\n\n", .{ev.len}); try out.flush(); } fn fmtEps(val: f64) [12]u8 { var buf: [12]u8 = .{' '} ** 12; _ = std.fmt.bufPrint(&buf, "${d:.2}", .{val}) catch {}; return buf; } fn cmdEtf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { const result = svc.getEtfProfile(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try stderr_print("Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n"); return; }, else => { try stderr_print("Error fetching ETF profile.\n"); return; }, }; const profile = result.data; defer { if (profile.holdings) |h| { for (h) |holding| { if (holding.symbol) |s| allocator.free(s); allocator.free(holding.name); } allocator.free(h); } if (profile.sectors) |s| { for (s) |sec| allocator.free(sec.sector); allocator.free(s); } } if (result.source == .cached) try stderr_print("(using cached ETF profile)\n"); try printEtfProfile(profile, symbol, color); } fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool) !void { var buf: [16384]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; try setBold(out, color); try out.print("\nETF Profile: {s}\n", .{symbol}); try reset(out, color); try out.print("========================================\n", .{}); if (profile.expense_ratio) |er| { try out.print(" Expense Ratio: {d:.2}%\n", .{er * 100.0}); } if (profile.net_assets) |na| { try out.print(" Net Assets: ${s}\n", .{std.mem.trimRight(u8, &fmt.fmtLargeNum(na), &.{' '})}); } if (profile.dividend_yield) |dy| { try out.print(" Dividend Yield: {d:.2}%\n", .{dy * 100.0}); } if (profile.portfolio_turnover) |pt| { try out.print(" Portfolio Turnover: {d:.1}%\n", .{pt * 100.0}); } if (profile.inception_date) |d| { var db: [10]u8 = undefined; try out.print(" Inception Date: {s}\n", .{d.format(&db)}); } if (profile.leveraged) { try setFg(out, color, CLR_RED); try out.print(" Leveraged: YES\n", .{}); try reset(out, color); } if (profile.total_holdings) |th| { try out.print(" Total Holdings: {d}\n", .{th}); } // Sectors if (profile.sectors) |sectors| { if (sectors.len > 0) { try setBold(out, color); try out.print("\n Sector Allocation:\n", .{}); try reset(out, color); for (sectors) |sec| { try setFg(out, color, CLR_ACCENT); try out.print(" {d:>5.1}%", .{sec.weight * 100.0}); try reset(out, color); try out.print(" {s}\n", .{sec.sector}); } } } // Top holdings if (profile.holdings) |holdings| { if (holdings.len > 0) { try setBold(out, color); try out.print("\n Top Holdings:\n", .{}); try reset(out, color); try setFg(out, color, CLR_MUTED); try out.print(" {s:>6} {s:>7} {s}\n", .{ "Symbol", "Weight", "Name" }); try out.print(" {s:->6} {s:->7} {s:->30}\n", .{ "", "", "" }); try reset(out, color); for (holdings) |h| { if (h.symbol) |s| { try setFg(out, color, CLR_ACCENT); try out.print(" {s:>6}", .{s}); try reset(out, color); try out.print(" {d:>6.2}% {s}\n", .{ h.weight * 100.0, h.name }); } else { try out.print(" {s:>6} {d:>6.2}% {s}\n", .{ "--", h.weight * 100.0, h.name }); } } } } try out.print("\n", .{}); try out.flush(); } fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool) !void { // Load portfolio from SRF file const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| { try stderr_print("Error reading portfolio file: "); try stderr_print(@errorName(err)); try stderr_print("\n"); return; }; defer allocator.free(data); var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch { try stderr_print("Error parsing portfolio file.\n"); return; }; defer portfolio.deinit(); if (portfolio.lots.len == 0) { try stderr_print("Portfolio is empty.\n"); return; } // Get stock/ETF positions (excludes options, CDs, cash) const positions = try portfolio.positions(allocator); defer allocator.free(positions); // Get unique stock/ETF symbols and fetch current prices const syms = try portfolio.stockSymbols(allocator); defer allocator.free(syms); var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); var fail_count: usize = 0; // Also collect watch symbols that need fetching var watch_syms: std.ArrayList([]const u8) = .empty; defer watch_syms.deinit(allocator); { var seen = std.StringHashMap(void).init(allocator); defer seen.deinit(); for (syms) |s| try seen.put(s, {}); for (portfolio.lots) |lot| { if (lot.lot_type == .watch and !seen.contains(lot.priceSymbol())) { try seen.put(lot.priceSymbol(), {}); try watch_syms.append(allocator, lot.priceSymbol()); } } } // All symbols to fetch (stock positions + watch) const all_syms_count = syms.len + watch_syms.items.len; if (all_syms_count > 0) { if (config.twelvedata_key == null) { try stderr_print("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n"); } var loaded_count: usize = 0; var cached_count: usize = 0; // Fetch stock/ETF prices via DataService (respects cache TTL) for (syms) |sym| { loaded_count += 1; // If --refresh, invalidate cache for this symbol if (force_refresh) { svc.invalidate(sym, .candles_daily); } // Check if cached and fresh (will be a fast no-op) const is_fresh = svc.isCandleCacheFresh(sym); if (is_fresh and !force_refresh) { // Load from cache (no network) if (svc.getCachedCandles(sym)) |cs| { defer allocator.free(cs); if (cs.len > 0) { try prices.put(sym, cs[cs.len - 1].close); cached_count += 1; try stderrProgress(sym, " (cached)", loaded_count, all_syms_count, color); continue; } } } // Need to fetch from API const wait_s = svc.estimateWaitSeconds(); if (wait_s) |w| { if (w > 0) { try stderrRateLimitWait(w, color); } } try stderrProgress(sym, " (fetching)", loaded_count, all_syms_count, color); const result = svc.getCandles(sym) catch { fail_count += 1; try stderrProgress(sym, " FAILED", loaded_count, all_syms_count, color); continue; }; defer allocator.free(result.data); if (result.data.len > 0) { try prices.put(sym, result.data[result.data.len - 1].close); } } // Fetch watch symbol candles (for watchlist display) for (watch_syms.items) |sym| { loaded_count += 1; if (force_refresh) { svc.invalidate(sym, .candles_daily); } const is_fresh = svc.isCandleCacheFresh(sym); if (is_fresh and !force_refresh) { cached_count += 1; try stderrProgress(sym, " (cached)", loaded_count, all_syms_count, color); continue; } const wait_s = svc.estimateWaitSeconds(); if (wait_s) |w| { if (w > 0) { try stderrRateLimitWait(w, color); } } try stderrProgress(sym, " (fetching)", loaded_count, all_syms_count, color); const result = svc.getCandles(sym) catch { try stderrProgress(sym, " FAILED", loaded_count, all_syms_count, color); continue; }; allocator.free(result.data); } // Summary line { var msg_buf: [256]u8 = undefined; if (cached_count == all_syms_count) { const msg = std.fmt.bufPrint(&msg_buf, "All {d} symbols loaded from cache\n", .{all_syms_count}) catch "Loaded from cache\n"; try stderr_print(msg); } else { const fetched_count = all_syms_count - cached_count - fail_count; const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ all_syms_count, cached_count, fetched_count, fail_count }) catch "Done loading\n"; try stderr_print(msg); } } } // Compute summary // Build fallback prices for symbols that failed API fetch: // 1. Use manual price:: from SRF if available // 2. Otherwise use position avg_cost (open_price) so the position still appears var manual_price_set = std.StringHashMap(void).init(allocator); defer manual_price_set.deinit(); // First pass: manual price:: overrides for (portfolio.lots) |lot| { if (lot.lot_type != .stock) continue; const sym = lot.priceSymbol(); if (lot.price) |p| { if (!prices.contains(sym)) { try prices.put(sym, p); try manual_price_set.put(sym, {}); } } } // Second pass: fall back to avg_cost for anything still missing for (positions) |pos| { if (!prices.contains(pos.symbol) and pos.shares > 0) { try prices.put(pos.symbol, pos.avg_cost); try manual_price_set.put(pos.symbol, {}); } } var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch { try stderr_print("Error computing portfolio summary.\n"); return; }; defer summary.deinit(allocator); // Sort allocations alphabetically by symbol std.mem.sort(zfin.risk.Allocation, summary.allocations, {}, struct { fn f(_: void, a: zfin.risk.Allocation, b: zfin.risk.Allocation) bool { return std.mem.lessThan(u8, a.display_symbol, b.display_symbol); } }.f); // Include non-stock assets in the grand total const cash_total = portfolio.totalCash(); const cd_total = portfolio.totalCdFaceValue(); const opt_total = portfolio.totalOptionCost(); const non_stock = cash_total + cd_total + opt_total; summary.total_value += non_stock; summary.total_cost += non_stock; if (summary.total_cost > 0) { summary.unrealized_return = summary.unrealized_pnl / summary.total_cost; } // Reweight allocations against grand total if (summary.total_value > 0) { for (summary.allocations) |*a| { a.weight = a.market_value / summary.total_value; } } var buf: [32768]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; // Header with summary try setBold(out, color); try out.print("\nPortfolio Summary ({s})\n", .{file_path}); try reset(out, color); try out.print("========================================\n", .{}); // Summary bar { var val_buf: [24]u8 = undefined; var cost_buf: [24]u8 = undefined; var gl_buf: [24]u8 = undefined; const gl_abs = if (summary.unrealized_pnl >= 0) summary.unrealized_pnl else -summary.unrealized_pnl; try out.print(" Value: {s} Cost: {s} ", .{ fmt.fmtMoney(&val_buf, summary.total_value), fmt.fmtMoney(&cost_buf, summary.total_cost) }); try setGainLoss(out, color, summary.unrealized_pnl); if (summary.unrealized_pnl >= 0) { try out.print("Gain/Loss: +{s} ({d:.1}%)", .{ fmt.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 }); } else { try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 }); } try reset(out, color); try out.print("\n", .{}); } // Lot counts (stocks/ETFs only) var open_lots: u32 = 0; var closed_lots: u32 = 0; for (portfolio.lots) |lot| { if (lot.lot_type != .stock) continue; if (lot.isOpen()) open_lots += 1 else closed_lots += 1; } try setFg(out, color, CLR_MUTED); try out.print(" Lots: {d} open, {d} closed Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len }); try reset(out, color); // Column headers try out.print("\n", .{}); try setFg(out, color, CLR_MUTED); try out.print(" " ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}\n", .{ "Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account", }); try out.print(" " ++ std.fmt.comptimePrint("{{s:->{d}}}", .{fmt.sym_col_width}) ++ " {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8} {s:->13} {s:->8}\n", .{ "", "", "", "", "", "", "", "", "", }); try reset(out, color); // Position rows with lot detail for (summary.allocations) |a| { // Count stock lots for this symbol var lots_for_sym: std.ArrayList(zfin.Lot) = .empty; defer lots_for_sym.deinit(allocator); for (portfolio.lots) |lot| { if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { try lots_for_sym.append(allocator, lot); } } std.mem.sort(zfin.Lot, lots_for_sym.items, {}, fmt.lotSortFn); const is_multi = lots_for_sym.items.len > 1; // Position summary row { var mv_buf: [24]u8 = undefined; var cost_buf2: [24]u8 = undefined; var price_buf2: [24]u8 = undefined; var gl_val_buf: [24]u8 = undefined; const gl_abs = if (a.unrealized_pnl >= 0) a.unrealized_pnl else -a.unrealized_pnl; const gl_money = fmt.fmtMoney(&gl_val_buf, gl_abs); const sign: []const u8 = if (a.unrealized_pnl >= 0) "+" else "-"; // Date + ST/LT for single-lot positions var date_col: [24]u8 = .{' '} ** 24; var date_col_len: usize = 0; if (!is_multi and lots_for_sym.items.len == 1) { const lot = lots_for_sym.items[0]; var pos_date_buf: [10]u8 = undefined; const ds = lot.open_date.format(&pos_date_buf); const indicator = fmt.capitalGainsIndicator(lot.open_date); const written = std.fmt.bufPrint(&date_col, "{s} {s}", .{ ds, indicator }) catch ""; date_col_len = written.len; } try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{ a.display_symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost), }); if (a.is_manual_price) try setFg(out, color, CLR_YELLOW); try out.print("{s:>10}", .{fmt.fmtMoney2(&price_buf2, a.current_price)}); if (a.is_manual_price) try reset(out, color); try out.print(" {s:>16} ", .{fmt.fmtMoney(&mv_buf, a.market_value)}); try setGainLoss(out, color, a.unrealized_pnl); try out.print("{s}{s:>13}", .{ sign, gl_money }); try reset(out, color); try out.print(" {d:>7.1}%", .{a.weight * 100.0}); if (date_col_len > 0) { try out.print(" {s}", .{date_col[0..date_col_len]}); } // Account for single-lot if (!is_multi and lots_for_sym.items.len == 1) { if (lots_for_sym.items[0].account) |acct| { try out.print(" {s}", .{acct}); } } try out.print("\n", .{}); } // Lot detail rows (always expanded for CLI) if (is_multi) { // Check if any lots are DRIP var has_drip = false; for (lots_for_sym.items) |lot| { if (lot.drip) { has_drip = true; break; } } if (!has_drip) { // No DRIP: show all individually for (lots_for_sym.items) |lot| { try printCliLotRow(out, color, lot, a.current_price); } } else { // Show non-DRIP lots individually for (lots_for_sym.items) |lot| { if (!lot.drip) { try printCliLotRow(out, color, lot, a.current_price); } } // Summarize DRIP lots as ST/LT var st_lots: usize = 0; var st_shares: f64 = 0; var st_cost: f64 = 0; var st_first: ?zfin.Date = null; var st_last: ?zfin.Date = null; var lt_lots: usize = 0; var lt_shares: f64 = 0; var lt_cost: f64 = 0; var lt_first: ?zfin.Date = null; var lt_last: ?zfin.Date = null; for (lots_for_sym.items) |lot| { if (!lot.drip) continue; const is_lt = std.mem.eql(u8, fmt.capitalGainsIndicator(lot.open_date), "LT"); if (is_lt) { lt_lots += 1; lt_shares += lot.shares; lt_cost += lot.costBasis(); if (lt_first == null or lot.open_date.days < lt_first.?.days) lt_first = lot.open_date; if (lt_last == null or lot.open_date.days > lt_last.?.days) lt_last = lot.open_date; } else { st_lots += 1; st_shares += lot.shares; st_cost += lot.costBasis(); if (st_first == null or lot.open_date.days < st_first.?.days) st_first = lot.open_date; if (st_last == null or lot.open_date.days > st_last.?.days) st_last = lot.open_date; } } if (st_lots > 0) { var avg_buf: [24]u8 = undefined; var d1_buf: [10]u8 = undefined; var d2_buf: [10]u8 = undefined; try setFg(out, color, CLR_MUTED); try out.print(" ST: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{ st_lots, st_shares, fmt.fmtMoney2(&avg_buf, if (st_shares > 0) st_cost / st_shares else 0), if (st_first) |d| d.format(&d1_buf)[0..7] else "?", if (st_last) |d| d.format(&d2_buf)[0..7] else "?", }); try reset(out, color); } if (lt_lots > 0) { var avg_buf2: [24]u8 = undefined; var d1_buf2: [10]u8 = undefined; var d2_buf2: [10]u8 = undefined; try setFg(out, color, CLR_MUTED); try out.print(" LT: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{ lt_lots, lt_shares, fmt.fmtMoney2(&avg_buf2, if (lt_shares > 0) lt_cost / lt_shares else 0), if (lt_first) |d| d.format(&d1_buf2)[0..7] else "?", if (lt_last) |d| d.format(&d2_buf2)[0..7] else "?", }); try reset(out, color); } } } } // Totals line try setFg(out, color, CLR_MUTED); try out.print(" {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8}\n", .{ "", "", "", "", "", "", "", }); try reset(out, color); { var total_mv_buf: [24]u8 = undefined; var total_gl_buf: [24]u8 = undefined; const gl_abs = if (summary.unrealized_pnl >= 0) summary.unrealized_pnl else -summary.unrealized_pnl; try out.print(" {s:>6} {s:>8} {s:>10} {s:>10} {s:>16} ", .{ "", "", "", "TOTAL", fmt.fmtMoney(&total_mv_buf, summary.total_value), }); try setGainLoss(out, color, summary.unrealized_pnl); if (summary.unrealized_pnl >= 0) { try out.print("+{s:>13}", .{fmt.fmtMoney(&total_gl_buf, gl_abs)}); } else { try out.print("-{s:>13}", .{fmt.fmtMoney(&total_gl_buf, gl_abs)}); } try reset(out, color); try out.print(" {s:>7}\n", .{"100.0%"}); } if (summary.realized_pnl != 0) { var rpl_buf: [24]u8 = undefined; const rpl_abs = if (summary.realized_pnl >= 0) summary.realized_pnl else -summary.realized_pnl; try setGainLoss(out, color, summary.realized_pnl); if (summary.realized_pnl >= 0) { try out.print("\n Realized P&L: +{s}\n", .{fmt.fmtMoney(&rpl_buf, rpl_abs)}); } else { try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoney(&rpl_buf, rpl_abs)}); } try reset(out, color); } // Options section if (portfolio.hasType(.option)) { try out.print("\n", .{}); try setBold(out, color); try out.print(" Options\n", .{}); try reset(out, color); try setFg(out, color, CLR_MUTED); try out.print(" {s:<30} {s:>6} {s:>12} {s:>14} {s}\n", .{ "Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account", }); try out.print(" {s:->30} {s:->6} {s:->12} {s:->14} {s:->10}\n", .{ "", "", "", "", "", }); try reset(out, color); var opt_total_cost: f64 = 0; for (portfolio.lots) |lot| { if (lot.lot_type != .option) continue; const qty = lot.shares; const cost_per = lot.open_price; const total_cost_opt = @abs(qty) * cost_per; opt_total_cost += total_cost_opt; var cost_per_buf: [24]u8 = undefined; var total_cost_buf: [24]u8 = undefined; const acct: []const u8 = lot.account orelse ""; try out.print(" {s:<30} {d:>6.0} {s:>12} {s:>14} {s}\n", .{ lot.symbol, qty, fmt.fmtMoney2(&cost_per_buf, cost_per), fmt.fmtMoney(&total_cost_buf, total_cost_opt), acct, }); } // Options total try setFg(out, color, CLR_MUTED); try out.print(" {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" }); try reset(out, color); var opt_total_buf: [24]u8 = undefined; try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{ "", "", "TOTAL", fmt.fmtMoney(&opt_total_buf, opt_total_cost), }); } // CDs section if (portfolio.hasType(.cd)) { try out.print("\n", .{}); try setBold(out, color); try out.print(" Certificates of Deposit\n", .{}); try reset(out, color); try setFg(out, color, CLR_MUTED); try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{ "CUSIP", "Face Value", "Rate", "Maturity", "Description", }); try out.print(" {s:->12} {s:->14} {s:->7} {s:->10} {s:->30}\n", .{ "", "", "", "", "", }); try reset(out, color); // Collect and sort CDs by maturity date (earliest first) var cd_lots: std.ArrayList(zfin.Lot) = .empty; defer cd_lots.deinit(allocator); for (portfolio.lots) |lot| { if (lot.lot_type == .cd) { try cd_lots.append(allocator, lot); } } std.mem.sort(zfin.Lot, cd_lots.items, {}, struct { fn f(ctx: void, a: zfin.Lot, b: zfin.Lot) bool { _ = ctx; const ad = if (a.maturity_date) |d| d.days else std.math.maxInt(i32); const bd = if (b.maturity_date) |d| d.days else std.math.maxInt(i32); return ad < bd; } }.f); var cd_section_total: f64 = 0; for (cd_lots.items) |lot| { cd_section_total += lot.shares; var face_buf: [24]u8 = undefined; var mat_buf: [10]u8 = undefined; const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--"; var rate_buf: [10]u8 = undefined; const rate_str: []const u8 = if (lot.rate) |r| std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--" else "--"; const note_str: []const u8 = lot.note orelse ""; const note_display = if (note_str.len > 50) note_str[0..50] else note_str; try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{ lot.symbol, fmt.fmtMoney(&face_buf, lot.shares), rate_str, mat_str, note_display, }); } // CD total try setFg(out, color, CLR_MUTED); try out.print(" {s:->12} {s:->14}\n", .{ "", "" }); try reset(out, color); var cd_total_buf: [24]u8 = undefined; try out.print(" {s:>12} {s:>14}\n", .{ "TOTAL", fmt.fmtMoney(&cd_total_buf, cd_section_total), }); } // Cash section if (portfolio.hasType(.cash)) { try out.print("\n", .{}); try setBold(out, color); try out.print(" Cash\n", .{}); try reset(out, color); try setFg(out, color, CLR_MUTED); var cash_hdr_buf: [80]u8 = undefined; try out.print("{s}\n", .{fmt.fmtCashHeader(&cash_hdr_buf)}); var cash_sep_buf: [80]u8 = undefined; try out.print("{s}\n", .{fmt.fmtCashSep(&cash_sep_buf)}); try reset(out, color); for (portfolio.lots) |lot| { if (lot.lot_type != .cash) continue; const acct2: []const u8 = lot.account orelse "Unknown"; var row_buf: [160]u8 = undefined; try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares, lot.note)}); } // Cash total var sep_buf: [80]u8 = undefined; try setFg(out, color, CLR_MUTED); try out.print("{s}\n", .{fmt.fmtCashSep(&sep_buf)}); try reset(out, color); var total_buf: [80]u8 = undefined; try setBold(out, color); try out.print("{s}\n", .{fmt.fmtCashTotal(&total_buf, portfolio.totalCash())}); try reset(out, color); } // Watchlist (from watch lots in portfolio + separate watchlist file) { var any_watch = false; var watch_seen = std.StringHashMap(void).init(allocator); defer watch_seen.deinit(); // Mark portfolio position symbols as seen for (summary.allocations) |a| { try watch_seen.put(a.symbol, {}); } // Helper to render a watch symbol const renderWatch = struct { fn f(o: anytype, c: bool, s: *zfin.DataService, a2: std.mem.Allocator, sym: []const u8, any: *bool) !void { if (!any.*) { try o.print("\n", .{}); try setBold(o, c); try o.print(" Watchlist:\n", .{}); try reset(o, c); any.* = true; } var price_str2: [16]u8 = undefined; var ps2: []const u8 = "--"; if (s.getCachedCandles(sym)) |candles2| { defer a2.free(candles2); if (candles2.len > 0) { ps2 = fmt.fmtMoney2(&price_str2, candles2[candles2.len - 1].close); } } try o.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps2 }); } }.f; // Watch lots from portfolio for (portfolio.lots) |lot| { if (lot.lot_type == .watch) { if (watch_seen.contains(lot.priceSymbol())) continue; try watch_seen.put(lot.priceSymbol(), {}); try renderWatch(out, color, svc, allocator, lot.priceSymbol(), &any_watch); } } // Separate watchlist file (backward compat) if (watchlist_path) |wl_path| { const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null; if (wl_data) |wd| { defer allocator.free(wd); var wl_lines = std.mem.splitScalar(u8, wd, '\n'); while (wl_lines.next()) |line| { const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); if (trimmed.len == 0 or trimmed[0] == '#') continue; if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| { const rest = trimmed[idx + "symbol::".len ..]; const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len; const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace); if (sym.len > 0 and sym.len <= 10) { if (watch_seen.contains(sym)) continue; try watch_seen.put(sym, {}); try renderWatch(out, color, svc, allocator, sym, &any_watch); } } } } } } // Risk metrics { var any_risk = false; for (summary.allocations) |a| { if (svc.getCachedCandles(a.symbol)) |candles| { defer allocator.free(candles); if (zfin.risk.computeRisk(candles)) |metrics| { if (!any_risk) { try out.print("\n", .{}); try setBold(out, color); try out.print(" Risk Metrics (from cached price data):\n", .{}); try reset(out, color); try setFg(out, color, CLR_MUTED); try out.print(" {s:>6} {s:>10} {s:>8} {s:>10}\n", .{ "Symbol", "Volatility", "Sharpe", "Max DD", }); try out.print(" {s:->6} {s:->10} {s:->8} {s:->10}\n", .{ "", "", "", "", }); try reset(out, color); any_risk = true; } try out.print(" {s:>6} {d:>9.1}% {d:>8.2} ", .{ a.symbol, metrics.volatility * 100.0, metrics.sharpe, }); try setFg(out, color, CLR_RED); try out.print("{d:>9.1}%", .{metrics.max_drawdown * 100.0}); try reset(out, color); if (metrics.drawdown_trough) |dt| { var db: [10]u8 = undefined; try setFg(out, color, CLR_MUTED); try out.print(" (trough {s})", .{dt.format(&db)}); try reset(out, color); } try out.print("\n", .{}); } } } } try out.print("\n", .{}); try out.flush(); } fn printCliLotRow(out: anytype, color: bool, lot: zfin.Lot, current_price: f64) !void { var lot_price_buf: [24]u8 = undefined; var lot_date_buf: [10]u8 = undefined; const date_str = lot.open_date.format(&lot_date_buf); const indicator = fmt.capitalGainsIndicator(lot.open_date); const status_str: []const u8 = if (lot.isOpen()) "open" else "closed"; const acct_col: []const u8 = lot.account orelse ""; const use_price = lot.close_price orelse current_price; const gl = lot.shares * (use_price - lot.open_price); var lot_gl_buf: [24]u8 = undefined; const lot_gl_abs = if (gl >= 0) gl else -gl; const lot_gl_money = fmt.fmtMoney(&lot_gl_buf, lot_gl_abs); const lot_sign: []const u8 = if (gl >= 0) "+" else "-"; try setFg(out, color, CLR_MUTED); try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{ status_str, lot.shares, fmt.fmtMoney2(&lot_price_buf, lot.open_price), "", "", }); try reset(out, color); try setGainLoss(out, color, gl); try out.print("{s}{s:>13}", .{ lot_sign, lot_gl_money }); try reset(out, color); try setFg(out, color, CLR_MUTED); try out.print(" {s:>8} {s} {s} {s}\n", .{ "", date_str, indicator, acct_col }); try reset(out, color); } fn cmdLookup(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool) !void { var buf: [4096]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; if (!zfin.OpenFigi.isCusipLike(cusip)) { try setFg(out, color, CLR_MUTED); try out.print("Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{cusip}); try reset(out, color); } try stderr_print("Looking up via OpenFIGI...\n"); // Try full batch lookup for richer output const results = zfin.OpenFigi.lookupCusips(allocator, &.{cusip}, svc.config.openfigi_key) catch { try stderr_print("Error: OpenFIGI request failed (network error)\n"); return; }; defer { for (results) |r| { if (r.ticker) |t| allocator.free(t); if (r.name) |n| allocator.free(n); if (r.security_type) |s| allocator.free(s); } allocator.free(results); } if (results.len == 0 or !results[0].found) { try out.print("No result from OpenFIGI for '{s}'\n", .{cusip}); try out.flush(); return; } const r = results[0]; if (r.ticker) |ticker| { try setBold(out, color); try out.print("{s}", .{cusip}); try reset(out, color); try out.print(" -> ", .{}); try setFg(out, color, CLR_ACCENT); try out.print("{s}", .{ticker}); try reset(out, color); try out.print("\n", .{}); if (r.name) |name| { try setFg(out, color, CLR_MUTED); try out.print(" Name: {s}\n", .{name}); try reset(out, color); } if (r.security_type) |st| { try setFg(out, color, CLR_MUTED); try out.print(" Type: {s}\n", .{st}); try reset(out, color); } try out.print("\n To use in portfolio: ticker::{s}\n", .{ticker}); // Also cache it svc.cacheCusipTicker(cusip, ticker); } else { try out.print("No ticker found for CUSIP '{s}'\n", .{cusip}); if (r.name) |name| { try setFg(out, color, CLR_MUTED); try out.print(" Name: {s}\n", .{name}); try reset(out, color); } try out.print("\n Tip: For mutual funds, OpenFIGI often has no coverage.\n", .{}); try out.print(" Add manually: symbol::{s},ticker::XXXX,...\n", .{cusip}); } try out.flush(); } fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8) !void { if (std.mem.eql(u8, subcommand, "stats")) { var buf: [4096]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; try out.print("Cache directory: {s}\n", .{config.cache_dir}); std.fs.cwd().access(config.cache_dir, .{}) catch { try out.print(" (empty -- no cached data)\n", .{}); try out.flush(); return; }; var dir = std.fs.cwd().openDir(config.cache_dir, .{ .iterate = true }) catch { try out.print(" (empty -- no cached data)\n", .{}); try out.flush(); return; }; defer dir.close(); var count: usize = 0; var iter = dir.iterate(); while (iter.next() catch null) |entry| { if (entry.kind == .directory) { try out.print(" {s}/\n", .{entry.name}); count += 1; } } if (count == 0) { try out.print(" (empty -- no cached data)\n", .{}); } else { try out.print("\n {d} symbol(s) cached\n", .{count}); } try out.flush(); } else if (std.mem.eql(u8, subcommand, "clear")) { var store = zfin.cache.Store.init(allocator, config.cache_dir); try store.clearAll(); try stdout_print("Cache cleared.\n"); } else { try stderr_print("Unknown cache subcommand. Use 'stats' or 'clear'.\n"); } } // ── Output helpers ─────────────────────────────────────────── fn stdout_print(msg: []const u8) !void { var buf: [4096]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; try out.writeAll(msg); try out.flush(); } /// Print progress line to stderr: " [N/M] SYMBOL (status)" fn stderrProgress(symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) !void { var buf: [256]u8 = undefined; var writer = std.fs.File.stderr().writer(&buf); const out = &writer.interface; if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); try out.print(" [{d}/{d}] ", .{ current, total }); if (color) try fmt.ansiReset(out); try out.print("{s}", .{symbol}); if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); try out.print("{s}\n", .{status}); if (color) try fmt.ansiReset(out); try out.flush(); } /// Print rate-limit wait message to stderr fn stderrRateLimitWait(wait_seconds: u64, color: bool) !void { var buf: [256]u8 = undefined; var writer = std.fs.File.stderr().writer(&buf); const out = &writer.interface; if (color) try fmt.ansiSetFg(out, CLR_RED[0], CLR_RED[1], CLR_RED[2]); if (wait_seconds >= 60) { const mins = wait_seconds / 60; const secs = wait_seconds % 60; if (secs > 0) { try out.print(" (rate limit -- waiting {d}m {d}s)\n", .{ mins, secs }); } else { try out.print(" (rate limit -- waiting {d}m)\n", .{mins}); } } else { try out.print(" (rate limit -- waiting {d}s)\n", .{wait_seconds}); } if (color) try fmt.ansiReset(out); try out.flush(); } fn stderr_print(msg: []const u8) !void { var buf: [1024]u8 = undefined; var writer = std.fs.File.stderr().writer(&buf); const out = &writer.interface; try out.writeAll(msg); try out.flush(); }