ai: add tests to portfolio

This commit is contained in:
Emil Lerch 2026-02-27 14:36:26 -08:00
parent 73b96f7399
commit 24924460d2
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

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