const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); const fmt = cli.fmt; const Money = @import("../Money.zig"); const views = @import("../views/portfolio_sections.zig"); pub const ParsedArgs = struct {}; pub const meta: framework.Meta = .{ .name = "portfolio", .group = .portfolio, .synopsis = "Load and analyze the portfolio (positions + valuations + watchlist)", .help = \\Usage: zfin portfolio \\ \\Load `portfolio.srf` (cwd → ZFIN_HOME), refresh per-symbol \\prices in parallel (server sync where ZFIN_SERVER is set, \\else providers), and print the position table + valuations \\+ historical-snapshot mini-tables. The watchlist (if \\`watchlist.srf` exists) is appended to the price-load step \\so its quotes show alongside. \\ \\Refresh policy comes from the global `--refresh-data=` \\flag (default: auto). Use `--refresh-data=force` to force a \\re-fetch of every symbol's candles, bypassing the per-symbol \\TTL freshness check. Use `--refresh-data=never` to serve \\cache contents only (offline mode). \\ , .uppercase_first_arg = false, .user_errors = error{UnexpectedArg}, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { if (cmd_args.len > 0) { const a = cmd_args[0]; if (std.mem.eql(u8, a, "--refresh")) { try cli.stderrPrint(ctx.io, "Error: --refresh is now a global flag. Use `zfin --refresh-data=force portfolio` instead.\n"); return error.UnexpectedArg; } try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': "); try cli.stderrPrint(ctx.io, a); try cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } return .{}; } pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; const io = ctx.io; const allocator = ctx.allocator; const out = ctx.out; const color = ctx.color; const as_of = ctx.today; const pf = ctx.resolvePortfolioPath(); defer pf.deinit(allocator); const file_path = pf.path; const wl = ctx.resolveWatchlistPath(); defer wl.deinit(allocator); const watchlist_path: ?[]const u8 = if (ctx.globals.watchlist_path != null or wl.resolved != null) wl.path else null; // Load portfolio from SRF file var loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return; defer loaded.deinit(allocator); const portfolio = loaded.portfolio; const positions = loaded.positions; const syms = loaded.syms; if (portfolio.lots.len == 0) { try cli.stderrPrint(io, "Portfolio is empty.\n"); return; } 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.security_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) { // Use consolidated parallel loader var load_result = cli.loadPortfolioPrices( io, svc, syms, watch_syms.items, ctx.globals.refresh_policy, color, ); defer load_result.deinit(); // Free the prices hashmap after we copy // Transfer prices to our local map var it = load_result.prices.iterator(); while (it.next()) |entry| { prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; } fail_count = load_result.failed_count; } // Build portfolio summary, candle map, and historical snapshots var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { try cli.stderrPrint(io, "Error computing portfolio summary.\n"); return; }, else => return err, }; defer pf_data.deinit(allocator); // Sort allocations alphabetically by symbol std.mem.sort(zfin.valuation.Allocation, pf_data.summary.allocations, {}, struct { fn f(_: void, a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool { return std.mem.lessThan(u8, a.display_symbol, b.display_symbol); } }.f); // Collect watch symbols and their prices for display. // Includes watch lots from portfolio + symbols from separate watchlist file. var watch_list: std.ArrayList([]const u8) = .empty; defer watch_list.deinit(allocator); var watch_prices = std.StringHashMap(f64).init(allocator); defer watch_prices.deinit(); { var watch_seen = std.StringHashMap(void).init(allocator); defer watch_seen.deinit(); // Exclude portfolio position symbols from watchlist for (pf_data.summary.allocations) |a| { try watch_seen.put(a.symbol, {}); } // Watch lots from portfolio for (portfolio.lots) |lot| { if (lot.security_type == .watch) { const sym = lot.priceSymbol(); if (watch_seen.contains(sym)) continue; try watch_seen.put(sym, {}); try watch_list.append(allocator, sym); if (svc.getCachedLastClose(sym)) |close| { try watch_prices.put(sym, close); } } } // Separate watchlist file (backward compat) if (watchlist_path) |wl_path| { const wl_syms = cli.loadWatchlist(io, allocator, wl_path); defer cli.freeWatchlist(allocator, wl_syms); if (wl_syms) |syms_list| { for (syms_list) |sym| { if (watch_seen.contains(sym)) continue; try watch_seen.put(sym, {}); try watch_list.append(allocator, sym); if (svc.getCachedLastClose(sym)) |close| { try watch_prices.put(sym, close); } } } } } try display( allocator, out, color, file_path, &portfolio, positions, &pf_data, watch_list.items, watch_prices, as_of, ); } /// Render the full portfolio display. All data is pre-fetched; no service calls. pub fn display( allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, file_path: []const u8, portfolio: *const zfin.Portfolio, positions: []const zfin.Position, pf_data: *const cli.PortfolioData, watch_symbols: []const []const u8, watch_prices: std.StringHashMap(f64), as_of: zfin.Date, ) !void { const summary = &pf_data.summary; // Header with summary try cli.printBold(out, color, "\nPortfolio Summary ({s})\n", .{file_path}); try out.print("========================================\n", .{}); // Summary bar { const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss; try out.print(" Value: {f} Cost: {f} ", .{ Money.from(summary.total_value), Money.from(summary.total_cost) }); try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "Gain/Loss: {c}{f} ({d:.1}%)", .{ @as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'), Money.from(gl_abs), summary.unrealized_return * 100.0, }); 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.security_type != .stock) continue; if (lot.isOpen(as_of)) open_lots += 1 else closed_lots += 1; } try cli.printFg(out, color, cli.CLR_MUTED, " Lots: {d} open, {d} closed Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len }); // Historical portfolio value snapshots { if (pf_data.snapshots) |snapshots| { try out.print(" Historical: ", .{}); try cli.setFg(out, color, cli.CLR_MUTED); for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| { const snap = snapshots[pi]; var hbuf: [16]u8 = undefined; const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct()); if (snap.position_count > 0) try cli.setGainLoss(out, color, snap.changePct()); try out.print(" {s}: {s}", .{ period.label(), change_str }); if (pi < zfin.valuation.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.security_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, as_of, fmt.lotSortFn); const is_multi = lots_for_sym.items.len > 1; // Position summary row { const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss; const sign: []const u8 = if (a.unrealized_gain_loss >= 0) "+" else "-"; // Date + ST/LT for single-lot positions var date_col: [24]u8 = @splat(' '); var date_col_len: usize = 0; if (!is_multi and lots_for_sym.items.len == 1) { const lot = lots_for_sym.items[0]; const indicator = fmt.capitalGainsIndicator(as_of, lot.open_date); const written = std.fmt.bufPrint(&date_col, "{f} {s}", .{ lot.open_date, 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} {f} ", .{ a.display_symbol, a.shares, Money.from(a.avg_cost).padRight(10), }); try out.print("{f}", .{Money.from(a.current_price).padRight(10)}); try out.print(" {f} ", .{Money.from(a.market_value).padRight(16)}); try cli.setGainLoss(out, color, a.unrealized_gain_loss); try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(13) }); 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(as_of, out, color, lot, a.current_price); } } else { // Show non-DRIP lots individually for (lots_for_sym.items) |lot| { if (!lot.drip) { try printLotRow(as_of, out, color, lot, a.current_price); } } // Summarize DRIP lots as ST/LT const drip = fmt.aggregateDripLots(as_of, lots_for_sym.items); if (!drip.st.isEmpty()) { var drip_buf: [128]u8 = undefined; try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{fmt.fmtDripSummary(&drip_buf, "ST", drip.st)}); } if (!drip.lt.isEmpty()) { var drip_buf2: [128]u8 = undefined; try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{fmt.fmtDripSummary(&drip_buf2, "LT", drip.lt)}); } } } } // Totals line try cli.printFg(out, color, cli.CLR_MUTED, " {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8}\n", .{ "", "", "", "", "", "", "", }); { const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss; try out.print(" {s:>6} {s:>8} {s:>10} {s:>10} {f} ", .{ "", "", "", "TOTAL", Money.from(summary.total_value).padRight(16), }); try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "{c}{f}", .{ @as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'), Money.from(gl_abs).padRight(13), }); try out.print(" {s:>7}\n", .{"100.0%"}); } if (summary.realized_gain_loss != 0) { const rpl_abs = if (summary.realized_gain_loss >= 0) summary.realized_gain_loss else -summary.realized_gain_loss; try cli.printGainLoss(out, color, summary.realized_gain_loss, "\n Realized P&L: {c}{f}\n", .{ @as(u8, if (summary.realized_gain_loss >= 0) '+' else '-'), Money.from(rpl_abs), }); } // Options section if (portfolio.hasType(.option)) { var prepared_opts = try views.Options.init(as_of, allocator, portfolio.lots, null); defer prepared_opts.deinit(); if (prepared_opts.items.len > 0) { try out.print("\n", .{}); try cli.printBold(out, color, " Options\n", .{}); try cli.setFg(out, color, cli.CLR_MUTED); try out.print(views.OptionsLayout.header ++ "\n", views.OptionsLayout.header_labels); try out.print(views.OptionsLayout.separator ++ "\n", views.OptionsLayout.separator_fills); try cli.reset(out, color); var opt_total_premium: f64 = 0; for (prepared_opts.items) |po| { opt_total_premium += po.premium; const text = po.columns[0].text; const prem_start = po.premium_col_start; const prem_end = @min(prem_start + views.OptionsLayout.premium_w, text.len); // Pre-premium portion try cli.printIntent(out, color, po.row_style, "{s}", .{text[0..prem_start]}); // Premium column try cli.printIntent(out, color, po.premium_style, "{s}", .{text[prem_start..prem_end]}); // Post-premium portion (account) if (prem_end < text.len) try cli.printIntent(out, color, po.row_style, "{s}", .{text[prem_end..]}); try out.print("\n", .{}); } // Options total try cli.printFg(out, color, cli.CLR_MUTED, " {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" }); try out.print(" {s:>30} {s:>6} {s:>12} {f}\n", .{ "", "", "TOTAL", Money.from(opt_total_premium).padRight(14), }); } } // CDs section if (portfolio.hasType(.cd)) { var prepared_cds = try views.CDs.init(as_of, allocator, portfolio.lots, null); defer prepared_cds.deinit(); if (prepared_cds.items.len > 0) { try out.print("\n", .{}); try cli.printBold(out, color, " Certificates of Deposit\n", .{}); try cli.setFg(out, color, cli.CLR_MUTED); try out.print(views.CDsLayout.header ++ "\n", views.CDsLayout.header_labels); try out.print(views.CDsLayout.separator ++ "\n", views.CDsLayout.separator_fills); try cli.reset(out, color); var cd_section_total: f64 = 0; for (prepared_cds.items) |pc| { cd_section_total += pc.lot.shares; try cli.printIntent(out, color, pc.row_style, "{s}\n", .{pc.text}); } // CD total try cli.printFg(out, color, cli.CLR_MUTED, " {s:->12} {s:->14}\n", .{ "", "" }); try out.print(" {s:>12} {f}\n", .{ "TOTAL", Money.from(cd_section_total).padRight(14), }); } } // Cash section if (portfolio.hasType(.cash)) { try out.print("\n", .{}); try cli.printBold(out, color, " Cash\n", .{}); 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.security_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.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{fmt.fmtCashSep(&sep_buf)}); var total_buf: [80]u8 = undefined; try cli.printBold(out, color, "{s}\n", .{fmt.fmtCashTotal(&total_buf, portfolio.totalCash(as_of))}); } // Illiquid assets section if (portfolio.hasType(.illiquid)) { try out.print("\n", .{}); try cli.printBold(out, color, " Illiquid Assets\n", .{}); 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.security_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.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf2)}); var il_total_buf: [80]u8 = undefined; try cli.printBold(out, color, "{s}\n", .{fmt.fmtIlliquidTotal(&il_total_buf, portfolio.totalIlliquid(as_of))}); } // Net Worth (if illiquid assets exist) if (portfolio.hasType(.illiquid)) { const illiquid_total = portfolio.totalIlliquid(as_of); const net_worth = zfin.valuation.netWorth(as_of, portfolio.*, summary.*); try out.print("\n", .{}); try cli.printBold(out, color, " Net Worth: {f} (Liquid: {f} Illiquid: {f})\n", .{ Money.from(net_worth), Money.from(summary.total_value), Money.from(illiquid_total), }); } // Watchlist if (watch_symbols.len > 0) { try out.print("\n", .{}); try cli.printBold(out, color, " Watchlist:\n", .{}); for (watch_symbols) |sym| { var price_str: [16]u8 = undefined; const ps: []const u8 = if (watch_prices.get(sym)) |close| std.fmt.bufPrint(&price_str, "{f}", .{Money.from(close)}) catch "$?" else "--"; try out.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps }); } } // Risk metrics (3-year, matching Morningstar default) { var any_risk = false; for (summary.allocations) |a| { if (pf_data.candle_map.get(a.symbol)) |candles| { const tr = zfin.risk.trailingRisk(candles); if (tr.three_year) |metrics| { if (!any_risk) { try out.print("\n", .{}); try cli.printBold(out, color, " Risk Metrics (3-Year, monthly returns):\n", .{}); 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.display_symbol, metrics.volatility * 100.0, metrics.sharpe, }); try cli.printFg(out, color, cli.CLR_NEGATIVE, "{d:>9.1}%", .{metrics.max_drawdown * 100.0}); try out.print("\n", .{}); } } } } try out.print("\n", .{}); } pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64) !void { const indicator = fmt.capitalGainsIndicator(as_of, lot.open_date); const status_str: []const u8 = if (lot.isOpen(as_of)) "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); const lot_gl_abs = if (gl >= 0) gl else -gl; const lot_sign: []const u8 = if (gl >= 0) "+" else "-"; try cli.printFg(out, color, cli.CLR_MUTED, " " ++ fmt.sym_col_spec ++ " {d:>8.1} {f} {s:>10} {f} ", .{ status_str, lot.shares, Money.from(lot.open_price).padRight(10), "", Money.from(lot.shares * use_price).padRight(16), }); try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(13) }); try cli.printFg(out, color, cli.CLR_MUTED, " {s:>8} {f} {s} {s}\n", .{ "", lot.open_date, indicator, acct_col }); } // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; /// Helper: build a minimal portfolio for testing. /// Returns lots as a stack-allocated array and a Portfolio that references them. /// Caller must NOT call deinit() since lots are stack-allocated. fn testPortfolio(lots: []const zfin.Lot) zfin.Portfolio { return .{ .lots = @constCast(lots), .allocator = testing.allocator, }; } fn testSummary(allocations: []zfin.valuation.Allocation) zfin.valuation.PortfolioSummary { var total_value: f64 = 0; var total_cost: f64 = 0; var unrealized_gain_loss: f64 = 0; for (allocations) |a| { total_value += a.market_value; total_cost += a.cost_basis; unrealized_gain_loss += a.unrealized_gain_loss; } return .{ .total_value = total_value, .total_cost = total_cost, .unrealized_gain_loss = unrealized_gain_loss, .unrealized_return = if (total_cost > 0) unrealized_gain_loss / total_cost else 0, .realized_gain_loss = 0, .allocations = allocations, }; } fn testPortfolioData(summary: zfin.valuation.PortfolioSummary, candle_map: std.StringHashMap([]const zfin.Candle)) cli.PortfolioData { return .{ .summary = summary, .candle_map = candle_map, .snapshots = null, }; } test "display shows header and summary" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); var lots = [_]zfin.Lot{ .{ .symbol = "AAPL", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 15), .open_price = 150.0 }, .{ .symbol = "GOOG", .shares = 5, .open_date = zfin.Date.fromYmd(2023, 6, 1), .open_price = 120.0 }, }; var portfolio = testPortfolio(&lots); var positions = [_]zfin.Position{ .{ .symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, .{ .symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .total_cost = 600.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "AAPL", .display_symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .current_price = 175.0, .market_value = 1750.0, .cost_basis = 1500.0, .weight = 0.745, .unrealized_gain_loss = 250.0, .unrealized_return = 0.167 }, .{ .symbol = "GOOG", .display_symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .current_price = 140.0, .market_value = 700.0, .cost_basis = 600.0, .weight = 0.255, .unrealized_gain_loss = 100.0, .unrealized_return = 0.167 }, }; const summary = testSummary(&allocs); var prices = std.StringHashMap(f64).init(testing.allocator); defer prices.deinit(); try prices.put("AAPL", 175.0); try prices.put("GOOG", 140.0); var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); defer candle_map.deinit(); const pf_data = testPortfolioData(summary, candle_map); var watch_prices = std.StringHashMap(f64).init(testing.allocator); defer watch_prices.deinit(); const watch_syms: []const []const u8 = &.{}; try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8)); const out = w.buffered(); // Header present try testing.expect(std.mem.indexOf(u8, out, "Portfolio Summary (test.srf)") != null); // Symbols present try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); try testing.expect(std.mem.indexOf(u8, out, "GOOG") != null); // Column headers present try testing.expect(std.mem.indexOf(u8, out, "Symbol") != null); try testing.expect(std.mem.indexOf(u8, out, "Market Value") != null); try testing.expect(std.mem.indexOf(u8, out, "Gain/Loss") != null); // TOTAL line present try testing.expect(std.mem.indexOf(u8, out, "TOTAL") != null); try testing.expect(std.mem.indexOf(u8, out, "100.0%") != null); // No ANSI codes try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); } test "display with watchlist" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); var lots = [_]zfin.Lot{ .{ .symbol = "VTI", .shares = 20, .open_date = zfin.Date.fromYmd(2022, 3, 1), .open_price = 200.0 }, }; var portfolio = testPortfolio(&lots); var positions = [_]zfin.Position{ .{ .symbol = "VTI", .shares = 20, .avg_cost = 200.0, .total_cost = 4000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 20, .avg_cost = 200.0, .current_price = 220.0, .market_value = 4400.0, .cost_basis = 4000.0, .weight = 1.0, .unrealized_gain_loss = 400.0, .unrealized_return = 0.1 }, }; const summary = testSummary(&allocs); var prices = std.StringHashMap(f64).init(testing.allocator); defer prices.deinit(); try prices.put("VTI", 220.0); var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); defer candle_map.deinit(); const pf_data = testPortfolioData(summary, candle_map); // Watchlist with prices const watch_syms: []const []const u8 = &.{ "TSLA", "NVDA" }; var watch_prices = std.StringHashMap(f64).init(testing.allocator); defer watch_prices.deinit(); try watch_prices.put("TSLA", 250.50); try watch_prices.put("NVDA", 800.25); try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8)); const out = w.buffered(); // Watchlist header and symbols try testing.expect(std.mem.indexOf(u8, out, "Watchlist:") != null); try testing.expect(std.mem.indexOf(u8, out, "TSLA") != null); try testing.expect(std.mem.indexOf(u8, out, "NVDA") != null); } test "display with options section" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); var lots = [_]zfin.Lot{ .{ .symbol = "SPY", .shares = 50, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 400.0 }, .{ .symbol = "SPY 240119C00450000", .shares = 2, .open_date = zfin.Date.fromYmd(2023, 6, 1), .open_price = 5.50, .security_type = .option }, }; var portfolio = testPortfolio(&lots); var positions = [_]zfin.Position{ .{ .symbol = "SPY", .shares = 50, .avg_cost = 400.0, .total_cost = 20000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "SPY", .display_symbol = "SPY", .shares = 50, .avg_cost = 400.0, .current_price = 450.0, .market_value = 22500.0, .cost_basis = 20000.0, .weight = 1.0, .unrealized_gain_loss = 2500.0, .unrealized_return = 0.125 }, }; var summary = testSummary(&allocs); // Include option cost in totals (like run() does) summary.total_value += portfolio.totalOptionCost(zfin.Date.fromYmd(2026, 5, 8)); summary.total_cost += portfolio.totalOptionCost(zfin.Date.fromYmd(2026, 5, 8)); var prices = std.StringHashMap(f64).init(testing.allocator); defer prices.deinit(); try prices.put("SPY", 450.0); var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); defer candle_map.deinit(); const pf_data = testPortfolioData(summary, candle_map); var watch_prices = std.StringHashMap(f64).init(testing.allocator); defer watch_prices.deinit(); const watch_syms: []const []const u8 = &.{}; try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8)); const out = w.buffered(); // Options section present try testing.expect(std.mem.indexOf(u8, out, "Options") != null); try testing.expect(std.mem.indexOf(u8, out, "SPY 240119C00450000") != null); try testing.expect(std.mem.indexOf(u8, out, "Cost/Ctrct") != null); } test "display with CDs and cash" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); var lots = [_]zfin.Lot{ .{ .symbol = "VTI", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 200.0 }, .{ .symbol = "912828ZT0", .shares = 10000, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 100.0, .security_type = .cd, .rate = 4.5, .maturity_date = zfin.Date.fromYmd(2025, 6, 15) }, .{ .symbol = "CASH", .shares = 5000, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 0, .security_type = .cash, .account = "Brokerage" }, }; var portfolio = testPortfolio(&lots); var positions = [_]zfin.Position{ .{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 }, }; var summary = testSummary(&allocs); summary.total_value += portfolio.totalCash(zfin.Date.fromYmd(2026, 5, 8)) + portfolio.totalCdFaceValue(zfin.Date.fromYmd(2026, 5, 8)); summary.total_cost += portfolio.totalCash(zfin.Date.fromYmd(2026, 5, 8)) + portfolio.totalCdFaceValue(zfin.Date.fromYmd(2026, 5, 8)); var prices = std.StringHashMap(f64).init(testing.allocator); defer prices.deinit(); try prices.put("VTI", 220.0); var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); defer candle_map.deinit(); const pf_data = testPortfolioData(summary, candle_map); var watch_prices = std.StringHashMap(f64).init(testing.allocator); defer watch_prices.deinit(); const watch_syms: []const []const u8 = &.{}; try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8)); const out = w.buffered(); // CDs section present try testing.expect(std.mem.indexOf(u8, out, "Certificates of Deposit") != null); try testing.expect(std.mem.indexOf(u8, out, "912828ZT0") != null); try testing.expect(std.mem.indexOf(u8, out, "4.50%") != null); // Cash section present try testing.expect(std.mem.indexOf(u8, out, "Cash") != null); try testing.expect(std.mem.indexOf(u8, out, "Brokerage") != null); } test "display realized PnL shown when nonzero" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); var lots = [_]zfin.Lot{ .{ .symbol = "MSFT", .shares = 10, .open_date = zfin.Date.fromYmd(2022, 1, 1), .open_price = 300.0 }, .{ .symbol = "MSFT", .shares = 5, .open_date = zfin.Date.fromYmd(2022, 6, 1), .open_price = 280.0, .close_date = zfin.Date.fromYmd(2023, 6, 1), .close_price = 350.0 }, }; var portfolio = testPortfolio(&lots); var positions = [_]zfin.Position{ .{ .symbol = "MSFT", .shares = 10, .avg_cost = 300.0, .total_cost = 3000.0, .open_lots = 1, .closed_lots = 1, .realized_gain_loss = 350.0 }, }; var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "MSFT", .display_symbol = "MSFT", .shares = 10, .avg_cost = 300.0, .current_price = 400.0, .market_value = 4000.0, .cost_basis = 3000.0, .weight = 1.0, .unrealized_gain_loss = 1000.0, .unrealized_return = 0.333 }, }; var summary = testSummary(&allocs); summary.realized_gain_loss = 350.0; var prices = std.StringHashMap(f64).init(testing.allocator); defer prices.deinit(); try prices.put("MSFT", 400.0); var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); defer candle_map.deinit(); const pf_data = testPortfolioData(summary, candle_map); var watch_prices = std.StringHashMap(f64).init(testing.allocator); defer watch_prices.deinit(); const watch_syms: []const []const u8 = &.{}; try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8)); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "Realized P&L") != null); } test "display empty watchlist not shown" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); var lots = [_]zfin.Lot{ .{ .symbol = "VTI", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 200.0 }, }; var portfolio = testPortfolio(&lots); var positions = [_]zfin.Position{ .{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 }, }; const summary = testSummary(&allocs); var prices = std.StringHashMap(f64).init(testing.allocator); defer prices.deinit(); try prices.put("VTI", 220.0); var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); defer candle_map.deinit(); const pf_data = testPortfolioData(summary, candle_map); var watch_prices = std.StringHashMap(f64).init(testing.allocator); defer watch_prices.deinit(); const watch_syms: []const []const u8 = &.{}; try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8)); const out = w.buffered(); // Watchlist header should NOT appear when there are no watch symbols try testing.expect(std.mem.indexOf(u8, out, "Watchlist") == null); } // ── parseArgs tests ──────────────────────────────────────────── test "parseArgs: no args returns empty ParsedArgs" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{}; _ = try parseArgs(&ctx, &args); } test "parseArgs: --refresh rejected (now a global)" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{"--refresh"}; try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); } test "parseArgs: unexpected args error" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{"unexpected"}; try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); }