1326 lines
59 KiB
Zig
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);
|
|
}
|