const std = @import("std"); const Date = @import("date.zig").Date; const Candle = @import("candle.zig").Candle; /// Synthesize a stable-NAV (= $1) candle for a given date. Used when /// historical price data for a money-market fund doesn't reach back as /// far as the period under analysis — the close is known to be $1 by /// construction, so we can extrapolate backward without inventing data. pub fn stableNavCandle(date: Date) Candle { return .{ .date = date, .open = 1, .high = 1, .low = 1, .close = 1, .adj_close = 1, .volume = 0 }; } /// Type of holding in a portfolio lot. pub const LotType = enum { stock, // stocks and ETFs (default) option, // option contracts cd, // certificates of deposit cash, // cash/money market illiquid, // illiquid assets (real estate, vehicles, etc.) watch, // watchlist item (no position, just track price) pub fn label(self: LotType) []const u8 { return switch (self) { .stock => "Stock", .option => "Option", .cd => "CD", .cash => "Cash", .illiquid => "Illiquid", .watch => "Watch", }; } pub fn fromString(s: []const u8) LotType { if (std.mem.eql(u8, s, "option")) return .option; if (std.mem.eql(u8, s, "cd")) return .cd; if (std.mem.eql(u8, s, "cash")) return .cash; if (std.mem.eql(u8, s, "illiquid")) return .illiquid; if (std.mem.eql(u8, s, "watch")) return .watch; return .stock; } }; /// Call or put option type. pub const OptionType = enum { call, put, pub fn fromString(s: []const u8) OptionType { if (std.mem.eql(u8, s, "put")) return .put; return .call; } }; /// A single lot in a portfolio -- one purchase/sale event. /// Open lots have no close_date/close_price. /// Closed lots have both. pub const Lot = struct { symbol: []const u8 = "", shares: f64, open_date: Date, open_price: f64, close_date: ?Date = null, close_price: ?f64 = null, /// Optional note/tag for the lot note: ?[]const u8 = null, /// Optional account identifier (e.g. "Roth IRA", "Brokerage") account: ?[]const u8 = null, /// Type of holding (stock, option, cd, cash) security_type: LotType = .stock, /// Maturity date (for CDs) maturity_date: ?Date = null, /// Interest rate (for CDs, as percentage e.g. 3.8 = 3.8%) rate: ?f64 = null, /// Whether this lot is from dividend reinvestment (DRIP). /// DRIP lots are summarized as ST/LT groups instead of shown individually. drip: bool = false, /// Ticker alias for price fetching (e.g. CUSIP symbol with ticker::VTTHX). /// When set, this ticker is used for API calls instead of the symbol field. ticker: ?[]const u8 = null, /// Manual price override (e.g. for mutual funds not covered by data providers). /// Used as fallback when API price fetch fails. price: ?f64 = null, /// Date of the manual price (for display/staleness tracking). price_date: ?Date = null, /// Price ratio for institutional share classes. When set, the fetched price /// (from the `ticker` symbol) is multiplied by this ratio to get the actual /// institutional NAV. E.g. if VTTHX (investor) is $27.78 and the institutional /// class trades at $144.04, price_ratio = 144.04 / 27.78 ≈ 5.185. price_ratio: f64 = 1.0, /// Underlying stock symbol for option lots (e.g. "AMZN"). underlying: ?[]const u8 = null, /// Strike price for option lots. strike: ?f64 = null, /// Contract multiplier (shares per contract). Default 100 for standard US equity options. multiplier: f64 = 100.0, /// Call or put (for option lots). option_type: OptionType = .call, /// The symbol to use for price fetching (ticker if set, else symbol). pub fn priceSymbol(self: Lot) []const u8 { return self.ticker orelse self.symbol; } pub fn isOpen(self: Lot) bool { if (self.close_date != null) return false; if (self.maturity_date) |mat| { const today = Date.fromEpoch(std.time.timestamp()); if (!today.lessThan(mat)) return false; } return true; } pub fn costBasis(self: Lot) f64 { return self.shares * self.open_price; } pub fn marketValue(self: Lot, current_price: f64) f64 { return self.shares * current_price; } /// Realized gain/loss for a closed lot: shares * (close_price - open_price). /// Returns null if the lot is still open. pub fn realizedGainLoss(self: Lot) ?f64 { const cp = self.close_price orelse return null; return self.shares * (cp - self.open_price); } /// Unrealized gain/loss for an open lot at the given market price. pub fn unrealizedGainLoss(self: Lot, current_price: f64) f64 { return self.shares * (current_price - self.open_price); } pub fn returnPct(self: Lot, current_price: f64) f64 { if (self.open_price == 0) return 0; const price = if (self.close_price) |cp| cp else current_price; return (price / self.open_price) - 1.0; } }; /// Aggregated position for a single symbol across multiple lots. pub const Position = struct { symbol: []const u8, /// Original lot symbol before ticker aliasing (e.g. CUSIP "02315N600"). /// Same as `symbol` when no ticker alias is set. lot_symbol: []const u8 = "", /// Total open shares shares: f64, /// Weighted average cost basis per share (open lots only) avg_cost: f64, /// Total cost basis of open lots total_cost: f64, /// Number of open lots open_lots: u32, /// Number of closed lots closed_lots: u32, /// Total realized P&L from closed lots realized_gain_loss: f64, /// Account name (shared across lots, or "Multiple" if mixed). account: []const u8 = "", /// Note from the first lot (e.g. "VANGUARD TARGET 2035"). note: ?[]const u8 = null, /// Price ratio for institutional share classes (from lot). /// NOTE: If lots with different price_ratios (or a mix of ratio/no-ratio) /// share the same priceSymbol(), the position grouping would be incorrect. /// Currently positions() takes the ratio from the first lot that has one. /// Supporting dual-holding of investor + institutional shares of the same /// ticker would require a different grouping key in positions(). price_ratio: f64 = 1.0, }; /// A portfolio is a collection of lots. pub const Portfolio = struct { lots: []Lot, allocator: std.mem.Allocator, pub fn deinit(self: *Portfolio) void { for (self.lots) |lot| { self.allocator.free(lot.symbol); if (lot.note) |n| self.allocator.free(n); if (lot.account) |a| self.allocator.free(a); if (lot.ticker) |t| self.allocator.free(t); if (lot.underlying) |u| self.allocator.free(u); } self.allocator.free(self.lots); } /// Get all unique symbols in the portfolio (all types). pub fn symbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 { var seen = std.StringHashMap(void).init(allocator); defer seen.deinit(); for (self.lots) |lot| { try seen.put(lot.symbol, {}); } var result = std.ArrayList([]const u8).empty; errdefer result.deinit(allocator); var iter = seen.keyIterator(); while (iter.next()) |key| { try result.append(allocator, key.*); } return result.toOwnedSlice(allocator); } /// Get unique symbols for stock/ETF lots only (skips options, CDs, cash). /// Returns the price symbol (ticker alias if set, otherwise raw symbol). /// Excludes manual-price-only lots (price:: set, no ticker::) since those /// have no API coverage and should never be fetched. pub fn stockSymbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 { var seen = std.StringHashMap(void).init(allocator); defer seen.deinit(); for (self.lots) |lot| { if (lot.security_type == .stock) { // Skip lots that have a manual price but no ticker alias — // these are securities without API coverage (e.g. 401k CIT shares). if (lot.price != null and lot.ticker == null) continue; try seen.put(lot.priceSymbol(), {}); } } var result = std.ArrayList([]const u8).empty; errdefer result.deinit(allocator); var iter = seen.keyIterator(); while (iter.next()) |key| { try result.append(allocator, key.*); } return result.toOwnedSlice(allocator); } /// Get all lots for a given symbol. pub fn lotsForSymbol(self: Portfolio, allocator: std.mem.Allocator, symbol: []const u8) ![]Lot { var result = std.ArrayList(Lot).empty; errdefer result.deinit(allocator); for (self.lots) |lot| { if (std.mem.eql(u8, lot.symbol, symbol)) { try result.append(allocator, lot); } } return result.toOwnedSlice(allocator); } /// Get all lots of a given security type (allocated copy). pub fn lotsOfTypeAlloc(self: Portfolio, allocator: std.mem.Allocator, sec_type: LotType) ![]Lot { var result = std.ArrayList(Lot).empty; errdefer result.deinit(allocator); for (self.lots) |lot| { if (lot.security_type == sec_type) { try result.append(allocator, lot); } } return result.toOwnedSlice(allocator); } /// Aggregate stock/ETF lots into positions by symbol (skips options, CDs, cash). /// Keys by priceSymbol() so CUSIP lots with ticker aliases aggregate under the ticker. pub fn positions(self: Portfolio, allocator: std.mem.Allocator) ![]Position { var map = std.StringHashMap(Position).init(allocator); defer map.deinit(); for (self.lots) |lot| { if (lot.security_type != .stock) continue; const sym = lot.priceSymbol(); const entry = try map.getOrPut(sym); if (!entry.found_existing) { entry.value_ptr.* = .{ .symbol = sym, .lot_symbol = lot.symbol, .shares = 0, .avg_cost = 0, .total_cost = 0, .open_lots = 0, .closed_lots = 0, .realized_gain_loss = 0, .account = lot.account orelse "", .note = lot.note, .price_ratio = lot.price_ratio, }; } else { // Track account: if lots have different accounts, mark as "Multiple" const existing = entry.value_ptr.account; const new_acct = lot.account orelse ""; if (existing.len > 0 and !std.mem.eql(u8, existing, "Multiple") and !std.mem.eql(u8, existing, new_acct)) { entry.value_ptr.account = "Multiple"; } // Propagate price_ratio from the first lot that has one if (entry.value_ptr.price_ratio == 1.0 and lot.price_ratio != 1.0) { entry.value_ptr.price_ratio = lot.price_ratio; } } if (lot.isOpen()) { entry.value_ptr.shares += lot.shares; entry.value_ptr.total_cost += lot.costBasis(); entry.value_ptr.open_lots += 1; } else { entry.value_ptr.closed_lots += 1; entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0; } } // Compute avg_cost var iter = map.valueIterator(); while (iter.next()) |pos| { if (pos.shares > 0) { pos.avg_cost = pos.total_cost / pos.shares; } } var result = std.ArrayList(Position).empty; errdefer result.deinit(allocator); var viter = map.valueIterator(); while (viter.next()) |pos| { try result.append(allocator, pos.*); } return result.toOwnedSlice(allocator); } /// Aggregate stock/ETF lots into positions for a single account. /// Same logic as positions() but filtered to lots matching `account_name`. /// Only includes positions with at least one open lot (closed-only symbols are excluded). pub fn positionsForAccount(self: Portfolio, allocator: std.mem.Allocator, account_name: []const u8) ![]Position { var map = std.StringHashMap(Position).init(allocator); defer map.deinit(); for (self.lots) |lot| { if (lot.security_type != .stock) continue; const lot_acct = lot.account orelse continue; if (!std.mem.eql(u8, lot_acct, account_name)) continue; const sym = lot.priceSymbol(); const entry = try map.getOrPut(sym); if (!entry.found_existing) { entry.value_ptr.* = .{ .symbol = sym, .lot_symbol = lot.symbol, .shares = 0, .avg_cost = 0, .total_cost = 0, .open_lots = 0, .closed_lots = 0, .realized_gain_loss = 0, .account = lot_acct, .note = lot.note, .price_ratio = lot.price_ratio, }; } else { if (entry.value_ptr.price_ratio == 1.0 and lot.price_ratio != 1.0) { entry.value_ptr.price_ratio = lot.price_ratio; } } if (lot.isOpen()) { entry.value_ptr.shares += lot.shares; entry.value_ptr.total_cost += lot.costBasis(); entry.value_ptr.open_lots += 1; } else { entry.value_ptr.closed_lots += 1; entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0; } } var iter = map.valueIterator(); while (iter.next()) |pos| { if (pos.shares > 0) { pos.avg_cost = pos.total_cost / pos.shares; } } var result = std.ArrayList(Position).empty; errdefer result.deinit(allocator); var viter = map.valueIterator(); while (viter.next()) |pos| { if (pos.open_lots == 0) continue; try result.append(allocator, pos.*); } return result.toOwnedSlice(allocator); } /// Total cash for a single account. pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 { var total: f64 = 0; for (self.lots) |lot| { if (lot.security_type != .cash) continue; const lot_acct = lot.account orelse continue; if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares; } return total; } /// Total value of non-stock holdings (cash, CDs, options) for a single account. /// Only includes open lots (respects close_date and maturity_date). pub fn nonStockValueForAccount(self: Portfolio, account_name: []const u8) f64 { var total: f64 = 0; for (self.lots) |lot| { if (!lot.isOpen()) continue; const lot_acct = lot.account orelse continue; if (!std.mem.eql(u8, lot_acct, account_name)) continue; switch (lot.security_type) { .cash => total += lot.shares, .cd => total += lot.shares, .option => total += @abs(lot.shares) * lot.open_price * lot.multiplier, else => {}, } } return total; } /// Total value of an account: stocks (priced from the given map, falling back to avg_cost) /// plus cash, CDs, and options. Only includes open lots. pub fn totalForAccount(self: Portfolio, allocator: std.mem.Allocator, account_name: []const u8, prices: std.StringHashMap(f64)) f64 { var total: f64 = 0; const acct_positions = self.positionsForAccount(allocator, account_name) catch return self.nonStockValueForAccount(account_name); defer allocator.free(acct_positions); for (acct_positions) |pos| { const price = prices.get(pos.symbol) orelse pos.avg_cost; total += pos.shares * price * pos.price_ratio; } total += self.nonStockValueForAccount(account_name); return total; } /// Total cost basis of all open stock lots. pub fn totalCostBasis(self: Portfolio) f64 { var total: f64 = 0; for (self.lots) |lot| { if (lot.isOpen() and lot.security_type == .stock) total += lot.costBasis(); } return total; } /// Total realized P&L from all closed stock lots. pub fn totalRealizedGainLoss(self: Portfolio) f64 { var total: f64 = 0; for (self.lots) |lot| { if (lot.security_type == .stock) { if (lot.realizedGainLoss()) |pnl| total += pnl; } } return total; } /// Total cash across all accounts. pub fn totalCash(self: Portfolio) f64 { var total: f64 = 0; for (self.lots) |lot| { if (lot.security_type == .cash) total += lot.shares; } return total; } /// Total illiquid asset value across all accounts. pub fn totalIlliquid(self: Portfolio) f64 { var total: f64 = 0; for (self.lots) |lot| { if (lot.security_type == .illiquid) total += lot.shares; } return total; } /// Total CD face value across all accounts. pub fn totalCdFaceValue(self: Portfolio) f64 { var total: f64 = 0; for (self.lots) |lot| { if (lot.security_type == .cd) total += lot.shares; } return total; } /// Total option cost basis (absolute value of shares * open_price). pub fn totalOptionCost(self: Portfolio) f64 { var total: f64 = 0; for (self.lots) |lot| { if (lot.security_type == .option) { // open_price is per-share option price; multiply by contract size total += @abs(lot.shares) * lot.open_price * lot.multiplier; } } return total; } /// Check if portfolio has any lots of a given type. pub fn hasType(self: Portfolio, sec_type: LotType) bool { for (self.lots) |lot| { if (lot.security_type == sec_type) return true; } return false; } /// Get watchlist symbols (from watch lots in the portfolio). pub fn watchSymbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 { var result = std.ArrayList([]const u8).empty; errdefer result.deinit(allocator); for (self.lots) |lot| { if (lot.security_type == .watch) { try result.append(allocator, lot.symbol); } } return result.toOwnedSlice(allocator); } }; /// Check if a string looks like a CUSIP (9 alphanumeric characters). /// CUSIPs have 6 alphanumeric issuer chars + 2 issue chars + 1 check digit. /// This is a heuristic -- it won't catch all CUSIPs and may have false positives. pub fn isCusipLike(s: []const u8) bool { if (s.len != 9) return false; // Must contain at least one digit (all-alpha would be a ticker) var has_digit = false; for (s) |c| { if (!std.ascii.isAlphanumeric(c)) return false; if (std.ascii.isDigit(c)) has_digit = true; } return has_digit; } test "lot basics" { const lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 15), .open_price = 150.0, }; try std.testing.expect(lot.isOpen()); try std.testing.expectApproxEqAbs(@as(f64, 1500.0), lot.costBasis(), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 2000.0), lot.marketValue(200.0), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.unrealizedGainLoss(200.0), 0.01); try std.testing.expect(lot.realizedGainLoss() == null); } test "closed lot" { const lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 15), .open_price = 150.0, .close_date = Date.fromYmd(2024, 6, 15), .close_price = 200.0, }; try std.testing.expect(!lot.isOpen()); try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLoss().?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0), 0.001); } test "portfolio positions" { const allocator = std.testing.allocator; var lots = [_]Lot{ .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 }, .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 3, 1), .open_price = 160.0 }, .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 220.0 }, .{ .symbol = "AAPL", .shares = 3, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 130.0, .close_date = Date.fromYmd(2024, 2, 1), .close_price = 155.0 }, }; var portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; // Don't call deinit since these are stack-allocated test strings const pos = try portfolio.positions(allocator); defer allocator.free(pos); try std.testing.expectEqual(@as(usize, 2), pos.len); // Find AAPL position var aapl: ?Position = null; for (pos) |p| { if (std.mem.eql(u8, p.symbol, "AAPL")) aapl = p; } try std.testing.expect(aapl != null); try std.testing.expectApproxEqAbs(@as(f64, 15.0), aapl.?.shares, 0.01); try std.testing.expectEqual(@as(u32, 2), aapl.?.open_lots); try std.testing.expectEqual(@as(u32, 1), aapl.?.closed_lots); try std.testing.expectApproxEqAbs(@as(f64, 75.0), aapl.?.realized_gain_loss, 0.01); // 3 * (155-130) } test "LotType label and fromString" { try std.testing.expectEqualStrings("Stock", LotType.stock.label()); try std.testing.expectEqualStrings("Option", LotType.option.label()); try std.testing.expectEqualStrings("CD", LotType.cd.label()); try std.testing.expectEqualStrings("Cash", LotType.cash.label()); try std.testing.expectEqualStrings("Illiquid", LotType.illiquid.label()); try std.testing.expectEqualStrings("Watch", LotType.watch.label()); try std.testing.expectEqual(LotType.option, LotType.fromString("option")); try std.testing.expectEqual(LotType.cd, LotType.fromString("cd")); try std.testing.expectEqual(LotType.cash, LotType.fromString("cash")); try std.testing.expectEqual(LotType.illiquid, LotType.fromString("illiquid")); try std.testing.expectEqual(LotType.watch, LotType.fromString("watch")); try std.testing.expectEqual(LotType.stock, LotType.fromString("unknown")); try std.testing.expectEqual(LotType.stock, LotType.fromString("")); } test "Lot.priceSymbol" { const with_ticker = Lot{ .symbol = "9128283H2", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .ticker = "VTTHX" }; try std.testing.expectEqualStrings("VTTHX", with_ticker.priceSymbol()); const without_ticker = Lot{ .symbol = "AAPL", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 }; try std.testing.expectEqualStrings("AAPL", without_ticker.priceSymbol()); } test "Lot.returnPct" { // Open lot: uses current_price param const open_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 }; try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150), 0.001); // Closed lot: uses close_price, ignores current_price const closed_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 120 }; try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999), 0.001); // Zero open_price: returns 0 const zero_lot = Lot{ .symbol = "X", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0 }; try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100), 0.001); } test "Portfolio totals" { var lots = [_]Lot{ .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .security_type = .stock }, .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140, .security_type = .stock, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 160 }, .{ .symbol = "Savings", .shares = 50000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cash }, .{ .symbol = "CD-1Y", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cd }, .{ .symbol = "House", .shares = 500000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 0, .security_type = .illiquid }, .{ .symbol = "SPY_CALL", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.50, .security_type = .option }, .{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator }; // totalCostBasis: only open stock lots -> 10 * 150 = 1500 try std.testing.expectApproxEqAbs(@as(f64, 1500.0), portfolio.totalCostBasis(), 0.01); // totalRealizedGainLoss: closed stock lots -> 5 * (160-140) = 100 try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(), 0.01); // totalCash try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCash(), 0.01); // totalIlliquid try std.testing.expectApproxEqAbs(@as(f64, 500000.0), portfolio.totalIlliquid(), 0.01); // totalCdFaceValue try std.testing.expectApproxEqAbs(@as(f64, 10000.0), portfolio.totalCdFaceValue(), 0.01); // totalOptionCost: |2| * 5.50 * 100 = 1100 try std.testing.expectApproxEqAbs(@as(f64, 1100.0), portfolio.totalOptionCost(), 0.01); // hasType try std.testing.expect(portfolio.hasType(.stock)); try std.testing.expect(portfolio.hasType(.cash)); try std.testing.expect(portfolio.hasType(.cd)); try std.testing.expect(portfolio.hasType(.illiquid)); try std.testing.expect(portfolio.hasType(.option)); try std.testing.expect(portfolio.hasType(.watch)); } test "Portfolio watchSymbols" { const allocator = std.testing.allocator; var lots = [_]Lot{ .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 }, .{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch }, .{ .symbol = "NVDA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; const watch = try portfolio.watchSymbols(allocator); defer allocator.free(watch); try std.testing.expectEqual(@as(usize, 2), watch.len); } test "positions propagates price_ratio from lot" { const allocator = std.testing.allocator; var lots = [_]Lot{ // Two institutional lots for the same CUSIP, both with ticker alias and price_ratio .{ .symbol = "02315N600", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .ticker = "VTTHX", .price_ratio = 5.185 }, .{ .symbol = "02315N600", .shares = 50, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 142.0, .ticker = "VTTHX", .price_ratio = 5.185 }, // Regular stock lot — no price_ratio .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 }, }; var portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; const pos = try portfolio.positions(allocator); defer allocator.free(pos); try std.testing.expectEqual(@as(usize, 2), pos.len); for (pos) |p| { if (std.mem.eql(u8, p.symbol, "VTTHX")) { try std.testing.expectApproxEqAbs(@as(f64, 150.0), p.shares, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 5.185), p.price_ratio, 0.001); } else { try std.testing.expectEqualStrings("AAPL", p.symbol); try std.testing.expectApproxEqAbs(@as(f64, 1.0), p.price_ratio, 0.001); } } } test "positionsForAccount excludes closed-only symbols" { const allocator = std.testing.allocator; var lots = [_]Lot{ // Open lot in account A .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, // Closed lot in account A (was sold) .{ .symbol = "XLV", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 150.0, .account = "Acct A" }, // Open lot for same symbol in a different account .{ .symbol = "XLV", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .account = "Acct B" }, }; var portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; // Account A: should only see AAPL (XLV is fully closed there) const pos_a = try portfolio.positionsForAccount(allocator, "Acct A"); defer allocator.free(pos_a); try std.testing.expectEqual(@as(usize, 1), pos_a.len); try std.testing.expectEqualStrings("AAPL", pos_a[0].symbol); try std.testing.expectApproxEqAbs(@as(f64, 10.0), pos_a[0].shares, 0.01); // Account B: should see XLV with 50 shares const pos_b = try portfolio.positionsForAccount(allocator, "Acct B"); defer allocator.free(pos_b); try std.testing.expectEqual(@as(usize, 1), pos_b.len); try std.testing.expectEqualStrings("XLV", pos_b[0].symbol); try std.testing.expectApproxEqAbs(@as(f64, 50.0), pos_b[0].shares, 0.01); } test "isOpen respects maturity_date" { const past = Date.fromYmd(2024, 1, 1); const future = Date.fromYmd(2099, 12, 31); const expired_option = Lot{ .symbol = "AAPL 01/01/2024 150 C", .shares = -1, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 5.0, .security_type = .option, .maturity_date = past, }; try std.testing.expect(!expired_option.isOpen()); const active_option = Lot{ .symbol = "AAPL 12/31/2099 150 C", .shares = -1, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 5.0, .security_type = .option, .maturity_date = future, }; try std.testing.expect(active_option.isOpen()); const closed_option = Lot{ .symbol = "AAPL 12/31/2099 150 C", .shares = -1, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 5.0, .security_type = .option, .maturity_date = future, .close_date = Date.fromYmd(2024, 6, 1), }; try std.testing.expect(!closed_option.isOpen()); const stock = Lot{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 150.0, }; try std.testing.expect(stock.isOpen()); } test "nonStockValueForAccount" { const allocator = std.testing.allocator; const future = Date.fromYmd(2099, 12, 31); const past = Date.fromYmd(2024, 1, 1); var lots = [_]Lot{ .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "IRA" }, .{ .symbol = "", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "IRA" }, .{ .symbol = "CD123", .shares = 50000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = future }, .{ .symbol = "AAPL 12/31/2099 200 C", .shares = -2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 3.50, .security_type = .option, .account = "IRA", .maturity_date = future, .multiplier = 100 }, .{ .symbol = "AAPL 01/01/2024 180 C", .shares = -1, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 4.0, .security_type = .option, .account = "IRA", .maturity_date = past, .multiplier = 100 }, .{ .symbol = "", .shares = 1000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Other" }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; // cash(5000) + cd(50000) + open option(2*3.50*100=700) = 55700 // expired option excluded const ns = portfolio.nonStockValueForAccount("IRA"); try std.testing.expectApproxEqAbs(@as(f64, 55700.0), ns, 0.01); const ns_other = portfolio.nonStockValueForAccount("Other"); try std.testing.expectApproxEqAbs(@as(f64, 1000.0), ns_other, 0.01); } test "totalForAccount" { const allocator = std.testing.allocator; const future = Date.fromYmd(2099, 12, 31); var lots = [_]Lot{ .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "IRA" }, .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "IRA" }, .{ .symbol = "", .shares = 2000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "IRA" }, .{ .symbol = "CD456", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = future }, .{ .symbol = "AAPL C", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .option, .account = "IRA", .maturity_date = future, .multiplier = 100 }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); try prices.put("AAPL", 170.0); // MSFT not in prices — should fall back to avg_cost (300.0) // stocks: AAPL(100*170=17000) + MSFT(50*300=15000) = 32000 // non-stock: cash(2000) + cd(10000) + option(1*5*100=500) = 12500 // total = 44500 const total = portfolio.totalForAccount(allocator, "IRA", prices); try std.testing.expectApproxEqAbs(@as(f64, 44500.0), total, 0.01); } test "stableNavCandle: fills all fields at $1" { const c = stableNavCandle(Date.fromYmd(2026, 4, 1)); try std.testing.expectEqual(@as(f64, 1), c.close); try std.testing.expectEqual(@as(f64, 1), c.open); try std.testing.expectEqual(@as(f64, 1), c.high); try std.testing.expectEqual(@as(f64, 1), c.low); try std.testing.expectEqual(@as(f64, 1), c.adj_close); try std.testing.expectEqual(@as(u64, 0), c.volume); }