collapse expired options/matured cds
This commit is contained in:
parent
ed2c0b8c5d
commit
839f0e759f
3 changed files with 464 additions and 47 deletions
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue