diff --git a/src/cli/commands/analysis.zig b/src/cli/commands/analysis.zig new file mode 100644 index 0000000..24ac358 --- /dev/null +++ b/src/cli/commands/analysis.zig @@ -0,0 +1,328 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const cli = @import("../common.zig"); +const fmt = cli.fmt; + +/// CLI `analysis` command: show portfolio analysis breakdowns. +pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, color: bool, out: *std.Io.Writer) !void { + _ = config; + + // Load portfolio + const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch { + try cli.stderrPrint("Error: Cannot read portfolio file\n"); + return; + }; + defer allocator.free(file_data); + + var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch { + try cli.stderrPrint("Error: Cannot parse portfolio file\n"); + return; + }; + defer portfolio.deinit(); + + const positions = try portfolio.positions(allocator); + defer allocator.free(positions); + + // Build prices map from cache + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + var manual_price_set = std.StringHashMap(void).init(allocator); + defer manual_price_set.deinit(); + + // First pass: try cached candle prices + manual prices from lots + for (positions) |pos| { + if (pos.shares <= 0) continue; + if (svc.getCachedCandles(pos.symbol)) |cs| { + defer allocator.free(cs); + if (cs.len > 0) { + try prices.put(pos.symbol, cs[cs.len - 1].close); + continue; + } + } + for (portfolio.lots) |lot| { + if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), pos.symbol)) { + if (lot.price) |mp| { + try prices.put(pos.symbol, mp); + try manual_price_set.put(pos.symbol, {}); + break; + } + } + } + } + // Fallback to avg_cost + 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 cli.stderrPrint("Error computing portfolio summary.\n"); + return; + }; + defer summary.deinit(allocator); + + // Include non-stock assets in grand total (same as portfolio command) + 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; + + // Load classification metadata + const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0; + const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch return; + defer allocator.free(meta_path); + + const meta_data = std.fs.cwd().readFileAlloc(allocator, meta_path, 1024 * 1024) catch { + try cli.stderrPrint("Error: No metadata.srf found. Run: zfin enrich > metadata.srf\n"); + return; + }; + defer allocator.free(meta_data); + + var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch { + try cli.stderrPrint("Error: Cannot parse metadata.srf\n"); + return; + }; + defer cm.deinit(); + + // Load account tax type metadata (optional) + const acct_path = std.fmt.allocPrint(allocator, "{s}accounts.srf", .{file_path[0..dir_end]}) catch return; + defer allocator.free(acct_path); + + var acct_map_opt: ?zfin.analysis.AccountMap = null; + const acct_data = std.fs.cwd().readFileAlloc(allocator, acct_path, 1024 * 1024) catch null; + if (acct_data) |ad| { + defer allocator.free(ad); + acct_map_opt = zfin.analysis.parseAccountsFile(allocator, ad) catch null; + } + defer if (acct_map_opt) |*am| am.deinit(); + + var result = zfin.analysis.analyzePortfolio( + allocator, + summary.allocations, + cm, + portfolio, + summary.total_value, + acct_map_opt, + ) catch { + try cli.stderrPrint("Error computing analysis.\n"); + return; + }; + defer result.deinit(allocator); + + try display(result, file_path, color, out); +} + +pub fn display(result: zfin.analysis.AnalysisResult, file_path: []const u8, color: bool, out: *std.Io.Writer) !void { + const label_width: usize = 24; + const bar_width: usize = 30; + + try cli.setBold(out, color); + try out.print("\nPortfolio Analysis ({s})\n", .{file_path}); + try cli.reset(out, color); + try out.print("========================================\n\n", .{}); + + // Asset Class + try cli.setBold(out, color); + try cli.setFg(out, color, cli.CLR_HEADER); + try out.print(" Asset Class\n", .{}); + try cli.reset(out, color); + try printBreakdownSection(out, result.asset_class, label_width, bar_width, color); + + // Sector + if (result.sector.len > 0) { + try out.print("\n", .{}); + try cli.setBold(out, color); + try cli.setFg(out, color, cli.CLR_HEADER); + try out.print(" Sector (Equities)\n", .{}); + try cli.reset(out, color); + try printBreakdownSection(out, result.sector, label_width, bar_width, color); + } + + // Geographic + if (result.geo.len > 0) { + try out.print("\n", .{}); + try cli.setBold(out, color); + try cli.setFg(out, color, cli.CLR_HEADER); + try out.print(" Geographic\n", .{}); + try cli.reset(out, color); + try printBreakdownSection(out, result.geo, label_width, bar_width, color); + } + + // By Account + if (result.account.len > 0) { + try out.print("\n", .{}); + try cli.setBold(out, color); + try cli.setFg(out, color, cli.CLR_HEADER); + try out.print(" By Account\n", .{}); + try cli.reset(out, color); + try printBreakdownSection(out, result.account, label_width, bar_width, color); + } + + // Tax Type + if (result.tax_type.len > 0) { + try out.print("\n", .{}); + try cli.setBold(out, color); + try cli.setFg(out, color, cli.CLR_HEADER); + try out.print(" By Tax Type\n", .{}); + try cli.reset(out, color); + try printBreakdownSection(out, result.tax_type, label_width, bar_width, color); + } + + // Unclassified + if (result.unclassified.len > 0) { + try out.print("\n", .{}); + try cli.setFg(out, color, cli.CLR_WARNING); + try out.print(" Unclassified (not in metadata.srf)\n", .{}); + try cli.reset(out, color); + for (result.unclassified) |sym| { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" {s}\n", .{sym}); + try cli.reset(out, color); + } + } + + try out.print("\n", .{}); +} + +/// Print a breakdown section with block-element bar charts to the CLI output. +pub fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void { + // Unicode block elements: U+2588 full, U+2589..U+258F partials (7/8..1/8) + const full_block = "\xE2\x96\x88"; + // partial_blocks[0]=7/8, [1]=3/4, ..., [6]=1/8 + const partial_blocks = [7][]const u8{ + "\xE2\x96\x89", // 7/8 + "\xE2\x96\x8A", // 3/4 + "\xE2\x96\x8B", // 5/8 + "\xE2\x96\x8C", // 1/2 + "\xE2\x96\x8D", // 3/8 + "\xE2\x96\x8E", // 1/4 + "\xE2\x96\x8F", // 1/8 + }; + + for (items) |item| { + var val_buf: [24]u8 = undefined; + const pct = item.weight * 100.0; + + // Compute filled eighths + const total_eighths: f64 = @as(f64, @floatFromInt(bar_width)) * 8.0; + const filled_eighths_f = item.weight * total_eighths; + const filled_eighths: usize = @intFromFloat(@min(@max(filled_eighths_f, 0), total_eighths)); + const full_count = filled_eighths / 8; + const partial = filled_eighths % 8; + + // Padded label + const lbl_len = @min(item.label.len, label_width); + try out.print(" ", .{}); + try out.writeAll(item.label[0..lbl_len]); + if (lbl_len < label_width) { + for (0..label_width - lbl_len) |_| try out.writeAll(" "); + } + try out.writeAll(" "); + if (color) try fmt.ansiSetFg(out, cli.CLR_ACCENT[0], cli.CLR_ACCENT[1], cli.CLR_ACCENT[2]); + for (0..full_count) |_| try out.writeAll(full_block); + if (partial > 0) try out.writeAll(partial_blocks[8 - partial - 1]); + const used = full_count + @as(usize, if (partial > 0) 1 else 0); + if (used < bar_width) { + for (0..bar_width - used) |_| try out.writeAll(" "); + } + if (color) try fmt.ansiReset(out); + try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoney(&val_buf, item.value) }); + } +} + +// ── Tests ──────────────────────────────────────────────────── + +test "printBreakdownSection single item no color" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const items = [_]zfin.analysis.BreakdownItem{ + .{ .label = "US Large Cap", .weight = 0.60, .value = 60000.0 }, + }; + try printBreakdownSection(&w, &items, 24, 30, false); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "US Large Cap") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "60.0%") != null); + // No ANSI when color=false + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} + +test "printBreakdownSection multiple items" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const items = [_]zfin.analysis.BreakdownItem{ + .{ .label = "Stocks", .weight = 0.70, .value = 70000.0 }, + .{ .label = "Bonds", .weight = 0.20, .value = 20000.0 }, + .{ .label = "Cash", .weight = 0.10, .value = 10000.0 }, + }; + try printBreakdownSection(&w, &items, 24, 30, false); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "Stocks") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Bonds") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Cash") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "70.0%") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "10.0%") != null); +} + +test "printBreakdownSection zero weight" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const items = [_]zfin.analysis.BreakdownItem{ + .{ .label = "Empty", .weight = 0.0, .value = 0.0 }, + }; + try printBreakdownSection(&w, &items, 24, 30, false); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "0.0%") != null); +} + +test "printBreakdownSection with color emits ANSI" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const items = [_]zfin.analysis.BreakdownItem{ + .{ .label = "Test", .weight = 0.50, .value = 50000.0 }, + }; + try printBreakdownSection(&w, &items, 24, 30, true); + const out = w.buffered(); + // Should contain ANSI escape for bar color + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); +} + +test "display shows all sections" { + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const asset_class = [_]zfin.analysis.BreakdownItem{ + .{ .label = "US Large Cap", .weight = 0.60, .value = 60000.0 }, + .{ .label = "International", .weight = 0.40, .value = 40000.0 }, + }; + const sector = [_]zfin.analysis.BreakdownItem{ + .{ .label = "Technology", .weight = 0.35, .value = 35000.0 }, + }; + const geo = [_]zfin.analysis.BreakdownItem{ + .{ .label = "US", .weight = 0.80, .value = 80000.0 }, + }; + const empty = [_]zfin.analysis.BreakdownItem{}; + const unclassified = [_][]const u8{"WEIRD"}; + const result: zfin.analysis.AnalysisResult = .{ + .asset_class = @constCast(&asset_class), + .sector = @constCast(§or), + .geo = @constCast(&geo), + .account = @constCast(&empty), + .tax_type = @constCast(&empty), + .unclassified = @constCast(&unclassified), + .total_value = 100000.0, + }; + try display(result, "test.srf", false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Analysis") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Asset Class") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "US Large Cap") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Sector") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Technology") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Geographic") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Unclassified") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "WEIRD") != null); + // No ANSI when color=false + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} diff --git a/src/cli/commands/cache.zig b/src/cli/commands/cache.zig new file mode 100644 index 0000000..d66dedd --- /dev/null +++ b/src/cli/commands/cache.zig @@ -0,0 +1,37 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const cli = @import("../common.zig"); + +pub fn run(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8, out: *std.Io.Writer) !void { + if (std.mem.eql(u8, subcommand, "stats")) { + 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", .{}); + return; + }; + var dir = std.fs.cwd().openDir(config.cache_dir, .{ .iterate = true }) catch { + try out.print(" (empty -- no cached data)\n", .{}); + 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}); + } + } else if (std.mem.eql(u8, subcommand, "clear")) { + var store = zfin.cache.Store.init(allocator, config.cache_dir); + try store.clearAll(); + try out.writeAll("Cache cleared.\n"); + } else { + try cli.stderrPrint("Unknown cache subcommand. Use 'stats' or 'clear'.\n"); + } +} diff --git a/src/cli/commands/divs.zig b/src/cli/commands/divs.zig new file mode 100644 index 0000000..d3963fb --- /dev/null +++ b/src/cli/commands/divs.zig @@ -0,0 +1,147 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const cli = @import("../common.zig"); +const fmt = cli.fmt; + +pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { + const result = svc.getDividends(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try cli.stderrPrint("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); + return; + }, + else => { + try cli.stderrPrint("Error fetching dividend data.\n"); + return; + }, + }; + defer allocator.free(result.data); + + if (result.source == .cached) try cli.stderrPrint("(using cached dividend data)\n"); + + // 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 |_| {} + } + + try display(result.data, symbol, current_price, color, out); +} + +pub fn display(dividends: []const zfin.Dividend, symbol: []const u8, current_price: ?f64, color: bool, out: *std.Io.Writer) !void { + try cli.setBold(out, color); + try out.print("\nDividend History for {s}\n", .{symbol}); + try cli.reset(out, color); + try out.print("========================================\n", .{}); + + if (dividends.len == 0) { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" No dividends found.\n\n", .{}); + try cli.reset(out, color); + return; + } + + try cli.setFg(out, color, cli.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 cli.reset(out, color); + + const today = fmt.todayDate(); + const one_year_ago = today.subtractYears(1); + var total: f64 = 0; + var ttm: f64 = 0; + + for (dividends) |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", .{ dividends.len, total }); + try cli.setFg(out, color, cli.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 cli.reset(out, color); + try out.print("\n\n", .{}); +} + +// ── Tests ──────────────────────────────────────────────────── + +test "display shows dividend data with yield" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const divs = [_]zfin.Dividend{ + .{ .ex_date = .{ .days = 20000 }, .amount = 0.88, .distribution_type = .regular }, + .{ .ex_date = .{ .days = 19900 }, .amount = 0.88, .distribution_type = .regular }, + }; + try display(&divs, "VTI", 250.0, false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "VTI") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "0.8800") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "2 dividends") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "TTM") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "yield") != null); +} + +test "display shows empty message" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const divs = [_]zfin.Dividend{}; + try display(&divs, "BRK.A", null, false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "No dividends found") != null); +} + +test "display without price omits yield" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const divs = [_]zfin.Dividend{ + .{ .ex_date = .{ .days = 20000 }, .amount = 1.50, .distribution_type = .regular }, + }; + try display(&divs, "T", null, false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "yield") == null); + try std.testing.expect(std.mem.indexOf(u8, out, "1 dividends") != null); +} + +test "display no ANSI without color" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const divs = [_]zfin.Dividend{ + .{ .ex_date = .{ .days = 20000 }, .amount = 0.50, .distribution_type = .regular }, + }; + try display(&divs, "SPY", 500.0, false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} diff --git a/src/cli/commands/earnings.zig b/src/cli/commands/earnings.zig new file mode 100644 index 0000000..6d5fe1e --- /dev/null +++ b/src/cli/commands/earnings.zig @@ -0,0 +1,144 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const cli = @import("../common.zig"); +const fmt = cli.fmt; + +pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { + const result = svc.getEarnings(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try cli.stderrPrint("Error: FINNHUB_API_KEY not set. Get a free key at https://finnhub.io\n"); + return; + }, + else => { + try cli.stderrPrint("Error fetching earnings data.\n"); + return; + }, + }; + defer allocator.free(result.data); + + if (result.source == .cached) try cli.stderrPrint("(using cached earnings data)\n"); + + try display(result.data, symbol, color, out); +} + +pub fn display(events: []const zfin.EarningsEvent, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { + try cli.setBold(out, color); + try out.print("\nEarnings History for {s}\n", .{symbol}); + try cli.reset(out, color); + try out.print("========================================\n", .{}); + + if (events.len == 0) { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" No earnings data found.\n\n", .{}); + try cli.reset(out, color); + return; + } + + try cli.setFg(out, color, cli.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 cli.reset(out, color); + + for (events) |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 cli.setFg(out, color, cli.CLR_MUTED); + } else if (surprise_positive) { + try cli.setFg(out, color, cli.CLR_POSITIVE); + } else { + try cli.setFg(out, color, cli.CLR_NEGATIVE); + } + + 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 cli.reset(out, color); + try out.print("\n", .{}); + } + + try out.print("\n{d} earnings event(s)\n\n", .{events.len}); +} + +pub fn fmtEps(val: f64) [12]u8 { + var buf: [12]u8 = .{' '} ** 12; + _ = std.fmt.bufPrint(&buf, "${d:.2}", .{val}) catch {}; + return buf; +} + +// ── Tests ──────────────────────────────────────────────────── + +test "fmtEps formats positive value" { + const result = fmtEps(1.25); + const trimmed = std.mem.trimRight(u8, &result, &.{' '}); + try std.testing.expectEqualStrings("$1.25", trimmed); +} + +test "fmtEps formats negative value" { + const result = fmtEps(-0.50); + const trimmed = std.mem.trimRight(u8, &result, &.{' '}); + try std.testing.expect(std.mem.indexOf(u8, trimmed, "0.5") != null); +} + +test "fmtEps formats zero" { + const result = fmtEps(0.0); + const trimmed = std.mem.trimRight(u8, &result, &.{' '}); + try std.testing.expectEqualStrings("$0.00", trimmed); +} + +test "display shows earnings with beat" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const events = [_]zfin.EarningsEvent{ + .{ .symbol = "AAPL", .date = .{ .days = 19000 }, .quarter = 4, .estimate = 1.50, .actual = 1.65, .surprise = 0.15, .surprise_percent = 10.0, .report_time = .amc }, + }; + try display(&events, "AAPL", 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, "Q4") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "$1.50") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "$1.65") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "1 earnings event(s)") != null); +} + +test "display shows empty message" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const events = [_]zfin.EarningsEvent{}; + try display(&events, "XYZ", false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "No earnings data found") != null); +} + +test "display no ANSI without color" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const events = [_]zfin.EarningsEvent{ + .{ .symbol = "MSFT", .date = .{ .days = 19000 }, .report_time = .bmo }, + }; + try display(&events, "MSFT", false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} diff --git a/src/cli/commands/enrich.zig b/src/cli/commands/enrich.zig new file mode 100644 index 0000000..1fe0ce5 --- /dev/null +++ b/src/cli/commands/enrich.zig @@ -0,0 +1,205 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const cli = @import("../common.zig"); + +/// CLI `enrich` command: bootstrap a metadata.srf file from Alpha Vantage OVERVIEW data. +/// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each, +/// and outputs a metadata SRF file to stdout. +/// If the argument looks like a symbol (no path separators, no .srf extension), enrich just that symbol. +pub fn run(allocator: std.mem.Allocator, config: zfin.Config, arg: []const u8, out: *std.Io.Writer) !void { + // Check for Alpha Vantage API key + const av_key = config.alphavantage_key orelse { + try cli.stderrPrint("Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n"); + return; + }; + + // Determine if arg is a symbol or a file path + const is_file = std.mem.endsWith(u8, arg, ".srf") or + std.mem.indexOfScalar(u8, arg, '/') != null or + std.mem.indexOfScalar(u8, arg, '.') != null; + + if (!is_file) { + // Single symbol mode: enrich one symbol, output appendable SRF (no header) + try enrichSymbol(allocator, av_key, arg, out); + return; + } + + // Portfolio file mode: enrich all symbols + try enrichPortfolio(allocator, av_key, arg, out); +} + +/// Enrich a single symbol and output appendable SRF lines to stdout. +fn enrichSymbol(allocator: std.mem.Allocator, av_key: []const u8, sym: []const u8, out: *std.Io.Writer) !void { + const AV = zfin.AlphaVantage; + var av = AV.init(allocator, av_key); + defer av.deinit(); + + { + var msg_buf: [128]u8 = undefined; + const msg = std.fmt.bufPrint(&msg_buf, " Fetching {s}...\n", .{sym}) catch " ...\n"; + try cli.stderrPrint(msg); + } + + const overview = av.fetchCompanyOverview(allocator, sym) catch { + try cli.stderrPrint("Error: Failed to fetch data for symbol\n"); + try out.print("# {s} -- fetch failed\n", .{sym}); + try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n", .{sym}); + return; + }; + defer { + if (overview.name) |n| allocator.free(n); + if (overview.sector) |s| allocator.free(s); + if (overview.industry) |ind| allocator.free(ind); + if (overview.country) |c| allocator.free(c); + if (overview.market_cap) |mc| allocator.free(mc); + if (overview.asset_type) |at| allocator.free(at); + } + + const sector_str = overview.sector orelse "Unknown"; + const country_str = overview.country orelse "US"; + const geo_str = if (std.mem.eql(u8, country_str, "USA")) "US" else country_str; + + const asset_class_str = blk: { + if (overview.asset_type) |at| { + if (std.mem.eql(u8, at, "ETF")) break :blk "ETF"; + if (std.mem.eql(u8, at, "Mutual Fund")) break :blk "Mutual Fund"; + } + if (overview.market_cap) |mc_str| { + const mc = std.fmt.parseInt(u64, mc_str, 10) catch 0; + if (mc >= 10_000_000_000) break :blk "US Large Cap"; + if (mc >= 2_000_000_000) break :blk "US Mid Cap"; + break :blk "US Small Cap"; + } + break :blk "US Large Cap"; + }; + + if (overview.name) |name| { + try out.print("# {s}\n", .{name}); + } + try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n", .{ + sym, sector_str, geo_str, asset_class_str, + }); +} + +/// Enrich all symbols from a portfolio file. +fn enrichPortfolio(allocator: std.mem.Allocator, av_key: []const u8, file_path: []const u8, out: *std.Io.Writer) !void { + const AV = zfin.AlphaVantage; + + // Load portfolio + const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch { + try cli.stderrPrint("Error: Cannot read portfolio file\n"); + return; + }; + defer allocator.free(file_data); + + var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch { + try cli.stderrPrint("Error: Cannot parse portfolio file\n"); + return; + }; + defer portfolio.deinit(); + + // Get unique stock symbols (using display-oriented names) + const positions = try portfolio.positions(allocator); + defer allocator.free(positions); + + // Get unique price symbols (raw API symbols) + const syms = try portfolio.stockSymbols(allocator); + defer allocator.free(syms); + + var av = AV.init(allocator, av_key); + defer av.deinit(); + + try out.print("#!srfv1\n", .{}); + try out.print("# Portfolio classification metadata\n", .{}); + try out.print("# Generated from Alpha Vantage OVERVIEW data\n", .{}); + try out.print("# Edit as needed: sector, geo, asset_class, pct:num:N\n", .{}); + try out.print("#\n", .{}); + try out.print("# For ETFs/funds with multi-class exposure, add multiple lines\n", .{}); + try out.print("# with pct:num: values that sum to ~100\n\n", .{}); + + var success: usize = 0; + var skipped: usize = 0; + var failed: usize = 0; + + for (syms, 0..) |sym, i| { + // Skip CUSIPs and known non-stock symbols + if (zfin.OpenFigi.isCusipLike(sym)) { + // Find the display name for this CUSIP + const display: []const u8 = sym; + var note: ?[]const u8 = null; + for (positions) |pos| { + if (std.mem.eql(u8, pos.symbol, sym)) { + if (pos.note) |n| { + note = n; + } + break; + } + } + try out.print("# CUSIP {s}", .{sym}); + if (note) |n| try out.print(" ({s})", .{n}); + try out.print(" -- fill in manually\n", .{}); + try out.print("# symbol::{s},asset_class::TODO,geo::TODO\n\n", .{display}); + skipped += 1; + continue; + } + + // Progress to stderr + { + var msg_buf: [128]u8 = undefined; + const msg = std.fmt.bufPrint(&msg_buf, " [{d}/{d}] {s}...\n", .{ i + 1, syms.len, sym }) catch " ...\n"; + try cli.stderrPrint(msg); + } + + const overview = av.fetchCompanyOverview(allocator, sym) catch { + try out.print("# {s} -- fetch failed\n", .{sym}); + try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n\n", .{sym}); + failed += 1; + continue; + }; + // Free allocated strings from overview when done + defer { + if (overview.name) |n| allocator.free(n); + if (overview.sector) |s| allocator.free(s); + if (overview.industry) |ind| allocator.free(ind); + if (overview.country) |c| allocator.free(c); + if (overview.market_cap) |mc| allocator.free(mc); + if (overview.asset_type) |at| allocator.free(at); + } + + const sector_str = overview.sector orelse "Unknown"; + const country_str = overview.country orelse "US"; + const geo_str = if (std.mem.eql(u8, country_str, "USA")) "US" else country_str; + + // Determine asset_class from asset type + market cap + const asset_class_str = blk: { + if (overview.asset_type) |at| { + if (std.mem.eql(u8, at, "ETF")) break :blk "ETF"; + if (std.mem.eql(u8, at, "Mutual Fund")) break :blk "Mutual Fund"; + } + // For common stocks, infer from market cap + if (overview.market_cap) |mc_str| { + const mc = std.fmt.parseInt(u64, mc_str, 10) catch 0; + if (mc >= 10_000_000_000) break :blk "US Large Cap"; + if (mc >= 2_000_000_000) break :blk "US Mid Cap"; + break :blk "US Small Cap"; + } + break :blk "US Large Cap"; + }; + + // Comment with the name for readability + if (overview.name) |name| { + try out.print("# {s}\n", .{name}); + } + try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n\n", .{ + sym, sector_str, geo_str, asset_class_str, + }); + success += 1; + } + + // Summary comment + try out.print("# ---\n", .{}); + try out.print("# Enriched {d} symbols ({d} success, {d} skipped, {d} failed)\n", .{ + syms.len, success, skipped, failed, + }); + try out.print("# Review and edit this file, then save as metadata.srf\n", .{}); +} diff --git a/src/cli/commands/etf.zig b/src/cli/commands/etf.zig new file mode 100644 index 0000000..121cdbd --- /dev/null +++ b/src/cli/commands/etf.zig @@ -0,0 +1,143 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const cli = @import("../common.zig"); +const fmt = cli.fmt; + +pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { + const result = svc.getEtfProfile(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try cli.stderrPrint("Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n"); + return; + }, + else => { + try cli.stderrPrint("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 cli.stderrPrint("(using cached ETF profile)\n"); + + try printProfile(profile, symbol, color, out); +} + +pub fn printProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { + try cli.setBold(out, color); + try out.print("\nETF Profile: {s}\n", .{symbol}); + try cli.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 cli.setFg(out, color, cli.CLR_NEGATIVE); + try out.print(" Leveraged: YES\n", .{}); + try cli.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 cli.setBold(out, color); + try out.print("\n Sector Allocation:\n", .{}); + try cli.reset(out, color); + for (sectors) |sec| { + try cli.setFg(out, color, cli.CLR_ACCENT); + try out.print(" {d:>5.1}%", .{sec.weight * 100.0}); + try cli.reset(out, color); + try out.print(" {s}\n", .{sec.sector}); + } + } + } + + // Top holdings + if (profile.holdings) |holdings| { + if (holdings.len > 0) { + try cli.setBold(out, color); + try out.print("\n Top Holdings:\n", .{}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.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 cli.reset(out, color); + for (holdings) |h| { + if (h.symbol) |s| { + try cli.setFg(out, color, cli.CLR_ACCENT); + try out.print(" {s:>6}", .{s}); + try cli.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", .{}); +} + +// ── Tests ──────────────────────────────────────────────────── + +test "printProfile minimal ETF no color" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const profile: zfin.EtfProfile = .{ + .symbol = "VTI", + .expense_ratio = 0.0003, + .dividend_yield = 0.015, + .total_holdings = 3500, + }; + try printProfile(profile, "VTI", false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "VTI") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Expense Ratio") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "0.03%") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "3500") != null); + // No ANSI when color=false + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} + +test "printProfile leveraged ETF shows warning" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const profile: zfin.EtfProfile = .{ + .symbol = "TQQQ", + .expense_ratio = 0.0095, + .leveraged = true, + }; + try printProfile(profile, "TQQQ", false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "Leveraged") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "YES") != null); +} diff --git a/src/cli/commands/history.zig b/src/cli/commands/history.zig new file mode 100644 index 0000000..1a9583e --- /dev/null +++ b/src/cli/commands/history.zig @@ -0,0 +1,85 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const cli = @import("../common.zig"); +const fmt = cli.fmt; + +pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { + const result = svc.getCandles(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try cli.stderrPrint("Error: TWELVEDATA_API_KEY not set.\n"); + return; + }, + else => { + try cli.stderrPrint("Error fetching data.\n"); + return; + }, + }; + defer allocator.free(result.data); + + if (result.source == .cached) try cli.stderrPrint("(using cached data)\n"); + + const all = result.data; + if (all.len == 0) return try cli.stderrPrint("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 cli.stderrPrint("No data available.\n"); + + try display(c, symbol, color, out); +} + +pub fn display(candles: []const zfin.Candle, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { + try cli.setBold(out, color); + try out.print("\nPrice History for {s} (last 30 days)\n", .{symbol}); + try cli.reset(out, color); + try out.print("========================================\n", .{}); + try cli.setFg(out, color, cli.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 cli.reset(out, color); + + for (candles) |candle| { + var db: [10]u8 = undefined; + var vb: [32]u8 = undefined; + try cli.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 cli.reset(out, color); + } + try out.print("\n{d} trading days\n\n", .{candles.len}); +} + +// ── Tests ──────────────────────────────────────────────────── + +test "display shows header and candle data" { + var buf: [4096]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_500_000 }, + .{ .date = .{ .days = 20001 }, .open = 103.0, .high = 107.0, .low = 102.0, .close = 101.0, .adj_close = 101.0, .volume = 2_000_000 }, + }; + try display(&candles, "AAPL", 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, "Date") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Open") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "2 trading days") != null); + // No ANSI when color=false + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} + +test "display empty candles" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const candles = [_]zfin.Candle{}; + try display(&candles, "XYZ", false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "XYZ") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "0 trading days") != null); +} diff --git a/src/cli/commands/lookup.zig b/src/cli/commands/lookup.zig new file mode 100644 index 0000000..2c6a0b2 --- /dev/null +++ b/src/cli/commands/lookup.zig @@ -0,0 +1,124 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const cli = @import("../common.zig"); + +pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool, out: *std.Io.Writer) !void { + if (!zfin.OpenFigi.isCusipLike(cusip)) { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{cusip}); + try cli.reset(out, color); + } + + try cli.stderrPrint("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 cli.stderrPrint("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}); + return; + } + + try display(results[0], cusip, color, out); + + // Also cache it + if (results[0].ticker) |ticker| { + svc.cacheCusipTicker(cusip, ticker); + } +} + +pub fn display(result: zfin.OpenFigi.FigiResult, cusip: []const u8, color: bool, out: *std.Io.Writer) !void { + if (result.ticker) |ticker| { + try cli.setBold(out, color); + try out.print("{s}", .{cusip}); + try cli.reset(out, color); + try out.print(" -> ", .{}); + try cli.setFg(out, color, cli.CLR_ACCENT); + try out.print("{s}", .{ticker}); + try cli.reset(out, color); + try out.print("\n", .{}); + + if (result.name) |name| { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" Name: {s}\n", .{name}); + try cli.reset(out, color); + } + if (result.security_type) |st| { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" Type: {s}\n", .{st}); + try cli.reset(out, color); + } + + try out.print("\n To use in portfolio: ticker::{s}\n", .{ticker}); + } else { + try out.print("No ticker found for CUSIP '{s}'\n", .{cusip}); + if (result.name) |name| { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" Name: {s}\n", .{name}); + try cli.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}); + } +} + +// ── Tests ──────────────────────────────────────────────────── + +test "display shows ticker mapping" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const result: zfin.OpenFigi.FigiResult = .{ + .ticker = "AAPL", + .name = "Apple Inc", + .security_type = "Common Stock", + .found = true, + }; + try display(result, "037833100", false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "037833100") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Apple Inc") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Common Stock") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "ticker::AAPL") != null); +} + +test "display shows no-ticker message" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const result: zfin.OpenFigi.FigiResult = .{ + .ticker = null, + .name = "Some Fund", + .security_type = null, + .found = true, + }; + try display(result, "123456789", false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "No ticker found") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Some Fund") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "mutual funds") != null); +} + +test "display no ANSI without color" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const result: zfin.OpenFigi.FigiResult = .{ + .ticker = "MSFT", + .name = null, + .security_type = null, + .found = true, + }; + try display(result, "594918104", false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} diff --git a/src/cli/commands/options.zig b/src/cli/commands/options.zig new file mode 100644 index 0000000..8eca6b3 --- /dev/null +++ b/src/cli/commands/options.zig @@ -0,0 +1,164 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const cli = @import("../common.zig"); +const fmt = cli.fmt; + +pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool, out: *std.Io.Writer) !void { + const result = svc.getOptions(symbol) catch |err| switch (err) { + zfin.DataError.FetchFailed => { + try cli.stderrPrint("Error fetching options data from CBOE.\n"); + return; + }, + else => { + try cli.stderrPrint("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 cli.stderrPrint("(using cached options data)\n"); + + if (ch.len == 0) { + try cli.stderrPrint("No options data found.\n"); + return; + } + + try display(out, allocator, ch, symbol, ntm, color); +} + +pub fn display(out: *std.Io.Writer, allocator: std.mem.Allocator, chains: []const zfin.OptionsChain, symbol: []const u8, ntm: usize, color: bool) !void { + if (chains.len == 0) return; + + try cli.setBold(out, color); + try out.print("\nOptions Chain for {s}\n", .{symbol}); + try cli.reset(out, color); + try out.print("========================================\n", .{}); + if (chains[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), chains.len, ntm }); + } else { + try out.print("{d} expiration(s) available\n", .{chains.len}); + } + + // Find nearest monthly expiration to auto-expand + var auto_expand_idx: ?usize = null; + for (chains, 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 chains.len > 0) auto_expand_idx = 0; + + const atm_price = if (chains[0].underlying_price) |p| p else @as(f64, 0); + + // List all expirations, expanding the nearest monthly + for (chains, 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 cli.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 cli.reset(out, color); + try out.print("\n", .{}); + + // Print calls + try printSection(out, allocator, "CALLS", chain.calls, atm_price, ntm, true, color); + try out.print("\n", .{}); + // Print puts + try printSection(out, allocator, "PUTS", chain.puts, atm_price, ntm, false, color); + } else { + try cli.setFg(out, color, if (is_monthly) cli.CLR_HEADER else cli.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 cli.reset(out, color); + try out.print("\n", .{}); + } + } + + try out.print("\n", .{}); +} + +pub fn printSection( + out: *std.Io.Writer, + allocator: std.mem.Allocator, + label: []const u8, + contracts: []const zfin.OptionContract, + atm_price: f64, + ntm: usize, + is_calls: bool, + color: bool, +) !void { + try cli.setBold(out, color); + try out.print(" {s}\n", .{label}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.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 cli.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}); + } +} + +// ── Tests ──────────────────────────────────────────────────── + +test "printSection shows header and contracts" { + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const calls = [_]zfin.OptionContract{ + .{ .contract_type = .call, .strike = 150.0, .expiration = .{ .days = 20100 }, .bid = 5.0, .ask = 5.50, .last_price = 5.25 }, + .{ .contract_type = .call, .strike = 155.0, .expiration = .{ .days = 20100 }, .bid = 2.0, .ask = 2.50, .last_price = 2.25 }, + }; + try printSection(&w, gpa.allocator(), "CALLS", &calls, 152.0, 8, true, false); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "CALLS") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Strike") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} + +test "display shows chain header no color" { + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const calls = [_]zfin.OptionContract{}; + const puts = [_]zfin.OptionContract{}; + const chains = [_]zfin.OptionsChain{ + .{ .underlying_symbol = "SPY", .underlying_price = 500.0, .expiration = .{ .days = 20100 }, .calls = &calls, .puts = &puts }, + }; + try display(&w, gpa.allocator(), &chains, "SPY", 8, false); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "Options Chain for SPY") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "1 expiration(s)") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} diff --git a/src/cli/commands/perf.zig b/src/cli/commands/perf.zig new file mode 100644 index 0000000..317b987 --- /dev/null +++ b/src/cli/commands/perf.zig @@ -0,0 +1,214 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const cli = @import("../common.zig"); +const fmt = cli.fmt; + +pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { + const result = svc.getTrailingReturns(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try cli.stderrPrint("Error: TWELVEDATA_API_KEY not set. Get a free key at https://twelvedata.com\n"); + return; + }, + else => { + try cli.stderrPrint("Error fetching data.\n"); + return; + }, + }; + defer allocator.free(result.candles); + defer if (result.dividends) |d| allocator.free(d); + + if (result.source == .cached) try cli.stderrPrint("(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(); + + try cli.setBold(out, color); + try out.print("\nTrailing Returns for {s}\n", .{symbol}); + try cli.reset(out, color); + try out.print("========================================\n", .{}); + try cli.setFg(out, color, cli.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 cli.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 cli.setBold(out, color); + try out.print("\nAs-of {s}:\n", .{end_date.format(&db)}); + try cli.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 cli.setBold(out, color); + try out.print("\nMonth-end ({s}):\n", .{month_end.format(&db)}); + try cli.reset(out, color); + } + try printReturnsTable(out, result.me_price, if (has_divs) result.me_total else null, color); + + if (!has_divs) { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("\nSet POLYGON_API_KEY for total returns with dividend reinvestment.\n", .{}); + try cli.reset(out, color); + } + try out.print("\n", .{}); +} + +pub fn printReturnsTable( + out: *std.Io.Writer, + price: zfin.performance.TrailingReturns, + total: ?zfin.performance.TrailingReturns, + color: bool, +) !void { + const has_total = total != null; + + try cli.setFg(out, color, cli.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 cli.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 cli.setGainLoss(out, color, val); + try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)}); + try cli.reset(out, color); + } else { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" {s:>13}", .{"N/A"}); + try cli.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 cli.setGainLoss(out, color, val); + try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)}); + try cli.reset(out, color); + } else { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" {s:>13}", .{"N/A"}); + try cli.reset(out, color); + } + } + + if (period.years > 1) { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" ann.", .{}); + try cli.reset(out, color); + } + try out.print("\n", .{}); + } +} + +// ── Tests ──────────────────────────────────────────────────── + +test "printReturnsTable price-only with no data" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const empty: zfin.performance.TrailingReturns = .{ + .one_year = null, + .three_year = null, + .five_year = null, + .ten_year = null, + }; + try printReturnsTable(&w, empty, null, false); + const out = w.buffered(); + // Should contain header and N/A for all periods + try std.testing.expect(std.mem.indexOf(u8, out, "Price Only") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "N/A") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "1-Year") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "10-Year") != null); +} + +test "printReturnsTable price-only no ANSI without color" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const empty: zfin.performance.TrailingReturns = .{ + .one_year = null, + .three_year = null, + .five_year = null, + .ten_year = null, + }; + try printReturnsTable(&w, empty, null, false); + const out = w.buffered(); + // No ANSI escape sequences when color=false + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} + +test "printReturnsTable with total return columns" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const empty: zfin.performance.TrailingReturns = .{ + .one_year = null, + .three_year = null, + .five_year = null, + .ten_year = null, + }; + try printReturnsTable(&w, empty, empty, false); + const out = w.buffered(); + // Should contain both column headers + try std.testing.expect(std.mem.indexOf(u8, out, "Price Only") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Total Return") != null); +} + +test "printReturnsTable with actual returns" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const returns: zfin.performance.TrailingReturns = .{ + .one_year = .{ .total_return = 0.15, .annualized_return = null, .from = .{ .days = 0 }, .to = .{ .days = 365 } }, + .three_year = null, + .five_year = null, + .ten_year = null, + }; + try printReturnsTable(&w, returns, null, false); + const out = w.buffered(); + // 1-Year should show a value, not N/A + // Check that the line with "1-Year" does NOT have N/A right after it + // (crude check: the output should have fewer N/A occurrences than with all nulls) + try std.testing.expect(std.mem.indexOf(u8, out, "1-Year") != null); + // 3-year should still show N/A + try std.testing.expect(std.mem.indexOf(u8, out, "ann.") != null); +} diff --git a/src/cli/commands/portfolio.zig b/src/cli/commands/portfolio.zig new file mode 100644 index 0000000..95def88 --- /dev/null +++ b/src/cli/commands/portfolio.zig @@ -0,0 +1,800 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const cli = @import("../common.zig"); +const fmt = cli.fmt; + +pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void { + // Load portfolio from SRF file + const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| { + try cli.stderrPrint("Error reading portfolio file: "); + try cli.stderrPrint(@errorName(err)); + try cli.stderrPrint("\n"); + return; + }; + defer allocator.free(data); + + var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch { + try cli.stderrPrint("Error parsing portfolio file.\n"); + return; + }; + defer portfolio.deinit(); + + if (portfolio.lots.len == 0) { + try cli.stderrPrint("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 cli.stderrPrint("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 cli.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 cli.stderrRateLimitWait(w, color); + } + } + try cli.stderrProgress(sym, " (fetching)", loaded_count, all_syms_count, color); + + const result = svc.getCandles(sym) catch { + fail_count += 1; + try cli.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 cli.stderrProgress(sym, " (cached)", loaded_count, all_syms_count, color); + continue; + } + + const wait_s = svc.estimateWaitSeconds(); + if (wait_s) |w| { + if (w > 0) { + try cli.stderrRateLimitWait(w, color); + } + } + try cli.stderrProgress(sym, " (fetching)", loaded_count, all_syms_count, color); + + const result = svc.getCandles(sym) catch { + try cli.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 cli.stderrPrint(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 cli.stderrPrint(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 cli.stderrPrint("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; + } + } + + // Header with summary + try cli.setBold(out, color); + try out.print("\nPortfolio Summary ({s})\n", .{file_path}); + try cli.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 cli.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 cli.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 cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" Lots: {d} open, {d} closed Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len }); + try cli.reset(out, color); + + // Historical portfolio value snapshots + { + var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator); + defer { + var it = candle_map.valueIterator(); + while (it.next()) |v| allocator.free(v.*); + candle_map.deinit(); + } + const stock_syms = try portfolio.stockSymbols(allocator); + defer allocator.free(stock_syms); + for (stock_syms) |sym| { + if (svc.getCachedCandles(sym)) |cs| { + try candle_map.put(sym, cs); + } + } + if (candle_map.count() > 0) { + const snapshots = zfin.risk.computeHistoricalSnapshots( + fmt.todayDate(), + positions, + prices, + candle_map, + ); + try out.print(" Historical: ", .{}); + try cli.setFg(out, color, cli.CLR_MUTED); + for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| { + const snap = snapshots[pi]; + if (snap.position_count == 0) { + try out.print(" {s}: --", .{period.label()}); + } else { + const pct = snap.changePct(); + try cli.setGainLoss(out, color, pct); + if (pct >= 0) { + try out.print(" {s}: +{d:.1}%", .{ period.label(), pct }); + } else { + try out.print(" {s}: {d:.1}%", .{ period.label(), pct }); + } + } + if (pi < zfin.risk.HistoricalPeriod.all.len - 1) try out.print(" ", .{}); + } + try cli.reset(out, color); + try out.print("\n", .{}); + } + } + + // Column headers + try out.print("\n", .{}); + try cli.setFg(out, color, cli.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 cli.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; + } + + if (a.is_manual_price) try cli.setFg(out, color, cli.CLR_WARNING); + try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{ + a.display_symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost), + }); + try out.print("{s:>10}", .{fmt.fmtMoney2(&price_buf2, a.current_price)}); + try out.print(" {s:>16} ", .{fmt.fmtMoney(&mv_buf, a.market_value)}); + try cli.setGainLoss(out, color, a.unrealized_pnl); + try out.print("{s}{s:>13}", .{ sign, gl_money }); + if (a.is_manual_price) { + try cli.setFg(out, color, cli.CLR_WARNING); + } else { + try cli.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}); + } + } + if (a.is_manual_price) try cli.reset(out, color); + 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 printLotRow(out, color, lot, a.current_price); + } + } else { + // Show non-DRIP lots individually + for (lots_for_sym.items) |lot| { + if (!lot.drip) { + try printLotRow(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 cli.setFg(out, color, cli.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 cli.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 cli.setFg(out, color, cli.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 cli.reset(out, color); + } + } + } + } + + // Totals line + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8}\n", .{ + "", "", "", "", "", "", "", + }); + try cli.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 cli.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 cli.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 cli.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 cli.reset(out, color); + } + + // Options section + if (portfolio.hasType(.option)) { + try out.print("\n", .{}); + try cli.setBold(out, color); + try out.print(" Options\n", .{}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.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 cli.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 cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" }); + try cli.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 cli.setBold(out, color); + try out.print(" Certificates of Deposit\n", .{}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.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 cli.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 cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" {s:->12} {s:->14}\n", .{ "", "" }); + try cli.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 cli.setBold(out, color); + try out.print(" Cash\n", .{}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.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 cli.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 cli.setFg(out, color, cli.CLR_MUTED); + try out.print("{s}\n", .{fmt.fmtCashSep(&sep_buf)}); + try cli.reset(out, color); + var total_buf: [80]u8 = undefined; + try cli.setBold(out, color); + try out.print("{s}\n", .{fmt.fmtCashTotal(&total_buf, portfolio.totalCash())}); + try cli.reset(out, color); + } + + // Illiquid assets section + if (portfolio.hasType(.illiquid)) { + try out.print("\n", .{}); + try cli.setBold(out, color); + try out.print(" Illiquid Assets\n", .{}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.CLR_MUTED); + var il_hdr_buf: [80]u8 = undefined; + try out.print("{s}\n", .{fmt.fmtIlliquidHeader(&il_hdr_buf)}); + var il_sep_buf1: [80]u8 = undefined; + try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf1)}); + try cli.reset(out, color); + + for (portfolio.lots) |lot| { + if (lot.lot_type != .illiquid) continue; + var il_row_buf: [160]u8 = undefined; + try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.symbol, lot.shares, lot.note)}); + } + // Illiquid total + var il_sep_buf2: [80]u8 = undefined; + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf2)}); + try cli.reset(out, color); + var il_total_buf: [80]u8 = undefined; + try cli.setBold(out, color); + try out.print("{s}\n", .{fmt.fmtIlliquidTotal(&il_total_buf, portfolio.totalIlliquid())}); + try cli.reset(out, color); + } + + // Net Worth (if illiquid assets exist) + if (portfolio.hasType(.illiquid)) { + const illiquid_total = portfolio.totalIlliquid(); + const net_worth = summary.total_value + illiquid_total; + var nw_buf: [24]u8 = undefined; + var liq_buf: [24]u8 = undefined; + var il_buf: [24]u8 = undefined; + try out.print("\n", .{}); + try cli.setBold(out, color); + try out.print(" Net Worth: {s} (Liquid: {s} Illiquid: {s})\n", .{ + fmt.fmtMoney(&nw_buf, net_worth), + fmt.fmtMoney(&liq_buf, summary.total_value), + fmt.fmtMoney(&il_buf, illiquid_total), + }); + try cli.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: *std.Io.Writer, c: bool, s: *zfin.DataService, a2: std.mem.Allocator, sym: []const u8, any: *bool) !void { + if (!any.*) { + try o.print("\n", .{}); + try cli.setBold(o, c); + try o.print(" Watchlist:\n", .{}); + try cli.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 cli.setBold(out, color); + try out.print(" Risk Metrics (from cached price data):\n", .{}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.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 cli.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 cli.setFg(out, color, cli.CLR_NEGATIVE); + try out.print("{d:>9.1}%", .{metrics.max_drawdown * 100.0}); + try cli.reset(out, color); + if (metrics.drawdown_trough) |dt| { + var db: [10]u8 = undefined; + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" (trough {s})", .{dt.format(&db)}); + try cli.reset(out, color); + } + try out.print("\n", .{}); + } + } + } + } + + try out.print("\n", .{}); +} + +pub fn printLotRow(out: *std.Io.Writer, 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 cli.setFg(out, color, cli.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 cli.reset(out, color); + try cli.setGainLoss(out, color, gl); + try out.print("{s}{s:>13}", .{ lot_sign, lot_gl_money }); + try cli.reset(out, color); + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" {s:>8} {s} {s} {s}\n", .{ "", date_str, indicator, acct_col }); + try cli.reset(out, color); +} diff --git a/src/cli/commands/quote.zig b/src/cli/commands/quote.zig new file mode 100644 index 0000000..5a6f3a7 --- /dev/null +++ b/src/cli/commands/quote.zig @@ -0,0 +1,203 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const cli = @import("../common.zig"); +const fmt = cli.fmt; + +/// 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 run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { + // Fetch candle data for chart and history + const candle_result = svc.getCandles(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try cli.stderrPrint("Error: TWELVEDATA_API_KEY not set.\n"); + return; + }, + else => { + try cli.stderrPrint("Error fetching candle data.\n"); + return; + }, + }; + defer allocator.free(candle_result.data); + const candles = candle_result.data; + + // Fetch real-time quote + var quote: ?QuoteData = null; + + 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(); + 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 fmt.todayDate(), + }; + } else |_| {} + } else |_| {} + } + + try display(allocator, candles, quote, symbol, color, out); +} + +pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { + const has_quote = quote != null; + + // Header + try cli.setBold(out, color); + if (quote) |q| { + var price_buf: [24]u8 = undefined; + try out.print("\n{s} {s}\n", .{ symbol, fmt.fmtMoney(&price_buf, q.price) }); + } 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 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 fmt.todayDate(); + 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 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 cli.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 cli.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, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null; + if (chart) |*ch| { + defer ch.deinit(allocator); + try fmt.writeBrailleAnsi(out, ch, color, cli.CLR_MUTED); + } + } + + // Recent history table (last 20 candles) + if (candles.len > 0) { + try out.print("\n", .{}); + try cli.setBold(out, color); + try out.print(" Recent History:\n", .{}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.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 cli.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 cli.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 cli.reset(out, color); + } + try out.print("\n {d} trading days shown\n", .{candles[start_idx..].len}); + } + + try out.print("\n", .{}); +} + +// ── Tests ──────────────────────────────────────────────────── + +test "display with candles only" { + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + 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(gpa.allocator(), &candles, null, "AAPL", 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); + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + 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(gpa.allocator(), &candles, quote, "AAPL", 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); + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + 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(gpa.allocator(), &candles, null, "SPY", false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} diff --git a/src/cli/commands/splits.zig b/src/cli/commands/splits.zig new file mode 100644 index 0000000..9449672 --- /dev/null +++ b/src/cli/commands/splits.zig @@ -0,0 +1,84 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const cli = @import("../common.zig"); +const fmt = cli.fmt; + +pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { + const result = svc.getSplits(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try cli.stderrPrint("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); + return; + }, + else => { + try cli.stderrPrint("Error fetching split data.\n"); + return; + }, + }; + defer allocator.free(result.data); + + if (result.source == .cached) try cli.stderrPrint("(using cached split data)\n"); + + try display(result.data, symbol, color, out); +} + +pub fn display(splits: []const zfin.Split, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { + try cli.setBold(out, color); + try out.print("\nSplit History for {s}\n", .{symbol}); + try cli.reset(out, color); + try out.print("========================================\n", .{}); + + if (splits.len == 0) { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" No splits found.\n\n", .{}); + try cli.reset(out, color); + return; + } + + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("{s:>12} {s:>10}\n", .{ "Date", "Ratio" }); + try out.print("{s:->12} {s:->10}\n", .{ "", "" }); + try cli.reset(out, color); + + for (splits) |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", .{splits.len}); +} + +// ── Tests ──────────────────────────────────────────────────── + +test "display shows split data" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const splits = [_]zfin.Split{ + .{ .date = .{ .days = 18000 }, .numerator = 4, .denominator = 1 }, + .{ .date = .{ .days = 15000 }, .numerator = 7, .denominator = 1 }, + }; + try display(&splits, "AAPL", 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, "4:1") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "7:1") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "2 split(s)") != null); +} + +test "display shows empty message" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const splits = [_]zfin.Split{}; + try display(&splits, "BRK.A", false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "No splits found") != null); +} + +test "display no ANSI without color" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const splits = [_]zfin.Split{ + .{ .date = .{ .days = 18000 }, .numerator = 2, .denominator = 1 }, + }; + try display(&splits, "GOOG", false, &w); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} diff --git a/src/cli/common.zig b/src/cli/common.zig new file mode 100644 index 0000000..85d1498 --- /dev/null +++ b/src/cli/common.zig @@ -0,0 +1,167 @@ +const std = @import("std"); +const zfin = @import("zfin"); +pub const fmt = zfin.format; + +// ── Default CLI colors (match TUI default Monokai theme) ───── +pub const CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive) +pub const CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; // losses (TUI .negative) +pub const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .text_muted) +pub const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent) +pub const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill) +pub const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning) + +// ── ANSI color helpers ─────────────────────────────────────── + +pub fn setFg(out: *std.Io.Writer, c: bool, rgb: [3]u8) !void { + if (c) try fmt.ansiSetFg(out, rgb[0], rgb[1], rgb[2]); +} + +pub fn setBold(out: *std.Io.Writer, c: bool) !void { + if (c) try fmt.ansiBold(out); +} + +pub fn reset(out: *std.Io.Writer, c: bool) !void { + if (c) try fmt.ansiReset(out); +} + +pub fn setGainLoss(out: *std.Io.Writer, c: bool, value: f64) !void { + if (c) { + if (value >= 0) + try fmt.ansiSetFg(out, CLR_POSITIVE[0], CLR_POSITIVE[1], CLR_POSITIVE[2]) + else + try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]); + } +} + +// ── Stderr helpers ─────────────────────────────────────────── + +pub fn stderrPrint(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(); +} + +/// Print progress line to stderr: " [N/M] SYMBOL (status)" +pub 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 +pub 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_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[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(); +} + +// ── Tests ──────────────────────────────────────────────────── + +test "setFg emits ANSI when color enabled" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try setFg(&w, true, CLR_POSITIVE); + const out = w.buffered(); + // Should contain ESC[ sequence with RGB values + try std.testing.expect(out.len > 0); + try std.testing.expect(std.mem.startsWith(u8, out, "\x1b[")); +} + +test "setFg is no-op when color disabled" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try setFg(&w, false, CLR_POSITIVE); + try std.testing.expectEqual(@as(usize, 0), w.buffered().len); +} + +test "setBold emits ANSI when color enabled" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try setBold(&w, true); + const out = w.buffered(); + try std.testing.expect(out.len > 0); + try std.testing.expect(std.mem.startsWith(u8, out, "\x1b[")); +} + +test "setBold is no-op when color disabled" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try setBold(&w, false); + try std.testing.expectEqual(@as(usize, 0), w.buffered().len); +} + +test "reset emits ANSI when color enabled" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try reset(&w, true); + const out = w.buffered(); + try std.testing.expect(out.len > 0); + try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); +} + +test "reset is no-op when color disabled" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try reset(&w, false); + try std.testing.expectEqual(@as(usize, 0), w.buffered().len); +} + +test "setGainLoss uses positive color for gains" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try setGainLoss(&w, true, 10.0); + const out = w.buffered(); + try std.testing.expect(out.len > 0); + // Should contain the positive green color RGB + try std.testing.expect(std.mem.indexOf(u8, out, "127") != null); +} + +test "setGainLoss uses negative color for losses" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try setGainLoss(&w, true, -5.0); + const out = w.buffered(); + try std.testing.expect(out.len > 0); + // Should contain the negative red color RGB + try std.testing.expect(std.mem.indexOf(u8, out, "224") != null); +} + +test "setGainLoss is no-op when color disabled" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try setGainLoss(&w, false, 10.0); + try std.testing.expectEqual(@as(usize, 0), w.buffered().len); +} + +test "setGainLoss treats zero as positive" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try setGainLoss(&w, true, 0.0); + const out = w.buffered(); + // Should use positive (green) color for zero + try std.testing.expect(std.mem.indexOf(u8, out, "127") != null); +} diff --git a/src/cli/main.zig b/src/cli/main.zig index 4c7bd69..81450be 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -2,6 +2,7 @@ const std = @import("std"); const zfin = @import("zfin"); const fmt = zfin.format; const tui = @import("tui"); +const cli = @import("common.zig"); const usage = \\Usage: zfin [options] @@ -18,7 +19,7 @@ const usage = \\ etf Show ETF profile (holdings, sectors, expense ratio) \\ portfolio [FILE] Load and analyze a portfolio (default: portfolio.srf) \\ analysis [FILE] Show portfolio analysis (default: portfolio.srf) - \\ enrich Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) + \\ enrich Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ lookup Look up CUSIP to ticker via OpenFIGI \\ cache stats Show cache statistics \\ cache clear Clear all cached data @@ -58,14 +59,6 @@ const usage = \\ ; -// ── Default CLI colors (match TUI default Monokai theme) ───── -const CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive) -const CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; // losses (TUI .negative) -const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .text_muted) -const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent) -const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill) -const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning) - pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -113,22 +106,22 @@ pub fn main() !void { defer svc.deinit(); if (std.mem.eql(u8, command, "perf")) { - if (args.len < 3) return try stderrPrint("Error: 'perf' requires a symbol argument\n"); - try cmdPerf(allocator, &svc, args[2], color, out); + if (args.len < 3) return try cli.stderrPrint("Error: 'perf' requires a symbol argument\n"); + try commands.perf.run(allocator, &svc, args[2], color, out); } else if (std.mem.eql(u8, command, "quote")) { - if (args.len < 3) return try stderrPrint("Error: 'quote' requires a symbol argument\n"); - try cmdQuote(allocator, config, &svc, args[2], color, out); + if (args.len < 3) return try cli.stderrPrint("Error: 'quote' requires a symbol argument\n"); + try commands.quote.run(allocator, config, &svc, args[2], color, out); } else if (std.mem.eql(u8, command, "history")) { - if (args.len < 3) return try stderrPrint("Error: 'history' requires a symbol argument\n"); - try cmdHistory(allocator, &svc, args[2], color, out); + if (args.len < 3) return try cli.stderrPrint("Error: 'history' requires a symbol argument\n"); + try commands.history.run(allocator, &svc, args[2], color, out); } else if (std.mem.eql(u8, command, "divs")) { - if (args.len < 3) return try stderrPrint("Error: 'divs' requires a symbol argument\n"); - try cmdDivs(allocator, &svc, config, args[2], color, out); + if (args.len < 3) return try cli.stderrPrint("Error: 'divs' requires a symbol argument\n"); + try commands.divs.run(allocator, &svc, config, args[2], color, out); } else if (std.mem.eql(u8, command, "splits")) { - if (args.len < 3) return try stderrPrint("Error: 'splits' requires a symbol argument\n"); - try cmdSplits(allocator, &svc, args[2], color, out); + if (args.len < 3) return try cli.stderrPrint("Error: 'splits' requires a symbol argument\n"); + try commands.splits.run(allocator, &svc, args[2], color, out); } else if (std.mem.eql(u8, command, "options")) { - if (args.len < 3) return try stderrPrint("Error: 'options' requires a symbol argument\n"); + if (args.len < 3) return try cli.stderrPrint("Error: 'options' requires a symbol argument\n"); // Parse --ntm flag var ntm: usize = 8; var ai: usize = 3; @@ -138,13 +131,13 @@ pub fn main() !void { ntm = std.fmt.parseInt(usize, args[ai], 10) catch 8; } } - try cmdOptions(allocator, &svc, args[2], ntm, color, out); + try commands.options.run(allocator, &svc, args[2], ntm, color, out); } else if (std.mem.eql(u8, command, "earnings")) { - if (args.len < 3) return try stderrPrint("Error: 'earnings' requires a symbol argument\n"); - try cmdEarnings(allocator, &svc, args[2], color, out); + if (args.len < 3) return try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n"); + try commands.earnings.run(allocator, &svc, args[2], color, out); } else if (std.mem.eql(u8, command, "etf")) { - if (args.len < 3) return try stderrPrint("Error: 'etf' requires a symbol argument\n"); - try cmdEtf(allocator, &svc, args[2], color, out); + if (args.len < 3) return try cli.stderrPrint("Error: 'etf' requires a symbol argument\n"); + try commands.etf.run(allocator, &svc, args[2], color, out); } else if (std.mem.eql(u8, command, "portfolio")) { // Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf) var watchlist_path: ?[]const u8 = null; @@ -163,16 +156,16 @@ pub fn main() !void { file_path = args[pi]; } } - try cmdPortfolio(allocator, config, &svc, file_path, watchlist_path, force_refresh, color, out); + try commands.portfolio.run(allocator, config, &svc, file_path, watchlist_path, force_refresh, color, out); } else if (std.mem.eql(u8, command, "lookup")) { - if (args.len < 3) return try stderrPrint("Error: 'lookup' requires a CUSIP argument\n"); - try cmdLookup(allocator, &svc, args[2], color, out); + if (args.len < 3) return try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n"); + try commands.lookup.run(allocator, &svc, args[2], color, out); } else if (std.mem.eql(u8, command, "cache")) { - if (args.len < 3) return try stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n"); - try cmdCache(allocator, config, args[2], out); + if (args.len < 3) return try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n"); + try commands.cache.run(allocator, config, args[2], out); } else if (std.mem.eql(u8, command, "enrich")) { - if (args.len < 3) return try stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n"); - try cmdEnrich(allocator, config, args[2], out); + if (args.len < 3) return try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n"); + try commands.enrich.run(allocator, config, args[2], out); } else if (std.mem.eql(u8, command, "analysis")) { // File path is first non-flag arg (default: portfolio.srf) var analysis_file: []const u8 = "portfolio.srf"; @@ -182,2168 +175,46 @@ pub fn main() !void { break; } } - try cmdAnalysis(allocator, config, &svc, analysis_file, color, out); + try commands.analysis.run(allocator, config, &svc, analysis_file, color, out); } else { - try stderrPrint("Unknown command. Run 'zfin help' for usage.\n"); + try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n"); } // Single flush for all stdout output try out.flush(); } -// ── ANSI color helpers ─────────────────────────────────────── +// ── Command modules ────────────────────────────────────────── +const commands = struct { + const perf = @import("commands/perf.zig"); + const quote = @import("commands/quote.zig"); + const history = @import("commands/history.zig"); + const divs = @import("commands/divs.zig"); + const splits = @import("commands/splits.zig"); + const options = @import("commands/options.zig"); + const earnings = @import("commands/earnings.zig"); + const etf = @import("commands/etf.zig"); + const portfolio = @import("commands/portfolio.zig"); + const lookup = @import("commands/lookup.zig"); + const cache = @import("commands/cache.zig"); + const analysis = @import("commands/analysis.zig"); + const enrich = @import("commands/enrich.zig"); +}; -fn setFg(out: *std.Io.Writer, c: bool, rgb: [3]u8) !void { - if (c) try fmt.ansiSetFg(out, rgb[0], rgb[1], rgb[2]); -} - -fn setBold(out: *std.Io.Writer, c: bool) !void { - if (c) try fmt.ansiBold(out); -} - -fn reset(out: *std.Io.Writer, c: bool) !void { - if (c) try fmt.ansiReset(out); -} - -fn setGainLoss(out: *std.Io.Writer, c: bool, value: f64) !void { - if (c) { - if (value >= 0) - try fmt.ansiSetFg(out, CLR_POSITIVE[0], CLR_POSITIVE[1], CLR_POSITIVE[2]) - else - try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]); - } -} - -// ── Commands ───────────────────────────────────────────────── - -fn cmdPerf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { - const result = svc.getTrailingReturns(symbol) catch |err| switch (err) { - zfin.DataError.NoApiKey => { - try stderrPrint("Error: TWELVEDATA_API_KEY not set. Get a free key at https://twelvedata.com\n"); - return; - }, - else => { - try stderrPrint("Error fetching data.\n"); - return; - }, - }; - defer allocator.free(result.candles); - defer if (result.dividends) |d| allocator.free(d); - - if (result.source == .cached) try stderrPrint("(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(); - - 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", .{}); -} - -fn printReturnsTable( - out: *std.Io.Writer, - 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, out: *std.Io.Writer) !void { - // Fetch candle data for chart and history - const candle_result = svc.getCandles(symbol) catch |err| switch (err) { - zfin.DataError.NoApiKey => { - try stderrPrint("Error: TWELVEDATA_API_KEY not set.\n"); - return; - }, - else => { - try stderrPrint("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 |_| {} - } - - // 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_POSITIVE, CLR_NEGATIVE) 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", .{}); -} - -fn cmdHistory(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { - const result = svc.getCandles(symbol) catch |err| switch (err) { - zfin.DataError.NoApiKey => { - try stderrPrint("Error: TWELVEDATA_API_KEY not set.\n"); - return; - }, - else => { - try stderrPrint("Error fetching data.\n"); - return; - }, - }; - defer allocator.free(result.data); - - if (result.source == .cached) try stderrPrint("(using cached data)\n"); - - const all = result.data; - if (all.len == 0) return try stderrPrint("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 stderrPrint("No data available.\n"); - - 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}); -} - -fn cmdDivs(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { - const result = svc.getDividends(symbol) catch |err| switch (err) { - zfin.DataError.NoApiKey => { - try stderrPrint("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); - return; - }, - else => { - try stderrPrint("Error fetching dividend data.\n"); - return; - }, - }; - defer allocator.free(result.data); - - if (result.source == .cached) try stderrPrint("(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 |_| {} - } - - 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); - 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", .{}); -} - -fn cmdSplits(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { - const result = svc.getSplits(symbol) catch |err| switch (err) { - zfin.DataError.NoApiKey => { - try stderrPrint("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); - return; - }, - else => { - try stderrPrint("Error fetching split data.\n"); - return; - }, - }; - defer allocator.free(result.data); - - if (result.source == .cached) try stderrPrint("(using cached split data)\n"); - - const sp = result.data; - - 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); - 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}); -} - -fn cmdOptions(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool, out: *std.Io.Writer) !void { - const result = svc.getOptions(symbol) catch |err| switch (err) { - zfin.DataError.FetchFailed => { - try stderrPrint("Error fetching options data from CBOE.\n"); - return; - }, - else => { - try stderrPrint("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 stderrPrint("(using cached options data)\n"); - - if (ch.len == 0) { - try stderrPrint("No options data found.\n"); - return; - } - - 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", .{}); -} - -fn printOptionsSection( - out: *std.Io.Writer, - 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, out: *std.Io.Writer) !void { - const result = svc.getEarnings(symbol) catch |err| switch (err) { - zfin.DataError.NoApiKey => { - try stderrPrint("Error: FINNHUB_API_KEY not set. Get a free key at https://finnhub.io\n"); - return; - }, - else => { - try stderrPrint("Error fetching earnings data.\n"); - return; - }, - }; - defer allocator.free(result.data); - - if (result.source == .cached) try stderrPrint("(using cached earnings data)\n"); - - const ev = result.data; - - 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); - 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_POSITIVE); - } else { - try setFg(out, color, CLR_NEGATIVE); - } - - 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}); -} - -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, out: *std.Io.Writer) !void { - const result = svc.getEtfProfile(symbol) catch |err| switch (err) { - zfin.DataError.NoApiKey => { - try stderrPrint("Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n"); - return; - }, - else => { - try stderrPrint("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 stderrPrint("(using cached ETF profile)\n"); - - try printEtfProfile(profile, symbol, color, out); -} - -fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { - 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_NEGATIVE); - 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", .{}); -} - -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, out: *std.Io.Writer) !void { - // Load portfolio from SRF file - const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| { - try stderrPrint("Error reading portfolio file: "); - try stderrPrint(@errorName(err)); - try stderrPrint("\n"); - return; - }; - defer allocator.free(data); - - var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch { - try stderrPrint("Error parsing portfolio file.\n"); - return; - }; - defer portfolio.deinit(); - - if (portfolio.lots.len == 0) { - try stderrPrint("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 stderrPrint("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 stderrPrint(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 stderrPrint(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 stderrPrint("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; - } - } - - // 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); - - // Historical portfolio value snapshots - { - var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator); - defer { - var it = candle_map.valueIterator(); - while (it.next()) |v| allocator.free(v.*); - candle_map.deinit(); - } - const stock_syms = try portfolio.stockSymbols(allocator); - defer allocator.free(stock_syms); - for (stock_syms) |sym| { - if (svc.getCachedCandles(sym)) |cs| { - try candle_map.put(sym, cs); - } - } - if (candle_map.count() > 0) { - const snapshots = zfin.risk.computeHistoricalSnapshots( - fmt.todayDate(), - positions, - prices, - candle_map, - ); - try out.print(" Historical: ", .{}); - try setFg(out, color, CLR_MUTED); - for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| { - const snap = snapshots[pi]; - if (snap.position_count == 0) { - try out.print(" {s}: --", .{period.label()}); - } else { - const pct = snap.changePct(); - try setGainLoss(out, color, pct); - if (pct >= 0) { - try out.print(" {s}: +{d:.1}%", .{ period.label(), pct }); - } else { - try out.print(" {s}: {d:.1}%", .{ period.label(), pct }); - } - } - if (pi < zfin.risk.HistoricalPeriod.all.len - 1) try out.print(" ", .{}); - } - try reset(out, color); - try out.print("\n", .{}); - } - } - - // 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; - } - - if (a.is_manual_price) try setFg(out, color, CLR_WARNING); - try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{ - a.display_symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost), - }); - try out.print("{s:>10}", .{fmt.fmtMoney2(&price_buf2, a.current_price)}); - 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 }); - if (a.is_manual_price) { - try setFg(out, color, CLR_WARNING); - } else { - 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}); - } - } - if (a.is_manual_price) try reset(out, color); - 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 printLotRow(out, color, lot, a.current_price); - } - } else { - // Show non-DRIP lots individually - for (lots_for_sym.items) |lot| { - if (!lot.drip) { - try printLotRow(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); - } - - // Illiquid assets section - if (portfolio.hasType(.illiquid)) { - try out.print("\n", .{}); - try setBold(out, color); - try out.print(" Illiquid Assets\n", .{}); - try reset(out, color); - try setFg(out, color, CLR_MUTED); - var il_hdr_buf: [80]u8 = undefined; - try out.print("{s}\n", .{fmt.fmtIlliquidHeader(&il_hdr_buf)}); - var il_sep_buf1: [80]u8 = undefined; - try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf1)}); - try reset(out, color); - - for (portfolio.lots) |lot| { - if (lot.lot_type != .illiquid) continue; - var il_row_buf: [160]u8 = undefined; - try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.symbol, lot.shares, lot.note)}); - } - // Illiquid total - var il_sep_buf2: [80]u8 = undefined; - try setFg(out, color, CLR_MUTED); - try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf2)}); - try reset(out, color); - var il_total_buf: [80]u8 = undefined; - try setBold(out, color); - try out.print("{s}\n", .{fmt.fmtIlliquidTotal(&il_total_buf, portfolio.totalIlliquid())}); - try reset(out, color); - } - - // Net Worth (if illiquid assets exist) - if (portfolio.hasType(.illiquid)) { - const illiquid_total = portfolio.totalIlliquid(); - const net_worth = summary.total_value + illiquid_total; - var nw_buf: [24]u8 = undefined; - var liq_buf: [24]u8 = undefined; - var il_buf: [24]u8 = undefined; - try out.print("\n", .{}); - try setBold(out, color); - try out.print(" Net Worth: {s} (Liquid: {s} Illiquid: {s})\n", .{ - fmt.fmtMoney(&nw_buf, net_worth), - fmt.fmtMoney(&liq_buf, summary.total_value), - fmt.fmtMoney(&il_buf, illiquid_total), - }); - 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: *std.Io.Writer, 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_NEGATIVE); - 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", .{}); -} - -fn printLotRow(out: *std.Io.Writer, 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, out: *std.Io.Writer) !void { - 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 stderrPrint("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 stderrPrint("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}); - 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}); - } -} - -fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8, out: *std.Io.Writer) !void { - if (std.mem.eql(u8, subcommand, "stats")) { - 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", .{}); - return; - }; - var dir = std.fs.cwd().openDir(config.cache_dir, .{ .iterate = true }) catch { - try out.print(" (empty -- no cached data)\n", .{}); - 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}); - } - } else if (std.mem.eql(u8, subcommand, "clear")) { - var store = zfin.cache.Store.init(allocator, config.cache_dir); - try store.clearAll(); - try out.writeAll("Cache cleared.\n"); - } else { - try stderrPrint("Unknown cache subcommand. Use 'stats' or 'clear'.\n"); - } -} - -/// CLI `analysis` command: show portfolio analysis breakdowns. -fn cmdAnalysis(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, color: bool, out: *std.Io.Writer) !void { - _ = config; - - // Load portfolio - const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch { - try stderrPrint("Error: Cannot read portfolio file\n"); - return; - }; - defer allocator.free(file_data); - - var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch { - try stderrPrint("Error: Cannot parse portfolio file\n"); - return; - }; - defer portfolio.deinit(); - - const positions = try portfolio.positions(allocator); - defer allocator.free(positions); - - // Build prices map from cache - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - var manual_price_set = std.StringHashMap(void).init(allocator); - defer manual_price_set.deinit(); - - // First pass: try cached candle prices + manual prices from lots - for (positions) |pos| { - if (pos.shares <= 0) continue; - // Try cached candles (latest close) - if (svc.getCachedCandles(pos.symbol)) |cs| { - defer allocator.free(cs); - if (cs.len > 0) { - try prices.put(pos.symbol, cs[cs.len - 1].close); - continue; - } - } - // Try manual price from lots - for (portfolio.lots) |lot| { - if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), pos.symbol)) { - if (lot.price) |mp| { - try prices.put(pos.symbol, mp); - try manual_price_set.put(pos.symbol, {}); - break; - } - } - } - } - // Fallback to avg_cost - 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 stderrPrint("Error computing portfolio summary.\n"); - return; - }; - defer summary.deinit(allocator); - - // Include non-stock assets in grand total (same as portfolio command) - 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; - - // Load classification metadata - const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0; - const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch return; - defer allocator.free(meta_path); - - const meta_data = std.fs.cwd().readFileAlloc(allocator, meta_path, 1024 * 1024) catch { - try stderrPrint("Error: No metadata.srf found. Run: zfin enrich > metadata.srf\n"); - return; - }; - defer allocator.free(meta_data); - - var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch { - try stderrPrint("Error: Cannot parse metadata.srf\n"); - return; - }; - defer cm.deinit(); - - // Load account tax type metadata (optional) - const acct_path = std.fmt.allocPrint(allocator, "{s}accounts.srf", .{file_path[0..dir_end]}) catch return; - defer allocator.free(acct_path); - - var acct_map_opt: ?zfin.analysis.AccountMap = null; - const acct_data = std.fs.cwd().readFileAlloc(allocator, acct_path, 1024 * 1024) catch null; - if (acct_data) |ad| { - defer allocator.free(ad); - acct_map_opt = zfin.analysis.parseAccountsFile(allocator, ad) catch null; - } - defer if (acct_map_opt) |*am| am.deinit(); - - var result = zfin.analysis.analyzePortfolio( - allocator, - summary.allocations, - cm, - portfolio, - summary.total_value, - acct_map_opt, - ) catch { - try stderrPrint("Error computing analysis.\n"); - return; - }; - defer result.deinit(allocator); - - // Output - const label_width: usize = 24; - const bar_width: usize = 30; - - try setBold(out, color); - try out.print("\nPortfolio Analysis ({s})\n", .{file_path}); - try reset(out, color); - try out.print("========================================\n\n", .{}); - - // Asset Class - try setBold(out, color); - try setFg(out, color, CLR_HEADER); - try out.print(" Asset Class\n", .{}); - try reset(out, color); - try printBreakdownSection(out, result.asset_class, label_width, bar_width, color); - - // Sector - if (result.sector.len > 0) { - try out.print("\n", .{}); - try setBold(out, color); - try setFg(out, color, CLR_HEADER); - try out.print(" Sector (Equities)\n", .{}); - try reset(out, color); - try printBreakdownSection(out, result.sector, label_width, bar_width, color); - } - - // Geographic - if (result.geo.len > 0) { - try out.print("\n", .{}); - try setBold(out, color); - try setFg(out, color, CLR_HEADER); - try out.print(" Geographic\n", .{}); - try reset(out, color); - try printBreakdownSection(out, result.geo, label_width, bar_width, color); - } - - // By Account - if (result.account.len > 0) { - try out.print("\n", .{}); - try setBold(out, color); - try setFg(out, color, CLR_HEADER); - try out.print(" By Account\n", .{}); - try reset(out, color); - try printBreakdownSection(out, result.account, label_width, bar_width, color); - } - - // Tax Type - if (result.tax_type.len > 0) { - try out.print("\n", .{}); - try setBold(out, color); - try setFg(out, color, CLR_HEADER); - try out.print(" By Tax Type\n", .{}); - try reset(out, color); - try printBreakdownSection(out, result.tax_type, label_width, bar_width, color); - } - - // Unclassified - if (result.unclassified.len > 0) { - try out.print("\n", .{}); - try setFg(out, color, CLR_WARNING); - try out.print(" Unclassified (not in metadata.srf)\n", .{}); - try reset(out, color); - for (result.unclassified) |sym| { - try setFg(out, color, CLR_MUTED); - try out.print(" {s}\n", .{sym}); - try reset(out, color); - } - } - - try out.print("\n", .{}); -} - -/// Print a breakdown section with block-element bar charts to the CLI output. -fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void { - // Unicode block elements: U+2588 full, U+2589..U+258F partials (7/8..1/8) - const full_block = "\xE2\x96\x88"; - // partial_blocks[0]=7/8, [1]=3/4, ..., [6]=1/8 - const partial_blocks = [7][]const u8{ - "\xE2\x96\x89", // 7/8 - "\xE2\x96\x8A", // 3/4 - "\xE2\x96\x8B", // 5/8 - "\xE2\x96\x8C", // 1/2 - "\xE2\x96\x8D", // 3/8 - "\xE2\x96\x8E", // 1/4 - "\xE2\x96\x8F", // 1/8 - }; - - for (items) |item| { - var val_buf: [24]u8 = undefined; - const pct = item.weight * 100.0; - - // Compute filled eighths - const total_eighths: f64 = @as(f64, @floatFromInt(bar_width)) * 8.0; - const filled_eighths_f = item.weight * total_eighths; - const filled_eighths: usize = @intFromFloat(@min(@max(filled_eighths_f, 0), total_eighths)); - const full_count = filled_eighths / 8; - const partial = filled_eighths % 8; - - // Padded label - const lbl_len = @min(item.label.len, label_width); - try out.print(" ", .{}); - try out.writeAll(item.label[0..lbl_len]); - if (lbl_len < label_width) { - for (0..label_width - lbl_len) |_| try out.writeAll(" "); - } - try out.writeAll(" "); - if (color) try fmt.ansiSetFg(out, CLR_ACCENT[0], CLR_ACCENT[1], CLR_ACCENT[2]); - for (0..full_count) |_| try out.writeAll(full_block); - if (partial > 0) try out.writeAll(partial_blocks[8 - partial - 1]); - const used = full_count + @as(usize, if (partial > 0) 1 else 0); - if (used < bar_width) { - for (0..bar_width - used) |_| try out.writeAll(" "); - } - if (color) try fmt.ansiReset(out); - try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoney(&val_buf, item.value) }); - } -} - -/// CLI `enrich` command: bootstrap a metadata.srf file from Alpha Vantage OVERVIEW data. -/// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each, -/// and outputs a metadata SRF file to stdout. -/// If the argument looks like a symbol (no path separators, no .srf extension), enrich just that symbol. -fn cmdEnrich(allocator: std.mem.Allocator, config: zfin.Config, arg: []const u8, out: *std.Io.Writer) !void { - // Check for Alpha Vantage API key - const av_key = config.alphavantage_key orelse { - try stderrPrint("Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n"); - return; - }; - - // Determine if arg is a symbol or a file path - const is_file = std.mem.endsWith(u8, arg, ".srf") or - std.mem.indexOfScalar(u8, arg, '/') != null or - std.mem.indexOfScalar(u8, arg, '.') != null; - - if (!is_file) { - // Single symbol mode: enrich one symbol, output appendable SRF (no header) - try cmdEnrichSymbol(allocator, av_key, arg, out); - return; - } - - // Portfolio file mode: enrich all symbols - try cmdEnrichPortfolio(allocator, config, av_key, arg, out); -} - -/// Enrich a single symbol and output appendable SRF lines to stdout. -fn cmdEnrichSymbol(allocator: std.mem.Allocator, av_key: []const u8, sym: []const u8, out: *std.Io.Writer) !void { - const AV = @import("zfin").AlphaVantage; - var av = AV.init(allocator, av_key); - defer av.deinit(); - - { - var msg_buf: [128]u8 = undefined; - const msg = std.fmt.bufPrint(&msg_buf, " Fetching {s}...\n", .{sym}) catch " ...\n"; - try stderrPrint(msg); - } - - const overview = av.fetchCompanyOverview(allocator, sym) catch { - try stderrPrint("Error: Failed to fetch data for symbol\n"); - try out.print("# {s} -- fetch failed\n", .{sym}); - try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n", .{sym}); - return; - }; - defer { - if (overview.name) |n| allocator.free(n); - if (overview.sector) |s| allocator.free(s); - if (overview.industry) |ind| allocator.free(ind); - if (overview.country) |c| allocator.free(c); - if (overview.market_cap) |mc| allocator.free(mc); - if (overview.asset_type) |at| allocator.free(at); - } - - const sector_str = overview.sector orelse "Unknown"; - const country_str = overview.country orelse "US"; - const geo_str = if (std.mem.eql(u8, country_str, "USA")) "US" else country_str; - - const asset_class_str = blk: { - if (overview.asset_type) |at| { - if (std.mem.eql(u8, at, "ETF")) break :blk "ETF"; - if (std.mem.eql(u8, at, "Mutual Fund")) break :blk "Mutual Fund"; - } - if (overview.market_cap) |mc_str| { - const mc = std.fmt.parseInt(u64, mc_str, 10) catch 0; - if (mc >= 10_000_000_000) break :blk "US Large Cap"; - if (mc >= 2_000_000_000) break :blk "US Mid Cap"; - break :blk "US Small Cap"; - } - break :blk "US Large Cap"; - }; - - if (overview.name) |name| { - try out.print("# {s}\n", .{name}); - } - try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n", .{ - sym, sector_str, geo_str, asset_class_str, - }); -} - -/// Enrich all symbols from a portfolio file. -fn cmdEnrichPortfolio(allocator: std.mem.Allocator, config: zfin.Config, av_key: []const u8, file_path: []const u8, out: *std.Io.Writer) !void { - const AV = @import("zfin").AlphaVantage; - _ = config; - - // Load portfolio - const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch { - try stderrPrint("Error: Cannot read portfolio file\n"); - return; - }; - defer allocator.free(file_data); - - var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch { - try stderrPrint("Error: Cannot parse portfolio file\n"); - return; - }; - defer portfolio.deinit(); - - // Get unique stock symbols (using display-oriented names) - const positions = try portfolio.positions(allocator); - defer allocator.free(positions); - - // Get unique price symbols (raw API symbols) - const syms = try portfolio.stockSymbols(allocator); - defer allocator.free(syms); - - var av = AV.init(allocator, av_key); - defer av.deinit(); - - try out.print("#!srfv1\n", .{}); - try out.print("# Portfolio classification metadata\n", .{}); - try out.print("# Generated from Alpha Vantage OVERVIEW data\n", .{}); - try out.print("# Edit as needed: sector, geo, asset_class, pct:num:N\n", .{}); - try out.print("#\n", .{}); - try out.print("# For ETFs/funds with multi-class exposure, add multiple lines\n", .{}); - try out.print("# with pct:num: values that sum to ~100\n\n", .{}); - - var success: usize = 0; - var skipped: usize = 0; - var failed: usize = 0; - - for (syms, 0..) |sym, i| { - // Skip CUSIPs and known non-stock symbols - const OpenFigi = @import("zfin").OpenFigi; - if (OpenFigi.isCusipLike(sym)) { - // Find the display name for this CUSIP - const display: []const u8 = sym; - var note: ?[]const u8 = null; - for (positions) |pos| { - if (std.mem.eql(u8, pos.symbol, sym)) { - if (pos.note) |n| { - note = n; - } - break; - } - } - try out.print("# CUSIP {s}", .{sym}); - if (note) |n| try out.print(" ({s})", .{n}); - try out.print(" -- fill in manually\n", .{}); - try out.print("# symbol::{s},asset_class::TODO,geo::TODO\n\n", .{display}); - skipped += 1; - continue; - } - - // Progress to stderr - { - var msg_buf: [128]u8 = undefined; - const msg = std.fmt.bufPrint(&msg_buf, " [{d}/{d}] {s}...\n", .{ i + 1, syms.len, sym }) catch " ...\n"; - try stderrPrint(msg); - } - - const overview = av.fetchCompanyOverview(allocator, sym) catch { - try out.print("# {s} -- fetch failed\n", .{sym}); - try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n\n", .{sym}); - failed += 1; - continue; - }; - // Free allocated strings from overview when done - defer { - if (overview.name) |n| allocator.free(n); - if (overview.sector) |s| allocator.free(s); - if (overview.industry) |ind| allocator.free(ind); - if (overview.country) |c| allocator.free(c); - if (overview.market_cap) |mc| allocator.free(mc); - if (overview.asset_type) |at| allocator.free(at); - } - - const sector_str = overview.sector orelse "Unknown"; - const country_str = overview.country orelse "US"; - const geo_str = if (std.mem.eql(u8, country_str, "USA")) "US" else country_str; - - // Determine asset_class from asset type + market cap - const asset_class_str = blk: { - if (overview.asset_type) |at| { - if (std.mem.eql(u8, at, "ETF")) break :blk "ETF"; - if (std.mem.eql(u8, at, "Mutual Fund")) break :blk "Mutual Fund"; - } - // For common stocks, infer from market cap - if (overview.market_cap) |mc_str| { - const mc = std.fmt.parseInt(u64, mc_str, 10) catch 0; - if (mc >= 10_000_000_000) break :blk "US Large Cap"; - if (mc >= 2_000_000_000) break :blk "US Mid Cap"; - break :blk "US Small Cap"; - } - break :blk "US Large Cap"; - }; - - // Comment with the name for readability - if (overview.name) |name| { - try out.print("# {s}\n", .{name}); - } - try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n\n", .{ - sym, sector_str, geo_str, asset_class_str, - }); - success += 1; - } - - // Summary comment - try out.print("# ---\n", .{}); - try out.print("# Enriched {d} symbols ({d} success, {d} skipped, {d} failed)\n", .{ - syms.len, success, skipped, failed, - }); - try out.print("# Review and edit this file, then save as metadata.srf\n", .{}); -} - -// ── Output helpers ─────────────────────────────────────────── - -/// 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_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[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 stderrPrint(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(); +// Ensure test runner discovers tests in all imported modules +comptime { + _ = cli; + _ = commands.perf; + _ = commands.quote; + _ = commands.history; + _ = commands.divs; + _ = commands.splits; + _ = commands.options; + _ = commands.earnings; + _ = commands.etf; + _ = commands.portfolio; + _ = commands.lookup; + _ = commands.cache; + _ = commands.analysis; + _ = commands.enrich; }