zfin/src/commands/portfolio.zig
Emil Lerch 9aaa374f12
All checks were successful
Generic zig build / build (push) Successful in 5m45s
Generic zig build / publish-macos (push) Successful in 12s
Generic zig build / deploy (push) Successful in 2m33s
address uaf in portfolio command watchlist
2026-07-06 13:12:10 -07:00

1326 lines
59 KiB
Zig

const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const views = @import("../views/portfolio_sections.zig");
/// Main holdings-table column layout (widths + format strings).
const pl = views.PositionsLayout;
/// Visibility of expired options / matured CDs within the Options
/// and CDs sections.
pub const ExpiredMode = enum {
/// Collapse expired rows into a one-line per-section summary
/// (the default).
rollup,
/// List every expired row (muted), alongside the active rows.
show,
/// Omit expired rows entirely.
hide,
fn parse(s: []const u8) ?ExpiredMode {
if (std.mem.eql(u8, s, "rollup")) return .rollup;
if (std.mem.eql(u8, s, "show")) return .show;
if (std.mem.eql(u8, s, "hide")) return .hide;
return null;
}
};
pub const ParsedArgs = struct {
expired: ExpiredMode = .rollup,
};
pub const meta: framework.Meta = .{
.name = "portfolio",
.group = .portfolio,
.synopsis = "Load and analyze the portfolio (positions + valuations + watchlist)",
.help =
\\Usage: zfin portfolio [--expired=rollup|show|hide]
\\
\\Load `portfolio.srf` (cwd -> ZFIN_HOME), refresh per-symbol
\\prices in parallel (server sync where ZFIN_SERVER is set,
\\else providers), and print the position table + valuations
\\+ historical-snapshot mini-tables. The watchlist (if
\\`watchlist.srf` exists) is appended to the price-load step
\\so its quotes show alongside.
\\
\\Expired options and matured CDs are collapsed into a one-line
\\summary at the foot of their section by default (`--expired=rollup`).
\\Use `--expired=show` to list them (muted) or `--expired=hide`
\\to omit them entirely. Section TOTAL lines always reflect
\\active holdings only.
\\
\\Refresh policy comes from the global `--refresh-data=<value>`
\\flag (default: auto). Use `--refresh-data=force` to force a
\\re-fetch of every symbol's candles, bypassing the per-symbol
\\TTL freshness check. Use `--refresh-data=never` to serve
\\cache contents only (offline mode).
\\
,
.uppercase_first_arg = false,
.user_errors = error{ UnexpectedArg, InvalidExpiredValue },
};
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
var parsed: ParsedArgs = .{};
for (cmd_args) |a| {
if (std.mem.startsWith(u8, a, "--expired=")) {
const val = a["--expired=".len..];
parsed.expired = ExpiredMode.parse(val) orelse {
cli.stderrPrint(ctx.io, "Error: invalid --expired value '");
cli.stderrPrint(ctx.io, val);
cli.stderrPrint(ctx.io, "' (expected: rollup, show, or hide)\n");
return error.InvalidExpiredValue;
};
continue;
}
if (std.mem.eql(u8, a, "--expired")) {
cli.stderrPrint(ctx.io, "Error: --expired requires a value (--expired=rollup|show|hide)\n");
return error.InvalidExpiredValue;
}
if (std.mem.eql(u8, a, "--refresh")) {
cli.stderrPrint(ctx.io, "Error: --refresh is now a global flag. Use `zfin --refresh-data=force portfolio` instead.\n");
return error.UnexpectedArg;
}
cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': ");
cli.stderrPrint(ctx.io, a);
cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg;
}
return parsed;
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const io = ctx.io;
const allocator = ctx.allocator;
const out = ctx.out;
const color = ctx.color;
const as_of = ctx.today;
const wl = ctx.resolveWatchlistPath();
defer wl.deinit(allocator);
const watchlist_path: ?[]const u8 =
if (ctx.globals.watchlist_path != null or wl.resolved != null) wl.path else null;
// Resolve patterns + load (and union-merge) every matched
// portfolio file in one shot. The returned LoadedPortfolio
// carries the resolved paths so we can derive an anchor and
// a multi-file display label without a second resolver call.
var loaded = cli.loadPortfolio(ctx, as_of) orelse return;
defer loaded.deinit(allocator);
const portfolio = loaded.portfolio;
const positions = loaded.positions;
const syms = loaded.syms;
const anchor_path = loaded.anchor();
if (portfolio.lots.len == 0) {
cli.stderrPrint(io, "Portfolio is empty.\n");
return;
}
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
var fail_count: usize = 0;
// Also collect watch symbols that need fetching
var watch_syms: std.ArrayList([]const u8) = .empty;
defer watch_syms.deinit(allocator);
{
var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit();
for (syms) |s| try seen.put(s, {});
for (portfolio.lots) |lot| {
if (lot.security_type == .watch and !seen.contains(lot.priceSymbol())) {
try seen.put(lot.priceSymbol(), {});
try watch_syms.append(allocator, lot.priceSymbol());
}
}
}
// All symbols to fetch (stock positions + watch)
const all_syms_count = syms.len + watch_syms.items.len;
if (all_syms_count > 0) {
// Use consolidated parallel loader
var load_result = cli.loadPortfolioPrices(
io,
svc,
syms,
watch_syms.items,
ctx.globals.refresh_policy,
color,
);
defer load_result.deinit(); // Free the prices hashmap after we copy
// Transfer prices to our local map
var it = load_result.prices.iterator();
while (it.next()) |entry| {
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
fail_count = load_result.failed_count;
}
// Build portfolio summary, candle map, and historical snapshots
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
cli.stderrPrint(io, "Error computing portfolio summary.\n");
return;
},
else => return err,
};
defer pf_data.deinit(allocator);
// Sort allocations alphabetically by symbol
std.mem.sort(zfin.valuation.Allocation, pf_data.summary.allocations, {}, struct {
fn f(_: void, a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool {
return std.mem.lessThan(u8, a.display_symbol, b.display_symbol);
}
}.f);
// Separate watchlist file (backward compat). Loaded here at the run
// scope - NOT inside the collection block below - so its symbol
// strings outlive the `display` call at the end of `run`.
// `watch_list` (and `watch_prices`' keys) borrow these slices;
// freeing them before display - the previous behavior, where the
// load + `defer freeWatchlist` lived inside the collection block -
// rendered the file's watch symbols as freed-memory garbage on the
// CLI. The TUI is unaffected: it keeps watchlist symbols in its
// long-lived arena.
const wl_syms: ?[][]const u8 = if (watchlist_path) |wl_path|
cli.loadWatchlist(io, allocator, wl_path)
else
null;
defer cli.freeWatchlist(allocator, wl_syms);
// Collect watch symbols and their prices for display.
// Includes watch lots from portfolio + symbols from the watchlist file.
var watch_list: std.ArrayList([]const u8) = .empty;
defer watch_list.deinit(allocator);
var watch_prices = std.StringHashMap(f64).init(allocator);
defer watch_prices.deinit();
{
var watch_seen = std.StringHashMap(void).init(allocator);
defer watch_seen.deinit();
// Exclude portfolio position symbols from watchlist
for (pf_data.summary.allocations) |a| {
try watch_seen.put(a.symbol, {});
}
// Watch lots from portfolio
for (portfolio.lots) |lot| {
if (lot.security_type == .watch) {
const sym = lot.priceSymbol();
if (watch_seen.contains(sym)) continue;
try watch_seen.put(sym, {});
try watch_list.append(allocator, sym);
if (svc.getCachedLastClose(sym)) |close| {
try watch_prices.put(sym, close);
}
}
}
// Symbols from the separate watchlist file (loaded above).
if (wl_syms) |syms_list| {
for (syms_list) |sym| {
if (watch_seen.contains(sym)) continue;
try watch_seen.put(sym, {});
try watch_list.append(allocator, sym);
if (svc.getCachedLastClose(sym)) |close| {
try watch_prices.put(sym, close);
}
}
}
}
// Build a display label: anchor file plus "(+N more)" when
// multiple portfolio files contributed lots. Keeps the existing
// single-file UX unchanged while making multi-file mode visible.
var label_buf: [512]u8 = undefined;
const display_label: []const u8 = if (loaded.paths.len > 1)
std.fmt.bufPrint(&label_buf, "{s} (+{d} more)", .{ anchor_path, loaded.paths.len - 1 }) catch anchor_path
else
anchor_path;
try display(
allocator,
out,
color,
display_label,
&portfolio,
positions,
&pf_data,
watch_list.items,
watch_prices,
as_of,
parsed.expired,
);
}
/// Render the full portfolio display. All data is pre-fetched; no service calls.
pub fn display(
allocator: std.mem.Allocator,
out: *std.Io.Writer,
color: bool,
file_path: []const u8,
portfolio: *const zfin.Portfolio,
positions: []const zfin.Position,
pf_data: *const cli.PortfolioData,
watch_symbols: []const []const u8,
watch_prices: std.StringHashMap(f64),
as_of: zfin.Date,
expired_mode: ExpiredMode,
) !void {
const summary = &pf_data.summary;
// Header with summary
try cli.printBold(out, color, "\nPortfolio Summary ({s})\n", .{file_path});
try out.print("========================================\n", .{});
// Summary bar
{
const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
try out.print(" Value: {f} Cost: {f} ", .{ Money.from(summary.total_value), Money.from(summary.total_cost) });
try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "Gain/Loss: {c}{f} ({d:.1}%)", .{
@as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'),
Money.from(gl_abs),
summary.unrealized_return * 100.0,
});
try out.print("\n", .{});
}
// Lot counts (stocks/ETFs only)
var open_lots: u32 = 0;
var closed_lots: u32 = 0;
for (portfolio.lots) |lot| {
if (lot.security_type != .stock) continue;
if (lot.isOpen(as_of)) open_lots += 1 else closed_lots += 1;
}
try cli.printFg(out, color, cli.CLR_MUTED, " Lots: {d} open, {d} closed Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len });
// Historical portfolio value snapshots
{
if (pf_data.snapshots) |snapshots| {
try out.print(" Historical: ", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
const snap = snapshots[pi];
var hbuf: [16]u8 = undefined;
const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct());
if (snap.position_count > 0) try cli.setGainLoss(out, color, snap.changePct());
try out.print(" {s}: {s}", .{ period.label(), change_str });
if (pi < zfin.valuation.HistoricalPeriod.all.len - 1) try out.print(" ", .{});
}
try cli.reset(out, color);
try out.print("\n", .{});
}
}
// Column headers. Column widths fit the rendered content (each
// header label is the floor, the widest datum the ceiling); see
// views.computeWidths. Computed once and threaded through the rows,
// totals, and watchlist so every line aligns.
const w = views.computeWidths(
summary.allocations,
portfolio.lots,
summary.total_value,
summary.unrealized_gain_loss,
watch_symbols,
watch_prices,
);
try out.print("\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
try views.writeHeader(out, w);
try views.writeSeparator(out, w);
try cli.reset(out, color);
// Position rows with lot detail
for (summary.allocations) |a| {
// Count stock lots for this symbol
var lots_for_sym: std.ArrayList(zfin.Lot) = .empty;
defer lots_for_sym.deinit(allocator);
for (portfolio.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
try lots_for_sym.append(allocator, lot);
}
}
std.mem.sort(zfin.Lot, lots_for_sym.items, as_of, fmt.lotSortFn);
const is_multi = lots_for_sym.items.len > 1;
// Position summary row
{
const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss;
const sign: []const u8 = if (a.unrealized_gain_loss >= 0) "+" else "-";
// Date + ST/LT for single-lot positions
var date_col: [24]u8 = @splat(' ');
var date_col_len: usize = 0;
if (!is_multi and lots_for_sym.items.len == 1) {
const lot = lots_for_sym.items[0];
const indicator = fmt.capitalGainsIndicator(as_of, lot.open_date);
const written = std.fmt.bufPrint(&date_col, "{f} {s}", .{ lot.open_date, indicator }) catch "";
date_col_len = written.len;
}
if (a.is_manual_price) try cli.setFg(out, color, cli.CLR_WARNING);
try out.writeAll(" ");
try views.writeCol(out, a.display_symbol, w.symbol_w, false);
try out.writeByte(' ');
var shares_buf: [48]u8 = undefined;
const shares_str = std.fmt.bufPrint(&shares_buf, "{d:.1}", .{a.shares}) catch "?";
try views.writeCol(out, shares_str, w.shares_w, true);
try out.writeByte(' ');
try out.print("{f}", .{Money.from(a.avg_cost).padRight(w.price_w)});
try out.writeByte(' ');
try out.print("{f}", .{Money.from(a.current_price).padRight(w.price_w)});
try out.writeByte(' ');
try out.print("{f}", .{Money.from(a.market_value).padRight(w.value_w)});
try out.writeByte(' ');
try cli.setGainLoss(out, color, a.unrealized_gain_loss);
try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(w.gainloss_w - 1) });
if (a.is_manual_price) {
try cli.setFg(out, color, cli.CLR_WARNING);
} else {
try cli.reset(out, color);
}
try out.print(" " ++ pl.weight_num, .{a.weight * 100.0});
if (date_col_len > 0) {
try out.print(" {s}", .{date_col[0..date_col_len]});
}
// Account for single-lot
if (!is_multi and lots_for_sym.items.len == 1) {
if (lots_for_sym.items[0].account) |acct| {
try out.print(" {s}", .{acct});
}
}
if (a.is_manual_price) try cli.reset(out, color);
try out.print("\n", .{});
}
// Lot detail rows (always expanded for CLI)
if (is_multi) {
// Check if any lots are DRIP
var has_drip = false;
for (lots_for_sym.items) |lot| {
if (lot.drip) {
has_drip = true;
break;
}
}
if (!has_drip) {
// No DRIP: show all individually
for (lots_for_sym.items) |lot| {
try printLotRow(as_of, out, color, lot, a.current_price, w);
}
} else {
// Show non-DRIP lots individually
for (lots_for_sym.items) |lot| {
if (!lot.drip) {
try printLotRow(as_of, out, color, lot, a.current_price, w);
}
}
// Summarize DRIP lots as ST/LT
const drip = fmt.aggregateDripLots(as_of, lots_for_sym.items);
if (!drip.st.isEmpty()) {
var drip_buf: [128]u8 = undefined;
try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{fmt.fmtDripSummary(&drip_buf, "ST", drip.st)});
}
if (!drip.lt.isEmpty()) {
var drip_buf2: [128]u8 = undefined;
try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{fmt.fmtDripSummary(&drip_buf2, "LT", drip.lt)});
}
}
}
}
// Totals line
try cli.setFg(out, color, cli.CLR_MUTED);
try views.writeTotalSeparator(out, w);
try cli.reset(out, color);
{
const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
try out.writeAll(" ");
try views.writeCol(out, "", w.symbol_w, false); // blank symbol
try out.writeByte(' ');
try views.writeCol(out, "", w.shares_w, true); // blank shares
try out.writeByte(' ');
try views.writeCol(out, "", w.price_w, true); // blank avg cost
try out.writeByte(' ');
try views.writeCol(out, "TOTAL", w.price_w, true); // "TOTAL" in the price column
try out.writeByte(' ');
try out.print("{f} ", .{Money.from(summary.total_value).padRight(w.value_w)});
try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "{c}{f}", .{
@as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'),
Money.from(gl_abs).padRight(w.gainloss_w - 1),
});
try out.print(" " ++ pl.weight_str ++ "\n", .{"100.0%"});
}
if (summary.realized_gain_loss != 0) {
const rpl_abs = if (summary.realized_gain_loss >= 0) summary.realized_gain_loss else -summary.realized_gain_loss;
try cli.printGainLoss(out, color, summary.realized_gain_loss, "\n Realized P&L: {c}{f}\n", .{
@as(u8, if (summary.realized_gain_loss >= 0) '+' else '-'),
Money.from(rpl_abs),
});
}
// Options section
if (portfolio.hasType(.option)) {
var prepared_opts = try views.Options.init(as_of, allocator, portfolio.lots, null);
defer prepared_opts.deinit();
const active = prepared_opts.activeItems();
const expired = prepared_opts.expiredItems();
// `show` lists every row; `rollup`/`hide` list active only.
const opt_rows = if (expired_mode == .show) prepared_opts.items else active;
const show_rollup = expired_mode == .rollup and expired.len > 0;
if (opt_rows.len > 0 or show_rollup) {
try out.print("\n", .{});
try cli.printBold(out, color, " Options\n", .{});
if (opt_rows.len > 0) {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(views.OptionsLayout.header ++ "\n", views.OptionsLayout.header_labels);
try out.print(views.OptionsLayout.separator ++ "\n", views.OptionsLayout.separator_fills);
try cli.reset(out, color);
}
// TOTAL reflects active premium only, regardless of mode.
var opt_total_premium: f64 = 0;
for (active) |po| opt_total_premium += po.premium;
for (opt_rows) |po| {
const text = po.columns[0].text;
const prem_start = po.premium_col_start;
const prem_end = @min(prem_start + views.OptionsLayout.premium_w, text.len);
// Pre-premium portion
try cli.printIntent(out, color, po.row_style, "{s}", .{text[0..prem_start]});
// Premium column
try cli.printIntent(out, color, po.premium_style, "{s}", .{text[prem_start..prem_end]});
// Post-premium portion (account)
if (prem_end < text.len) try cli.printIntent(out, color, po.row_style, "{s}", .{text[prem_end..]});
try out.print("\n", .{});
}
// Options total (active only). 4-space prefix matches the
// section's data rows (OptionsLayout.prefix).
if (active.len > 0) {
try cli.printFg(out, color, cli.CLR_MUTED, " {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" });
try out.print(" {s:>30} {s:>6} {s:>12} {f}\n", .{
"", "", "TOTAL", Money.from(opt_total_premium).padRight(14),
});
}
// Rolled-up expired summary (count only).
if (show_rollup) {
const noun: []const u8 = if (expired.len == 1) "expired option" else "expired options";
try cli.printIntent(out, color, .muted, " {d} {s} (--expired=show)\n", .{ expired.len, noun });
}
}
}
// CDs section
if (portfolio.hasType(.cd)) {
var prepared_cds = try views.CDs.init(as_of, allocator, portfolio.lots, null);
defer prepared_cds.deinit();
const active = prepared_cds.activeItems();
const expired = prepared_cds.expiredItems();
const cd_rows = if (expired_mode == .show) prepared_cds.items else active;
const show_rollup = expired_mode == .rollup and expired.len > 0;
if (cd_rows.len > 0 or show_rollup) {
try out.print("\n", .{});
try cli.printBold(out, color, " Certificates of Deposit\n", .{});
if (cd_rows.len > 0) {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(views.CDsLayout.header ++ "\n", views.CDsLayout.header_labels);
try out.print(views.CDsLayout.separator ++ "\n", views.CDsLayout.separator_fills);
try cli.reset(out, color);
}
// TOTAL reflects active face value only, regardless of mode.
var cd_section_total: f64 = 0;
for (active) |pc| cd_section_total += pc.lot.shares;
for (cd_rows) |pc| {
try cli.printIntent(out, color, pc.row_style, "{s}\n", .{pc.text});
}
// CD total (active only). 4-space prefix matches the
// section's data rows (CDsLayout.prefix).
if (active.len > 0) {
try cli.printFg(out, color, cli.CLR_MUTED, " {s:->12} {s:->14}\n", .{ "", "" });
try out.print(" {s:>12} {f}\n", .{
"TOTAL", Money.from(cd_section_total).padRight(14),
});
}
// Rolled-up matured summary (count only).
if (show_rollup) {
const noun: []const u8 = if (expired.len == 1) "matured CD" else "matured CDs";
try cli.printIntent(out, color, .muted, " {d} {s} (--expired=show)\n", .{ expired.len, noun });
}
}
}
// Cash section
if (portfolio.hasType(.cash)) {
try out.print("\n", .{});
try cli.printBold(out, color, " Cash\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
var cash_hdr_buf: [80]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtCashHeader(&cash_hdr_buf)});
var cash_sep_buf: [80]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtCashSep(&cash_sep_buf)});
try cli.reset(out, color);
for (portfolio.lots) |lot| {
if (lot.security_type != .cash) continue;
const acct2: []const u8 = lot.account orelse "Unknown";
var row_buf: [160]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares, lot.note)});
}
// Cash total
var sep_buf: [80]u8 = undefined;
try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{fmt.fmtCashSep(&sep_buf)});
var total_buf: [80]u8 = undefined;
try cli.printBold(out, color, "{s}\n", .{fmt.fmtCashTotal(&total_buf, portfolio.totalCash(as_of))});
}
// Illiquid assets section
if (portfolio.hasType(.illiquid)) {
try out.print("\n", .{});
try cli.printBold(out, color, " Illiquid Assets\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
var il_hdr_buf: [80]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtIlliquidHeader(&il_hdr_buf)});
var il_sep_buf1: [80]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf1)});
try cli.reset(out, color);
for (portfolio.lots) |lot| {
if (lot.security_type != .illiquid) continue;
var il_row_buf: [160]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.displaySymbol(), lot.shares, lot.note)});
}
// Illiquid total
var il_sep_buf2: [80]u8 = undefined;
try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf2)});
var il_total_buf: [80]u8 = undefined;
try cli.printBold(out, color, "{s}\n", .{fmt.fmtIlliquidTotal(&il_total_buf, portfolio.totalIlliquid(as_of))});
}
// Net Worth (if illiquid assets exist)
if (portfolio.hasType(.illiquid)) {
const illiquid_total = portfolio.totalIlliquid(as_of);
const net_worth = zfin.valuation.netWorth(as_of, portfolio.*, summary.*);
try out.print("\n", .{});
try cli.printBold(out, color, " Net Worth: {f} (Liquid: {f} Illiquid: {f})\n", .{
Money.from(net_worth),
Money.from(summary.total_value),
Money.from(illiquid_total),
});
}
// Watchlist
if (watch_symbols.len > 0) {
try out.print("\n", .{});
try cli.printBold(out, color, " Watchlist:\n", .{});
for (watch_symbols) |sym| {
var price_str: [16]u8 = undefined;
const ps: []const u8 = if (watch_prices.get(sym)) |close|
std.fmt.bufPrint(&price_str, "{f}", .{Money.from(close)}) catch "$?"
else
"--";
try out.writeAll(" ");
try views.writeCol(out, sym, w.symbol_w, false);
try out.writeByte(' ');
try views.writeCol(out, ps, w.price_w, true);
try out.writeByte('\n');
}
}
// Per-symbol risk metrics (vol / Sharpe / max drawdown) used to
// print here. They moved to `zfin review`, which combines them
// with trailing returns + sector + tax-status into a single
// per-holding dashboard. The portfolio command now sticks to its
// job: positions + valuations + watchlist.
try out.print("\n", .{});
}
pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64, w: views.PositionsWidths) !void {
const indicator = fmt.capitalGainsIndicator(as_of, lot.open_date);
const status_str: []const u8 = if (lot.isOpen(as_of)) "open" else "closed";
const acct_col: []const u8 = lot.account orelse "";
const use_price = lot.close_price orelse current_price;
const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice());
const lot_gl_abs = if (gl >= 0) gl else -gl;
const lot_sign: []const u8 = if (gl >= 0) "+" else "-";
// Muted run: prefix (lots are indented one level past positions)
// through the market-value cell.
try cli.setFg(out, color, cli.CLR_MUTED);
try out.writeAll(" ");
try views.writeCol(out, status_str, w.symbol_w, false);
try out.writeByte(' ');
var shares_buf: [48]u8 = undefined;
const shares_str = std.fmt.bufPrint(&shares_buf, "{d:.1}", .{lot.effectiveShares()}) catch "?";
try views.writeCol(out, shares_str, w.shares_w, true);
try out.writeByte(' ');
try out.print("{f}", .{Money.from(lot.effectiveOpenPrice()).padRight(w.price_w)});
try out.writeByte(' ');
try views.writeCol(out, "", w.price_w, true); // blank current-price cell
try out.writeByte(' ');
try out.print("{f}", .{Money.from(lot.effectiveShares() * use_price).padRight(w.value_w)});
try out.writeByte(' ');
try cli.reset(out, color);
// Colored gain/loss cell.
try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(w.gainloss_w - 1) });
// Muted run: blank weight cell, then date / indicator / account.
try cli.setFg(out, color, cli.CLR_MUTED);
try out.writeByte(' ');
try views.writeCol(out, "", w.weight_w, true);
try out.print(" {f} {s} {s}\n", .{ lot.open_date, indicator, acct_col });
try cli.reset(out, color);
}
// ── Tests ────────────────────────────────────────────────────
const testing = std.testing;
/// Helper: build a minimal portfolio for testing.
/// Returns lots as a stack-allocated array and a Portfolio that references them.
/// Caller must NOT call deinit() since lots are stack-allocated.
fn testPortfolio(lots: []const zfin.Lot) zfin.Portfolio {
return .{
.lots = @constCast(lots),
.allocator = testing.allocator,
};
}
fn testSummary(allocations: []zfin.valuation.Allocation) zfin.valuation.PortfolioSummary {
var total_value: f64 = 0;
var total_cost: f64 = 0;
var unrealized_gain_loss: f64 = 0;
for (allocations) |a| {
total_value += a.market_value;
total_cost += a.cost_basis;
unrealized_gain_loss += a.unrealized_gain_loss;
}
return .{
.total_value = total_value,
.total_cost = total_cost,
.unrealized_gain_loss = unrealized_gain_loss,
.unrealized_return = if (total_cost > 0) unrealized_gain_loss / total_cost else 0,
.realized_gain_loss = 0,
.allocations = allocations,
};
}
fn testPortfolioData(summary: zfin.valuation.PortfolioSummary, candle_map: std.StringHashMap([]const zfin.Candle)) cli.PortfolioData {
return .{
.summary = summary,
.candle_map = candle_map,
.snapshots = null,
};
}
test "display shows header and summary" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var lots = [_]zfin.Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 15), .open_price = 150.0 },
.{ .symbol = "GOOG", .shares = 5, .open_date = zfin.Date.fromYmd(2023, 6, 1), .open_price = 120.0 },
};
var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{
.{ .symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
.{ .symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .total_cost = 600.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "AAPL", .display_symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .current_price = 175.0, .market_value = 1750.0, .cost_basis = 1500.0, .weight = 0.745, .unrealized_gain_loss = 250.0, .unrealized_return = 0.167 },
.{ .symbol = "GOOG", .display_symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .current_price = 140.0, .market_value = 700.0, .cost_basis = 600.0, .weight = 0.255, .unrealized_gain_loss = 100.0, .unrealized_return = 0.167 },
};
const summary = testSummary(&allocs);
var prices = std.StringHashMap(f64).init(testing.allocator);
defer prices.deinit();
try prices.put("AAPL", 175.0);
try prices.put("GOOG", 140.0);
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
const out = w.buffered();
// Header present
try testing.expect(std.mem.indexOf(u8, out, "Portfolio Summary (test.srf)") != null);
// Symbols present
try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try testing.expect(std.mem.indexOf(u8, out, "GOOG") != null);
// Column headers present
try testing.expect(std.mem.indexOf(u8, out, "Symbol") != null);
try testing.expect(std.mem.indexOf(u8, out, "Market Value") != null);
try testing.expect(std.mem.indexOf(u8, out, "Gain/Loss") != null);
// TOTAL line present
try testing.expect(std.mem.indexOf(u8, out, "TOTAL") != null);
try testing.expect(std.mem.indexOf(u8, out, "100.0%") != null);
// No ANSI codes
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
test "display widens columns for large crypto values" {
// Regression guard for the portfolio-table column widening. A long
// crypto symbol (DOGE-USD, 8 chars), a large share count, and a high
// price must render in full without overflowing or shifting the
// symbol / shares / price / value columns.
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var lots = [_]zfin.Lot{
.{ .symbol = "DOGE-USD", .shares = 10000, .open_date = zfin.Date.fromYmd(2024, 1, 2), .open_price = 41500.0, .account = "Sample Account" },
};
var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{
.{ .symbol = "DOGE-USD", .shares = 10000, .avg_cost = 41500.0, .total_cost = 415000000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "DOGE-USD", .display_symbol = "DOGE-USD", .shares = 10000, .avg_cost = 41500.0, .current_price = 42000.0, .market_value = 420000000.0, .cost_basis = 415000000.0, .weight = 1.0, .unrealized_gain_loss = 5000000.0, .unrealized_return = 0.01205 },
};
const summary = testSummary(&allocs);
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "crypto.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
const out = w.buffered();
// Full 8-char symbol present (not truncated).
try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD") != null);
// Fit-to-content: the Symbol column sizes to the longest symbol
// (DOGE-USD = 8 cols) and Shares to "10000.0" (7 cols), so the full
// symbol renders followed by exactly the 1-col separator and then
// the right-justified Shares value - no truncation, no wasted
// padding. (The old fixed 10-wide Symbol column left 2 trailing pad
// spaces here; fit-to-content removes them.)
try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD 10000.0") != null);
// Large share count, high price, and large market value render in full.
try testing.expect(std.mem.indexOf(u8, out, "10000.0") != null);
try testing.expect(std.mem.indexOf(u8, out, "$42,000.00") != null);
try testing.expect(std.mem.indexOf(u8, out, "$420,000,000.00") != null);
// Gain/Loss of $5M fits its column without truncation.
try testing.expect(std.mem.indexOf(u8, out, "$5,000,000.00") != null);
// No ANSI codes when color is disabled.
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
// The single position's market value equals the portfolio total, so
// it must appear at the same column in the data row and the TOTAL
// row. This guards the TOTAL line against drifting out of alignment
// when the data columns are widened.
var data_col: ?usize = null;
var total_col: ?usize = null;
var lit = std.mem.splitScalar(u8, out, '\n');
while (lit.next()) |line| {
const at = std.mem.indexOf(u8, line, "$420,000,000.00") orelse continue;
if (std.mem.indexOf(u8, line, "DOGE-USD") != null) {
data_col = at;
} else if (std.mem.indexOf(u8, line, "TOTAL") != null) {
total_col = at;
}
}
try testing.expect(data_col != null);
try testing.expect(total_col != null);
try testing.expectEqual(data_col.?, total_col.?);
// The Date column is left-justified text (like the symbol column), so
// the "Date" header sits over the START of the date values, and the
// Account column lines up too. Verify the header label and the data
// value share a column.
var hdr_date: ?usize = null;
var row_date: ?usize = null;
var hdr_acct: ?usize = null;
var row_acct: ?usize = null;
var dit = std.mem.splitScalar(u8, out, '\n');
while (dit.next()) |line| {
if (std.mem.indexOf(u8, line, "Symbol") != null) {
hdr_date = std.mem.indexOf(u8, line, "Date");
hdr_acct = std.mem.indexOf(u8, line, "Account");
} else if (std.mem.indexOf(u8, line, "DOGE-USD") != null) {
row_date = std.mem.indexOf(u8, line, "2024-01-02");
row_acct = std.mem.indexOf(u8, line, "Sample Account");
}
}
try testing.expect(hdr_date != null and row_date != null);
try testing.expectEqual(hdr_date.?, row_date.?);
try testing.expect(hdr_acct != null and row_acct != null);
try testing.expectEqual(hdr_acct.?, row_acct.?);
}
test "display tightens columns for a small portfolio" {
// Counterpart to the crypto test: small share counts and prices mean
// every dynamic column collapses to its header-label minimum, so the
// table is compact rather than padded out for hypothetical large
// values. The TOTAL row still aligns with the data row.
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var lots = [_]zfin.Lot{
.{ .symbol = "IBM", .shares = 10, .open_date = zfin.Date.fromYmd(2024, 1, 2), .open_price = 150.0, .account = "Sample Account" },
};
var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{
.{ .symbol = "IBM", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "IBM", .display_symbol = "IBM", .shares = 10, .avg_cost = 150.0, .current_price = 155.0, .market_value = 1550.0, .cost_basis = 1500.0, .weight = 1.0, .unrealized_gain_loss = 50.0, .unrealized_return = 0.0333 },
};
const summary = testSummary(&allocs);
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "small.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
const out = w.buffered();
// All dynamic columns are at their header-label minimums, so the
// labels sit flush against each other with a single separator space:
// "Symbol"(6) + " " + "Shares"(6) + " " + "Avg Cost"(8). With the old
// fixed widths (Symbol=10, Shares=12) there would be many spaces here,
// so this pins the "tighten, don't waste space" behavior.
try testing.expect(std.mem.indexOf(u8, out, "Symbol Shares Avg Cost") != null);
// Data row market value aligns with the TOTAL row market value.
var data_col: ?usize = null;
var total_col: ?usize = null;
var lit = std.mem.splitScalar(u8, out, '\n');
while (lit.next()) |line| {
const at = std.mem.indexOf(u8, line, "$1,550.00") orelse continue;
if (std.mem.indexOf(u8, line, "IBM") != null) {
data_col = at;
} else if (std.mem.indexOf(u8, line, "TOTAL") != null) {
total_col = at;
}
}
try testing.expect(data_col != null);
try testing.expect(total_col != null);
try testing.expectEqual(data_col.?, total_col.?);
// No ANSI codes when color is disabled.
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
test "display with watchlist" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var lots = [_]zfin.Lot{
.{ .symbol = "VTI", .shares = 20, .open_date = zfin.Date.fromYmd(2022, 3, 1), .open_price = 200.0 },
};
var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{
.{ .symbol = "VTI", .shares = 20, .avg_cost = 200.0, .total_cost = 4000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "VTI", .display_symbol = "VTI", .shares = 20, .avg_cost = 200.0, .current_price = 220.0, .market_value = 4400.0, .cost_basis = 4000.0, .weight = 1.0, .unrealized_gain_loss = 400.0, .unrealized_return = 0.1 },
};
const summary = testSummary(&allocs);
var prices = std.StringHashMap(f64).init(testing.allocator);
defer prices.deinit();
try prices.put("VTI", 220.0);
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
// Watchlist with prices
const watch_syms: []const []const u8 = &.{ "TSLA", "NVDA" };
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
try watch_prices.put("TSLA", 250.50);
try watch_prices.put("NVDA", 800.25);
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
const out = w.buffered();
// Watchlist header and symbols
try testing.expect(std.mem.indexOf(u8, out, "Watchlist:") != null);
try testing.expect(std.mem.indexOf(u8, out, "TSLA") != null);
try testing.expect(std.mem.indexOf(u8, out, "NVDA") != null);
}
test "display with options section" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var lots = [_]zfin.Lot{
.{ .symbol = "SPY", .shares = 50, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 400.0 },
.{ .symbol = "SPY 240119C00450000", .shares = 2, .open_date = zfin.Date.fromYmd(2023, 6, 1), .open_price = 5.50, .security_type = .option },
};
var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{
.{ .symbol = "SPY", .shares = 50, .avg_cost = 400.0, .total_cost = 20000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "SPY", .display_symbol = "SPY", .shares = 50, .avg_cost = 400.0, .current_price = 450.0, .market_value = 22500.0, .cost_basis = 20000.0, .weight = 1.0, .unrealized_gain_loss = 2500.0, .unrealized_return = 0.125 },
};
var summary = testSummary(&allocs);
// Include option cost in totals (like run() does)
summary.total_value += portfolio.totalOptionCost(zfin.Date.fromYmd(2026, 5, 8));
summary.total_cost += portfolio.totalOptionCost(zfin.Date.fromYmd(2026, 5, 8));
var prices = std.StringHashMap(f64).init(testing.allocator);
defer prices.deinit();
try prices.put("SPY", 450.0);
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
const out = w.buffered();
// Options section present
try testing.expect(std.mem.indexOf(u8, out, "Options") != null);
try testing.expect(std.mem.indexOf(u8, out, "SPY 240119C00450000") != null);
try testing.expect(std.mem.indexOf(u8, out, "Cost/Ctrct") != null);
}
test "display with CDs and cash" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var lots = [_]zfin.Lot{
.{ .symbol = "VTI", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 200.0 },
.{ .symbol = "912828ZT0", .shares = 10000, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 100.0, .security_type = .cd, .rate = 4.5, .maturity_date = zfin.Date.fromYmd(2027, 6, 15) },
.{ .symbol = "CASH", .shares = 5000, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 0, .security_type = .cash, .account = "Brokerage" },
};
var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{
.{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 },
};
var summary = testSummary(&allocs);
summary.total_value += portfolio.totalCash(zfin.Date.fromYmd(2026, 5, 8)) + portfolio.totalCdFaceValue(zfin.Date.fromYmd(2026, 5, 8));
summary.total_cost += portfolio.totalCash(zfin.Date.fromYmd(2026, 5, 8)) + portfolio.totalCdFaceValue(zfin.Date.fromYmd(2026, 5, 8));
var prices = std.StringHashMap(f64).init(testing.allocator);
defer prices.deinit();
try prices.put("VTI", 220.0);
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
const out = w.buffered();
// CDs section present
try testing.expect(std.mem.indexOf(u8, out, "Certificates of Deposit") != null);
try testing.expect(std.mem.indexOf(u8, out, "912828ZT0") != null);
try testing.expect(std.mem.indexOf(u8, out, "4.50%") != null);
// Cash section present
try testing.expect(std.mem.indexOf(u8, out, "Cash") != null);
try testing.expect(std.mem.indexOf(u8, out, "Brokerage") != null);
}
test "display realized PnL shown when nonzero" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var lots = [_]zfin.Lot{
.{ .symbol = "MSFT", .shares = 10, .open_date = zfin.Date.fromYmd(2022, 1, 1), .open_price = 300.0 },
.{ .symbol = "MSFT", .shares = 5, .open_date = zfin.Date.fromYmd(2022, 6, 1), .open_price = 280.0, .close_date = zfin.Date.fromYmd(2023, 6, 1), .close_price = 350.0 },
};
var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{
.{ .symbol = "MSFT", .shares = 10, .avg_cost = 300.0, .total_cost = 3000.0, .open_lots = 1, .closed_lots = 1, .realized_gain_loss = 350.0 },
};
var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "MSFT", .display_symbol = "MSFT", .shares = 10, .avg_cost = 300.0, .current_price = 400.0, .market_value = 4000.0, .cost_basis = 3000.0, .weight = 1.0, .unrealized_gain_loss = 1000.0, .unrealized_return = 0.333 },
};
var summary = testSummary(&allocs);
summary.realized_gain_loss = 350.0;
var prices = std.StringHashMap(f64).init(testing.allocator);
defer prices.deinit();
try prices.put("MSFT", 400.0);
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Realized P&L") != null);
}
test "display empty watchlist not shown" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var lots = [_]zfin.Lot{
.{ .symbol = "VTI", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 200.0 },
};
var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{
.{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 },
};
const summary = testSummary(&allocs);
var prices = std.StringHashMap(f64).init(testing.allocator);
defer prices.deinit();
try prices.put("VTI", 220.0);
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup);
const out = w.buffered();
// Watchlist header should NOT appear when there are no watch symbols
try testing.expect(std.mem.indexOf(u8, out, "Watchlist") == null);
}
// ── expired-mode display tests ─────────────────────────────────
/// Build a fixture with one active + one expired option, one active
/// + one matured CD (plus a stock position), and render it via
/// `display` in the given mode into `w`. as_of = 2026-05-08.
fn renderExpiredFixture(w: *std.Io.Writer, mode: ExpiredMode) !void {
var lots = [_]zfin.Lot{
.{ .symbol = "VTI", .shares = 10, .open_date = zfin.Date.fromYmd(2023, 1, 1), .open_price = 200.0 },
// active option (future expiry); sold-to-open
.{ .symbol = "OPT-ACTIVE", .shares = -1, .open_date = zfin.Date.fromYmd(2026, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = zfin.Date.fromYmd(2026, 12, 1) },
// expired option (past expiry)
.{ .symbol = "OPT-EXPIRED", .shares = 1, .open_date = zfin.Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .option, .maturity_date = zfin.Date.fromYmd(2024, 6, 1) },
// active CD (future maturity); $10,000 face
.{ .symbol = "CD-ACTIVE", .shares = 10000, .open_date = zfin.Date.fromYmd(2025, 1, 1), .open_price = 1.0, .security_type = .cd, .rate = 4.5, .maturity_date = zfin.Date.fromYmd(2027, 6, 15) },
// matured CD (past maturity); $5,000 face
.{ .symbol = "CD-MATURED", .shares = 5000, .open_date = zfin.Date.fromYmd(2022, 1, 1), .open_price = 1.0, .security_type = .cd, .rate = 3.0, .maturity_date = zfin.Date.fromYmd(2024, 6, 15) },
};
var portfolio = testPortfolio(&lots);
var positions = [_]zfin.Position{
.{ .symbol = "VTI", .shares = 10, .avg_cost = 200.0, .total_cost = 2000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 },
};
var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 },
};
const summary = testSummary(&allocs);
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), mode);
}
test "display: expired options/CDs are rolled up by default" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderExpiredFixture(&w, .rollup);
const out = w.buffered();
// Active rows visible.
try testing.expect(std.mem.indexOf(u8, out, "OPT-ACTIVE") != null);
try testing.expect(std.mem.indexOf(u8, out, "CD-ACTIVE") != null);
// Expired/matured rows hidden.
try testing.expect(std.mem.indexOf(u8, out, "OPT-EXPIRED") == null);
try testing.expect(std.mem.indexOf(u8, out, "CD-MATURED") == null);
// One-line rollup summaries with a hint.
try testing.expect(std.mem.indexOf(u8, out, "1 expired option") != null);
try testing.expect(std.mem.indexOf(u8, out, "1 matured CD") != null);
try testing.expect(std.mem.indexOf(u8, out, "--expired=show") != null);
// CD TOTAL is active-only ($10,000, not $15,000).
try testing.expect(std.mem.indexOf(u8, out, "$10,000.00") != null);
try testing.expect(std.mem.indexOf(u8, out, "$15,000.00") == null);
}
test "display: --expired=show lists expired rows, no rollup line" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderExpiredFixture(&w, .show);
const out = w.buffered();
// Every row visible, active and expired alike.
try testing.expect(std.mem.indexOf(u8, out, "OPT-ACTIVE") != null);
try testing.expect(std.mem.indexOf(u8, out, "OPT-EXPIRED") != null);
try testing.expect(std.mem.indexOf(u8, out, "CD-ACTIVE") != null);
try testing.expect(std.mem.indexOf(u8, out, "CD-MATURED") != null);
// No rollup summary in show mode.
try testing.expect(std.mem.indexOf(u8, out, "expired option") == null);
try testing.expect(std.mem.indexOf(u8, out, "matured CD") == null);
// TOTAL still active-only.
try testing.expect(std.mem.indexOf(u8, out, "$10,000.00") != null);
try testing.expect(std.mem.indexOf(u8, out, "$15,000.00") == null);
}
test "display: --expired=hide omits expired rows entirely" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderExpiredFixture(&w, .hide);
const out = w.buffered();
// Active rows visible.
try testing.expect(std.mem.indexOf(u8, out, "OPT-ACTIVE") != null);
try testing.expect(std.mem.indexOf(u8, out, "CD-ACTIVE") != null);
// Expired rows and rollup summaries both gone.
try testing.expect(std.mem.indexOf(u8, out, "OPT-EXPIRED") == null);
try testing.expect(std.mem.indexOf(u8, out, "CD-MATURED") == null);
try testing.expect(std.mem.indexOf(u8, out, "expired option") == null);
try testing.expect(std.mem.indexOf(u8, out, "matured CD") == null);
// TOTAL active-only.
try testing.expect(std.mem.indexOf(u8, out, "$10,000.00") != null);
}
test "display: section TOTAL line aligns with its data rows" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderExpiredFixture(&w, .rollup);
const out = w.buffered();
// The active CD face ($10,000.00) shows in both the CD-ACTIVE data
// row and the CD section TOTAL line; an aligned total puts the value
// at the same column in both. (Before the prefix fix the TOTAL was
// shifted 2 columns left.)
var first_col: ?usize = null;
var count: usize = 0;
var it = std.mem.splitScalar(u8, out, '\n');
while (it.next()) |line| {
if (std.mem.indexOf(u8, line, "$10,000.00")) |col| {
count += 1;
if (first_col) |fc| try testing.expectEqual(fc, col) else first_col = col;
}
}
try testing.expectEqual(@as(usize, 2), count);
}
// ── parseArgs tests ────────────────────────────────────────────
test "parseArgs: no args returns default ParsedArgs" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqual(ExpiredMode.rollup, parsed.expired);
}
test "parseArgs: --refresh rejected (now a global)" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--refresh"};
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: unexpected args error" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"unexpected"};
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: --expired=show parses" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--expired=show"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqual(ExpiredMode.show, parsed.expired);
}
test "parseArgs: --expired=hide parses" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--expired=hide"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqual(ExpiredMode.hide, parsed.expired);
}
test "parseArgs: --expired with bad value errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--expired=bogus"};
try std.testing.expectError(error.InvalidExpiredValue, parseArgs(&ctx, &args));
}
test "parseArgs: --expired without value errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--expired"};
try std.testing.expectError(error.InvalidExpiredValue, parseArgs(&ctx, &args));
}
test "printLotRow: renders effective (split-adjusted) shares, cost, and value" {
var lots = [_]zfin.Lot{
.{ .symbol = "NVDA", .shares = 100, .open_date = zfin.Date.fromYmd(2020, 1, 1), .open_price = 40, .split_factor = 10.0 },
};
const no_allocs: []const zfin.valuation.Allocation = &.{};
const no_watch: []const []const u8 = &.{};
const widths = views.computeWidths(no_allocs, &lots, 0, 0, no_watch, null);
var buf: [512]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
try printLotRow(zfin.Date.fromYmd(2026, 1, 1), &w, false, lots[0], 120.0, widths);
const out = w.buffered();
// Effective shares 1000.0 (not raw 100.0), effective cost $4.00
// (40 / 10), market value 1000 * 120 = $120,000.
try std.testing.expect(std.mem.indexOf(u8, out, "1000.0") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$4.00") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "120,000") != null);
}