collapse expired options/matured cds

This commit is contained in:
Emil Lerch 2026-06-19 17:39:22 -07:00
parent ed2c0b8c5d
commit 839f0e759f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 464 additions and 47 deletions

View file

@ -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=<value>`
\\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));
}

View file

@ -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);

View file

@ -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);
}