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