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"); /// Main holdings-table column layout (widths + format strings). const pl = views.PositionsLayout; /// Visibility of expired options / matured CDs within the Options /// and CDs sections. pub const ExpiredMode = enum { /// Collapse expired rows into a one-line per-section summary /// (the default). rollup, /// List every expired row (muted), alongside the active rows. show, /// Omit expired rows entirely. hide, fn parse(s: []const u8) ?ExpiredMode { if (std.mem.eql(u8, s, "rollup")) return .rollup; if (std.mem.eql(u8, s, "show")) return .show; if (std.mem.eql(u8, s, "hide")) return .hide; return null; } }; pub const ParsedArgs = struct { expired: ExpiredMode = .rollup, }; pub const meta: framework.Meta = .{ .name = "portfolio", .group = .portfolio, .synopsis = "Load and analyze the portfolio (positions + valuations + watchlist)", .help = \\Usage: zfin portfolio [--expired=rollup|show|hide] \\ \\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. \\ \\Expired options and matured CDs are collapsed into a one-line \\summary at the foot of their section by default (`--expired=rollup`). \\Use `--expired=show` to list them (muted) or `--expired=hide` \\to omit them entirely. Section TOTAL lines always reflect \\active holdings only. \\ \\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, InvalidExpiredValue }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { var parsed: ParsedArgs = .{}; for (cmd_args) |a| { if (std.mem.startsWith(u8, a, "--expired=")) { const val = a["--expired=".len..]; parsed.expired = ExpiredMode.parse(val) orelse { cli.stderrPrint(ctx.io, "Error: invalid --expired value '"); cli.stderrPrint(ctx.io, val); cli.stderrPrint(ctx.io, "' (expected: rollup, show, or hide)\n"); return error.InvalidExpiredValue; }; continue; } if (std.mem.eql(u8, a, "--expired")) { cli.stderrPrint(ctx.io, "Error: --expired requires a value (--expired=rollup|show|hide)\n"); return error.InvalidExpiredValue; } if (std.mem.eql(u8, a, "--refresh")) { cli.stderrPrint(ctx.io, "Error: --refresh is now a global flag. Use `zfin --refresh-data=force portfolio` instead.\n"); return error.UnexpectedArg; } cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': "); cli.stderrPrint(ctx.io, a); cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } return parsed; } pub fn run(ctx: *framework.RunCtx, parsed: 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 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; // Resolve patterns + load (and union-merge) every matched // portfolio file in one shot. The returned LoadedPortfolio // carries the resolved paths so we can derive an anchor and // a multi-file display label without a second resolver call. var loaded = cli.loadPortfolio(ctx, as_of) orelse return; defer loaded.deinit(allocator); const portfolio = loaded.portfolio; const positions = loaded.positions; const syms = loaded.syms; const anchor_path = loaded.anchor(); if (portfolio.lots.len == 0) { 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| { try prices.put(entry.key_ptr.*, entry.value_ptr.*); } 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 => { 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); // Separate watchlist file (backward compat). Loaded here at the run // scope - NOT inside the collection block below - so its symbol // strings outlive the `display` call at the end of `run`. // `watch_list` (and `watch_prices`' keys) borrow these slices; // freeing them before display - the previous behavior, where the // load + `defer freeWatchlist` lived inside the collection block - // rendered the file's watch symbols as freed-memory garbage on the // CLI. The TUI is unaffected: it keeps watchlist symbols in its // long-lived arena. const wl_syms: ?[][]const u8 = if (watchlist_path) |wl_path| cli.loadWatchlist(io, allocator, wl_path) else null; defer cli.freeWatchlist(allocator, wl_syms); // Collect watch symbols and their prices for display. // Includes watch lots from portfolio + symbols from the 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); } } } // Symbols from the separate watchlist file (loaded above). 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); } } } } // Build a display label: anchor file plus "(+N more)" when // multiple portfolio files contributed lots. Keeps the existing // single-file UX unchanged while making multi-file mode visible. var label_buf: [512]u8 = undefined; const display_label: []const u8 = if (loaded.paths.len > 1) std.fmt.bufPrint(&label_buf, "{s} (+{d} more)", .{ anchor_path, loaded.paths.len - 1 }) catch anchor_path else anchor_path; try display( allocator, out, color, display_label, &portfolio, positions, &pf_data, watch_list.items, watch_prices, as_of, parsed.expired, ); } /// 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, expired_mode: ExpiredMode, ) !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. Column widths fit the rendered content (each // header label is the floor, the widest datum the ceiling); see // views.computeWidths. Computed once and threaded through the rows, // totals, and watchlist so every line aligns. const w = views.computeWidths( summary.allocations, portfolio.lots, summary.total_value, summary.unrealized_gain_loss, watch_symbols, watch_prices, ); try out.print("\n", .{}); try cli.setFg(out, color, cli.CLR_MUTED); try views.writeHeader(out, w); try views.writeSeparator(out, w); 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.writeAll(" "); try views.writeCol(out, a.display_symbol, w.symbol_w, false); try out.writeByte(' '); var shares_buf: [48]u8 = undefined; const shares_str = std.fmt.bufPrint(&shares_buf, "{d:.1}", .{a.shares}) catch "?"; try views.writeCol(out, shares_str, w.shares_w, true); try out.writeByte(' '); try out.print("{f}", .{Money.from(a.avg_cost).padRight(w.price_w)}); try out.writeByte(' '); try out.print("{f}", .{Money.from(a.current_price).padRight(w.price_w)}); try out.writeByte(' '); try out.print("{f}", .{Money.from(a.market_value).padRight(w.value_w)}); try out.writeByte(' '); try cli.setGainLoss(out, color, a.unrealized_gain_loss); try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(w.gainloss_w - 1) }); if (a.is_manual_price) { try cli.setFg(out, color, cli.CLR_WARNING); } else { try cli.reset(out, color); } try out.print(" " ++ pl.weight_num, .{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, w); } } 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, w); } } // 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.setFg(out, color, cli.CLR_MUTED); try views.writeTotalSeparator(out, w); try cli.reset(out, color); { const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss; try out.writeAll(" "); try views.writeCol(out, "", w.symbol_w, false); // blank symbol try out.writeByte(' '); try views.writeCol(out, "", w.shares_w, true); // blank shares try out.writeByte(' '); try views.writeCol(out, "", w.price_w, true); // blank avg cost try out.writeByte(' '); try views.writeCol(out, "TOTAL", w.price_w, true); // "TOTAL" in the price column try out.writeByte(' '); try out.print("{f} ", .{Money.from(summary.total_value).padRight(w.value_w)}); 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(w.gainloss_w - 1), }); try out.print(" " ++ pl.weight_str ++ "\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(); const active = prepared_opts.activeItems(); const expired = prepared_opts.expiredItems(); // `show` lists every row; `rollup`/`hide` list active only. const opt_rows = if (expired_mode == .show) prepared_opts.items else active; const show_rollup = expired_mode == .rollup and expired.len > 0; if (opt_rows.len > 0 or show_rollup) { try out.print("\n", .{}); try cli.printBold(out, color, " Options\n", .{}); if (opt_rows.len > 0) { 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); } // TOTAL reflects active premium only, regardless of mode. var opt_total_premium: f64 = 0; for (active) |po| opt_total_premium += po.premium; for (opt_rows) |po| { 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 (active only). 4-space prefix matches the // section's data rows (OptionsLayout.prefix). if (active.len > 0) { 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), }); } // Rolled-up expired summary (count only). if (show_rollup) { const noun: []const u8 = if (expired.len == 1) "expired option" else "expired options"; try cli.printIntent(out, color, .muted, " {d} {s} (--expired=show)\n", .{ expired.len, noun }); } } } // CDs section if (portfolio.hasType(.cd)) { var prepared_cds = try views.CDs.init(as_of, allocator, portfolio.lots, null); defer prepared_cds.deinit(); const active = prepared_cds.activeItems(); const expired = prepared_cds.expiredItems(); const cd_rows = if (expired_mode == .show) prepared_cds.items else active; const show_rollup = expired_mode == .rollup and expired.len > 0; if (cd_rows.len > 0 or show_rollup) { try out.print("\n", .{}); try cli.printBold(out, color, " Certificates of Deposit\n", .{}); if (cd_rows.len > 0) { 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); } // TOTAL reflects active face value only, regardless of mode. var cd_section_total: f64 = 0; for (active) |pc| cd_section_total += pc.lot.shares; for (cd_rows) |pc| { try cli.printIntent(out, color, pc.row_style, "{s}\n", .{pc.text}); } // CD total (active only). 4-space prefix matches the // section's data rows (CDsLayout.prefix). if (active.len > 0) { 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), }); } // Rolled-up matured summary (count only). if (show_rollup) { const noun: []const u8 = if (expired.len == 1) "matured CD" else "matured CDs"; try cli.printIntent(out, color, .muted, " {d} {s} (--expired=show)\n", .{ expired.len, noun }); } } } // 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.displaySymbol(), 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.writeAll(" "); try views.writeCol(out, sym, w.symbol_w, false); try out.writeByte(' '); try views.writeCol(out, ps, w.price_w, true); try out.writeByte('\n'); } } // Per-symbol risk metrics (vol / Sharpe / max drawdown) used to // print here. They moved to `zfin review`, which combines them // with trailing returns + sector + tax-status into a single // per-holding dashboard. The portfolio command now sticks to its // job: positions + valuations + watchlist. try out.print("\n", .{}); } pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64, w: views.PositionsWidths) !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.effectiveShares() * (use_price - lot.effectiveOpenPrice()); const lot_gl_abs = if (gl >= 0) gl else -gl; const lot_sign: []const u8 = if (gl >= 0) "+" else "-"; // Muted run: prefix (lots are indented one level past positions) // through the market-value cell. try cli.setFg(out, color, cli.CLR_MUTED); try out.writeAll(" "); try views.writeCol(out, status_str, w.symbol_w, false); try out.writeByte(' '); var shares_buf: [48]u8 = undefined; const shares_str = std.fmt.bufPrint(&shares_buf, "{d:.1}", .{lot.effectiveShares()}) catch "?"; try views.writeCol(out, shares_str, w.shares_w, true); try out.writeByte(' '); try out.print("{f}", .{Money.from(lot.effectiveOpenPrice()).padRight(w.price_w)}); try out.writeByte(' '); try views.writeCol(out, "", w.price_w, true); // blank current-price cell try out.writeByte(' '); try out.print("{f}", .{Money.from(lot.effectiveShares() * use_price).padRight(w.value_w)}); try out.writeByte(' '); try cli.reset(out, color); // Colored gain/loss cell. try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(w.gainloss_w - 1) }); // Muted run: blank weight cell, then date / indicator / account. try cli.setFg(out, color, cli.CLR_MUTED); try out.writeByte(' '); try views.writeCol(out, "", w.weight_w, true); try out.print(" {f} {s} {s}\n", .{ lot.open_date, indicator, acct_col }); try cli.reset(out, color); } // ── 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), .rollup); 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 widens columns for large crypto values" { // Regression guard for the portfolio-table column widening. A long // crypto symbol (DOGE-USD, 8 chars), a large share count, and a high // price must render in full without overflowing or shifting the // symbol / shares / price / value columns. var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); var lots = [_]zfin.Lot{ .{ .symbol = "DOGE-USD", .shares = 10000, .open_date = zfin.Date.fromYmd(2024, 1, 2), .open_price = 41500.0, .account = "Sample Account" }, }; var portfolio = testPortfolio(&lots); var positions = [_]zfin.Position{ .{ .symbol = "DOGE-USD", .shares = 10000, .avg_cost = 41500.0, .total_cost = 415000000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "DOGE-USD", .display_symbol = "DOGE-USD", .shares = 10000, .avg_cost = 41500.0, .current_price = 42000.0, .market_value = 420000000.0, .cost_basis = 415000000.0, .weight = 1.0, .unrealized_gain_loss = 5000000.0, .unrealized_return = 0.01205 }, }; const summary = testSummary(&allocs); 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, "crypto.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup); const out = w.buffered(); // Full 8-char symbol present (not truncated). try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD") != null); // Fit-to-content: the Symbol column sizes to the longest symbol // (DOGE-USD = 8 cols) and Shares to "10000.0" (7 cols), so the full // symbol renders followed by exactly the 1-col separator and then // the right-justified Shares value - no truncation, no wasted // padding. (The old fixed 10-wide Symbol column left 2 trailing pad // spaces here; fit-to-content removes them.) try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD 10000.0") != null); // Large share count, high price, and large market value render in full. try testing.expect(std.mem.indexOf(u8, out, "10000.0") != null); try testing.expect(std.mem.indexOf(u8, out, "$42,000.00") != null); try testing.expect(std.mem.indexOf(u8, out, "$420,000,000.00") != null); // Gain/Loss of $5M fits its column without truncation. try testing.expect(std.mem.indexOf(u8, out, "$5,000,000.00") != null); // No ANSI codes when color is disabled. try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); // The single position's market value equals the portfolio total, so // it must appear at the same column in the data row and the TOTAL // row. This guards the TOTAL line against drifting out of alignment // when the data columns are widened. var data_col: ?usize = null; var total_col: ?usize = null; var lit = std.mem.splitScalar(u8, out, '\n'); while (lit.next()) |line| { const at = std.mem.indexOf(u8, line, "$420,000,000.00") orelse continue; if (std.mem.indexOf(u8, line, "DOGE-USD") != null) { data_col = at; } else if (std.mem.indexOf(u8, line, "TOTAL") != null) { total_col = at; } } try testing.expect(data_col != null); try testing.expect(total_col != null); try testing.expectEqual(data_col.?, total_col.?); // The Date column is left-justified text (like the symbol column), so // the "Date" header sits over the START of the date values, and the // Account column lines up too. Verify the header label and the data // value share a column. var hdr_date: ?usize = null; var row_date: ?usize = null; var hdr_acct: ?usize = null; var row_acct: ?usize = null; var dit = std.mem.splitScalar(u8, out, '\n'); while (dit.next()) |line| { if (std.mem.indexOf(u8, line, "Symbol") != null) { hdr_date = std.mem.indexOf(u8, line, "Date"); hdr_acct = std.mem.indexOf(u8, line, "Account"); } else if (std.mem.indexOf(u8, line, "DOGE-USD") != null) { row_date = std.mem.indexOf(u8, line, "2024-01-02"); row_acct = std.mem.indexOf(u8, line, "Sample Account"); } } try testing.expect(hdr_date != null and row_date != null); try testing.expectEqual(hdr_date.?, row_date.?); try testing.expect(hdr_acct != null and row_acct != null); try testing.expectEqual(hdr_acct.?, row_acct.?); } test "display tightens columns for a small portfolio" { // Counterpart to the crypto test: small share counts and prices mean // every dynamic column collapses to its header-label minimum, so the // table is compact rather than padded out for hypothetical large // values. The TOTAL row still aligns with the data row. var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); var lots = [_]zfin.Lot{ .{ .symbol = "IBM", .shares = 10, .open_date = zfin.Date.fromYmd(2024, 1, 2), .open_price = 150.0, .account = "Sample Account" }, }; var portfolio = testPortfolio(&lots); var positions = [_]zfin.Position{ .{ .symbol = "IBM", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; var allocs = [_]zfin.valuation.Allocation{ .{ .symbol = "IBM", .display_symbol = "IBM", .shares = 10, .avg_cost = 150.0, .current_price = 155.0, .market_value = 1550.0, .cost_basis = 1500.0, .weight = 1.0, .unrealized_gain_loss = 50.0, .unrealized_return = 0.0333 }, }; const summary = testSummary(&allocs); 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, "small.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup); const out = w.buffered(); // All dynamic columns are at their header-label minimums, so the // labels sit flush against each other with a single separator space: // "Symbol"(6) + " " + "Shares"(6) + " " + "Avg Cost"(8). With the old // fixed widths (Symbol=10, Shares=12) there would be many spaces here, // so this pins the "tighten, don't waste space" behavior. try testing.expect(std.mem.indexOf(u8, out, "Symbol Shares Avg Cost") != null); // Data row market value aligns with the TOTAL row market value. var data_col: ?usize = null; var total_col: ?usize = null; var lit = std.mem.splitScalar(u8, out, '\n'); while (lit.next()) |line| { const at = std.mem.indexOf(u8, line, "$1,550.00") orelse continue; if (std.mem.indexOf(u8, line, "IBM") != null) { data_col = at; } else if (std.mem.indexOf(u8, line, "TOTAL") != null) { total_col = at; } } try testing.expect(data_col != null); try testing.expect(total_col != null); try testing.expectEqual(data_col.?, total_col.?); // No ANSI codes when color is disabled. 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), .rollup); 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), .rollup); 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(2027, 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), .rollup); 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), .rollup); 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), .rollup); 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); } // ── expired-mode display tests ───────────────────────────────── /// Build a fixture with one active + one expired option, one active /// + one matured CD (plus a stock position), and render it via /// `display` in the given mode into `w`. as_of = 2026-05-08. fn renderExpiredFixture(w: *std.Io.Writer, mode: ExpiredMode) !void { var lots = [_]zfin.Lot{ .{ .symbol = "VTI", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 200.0 }, // active option (future expiry); sold-to-open .{ .symbol = "OPT-ACTIVE", .shares = -1, .open_date = zfin.Date.fromYmd(2026, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = zfin.Date.fromYmd(2026, 12, 1) }, // expired option (past expiry) .{ .symbol = "OPT-EXPIRED", .shares = 1, .open_date = zfin.Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .option, .maturity_date = zfin.Date.fromYmd(2024, 6, 1) }, // active CD (future maturity); $10,000 face .{ .symbol = "CD-ACTIVE", .shares = 10000, .open_date = zfin.Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cd, .rate = 4.5, .maturity_date = zfin.Date.fromYmd(2027, 6, 15) }, // matured CD (past maturity); $5,000 face .{ .symbol = "CD-MATURED", .shares = 5000, .open_date = zfin.Date.fromYmd(2022, 1, 1), .open_price = 1.0, .security_type = .cd, .rate = 3.0, .maturity_date = zfin.Date.fromYmd(2024, 6, 15) }, }; 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 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), mode); } test "display: expired options/CDs are rolled up by default" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try renderExpiredFixture(&w, .rollup); const out = w.buffered(); // Active rows visible. try testing.expect(std.mem.indexOf(u8, out, "OPT-ACTIVE") != null); try testing.expect(std.mem.indexOf(u8, out, "CD-ACTIVE") != null); // Expired/matured rows hidden. try testing.expect(std.mem.indexOf(u8, out, "OPT-EXPIRED") == null); try testing.expect(std.mem.indexOf(u8, out, "CD-MATURED") == null); // One-line rollup summaries with a hint. try testing.expect(std.mem.indexOf(u8, out, "1 expired option") != null); try testing.expect(std.mem.indexOf(u8, out, "1 matured CD") != null); try testing.expect(std.mem.indexOf(u8, out, "--expired=show") != null); // CD TOTAL is active-only ($10,000, not $15,000). try testing.expect(std.mem.indexOf(u8, out, "$10,000.00") != null); try testing.expect(std.mem.indexOf(u8, out, "$15,000.00") == null); } test "display: --expired=show lists expired rows, no rollup line" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try renderExpiredFixture(&w, .show); const out = w.buffered(); // Every row visible, active and expired alike. try testing.expect(std.mem.indexOf(u8, out, "OPT-ACTIVE") != null); try testing.expect(std.mem.indexOf(u8, out, "OPT-EXPIRED") != null); try testing.expect(std.mem.indexOf(u8, out, "CD-ACTIVE") != null); try testing.expect(std.mem.indexOf(u8, out, "CD-MATURED") != null); // No rollup summary in show mode. try testing.expect(std.mem.indexOf(u8, out, "expired option") == null); try testing.expect(std.mem.indexOf(u8, out, "matured CD") == null); // TOTAL still active-only. try testing.expect(std.mem.indexOf(u8, out, "$10,000.00") != null); try testing.expect(std.mem.indexOf(u8, out, "$15,000.00") == null); } test "display: --expired=hide omits expired rows entirely" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try renderExpiredFixture(&w, .hide); const out = w.buffered(); // Active rows visible. try testing.expect(std.mem.indexOf(u8, out, "OPT-ACTIVE") != null); try testing.expect(std.mem.indexOf(u8, out, "CD-ACTIVE") != null); // Expired rows and rollup summaries both gone. try testing.expect(std.mem.indexOf(u8, out, "OPT-EXPIRED") == null); try testing.expect(std.mem.indexOf(u8, out, "CD-MATURED") == null); try testing.expect(std.mem.indexOf(u8, out, "expired option") == null); try testing.expect(std.mem.indexOf(u8, out, "matured CD") == null); // TOTAL active-only. try testing.expect(std.mem.indexOf(u8, out, "$10,000.00") != null); } test "display: section TOTAL line aligns with its data rows" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try renderExpiredFixture(&w, .rollup); const out = w.buffered(); // The active CD face ($10,000.00) shows in both the CD-ACTIVE data // row and the CD section TOTAL line; an aligned total puts the value // at the same column in both. (Before the prefix fix the TOTAL was // shifted 2 columns left.) var first_col: ?usize = null; var count: usize = 0; var it = std.mem.splitScalar(u8, out, '\n'); while (it.next()) |line| { if (std.mem.indexOf(u8, line, "$10,000.00")) |col| { count += 1; if (first_col) |fc| try testing.expectEqual(fc, col) else first_col = col; } } try testing.expectEqual(@as(usize, 2), count); } // ── parseArgs tests ──────────────────────────────────────────── test "parseArgs: no args returns default ParsedArgs" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{}; const parsed = try parseArgs(&ctx, &args); try std.testing.expectEqual(ExpiredMode.rollup, parsed.expired); } 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)); } test "parseArgs: --expired=show parses" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{"--expired=show"}; const parsed = try parseArgs(&ctx, &args); try std.testing.expectEqual(ExpiredMode.show, parsed.expired); } test "parseArgs: --expired=hide parses" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{"--expired=hide"}; const parsed = try parseArgs(&ctx, &args); try std.testing.expectEqual(ExpiredMode.hide, parsed.expired); } test "parseArgs: --expired with bad value errors" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{"--expired=bogus"}; try std.testing.expectError(error.InvalidExpiredValue, parseArgs(&ctx, &args)); } test "parseArgs: --expired without value errors" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{"--expired"}; try std.testing.expectError(error.InvalidExpiredValue, parseArgs(&ctx, &args)); } test "printLotRow: renders effective (split-adjusted) shares, cost, and value" { var lots = [_]zfin.Lot{ .{ .symbol = "NVDA", .shares = 100, .open_date = zfin.Date.fromYmd(2020, 1, 1), .open_price = 40, .split_factor = 10.0 }, }; const no_allocs: []const zfin.valuation.Allocation = &.{}; const no_watch: []const []const u8 = &.{}; const widths = views.computeWidths(no_allocs, &lots, 0, 0, no_watch, null); var buf: [512]u8 = undefined; var w = std.Io.Writer.fixed(&buf); try printLotRow(zfin.Date.fromYmd(2026, 1, 1), &w, false, lots[0], 120.0, widths); const out = w.buffered(); // Effective shares 1000.0 (not raw 100.0), effective cost $4.00 // (40 / 10), market value 1000 * 120 = $120,000. try std.testing.expect(std.mem.indexOf(u8, out, "1000.0") != null); try std.testing.expect(std.mem.indexOf(u8, out, "$4.00") != null); try std.testing.expect(std.mem.indexOf(u8, out, "120,000") != null); }