From 24924460d290b3032318e4e63f1eda14ec22be72 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 27 Feb 2026 14:36:26 -0800 Subject: [PATCH] ai: add tests to portfolio --- src/cli/commands/portfolio.zig | 284 +++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) diff --git a/src/cli/commands/portfolio.zig b/src/cli/commands/portfolio.zig index 2e43f9e..2a96105 100644 --- a/src/cli/commands/portfolio.zig +++ b/src/cli/commands/portfolio.zig @@ -833,3 +833,287 @@ pub fn printLotRow(out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_pric 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_pnl: f64 = 0; + for (allocations) |a| { + total_value += a.market_value; + total_cost += a.cost_basis; + unrealized_pnl += a.unrealized_pnl; + } + return .{ + .total_value = total_value, + .total_cost = total_cost, + .unrealized_pnl = unrealized_pnl, + .unrealized_return = if (total_cost > 0) unrealized_pnl / total_cost else 0, + .realized_pnl = 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_pnl = 0 }, + .{ .symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .total_cost = 600.0, .open_lots = 1, .closed_lots = 0, .realized_pnl = 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_pnl = 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_pnl = 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_pnl = 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_pnl = 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, .lot_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_pnl = 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_pnl = 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, .lot_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, .lot_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_pnl = 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_pnl = 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_pnl = 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_pnl = 1000.0, .unrealized_return = 0.333 }, + }; + var summary = testSummary(&allocs); + summary.realized_pnl = 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_pnl = 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_pnl = 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); +}