zfin/src/commands/portfolio.zig

997 lines
42 KiB
Zig

const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void {
// Load portfolio from SRF file
const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| {
try cli.stderrPrint("Error reading portfolio file: ");
try cli.stderrPrint(@errorName(err));
try cli.stderrPrint("\n");
return;
};
defer allocator.free(data);
var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch {
try cli.stderrPrint("Error parsing portfolio file.\n");
return;
};
defer portfolio.deinit();
if (portfolio.lots.len == 0) {
try cli.stderrPrint("Portfolio is empty.\n");
return;
}
// Get stock/ETF positions (excludes options, CDs, cash)
const positions = try portfolio.positions(allocator);
defer allocator.free(positions);
// Get unique stock/ETF symbols and fetch current prices
const syms = try portfolio.stockSymbols(allocator);
defer allocator.free(syms);
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) {
if (config.twelvedata_key == null) {
try cli.stderrPrint("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n");
}
// Progress callback for per-symbol output
var progress_ctx = cli.LoadProgress{
.svc = svc,
.color = color,
.index_offset = 0,
.grand_total = all_syms_count,
};
// Load prices for stock/ETF positions
const load_result = svc.loadPrices(syms, &prices, force_refresh, progress_ctx.callback());
fail_count = load_result.fail_count;
// Fetch watch symbol candles (for watchlist display, not portfolio value)
progress_ctx.index_offset = syms.len;
_ = svc.loadPrices(watch_syms.items, &prices, force_refresh, progress_ctx.callback());
// Summary line
{
const cached_count = load_result.cached_count;
const fetched_count = load_result.fetched_count;
var msg_buf: [256]u8 = undefined;
if (cached_count == all_syms_count) {
const msg = std.fmt.bufPrint(&msg_buf, "All {d} symbols loaded from cache\n", .{all_syms_count}) catch "Loaded from cache\n";
try cli.stderrPrint(msg);
} else if (fail_count > 0) {
const stale = load_result.stale_count;
if (stale > 0) {
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed — {d} using stale cache)\n", .{ all_syms_count, cached_count, fetched_count, fail_count, stale }) catch "Done loading\n";
try cli.stderrPrint(msg);
} else {
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ all_syms_count, cached_count, fetched_count, fail_count }) catch "Done loading\n";
try cli.stderrPrint(msg);
}
} else {
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ all_syms_count, cached_count, fetched_count }) catch "Done loading\n";
try cli.stderrPrint(msg);
}
}
}
// Compute summary
// Build fallback prices for symbols that failed API fetch
var manual_price_set = try zfin.risk.buildFallbackPrices(allocator, portfolio.lots, positions, &prices);
defer manual_price_set.deinit();
var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch {
try cli.stderrPrint("Error computing portfolio summary.\n");
return;
};
defer summary.deinit(allocator);
// Sort allocations alphabetically by symbol
std.mem.sort(zfin.risk.Allocation, summary.allocations, {}, struct {
fn f(_: void, a: zfin.risk.Allocation, b: zfin.risk.Allocation) bool {
return std.mem.lessThan(u8, a.display_symbol, b.display_symbol);
}
}.f);
// Include non-stock assets in the grand total
summary.adjustForNonStockAssets(portfolio);
// Build candle map once for historical snapshots and risk metrics.
// This avoids parsing the full candle history multiple times.
var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator);
defer {
var it = candle_map.valueIterator();
while (it.next()) |v| allocator.free(v.*);
candle_map.deinit();
}
{
const stock_syms = try portfolio.stockSymbols(allocator);
defer allocator.free(stock_syms);
for (stock_syms) |sym| {
if (svc.getCachedCandles(sym)) |cs| {
try candle_map.put(sym, cs);
}
}
}
// Collect watch symbols and their prices for display.
// Includes watch lots from portfolio + symbols from separate 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 (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);
}
}
}
// Separate watchlist file (backward compat)
if (watchlist_path) |wl_path| {
const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null;
if (wl_data) |wd| {
defer allocator.free(wd);
var wl_lines = std.mem.splitScalar(u8, wd, '\n');
while (wl_lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0 or trimmed[0] == '#') continue;
if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| {
const rest = trimmed[idx + "symbol::".len ..];
const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len;
const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace);
if (sym.len > 0 and sym.len <= 10) {
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);
}
}
}
}
}
}
}
try display(
allocator,
out,
color,
file_path,
&portfolio,
positions,
&summary,
prices,
candle_map,
watch_list.items,
watch_prices,
);
}
/// 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,
summary: *const zfin.risk.PortfolioSummary,
prices: std.StringHashMap(f64),
candle_map: std.StringHashMap([]const zfin.Candle),
watch_symbols: []const []const u8,
watch_prices: std.StringHashMap(f64),
) !void {
// Header with summary
try cli.setBold(out, color);
try out.print("\nPortfolio Summary ({s})\n", .{file_path});
try cli.reset(out, color);
try out.print("========================================\n", .{});
// Summary bar
{
var val_buf: [24]u8 = undefined;
var cost_buf: [24]u8 = undefined;
var gl_buf: [24]u8 = undefined;
const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
try out.print(" Value: {s} Cost: {s} ", .{ fmt.fmtMoneyAbs(&val_buf, summary.total_value), fmt.fmtMoneyAbs(&cost_buf, summary.total_cost) });
try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
if (summary.unrealized_gain_loss >= 0) {
try out.print("Gain/Loss: +{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
} else {
try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
}
try cli.reset(out, color);
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()) open_lots += 1 else closed_lots += 1;
}
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Lots: {d} open, {d} closed Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len });
try cli.reset(out, color);
// Historical portfolio value snapshots
{
if (candle_map.count() > 0) {
const snapshots = zfin.risk.computeHistoricalSnapshots(
fmt.todayDate(),
positions,
prices,
candle_map,
);
try out.print(" Historical: ", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
for (zfin.risk.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.risk.HistoricalPeriod.all.len - 1) try out.print(" ", .{});
}
try cli.reset(out, color);
try out.print("\n", .{});
}
}
// Column headers
try out.print("\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" " ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}\n", .{
"Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account",
});
try out.print(" " ++ std.fmt.comptimePrint("{{s:->{d}}}", .{fmt.sym_col_width}) ++ " {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8} {s:->13} {s:->8}\n", .{
"", "", "", "", "", "", "", "", "",
});
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, {}, fmt.lotSortFn);
const is_multi = lots_for_sym.items.len > 1;
// Position summary row
{
var mv_buf: [24]u8 = undefined;
var cost_buf2: [24]u8 = undefined;
var price_buf2: [24]u8 = undefined;
var gl_val_buf: [24]u8 = undefined;
const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss;
const gl_money = fmt.fmtMoneyAbs(&gl_val_buf, gl_abs);
const sign: []const u8 = if (a.unrealized_gain_loss >= 0) "+" else "-";
// Date + ST/LT for single-lot positions
var date_col: [24]u8 = .{' '} ** 24;
var date_col_len: usize = 0;
if (!is_multi and lots_for_sym.items.len == 1) {
const lot = lots_for_sym.items[0];
var pos_date_buf: [10]u8 = undefined;
const ds = lot.open_date.format(&pos_date_buf);
const indicator = fmt.capitalGainsIndicator(lot.open_date);
const written = std.fmt.bufPrint(&date_col, "{s} {s}", .{ ds, indicator }) catch "";
date_col_len = written.len;
}
if (a.is_manual_price) try cli.setFg(out, color, cli.CLR_WARNING);
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{
a.display_symbol, a.shares, fmt.fmtMoneyAbs(&cost_buf2, a.avg_cost),
});
try out.print("{s:>10}", .{fmt.fmtMoneyAbs(&price_buf2, a.current_price)});
try out.print(" {s:>16} ", .{fmt.fmtMoneyAbs(&mv_buf, a.market_value)});
try cli.setGainLoss(out, color, a.unrealized_gain_loss);
try out.print("{s}{s:>13}", .{ sign, gl_money });
if (a.is_manual_price) {
try cli.setFg(out, color, cli.CLR_WARNING);
} else {
try cli.reset(out, color);
}
try out.print(" {d:>7.1}%", .{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(out, color, lot, a.current_price);
}
} else {
// Show non-DRIP lots individually
for (lots_for_sym.items) |lot| {
if (!lot.drip) {
try printLotRow(out, color, lot, a.current_price);
}
}
// Summarize DRIP lots as ST/LT
const drip = fmt.aggregateDripLots(lots_for_sym.items);
if (!drip.st.isEmpty()) {
var avg_buf: [24]u8 = undefined;
var d1_buf: [10]u8 = undefined;
var d2_buf: [10]u8 = undefined;
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" ST: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
drip.st.lot_count,
drip.st.shares,
fmt.fmtMoneyAbs(&avg_buf, drip.st.avgCost()),
if (drip.st.first_date) |d| d.format(&d1_buf)[0..7] else "?",
if (drip.st.last_date) |d| d.format(&d2_buf)[0..7] else "?",
});
try cli.reset(out, color);
}
if (!drip.lt.isEmpty()) {
var avg_buf2: [24]u8 = undefined;
var d1_buf2: [10]u8 = undefined;
var d2_buf2: [10]u8 = undefined;
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" LT: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
drip.lt.lot_count,
drip.lt.shares,
fmt.fmtMoneyAbs(&avg_buf2, drip.lt.avgCost()),
if (drip.lt.first_date) |d| d.format(&d1_buf2)[0..7] else "?",
if (drip.lt.last_date) |d| d.format(&d2_buf2)[0..7] else "?",
});
try cli.reset(out, color);
}
}
}
}
// Totals line
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8}\n", .{
"", "", "", "", "", "", "",
});
try cli.reset(out, color);
{
var total_mv_buf: [24]u8 = undefined;
var total_gl_buf: [24]u8 = undefined;
const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
try out.print(" {s:>6} {s:>8} {s:>10} {s:>10} {s:>16} ", .{
"", "", "", "TOTAL", fmt.fmtMoneyAbs(&total_mv_buf, summary.total_value),
});
try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
if (summary.unrealized_gain_loss >= 0) {
try out.print("+{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)});
} else {
try out.print("-{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)});
}
try cli.reset(out, color);
try out.print(" {s:>7}\n", .{"100.0%"});
}
if (summary.realized_gain_loss != 0) {
var rpl_buf: [24]u8 = undefined;
const rpl_abs = if (summary.realized_gain_loss >= 0) summary.realized_gain_loss else -summary.realized_gain_loss;
try cli.setGainLoss(out, color, summary.realized_gain_loss);
if (summary.realized_gain_loss >= 0) {
try out.print("\n Realized P&L: +{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)});
} else {
try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)});
}
try cli.reset(out, color);
}
// Options section
if (portfolio.hasType(.option)) {
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print(" Options\n", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:<30} {s:>6} {s:>12} {s:>14} {s}\n", .{
"Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account",
});
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14} {s:->10}\n", .{
"", "", "", "", "",
});
try cli.reset(out, color);
var opt_total_cost: f64 = 0;
for (portfolio.lots) |lot| {
if (lot.security_type != .option) continue;
const qty = lot.shares;
const cost_per = lot.open_price;
const total_cost_opt = @abs(qty) * cost_per;
opt_total_cost += total_cost_opt;
var cost_per_buf: [24]u8 = undefined;
var total_cost_buf: [24]u8 = undefined;
const acct: []const u8 = lot.account orelse "";
try out.print(" {s:<30} {d:>6.0} {s:>12} {s:>14} {s}\n", .{
lot.symbol,
qty,
fmt.fmtMoneyAbs(&cost_per_buf, cost_per),
fmt.fmtMoneyAbs(&total_cost_buf, total_cost_opt),
acct,
});
}
// Options total
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" });
try cli.reset(out, color);
var opt_total_buf: [24]u8 = undefined;
try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{
"", "", "TOTAL", fmt.fmtMoneyAbs(&opt_total_buf, opt_total_cost),
});
}
// CDs section
if (portfolio.hasType(.cd)) {
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print(" Certificates of Deposit\n", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
"CUSIP", "Face Value", "Rate", "Maturity", "Description",
});
try out.print(" {s:->12} {s:->14} {s:->7} {s:->10} {s:->30}\n", .{
"", "", "", "", "",
});
try cli.reset(out, color);
// Collect and sort CDs by maturity date (earliest first)
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
defer cd_lots.deinit(allocator);
for (portfolio.lots) |lot| {
if (lot.security_type == .cd) {
try cd_lots.append(allocator, lot);
}
}
std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn);
var cd_section_total: f64 = 0;
for (cd_lots.items) |lot| {
cd_section_total += lot.shares;
var face_buf: [24]u8 = undefined;
var mat_buf: [10]u8 = undefined;
const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--";
var rate_buf: [10]u8 = undefined;
const rate_str: []const u8 = if (lot.rate) |r|
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
else
"--";
const note_str: []const u8 = lot.note orelse "";
const note_display = if (note_str.len > 50) note_str[0..50] else note_str;
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
lot.symbol,
fmt.fmtMoneyAbs(&face_buf, lot.shares),
rate_str,
mat_str,
note_display,
});
}
// CD total
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:->12} {s:->14}\n", .{ "", "" });
try cli.reset(out, color);
var cd_total_buf: [24]u8 = undefined;
try out.print(" {s:>12} {s:>14}\n", .{
"TOTAL", fmt.fmtMoneyAbs(&cd_total_buf, cd_section_total),
});
}
// Cash section
if (portfolio.hasType(.cash)) {
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print(" Cash\n", .{});
try cli.reset(out, color);
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.setFg(out, color, cli.CLR_MUTED);
try out.print("{s}\n", .{fmt.fmtCashSep(&sep_buf)});
try cli.reset(out, color);
var total_buf: [80]u8 = undefined;
try cli.setBold(out, color);
try out.print("{s}\n", .{fmt.fmtCashTotal(&total_buf, portfolio.totalCash())});
try cli.reset(out, color);
}
// Illiquid assets section
if (portfolio.hasType(.illiquid)) {
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print(" Illiquid Assets\n", .{});
try cli.reset(out, color);
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.symbol, lot.shares, lot.note)});
}
// Illiquid total
var il_sep_buf2: [80]u8 = undefined;
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf2)});
try cli.reset(out, color);
var il_total_buf: [80]u8 = undefined;
try cli.setBold(out, color);
try out.print("{s}\n", .{fmt.fmtIlliquidTotal(&il_total_buf, portfolio.totalIlliquid())});
try cli.reset(out, color);
}
// Net Worth (if illiquid assets exist)
if (portfolio.hasType(.illiquid)) {
const illiquid_total = portfolio.totalIlliquid();
const net_worth = summary.total_value + illiquid_total;
var nw_buf: [24]u8 = undefined;
var liq_buf: [24]u8 = undefined;
var il_buf: [24]u8 = undefined;
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print(" Net Worth: {s} (Liquid: {s} Illiquid: {s})\n", .{
fmt.fmtMoneyAbs(&nw_buf, net_worth),
fmt.fmtMoneyAbs(&liq_buf, summary.total_value),
fmt.fmtMoneyAbs(&il_buf, illiquid_total),
});
try cli.reset(out, color);
}
// Watchlist
if (watch_symbols.len > 0) {
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print(" Watchlist:\n", .{});
try cli.reset(out, color);
for (watch_symbols) |sym| {
var price_str: [16]u8 = undefined;
const ps: []const u8 = if (watch_prices.get(sym)) |close|
fmt.fmtMoneyAbs(&price_str, close)
else
"--";
try out.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps });
}
}
// Risk metrics
{
var any_risk = false;
for (summary.allocations) |a| {
if (candle_map.get(a.symbol)) |candles| {
if (zfin.risk.computeRisk(candles, zfin.risk.default_risk_free_rate)) |metrics| {
if (!any_risk) {
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print(" Risk Metrics (from cached price data):\n", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:>6} {s:>10} {s:>8} {s:>10}\n", .{
"Symbol", "Volatility", "Sharpe", "Max DD",
});
try out.print(" {s:->6} {s:->10} {s:->8} {s:->10}\n", .{
"", "", "", "",
});
try cli.reset(out, color);
any_risk = true;
}
try out.print(" {s:>6} {d:>9.1}% {d:>8.2} ", .{
a.symbol, metrics.volatility * 100.0, metrics.sharpe,
});
try cli.setFg(out, color, cli.CLR_NEGATIVE);
try out.print("{d:>9.1}%", .{metrics.max_drawdown * 100.0});
try cli.reset(out, color);
if (metrics.drawdown_trough) |dt| {
var db: [10]u8 = undefined;
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" (trough {s})", .{dt.format(&db)});
try cli.reset(out, color);
}
try out.print("\n", .{});
}
}
}
}
try out.print("\n", .{});
}
pub fn printLotRow(out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64) !void {
var lot_price_buf: [24]u8 = undefined;
var lot_date_buf: [10]u8 = undefined;
const date_str = lot.open_date.format(&lot_date_buf);
const indicator = fmt.capitalGainsIndicator(lot.open_date);
const status_str: []const u8 = if (lot.isOpen()) "open" else "closed";
const acct_col: []const u8 = lot.account orelse "";
const use_price = lot.close_price orelse current_price;
const gl = lot.shares * (use_price - lot.open_price);
var lot_gl_buf: [24]u8 = undefined;
const lot_gl_abs = if (gl >= 0) gl else -gl;
const lot_gl_money = fmt.fmtMoneyAbs(&lot_gl_buf, lot_gl_abs);
const lot_sign: []const u8 = if (gl >= 0) "+" else "-";
var lot_mv_buf: [24]u8 = undefined;
const lot_mv = fmt.fmtMoneyAbs(&lot_mv_buf, lot.shares * use_price);
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{
status_str, lot.shares, fmt.fmtMoneyAbs(&lot_price_buf, lot.open_price), "", lot_mv,
});
try cli.reset(out, color);
try cli.setGainLoss(out, color, gl);
try out.print("{s}{s:>13}", .{ lot_sign, lot_gl_money });
try cli.reset(out, color);
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:>8} {s} {s} {s}\n", .{ "", date_str, 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.risk.Allocation) zfin.risk.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,
};
}
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.risk.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 },
};
var 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();
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, &summary, prices, candle_map, watch_syms, watch_prices);
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 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.risk.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 },
};
var 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();
// 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, &summary, prices, candle_map, watch_syms, watch_prices);
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.risk.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();
summary.total_cost += portfolio.totalOptionCost();
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();
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, &summary, prices, candle_map, watch_syms, watch_prices);
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(2025, 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.risk.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() + portfolio.totalCdFaceValue();
summary.total_cost += portfolio.totalCash() + portfolio.totalCdFaceValue();
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();
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, &summary, prices, candle_map, watch_syms, watch_prices);
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.risk.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();
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, &summary, prices, candle_map, watch_syms, watch_prices);
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.risk.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);
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();
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, &summary, prices, candle_map, watch_syms, watch_prices);
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);
}