diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 3dcd27a..6a8d34e 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -6,14 +6,35 @@ const fmt = cli.fmt; const Money = @import("../Money.zig"); const views = @import("../views/portfolio_sections.zig"); -pub const ParsedArgs = struct {}; +/// 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 + \\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, @@ -22,6 +43,12 @@ pub const meta: framework.Meta = .{ \\`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 @@ -30,12 +57,26 @@ pub const meta: framework.Meta = .{ \\ , .uppercase_first_arg = false, - .user_errors = error{UnexpectedArg}, + .user_errors = error{ UnexpectedArg, InvalidExpiredValue }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { - if (cmd_args.len > 0) { - const a = cmd_args[0]; + 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; @@ -45,10 +86,10 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } - return .{}; + return parsed; } -pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { +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; @@ -202,6 +243,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { watch_list.items, watch_prices, as_of, + parsed.expired, ); } @@ -217,6 +259,7 @@ pub fn display( 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 @@ -395,17 +438,25 @@ pub fn display( 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) { + 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", .{}); - 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); + 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 (prepared_opts.items) |po| { - opt_total_premium += po.premium; + 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); @@ -417,11 +468,19 @@ pub fn display( 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), - }); + // 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 }); + } } } @@ -429,24 +488,39 @@ pub fn display( 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) { + 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", .{}); - 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); + 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 (prepared_cds.items) |pc| { - cd_section_total += pc.lot.shares; + 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 - 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), - }); + // 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 }); + } } } @@ -625,7 +699,7 @@ test "display shows header and summary" { 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)); + 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 @@ -677,7 +751,7 @@ test "display with watchlist" { 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)); + 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 @@ -719,7 +793,7 @@ test "display with options section" { 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)); + 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 @@ -734,7 +808,7 @@ test "display with CDs and cash" { 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 = "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); @@ -761,7 +835,7 @@ test "display with CDs and cash" { 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)); + 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 @@ -805,7 +879,7 @@ test "display realized PnL shown when nonzero" { 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)); + 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); @@ -840,20 +914,138 @@ test "display empty watchlist not shown" { 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)); + 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 empty ParsedArgs" { +test "parseArgs: no args returns default ParsedArgs" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{}; - _ = try parseArgs(&ctx, &args); + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqual(ExpiredMode.rollup, parsed.expired); } test "parseArgs: --refresh rejected (now a global)" { @@ -869,3 +1061,33 @@ test "parseArgs: unexpected args error" { 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)); +} diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 75f1e41..2c76b45 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -103,7 +103,7 @@ pub const PortfolioRow = struct { /// Column offset for premium alt-style coloring (options only) premium_col_start: usize = 0, - const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary }; + const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, options_expired_rollup, cds_expired_rollup, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary }; }; // ── Tab-local action enum ───────────────────────────────────── @@ -175,6 +175,12 @@ pub const State = struct { /// Whether the illiquid section is expanded to show /// per-asset rows. illiquid_expanded: bool = false, + /// Whether the Options section's rolled-up expired contracts + /// are expanded to individual (muted) rows. + options_expired_expanded: bool = false, + /// Whether the CDs section's rolled-up matured CDs are expanded + /// to individual (muted) rows. + cds_expired_expanded: bool = false, /// Flat list of styled rows for the current view, rebuilt by /// `rebuildPortfolioRows` whenever sort / filter / expansion /// changes. Owned by State. @@ -481,6 +487,8 @@ pub const tab = struct { state.expanded = @splat(false); state.cash_expanded = false; state.illiquid_expanded = false; + state.options_expired_expanded = false; + state.cds_expired_expanded = false; state.cursor = 0; app.scroll_offset = 0; } @@ -642,6 +650,14 @@ fn toggleExpandAtCursor(state: *State, app: *App) void { } }, .lot, .option_row, .cd_row, .cash_row, .illiquid_row, .section_header, .drip_summary => {}, + .options_expired_rollup => { + state.options_expired_expanded = !state.options_expired_expanded; + rebuildPortfolioRows(state, app); + }, + .cds_expired_rollup => { + state.cds_expired_expanded = !state.cds_expired_expanded; + rebuildPortfolioRows(state, app); + }, .cash_total => { state.cash_expanded = !state.cash_expanded; rebuildPortfolioRows(state, app); @@ -929,12 +945,14 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void { if (app.portfolio.file) |pf| { state.prepared_options = views.Options.init(app.today, app.allocator, pf.lots, state.account_filter) catch null; if (state.prepared_options) |opts| { - if (opts.items.len > 0) { + const active = opts.activeItems(); + const expired = opts.expiredItems(); + if (active.len > 0 or expired.len > 0) { try state.rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Options", }); - for (opts.items) |po| { + for (active) |po| { state.rows.append(app.allocator, .{ .kind = .option_row, .symbol = po.lot.symbol, @@ -945,18 +963,41 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void { .premium_col_start = po.premium_col_start, }) catch continue; } + // Expired contracts: a rolled-up summary row, expandable + // into individual (muted) rows. + if (expired.len > 0) { + try state.rows.append(app.allocator, .{ + .kind = .options_expired_rollup, + .symbol = "Options", + }); + if (state.options_expired_expanded) { + for (expired) |po| { + state.rows.append(app.allocator, .{ + .kind = .option_row, + .symbol = po.lot.symbol, + .lot = po.lot, + .prepared_text = po.columns[0].text, + .row_style = po.row_style, + .premium_style = po.premium_style, + .premium_col_start = po.premium_col_start, + }) catch continue; + } + } + } } } // CDs section (sorted by maturity date, earliest first; filtered by account) state.prepared_cds = views.CDs.init(app.today, app.allocator, pf.lots, state.account_filter) catch null; if (state.prepared_cds) |cds| { - if (cds.items.len > 0) { + const active = cds.activeItems(); + const expired = cds.expiredItems(); + if (active.len > 0 or expired.len > 0) { try state.rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Certificates of Deposit", }); - for (cds.items) |pc| { + for (active) |pc| { state.rows.append(app.allocator, .{ .kind = .cd_row, .symbol = pc.lot.symbol, @@ -965,6 +1006,25 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void { .row_style = pc.row_style, }) catch continue; } + // Matured CDs: a rolled-up summary row, expandable into + // individual (muted) rows. + if (expired.len > 0) { + try state.rows.append(app.allocator, .{ + .kind = .cds_expired_rollup, + .symbol = "Certificates of Deposit", + }); + if (state.cds_expired_expanded) { + for (expired) |pc| { + state.rows.append(app.allocator, .{ + .kind = .cd_row, + .symbol = pc.lot.symbol, + .lot = pc.lot, + .prepared_text = pc.text, + .row_style = pc.row_style, + }) catch continue; + } + } + } } } @@ -1610,6 +1670,26 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va try lines.append(arena, .{ .text = text, .style = row_style3 }); } }, + .options_expired_rollup => { + if (state.prepared_options) |opts| { + const n = opts.expiredItems().len; + const arrow: []const u8 = if (state.options_expired_expanded) "v " else "> "; + const noun: []const u8 = if (n == 1) "expired option" else "expired options"; + const text = try std.fmt.allocPrint(arena, " {s}{d} {s}", .{ arrow, n, noun }); + const rollup_style = if (is_cursor) th.selectStyle() else th.mutedStyle(); + try lines.append(arena, .{ .text = text, .style = rollup_style }); + } + }, + .cds_expired_rollup => { + if (state.prepared_cds) |cds| { + const n = cds.expiredItems().len; + const arrow: []const u8 = if (state.cds_expired_expanded) "v " else "> "; + const noun: []const u8 = if (n == 1) "matured CD" else "matured CDs"; + const text = try std.fmt.allocPrint(arena, " {s}{d} {s}", .{ arrow, n, noun }); + const rollup_style = if (is_cursor) th.selectStyle() else th.mutedStyle(); + try lines.append(arena, .{ .text = text, .style = rollup_style }); + } + }, .cash_total => { if (app.portfolio.file) |pf| { const total_cash = pf.totalCash(app.today); diff --git a/src/views/portfolio_sections.zig b/src/views/portfolio_sections.zig index 26ea11a..528e2fc 100644 --- a/src/views/portfolio_sections.zig +++ b/src/views/portfolio_sections.zig @@ -53,10 +53,30 @@ pub const Option = struct { }; /// Collection of prepared option rows. Owns all allocated text. +/// +/// Rows are sorted by maturity ascending (then symbol), so expired +/// rows form a contiguous prefix: `items[0..expired_count]` are the +/// expired contracts and `items[expired_count..]` are the active ones +/// (including null-maturity lots, which sort last and are never +/// expired). Use `expiredItems()` / `activeItems()` rather than +/// re-deriving the split. pub const Options = struct { items: []const Option, + /// Count of leading expired rows (the contiguous prefix of + /// `items` whose `is_expired` is true). + expired_count: usize, allocator: std.mem.Allocator, + /// Expired option rows (matured strictly before `as_of`). + pub fn expiredItems(self: Options) []const Option { + return self.items[0..self.expired_count]; + } + + /// Active option rows (not yet expired, including null-maturity). + pub fn activeItems(self: Options) []const Option { + return self.items[self.expired_count..]; + } + /// Build sorted, filtered, display-ready option rows from raw lots. pub fn init(as_of: Date, allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !Options { var list: std.ArrayList(Option) = .empty; @@ -77,11 +97,13 @@ pub const Options = struct { } std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturityThenSymbolSortFn); + var expired_count: usize = 0; for (tmp.items) |lot| { const qty = lot.shares; const cost_per = lot.open_price; const premium = @abs(qty) * cost_per * lot.multiplier; const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false; + if (is_expired) expired_count += 1; const received = qty < 0; const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal; @@ -119,7 +141,7 @@ pub const Options = struct { .premium_col_start = OptionsLayout.premium_col_start, }); } - return .{ .items = try list.toOwnedSlice(allocator), .allocator = allocator }; + return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator }; } pub fn deinit(self: *Options) void { @@ -160,10 +182,29 @@ pub const CD = struct { }; /// Collection of prepared CD rows. Owns all allocated text. +/// +/// Rows are sorted by maturity ascending, so matured rows form a +/// contiguous prefix: `items[0..expired_count]` are matured and +/// `items[expired_count..]` are still-active (including null-maturity +/// lots, which sort last and are never expired). Use `expiredItems()` +/// / `activeItems()` rather than re-deriving the split. pub const CDs = struct { items: []const CD, + /// Count of leading matured rows (the contiguous prefix of + /// `items` whose `is_expired` is true). + expired_count: usize, allocator: std.mem.Allocator, + /// Matured CD rows (maturity strictly before `as_of`). + pub fn expiredItems(self: CDs) []const CD { + return self.items[0..self.expired_count]; + } + + /// Active CD rows (not yet matured, including null-maturity). + pub fn activeItems(self: CDs) []const CD { + return self.items[self.expired_count..]; + } + /// Build sorted, filtered, display-ready CD rows from raw lots. pub fn init(as_of: Date, allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !CDs { var list: std.ArrayList(CD) = .empty; @@ -184,8 +225,10 @@ pub const CDs = struct { } std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturitySortFn); + var expired_count: usize = 0; for (tmp.items) |lot| { const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false; + if (is_expired) expired_count += 1; const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal; var face_buf: [24]u8 = undefined; @@ -216,7 +259,7 @@ pub const CDs = struct { .text = text, }); } - return .{ .items = try list.toOwnedSlice(allocator), .allocator = allocator }; + return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator }; } pub fn deinit(self: *CDs) void { @@ -225,3 +268,75 @@ pub const CDs = struct { self.items = &.{}; } }; + +// ── Tests ───────────────────────────────────────────────────── + +const testing = std.testing; + +test "Options.init: expired rows form a prefix; active/expired slices split correctly" { + const as_of = Date.fromYmd(2024, 6, 1); + const lots = [_]Lot{ + // active (future maturity) + .{ .symbol = "AAA 2024-12-01 C100", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 12, 1) }, + // expired (past maturity) + .{ .symbol = "BBB 2024-01-01 C50", .shares = 1, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 1.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 1, 1) }, + // null-maturity option: never expired, sorts last + .{ .symbol = "CCC", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 3.0, .security_type = .option }, + // non-option: ignored + .{ .symbol = "ZZZ", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .stock }, + }; + var opts = try Options.init(as_of, testing.allocator, &lots, null); + defer opts.deinit(); + + try testing.expectEqual(@as(usize, 3), opts.items.len); + try testing.expectEqual(@as(usize, 1), opts.expired_count); + try testing.expectEqual(@as(usize, 1), opts.expiredItems().len); + try testing.expectEqual(@as(usize, 2), opts.activeItems().len); + try testing.expect(opts.expiredItems()[0].is_expired); + try testing.expectEqualStrings("BBB 2024-01-01 C50", opts.expiredItems()[0].lot.symbol); + for (opts.activeItems()) |a| try testing.expect(!a.is_expired); +} + +test "Options.init: no expired items yields empty expired slice" { + const as_of = Date.fromYmd(2024, 6, 1); + const lots = [_]Lot{ + .{ .symbol = "AAA", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 12, 1) }, + }; + var opts = try Options.init(as_of, testing.allocator, &lots, null); + defer opts.deinit(); + try testing.expectEqual(@as(usize, 0), opts.expired_count); + try testing.expectEqual(@as(usize, 0), opts.expiredItems().len); + try testing.expectEqual(@as(usize, 1), opts.activeItems().len); +} + +test "CDs.init: matured rows form a prefix; active/expired slices split correctly" { + const as_of = Date.fromYmd(2024, 6, 1); + const lots = [_]Lot{ + .{ .symbol = "CD-ACTIVE", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2025, 1, 1), .rate = 4.5 }, + .{ .symbol = "CD-MATURED", .shares = 5000, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2024, 1, 1), .rate = 3.0 }, + }; + var cds = try CDs.init(as_of, testing.allocator, &lots, null); + defer cds.deinit(); + + try testing.expectEqual(@as(usize, 2), cds.items.len); + try testing.expectEqual(@as(usize, 1), cds.expired_count); + try testing.expectEqual(@as(usize, 1), cds.expiredItems().len); + try testing.expectEqual(@as(usize, 1), cds.activeItems().len); + try testing.expect(cds.expiredItems()[0].is_expired); + try testing.expect(!cds.activeItems()[0].is_expired); + try testing.expectEqualStrings("CD-MATURED", cds.expiredItems()[0].lot.symbol); + try testing.expectEqualStrings("CD-ACTIVE", cds.activeItems()[0].lot.symbol); +} + +test "CDs.init: all matured yields empty active slice" { + const as_of = Date.fromYmd(2024, 6, 1); + const lots = [_]Lot{ + .{ .symbol = "CD-OLD-1", .shares = 5000, .open_date = Date.fromYmd(2022, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2023, 1, 1) }, + .{ .symbol = "CD-OLD-2", .shares = 7000, .open_date = Date.fromYmd(2022, 6, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2024, 1, 1) }, + }; + var cds = try CDs.init(as_of, testing.allocator, &lots, null); + defer cds.deinit(); + try testing.expectEqual(@as(usize, 2), cds.expired_count); + try testing.expectEqual(@as(usize, 2), cds.expiredItems().len); + try testing.expectEqual(@as(usize, 0), cds.activeItems().len); +}