const std = @import("std"); const Date = @import("date.zig").Date; const Candle = @import("candle.zig").Candle; // ── Pricing model ──────────────────────────────────────────── // // How a lot's market value gets computed is non-obvious because several // independent concerns overlap. Consolidated here so new readers (and // future-us) don't have to reverse-engineer it from call sites. // // ## Inputs // // 1. `lot.shares` — signed share count. Negative = short (written // options, short stock). Absolute value is what multiplies price for // cost/value; the sign flows through to P&L. // // 2. Some "raw price" from one of these sources, in priority order: // a. Candle close for the target date (live API — retail share // class). This is the common path. // b. `lot.price` manual override (`price::` in portfolio.srf). The // user enters what they see in their brokerage statement, so this // is in the LOT's share class already — no ratio needed. // c. `position.avg_cost` fallback when no candle is available and no // manual override exists. This is in the LOT's share class (user // paid institutional-class prices to open the lot). // // 3. `lot.price_ratio` — share-class conversion factor. Default 1.0 // for retail-class lots. Example: VTTHX (institutional, $144) holds // VTHR (retail, $27.78), ratio ≈ 5.185. API gives us the $27.78 // retail close; we multiply to get the $144 institutional price. // // ## The rule // // `effective_price = is_preadjusted ? raw_price : raw_price * price_ratio` // // Where `is_preadjusted` means "this raw price is already in the lot's // share-class terms, don't apply the ratio." Sources (2b) and (2c) are // preadjusted; source (2a) is not. // // `market_value = shares * effective_price` // // See `Lot.effectivePrice`, `Lot.marketValue`, and the matching methods // on `Position` for the canonical implementation. All callers in // snapshot.zig, audit.zig, and valuation.zig route through these — do // not reintroduce inline `price * price_ratio` expressions. // // ## Caching pre-multiply pattern // // When manual overrides (2b) get folded into a shared `prices` map // keyed by symbol, they're PRE-MULTIPLIED by `price_ratio` at insert // time (see `commands/snapshot.zig:buildSnapshot` and // `commands/audit.zig`). This normalizes the cached value so later // readers can treat every entry uniformly as "price in whichever terms // the lot needs." The `manual_set` (from `buildFallbackPrices`) then // tells readers which entries are preadjusted. // // ## avg_cost fallback // // When a symbol has no live price AND no manual override, callers fall // back to `position.avg_cost` (the weighted average lot open-price). // That value is already in the lot's share-class terms — the user paid // institutional-class prices to open the lot — so `is_preadjusted = true`. // Both snapshot and audit honor this: snapshot via `buildFallbackPrices` // + `manual_set`, audit via inline `prices.get(sym) orelse avg_cost` // with a matching `is_preadjusted` flag per branch. // ── Money-market / stable-NAV classification ──────────────── // // Centralized so that audit.zig, the Fidelity/Schwab parsers, and the // planned snapshot writer all agree on which symbols are fixed-$1-NAV // instruments. Prior to this the classification lived in three places // with three different heuristics that disagreed on edge cases. /// Well-known US money-market fund tickers. Schwab (SWVXX/SWTXX), /// Vanguard (VMFXX/VMRXX/VUSXX/VMSXX/VYFXX), Fidelity /// (SPAXX/SPRXX/FDRXX/FDLXX/FZFXX/FZDXX/FTEXX), and a handful of /// BlackRock / Federated / JPM common tickers. Extend as new funds /// appear. pub const money_market_symbols = [_][]const u8{ // Schwab "SWVXX", "SWTXX", "SNAXX", "SNVXX", "SNOXX", "SNSXX", // Vanguard "VMFXX", "VMRXX", "VUSXX", "VMSXX", "VYFXX", // Fidelity prime/gov/treasury "SPAXX", "SPRXX", "FDRXX", "FDLXX", "FZFXX", "FZDXX", "FTEXX", "FDIXX", // Federated, BlackRock, JPM common tickers "GOFXX", "TSCXX", "MJLXX", }; /// Returns true when `symbol` appears in the well-known money-market /// ticker list (case-insensitive). Use this anywhere you need to know /// "is this a fixed-$1-NAV cash equivalent?" based on the ticker alone. /// /// Symbols not in the list (including unknown MM funds) return false. /// Callers that have candle data on hand can supplement this with a /// trailing-$1-close check of their own if they need to catch funds /// missing from the whitelist. pub fn isMoneyMarketSymbol(symbol: []const u8) bool { if (symbol.len == 0) return false; // All tickers in `money_market_symbols` are uppercase; upper-case the // input once into a fixed-size buffer for the comparison. var buf: [16]u8 = undefined; if (symbol.len > buf.len) return false; for (symbol, 0..) |c, i| buf[i] = std.ascii.toUpper(c); const up = buf[0..symbol.len]; for (money_market_symbols) |mm| { if (std.mem.eql(u8, up, mm)) return true; } return false; } /// 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, as_of: Date) bool { return self.lotIsOpenAsOf(as_of); } /// Was the lot held at end-of-day on `as_of`? /// /// Used by historical snapshot backfill (`zfin snapshot --as-of`) /// where "open" must be evaluated against the target date rather /// than wall-clock today. `isOpen()` delegates to this with /// today as `as_of`. /// /// End-of-day semantics (see tests): /// - `open_date > as_of` → not yet bought → false /// - `close_date` on/before as_of → sold that day or earlier → false /// - `maturity_date` on/before as_of → matured that day or earlier → false /// - otherwise → true pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool { // Not yet bought on `as_of`. if (as_of.lessThan(self.open_date)) return false; // Sold on or before `as_of`. if (self.close_date) |cd| { if (!as_of.lessThan(cd)) return false; } // Matured on or before `as_of` (options, CDs). if (self.maturity_date) |mat| { if (!as_of.lessThan(mat)) return false; } return true; } pub fn costBasis(self: Lot) f64 { return self.shares * self.open_price; } /// Apply the share-class `price_ratio` to `raw_price`. See the /// "Pricing model" block at the top of this file for the full /// semantics of `is_preadjusted`. pub fn effectivePrice(self: Lot, raw_price: f64, is_preadjusted: bool) f64 { return if (is_preadjusted) raw_price else raw_price * self.price_ratio; } /// Market value of the lot at `raw_price`: `shares * effectivePrice`. pub fn marketValue(self: Lot, raw_price: f64, is_preadjusted: bool) f64 { return self.shares * self.effectivePrice(raw_price, is_preadjusted); } /// 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). /// positionsAsOf() groups by (priceSymbol, price_ratio), so lots with /// different ratios sharing the same ticker produce separate positions. /// portfolioSummary() then merges them back into a single rolled-up /// allocation with normalized (base-ticker-equivalent) shares. price_ratio: f64 = 1.0, /// Apply the share-class `price_ratio` to `raw_price` — the /// Position-aggregate mirror of `Lot.effectivePrice`. See the /// "Pricing model" block at the top of this file. pub fn effectivePrice(self: Position, raw_price: f64, is_preadjusted: bool) f64 { return if (is_preadjusted) raw_price else raw_price * self.price_ratio; } /// Market value of the position at `raw_price`: `shares * effectivePrice`. pub fn marketValue(self: Position, raw_price: f64, is_preadjusted: bool) f64 { return self.shares * self.effectivePrice(raw_price, is_preadjusted); } }; /// 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. /// /// Uses wall-clock today for the open/closed determination. For /// historical snapshot backfill where "today" is not the right /// reference, use `positionsAsOf(allocator, as_of)`. pub fn positions(self: Portfolio, as_of: Date, allocator: std.mem.Allocator) ![]Position { return self.positionsAsOf(allocator, as_of); } /// Like `positions` but evaluates lot open/closed against `as_of` /// rather than wall-clock today. See `Lot.lotIsOpenAsOf` for /// semantics. Used by historical snapshot backfill so a lot closed /// after `as_of` still contributes its shares on that date, and /// a lot opened after `as_of` does not. pub fn positionsAsOf(self: Portfolio, allocator: std.mem.Allocator, as_of: Date) ![]Position { var result = std.ArrayList(Position).empty; errdefer result.deinit(allocator); for (self.lots) |lot| { if (lot.security_type != .stock) continue; const sym = lot.priceSymbol(); // Find existing position matching both symbol AND price_ratio. // Lots with different ratios (e.g. direct SPY vs institutional CIT // using ticker::SPY) must produce separate positions to ensure // correct valuation. var found: ?*Position = null; for (result.items) |*pos| { if (std.mem.eql(u8, pos.symbol, sym) and pos.price_ratio == lot.price_ratio) { found = pos; break; } } if (found == null) { try result.append(allocator, .{ .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, }); found = &result.items[result.items.len - 1]; } else { // Track account: if lots have different accounts, mark as "Multiple" const existing = found.?.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)) { found.?.account = "Multiple"; } } const pos = found.?; if (lot.lotIsOpenAsOf(as_of)) { pos.shares += lot.shares; pos.total_cost += lot.costBasis(); pos.open_lots += 1; } else { const not_yet_opened = as_of.lessThan(lot.open_date); if (!not_yet_opened) { pos.closed_lots += 1; pos.realized_gain_loss += lot.realizedGainLoss() orelse 0; } } } // Compute avg_cost for (result.items) |*pos| { if (pos.shares > 0) { pos.avg_cost = pos.total_cost / pos.shares; } } 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, as_of: Date, allocator: std.mem.Allocator, account_name: []const u8) ![]Position { var result = std.ArrayList(Position).empty; errdefer result.deinit(allocator); 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(); // Find existing position matching both symbol AND price_ratio. var found: ?*Position = null; for (result.items) |*pos| { if (std.mem.eql(u8, pos.symbol, sym) and pos.price_ratio == lot.price_ratio) { found = pos; break; } } if (found == null) { try result.append(allocator, .{ .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, }); found = &result.items[result.items.len - 1]; } const pos = found.?; if (lot.isOpen(as_of)) { pos.shares += lot.shares; pos.total_cost += lot.costBasis(); pos.open_lots += 1; } else { pos.closed_lots += 1; pos.realized_gain_loss += lot.realizedGainLoss() orelse 0; } } // Compute avg_cost and filter to open-only var final = std.ArrayList(Position).empty; errdefer final.deinit(allocator); for (result.items) |*pos| { if (pos.open_lots == 0) continue; if (pos.shares > 0) { pos.avg_cost = pos.total_cost / pos.shares; } try final.append(allocator, pos.*); } result.deinit(allocator); return final.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, as_of: Date, account_name: []const u8) f64 { var total: f64 = 0; for (self.lots) |lot| { if (!lot.isOpen(as_of)) 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, as_of: Date, allocator: std.mem.Allocator, account_name: []const u8, prices: std.StringHashMap(f64)) f64 { var total: f64 = 0; const acct_positions = self.positionsForAccount(as_of, allocator, account_name) catch return self.nonStockValueForAccount(as_of, 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(as_of, account_name); return total; } /// Total cost basis of all open stock lots. pub fn totalCostBasis(self: Portfolio, as_of: Date) f64 { var total: f64 = 0; for (self.lots) |lot| { if (lot.isOpen(as_of) 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 (open lots only). pub fn totalCash(self: Portfolio, as_of: Date) f64 { return self.totalCashAsOf(as_of); } /// `totalCash` evaluated against an arbitrary date — used by /// historical snapshot backfill. See `Lot.lotIsOpenAsOf`. pub fn totalCashAsOf(self: Portfolio, as_of: Date) f64 { var total: f64 = 0; for (self.lots) |lot| { if (lot.security_type != .cash) continue; if (!lot.lotIsOpenAsOf(as_of)) continue; total += lot.shares; } return total; } /// Total illiquid asset value across all accounts (open lots only). pub fn totalIlliquid(self: Portfolio, as_of: Date) f64 { return self.totalIlliquidAsOf(as_of); } /// `totalIlliquid` evaluated against an arbitrary date. pub fn totalIlliquidAsOf(self: Portfolio, as_of: Date) f64 { var total: f64 = 0; for (self.lots) |lot| { if (lot.security_type != .illiquid) continue; if (!lot.lotIsOpenAsOf(as_of)) continue; total += lot.shares; } return total; } /// Total CD face value across all accounts (open lots only — /// matured CDs are excluded). pub fn totalCdFaceValue(self: Portfolio, as_of: Date) f64 { return self.totalCdFaceValueAsOf(as_of); } /// `totalCdFaceValue` evaluated against an arbitrary date. pub fn totalCdFaceValueAsOf(self: Portfolio, as_of: Date) f64 { var total: f64 = 0; for (self.lots) |lot| { if (lot.security_type != .cd) continue; if (!lot.lotIsOpenAsOf(as_of)) continue; total += lot.shares; } return total; } /// Total option cost basis (|shares| * open_price * multiplier) — /// open lots only. Closed/matured options are excluded. pub fn totalOptionCost(self: Portfolio, as_of: Date) f64 { return self.totalOptionCostAsOf(as_of); } /// `totalOptionCost` evaluated against an arbitrary date. pub fn totalOptionCostAsOf(self: Portfolio, as_of: Date) f64 { var total: f64 = 0; for (self.lots) |lot| { if (lot.security_type != .option) continue; if (!lot.lotIsOpenAsOf(as_of)) continue; // 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(Date.fromYmd(2026, 5, 8))); try std.testing.expectApproxEqAbs(@as(f64, 1500.0), lot.costBasis(), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 2000.0), lot.marketValue(200.0, true), 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(Date.fromYmd(2026, 5, 8))); 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(Date.fromYmd(2026, 5, 8), 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(Date.fromYmd(2026, 5, 8)), 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(Date.fromYmd(2026, 5, 8)), 0.01); // totalIlliquid try std.testing.expectApproxEqAbs(@as(f64, 500000.0), portfolio.totalIlliquid(Date.fromYmd(2026, 5, 8)), 0.01); // totalCdFaceValue try std.testing.expectApproxEqAbs(@as(f64, 10000.0), portfolio.totalCdFaceValue(Date.fromYmd(2026, 5, 8)), 0.01); // totalOptionCost: |2| * 5.50 * 100 = 1100 try std.testing.expectApproxEqAbs(@as(f64, 1100.0), portfolio.totalOptionCost(Date.fromYmd(2026, 5, 8)), 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)); } // ── Portfolio totals: open-lot filtering ────────────────────── // // The four non-stock totals (cash, cd, illiquid, option) now filter // by `lotIsOpenAsOf` rather than counting every lot of the given type. // Motivating scenario: user leaves a matured CD in portfolio.srf with // `maturity_date` set (for historical context). Pre-fix, totalCdFaceValue // would include it and over-report cash-equivalents. Post-fix, the // matured CD is correctly excluded from "right now" totals. test "Portfolio.totalOptionCost: excludes closed options" { var lots = [_]Lot{ .{ .symbol = "CALL_OPEN", .shares = -5, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 2.00, .security_type = .option, .maturity_date = Date.fromYmd(2099, 1, 1), }, .{ .symbol = "CALL_CLOSED", .shares = -3, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 4.00, .security_type = .option, .close_date = Date.fromYmd(2026, 3, 15), .close_price = 0.01, .maturity_date = Date.fromYmd(2099, 1, 1), }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator }; // Only CALL_OPEN contributes: |-5| * 2.00 * 100 = 1000. // Pre-fix would have been 1000 + |-3| * 4.00 * 100 = 2200. try std.testing.expectApproxEqAbs(@as(f64, 1000.0), portfolio.totalOptionCost(Date.fromYmd(2026, 5, 8)), 0.01); } test "Portfolio.totalOptionCost: excludes matured options" { var lots = [_]Lot{ .{ .symbol = "CALL_OPEN", .shares = -5, .open_date = Date.fromYmd(2026, 3, 1), .open_price = 2.00, .security_type = .option, .maturity_date = Date.fromYmd(2099, 1, 1), }, .{ .symbol = "CALL_MATURED", .shares = -3, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 4.00, .security_type = .option, .maturity_date = Date.fromYmd(2024, 6, 1), // long expired }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator }; try std.testing.expectApproxEqAbs(@as(f64, 1000.0), portfolio.totalOptionCost(Date.fromYmd(2026, 5, 8)), 0.01); } test "Portfolio.totalCdFaceValue: excludes matured CDs" { var lots = [_]Lot{ .{ .symbol = "CD_ACTIVE", .shares = 50000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.00, .security_type = .cd, .maturity_date = Date.fromYmd(2099, 1, 1), }, .{ .symbol = "CD_MATURED", .shares = 75000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.00, .security_type = .cd, .maturity_date = Date.fromYmd(2025, 12, 31), }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator }; // Pre-fix would have been 50000 + 75000 = 125000. try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCdFaceValue(Date.fromYmd(2026, 5, 8)), 0.01); } test "Portfolio.totalCash: excludes closed cash lots" { var lots = [_]Lot{ .{ .symbol = "ACTIVE_CASH", .shares = 10000, .open_date = Date.fromYmd(2026, 2, 25), .open_price = 1.00, .security_type = .cash, }, .{ .symbol = "MOVED_CASH", .shares = 25000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1.00, .security_type = .cash, .close_date = Date.fromYmd(2026, 1, 15), // cash was swept out }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator }; try std.testing.expectApproxEqAbs(@as(f64, 10000.0), portfolio.totalCash(Date.fromYmd(2026, 5, 8)), 0.01); } test "Portfolio.totalIlliquidAsOf: respects as_of for backfill" { // Illiquid lots rarely "close," but a property sale would set // close_date. Backfill to before the sale should include it; // backfill to after should not. var lots = [_]Lot{ .{ .symbol = "House", .shares = 800000, .open_date = Date.fromYmd(2020, 5, 1), .open_price = 0, .security_type = .illiquid, .close_date = Date.fromYmd(2026, 3, 15), // sold }, .{ .symbol = "Other", .shares = 200000, .open_date = Date.fromYmd(2022, 1, 1), .open_price = 0, .security_type = .illiquid, }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator }; // Before the sale: both count. try std.testing.expectApproxEqAbs( @as(f64, 1_000_000.0), portfolio.totalIlliquidAsOf(Date.fromYmd(2026, 1, 1)), 0.01, ); // After the sale: only Other counts. try std.testing.expectApproxEqAbs( @as(f64, 200_000.0), portfolio.totalIlliquidAsOf(Date.fromYmd(2026, 4, 1)), 0.01, ); } test "Portfolio totals: AsOf excludes not-yet-opened lots" { // Backfill to a date before a lot's open_date should exclude it. var lots = [_]Lot{ .{ .symbol = "EarlyCash", .shares = 1000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.00, .security_type = .cash, }, .{ .symbol = "LateCash", .shares = 5000, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 1.00, .security_type = .cash, }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator }; // 2026-02-15 is after EarlyCash's open but before LateCash's. try std.testing.expectApproxEqAbs( @as(f64, 1000.0), portfolio.totalCashAsOf(Date.fromYmd(2026, 2, 15)), 0.01, ); // 2026-04-15 is after both. try std.testing.expectApproxEqAbs( @as(f64, 6000.0), portfolio.totalCashAsOf(Date.fromYmd(2026, 4, 15)), 0.01, ); } 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(Date.fromYmd(2026, 5, 8), 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 "positions separates lots with different price_ratio" { const allocator = std.testing.allocator; var lots = [_]Lot{ // Direct SPY holding, price_ratio = 1.0 (default) .{ .symbol = "SPY", .shares = 717.34, .open_date = Date.fromYmd(2025, 2, 25), .open_price = 461.24, .account = "Tax Loss" }, // Institutional S&P 500 CIT, uses SPY as ticker with a ratio .{ .symbol = "NON40OR52", .shares = 5070.866, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 97.24, .ticker = "SPY", .price_ratio = 0.2381, .account = "Fidelity Kelly 401(k)" }, }; var portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; const pos = try portfolio.positions(Date.fromYmd(2026, 5, 8), allocator); defer allocator.free(pos); // Should produce 2 separate positions, not 1 merged position try std.testing.expectEqual(@as(usize, 2), pos.len); var found_direct = false; var found_institutional = false; for (pos) |p| { if (p.price_ratio == 1.0) { found_direct = true; try std.testing.expectApproxEqAbs(@as(f64, 717.34), p.shares, 0.01); try std.testing.expectEqualStrings("SPY", p.lot_symbol); } else { found_institutional = true; try std.testing.expectApproxEqAbs(@as(f64, 5070.866), p.shares, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.2381), p.price_ratio, 0.0001); try std.testing.expectEqualStrings("NON40OR52", p.lot_symbol); } } try std.testing.expect(found_direct); try std.testing.expect(found_institutional); } 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(Date.fromYmd(2026, 5, 8), 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(Date.fromYmd(2026, 5, 8), 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(Date.fromYmd(2026, 5, 8))); 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(Date.fromYmd(2026, 5, 8))); 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(Date.fromYmd(2026, 5, 8))); const stock = Lot{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 150.0, }; try std.testing.expect(stock.isOpen(Date.fromYmd(2026, 5, 8))); } // ── lotIsOpenAsOf ──────────────────────────────────────────── // // `isOpen()` asks "is this lot held right now (wall-clock today)?" // `lotIsOpenAsOf(as_of)` asks "was this lot held at end-of-day on // `as_of`?" — needed for historical snapshot backfill where wall-clock // `today` is not the relevant reference date. // // Rules (end-of-day semantics): // - open_date > as_of → not yet bought → CLOSED // - close_date set and <= as_of → sold on/before → CLOSED // - maturity_date set and <= as_of → matured on/before → CLOSED // - otherwise → open // // "Closed on D excluded from D snapshot" is deliberate (end-of-day // semantics: a lot sold on D is not held at day-end). Symmetric: "opened // on D included in D snapshot" — you bought it that day, you hold it at // day-end. test "lotIsOpenAsOf: open_date after as_of excludes" { const lot = Lot{ .symbol = "X", .shares = 10, .open_date = Date.fromYmd(2026, 4, 9), .open_price = 100.0, }; try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6))); try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 9))); // opened that day try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 10))); } test "lotIsOpenAsOf: close_date on or before as_of excludes" { const lot = Lot{ .symbol = "X", .shares = 10, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 100.0, .close_date = Date.fromYmd(2026, 4, 6), .close_price = 110.0, }; try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 5))); // still open try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6))); // sold that day try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 7))); } test "lotIsOpenAsOf: maturity relative to as_of, not wall clock" { // Option opened 03-16, matured 04-17. Asking about 04-06 should // return true — open, maturity hasn't happened yet on 04-06. // This was the real bug: isOpen() used wall-clock today, so // backfilling any date before today but after maturity wrongly // excluded the lot. const opt = Lot{ .symbol = "NVDA 04/17/2026 200 C", .shares = -5, .open_date = Date.fromYmd(2026, 3, 16), .open_price = 2.79, .security_type = .option, .maturity_date = Date.fromYmd(2026, 4, 17), }; try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6))); try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 16))); try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 17))); // matured that day try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 18))); } test "lotIsOpenAsOf: close wins over maturity (closed early)" { // Option opened 03-16, closed early 04-09, nominal maturity 04-17. // On 04-06 (before both): open. // On 04-09 (closed that day): not open. // On 04-15 (between close and maturity): not open (already closed). const opt = Lot{ .symbol = "NVDA 04/17/2026 200 C", .shares = -5, .open_date = Date.fromYmd(2026, 3, 16), .open_price = 2.79, .security_type = .option, .close_date = Date.fromYmd(2026, 4, 9), .close_price = 0.09, .maturity_date = Date.fromYmd(2026, 4, 17), }; try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6))); try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 8))); try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 9))); try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 15))); } test "lotIsOpenAsOf: plain stock with no close, no maturity" { const lot = Lot{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, }; try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2023, 12, 31))); try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2024, 1, 1))); try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2100, 1, 1))); } test "lotIsOpenAsOf: isOpen() stays compatible via today" { // Regression guard: isOpen() should still behave as before — // equivalent to lotIsOpenAsOf(today). Test with a lot whose // status doesn't depend on date to keep this deterministic. const stock = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 15), .open_price = 150.0, }; try std.testing.expectEqual(stock.isOpen(Date.fromYmd(2026, 5, 8)), stock.lotIsOpenAsOf(Date.fromYmd(2026, 5, 8))); const closed = 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.expectEqual(closed.isOpen(Date.fromYmd(2026, 5, 8)), closed.lotIsOpenAsOf(Date.fromYmd(2026, 5, 8))); } 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(Date.fromYmd(2026, 5, 8), "IRA"); try std.testing.expectApproxEqAbs(@as(f64, 55700.0), ns, 0.01); const ns_other = portfolio.nonStockValueForAccount(Date.fromYmd(2026, 5, 8), "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(Date.fromYmd(2026, 5, 8), allocator, "IRA", prices); try std.testing.expectApproxEqAbs(@as(f64, 44500.0), total, 0.01); } // ── Money-market predicate tests ───────────────────────────── test "isMoneyMarketSymbol: known Schwab and Fidelity tickers" { try std.testing.expect(isMoneyMarketSymbol("SWVXX")); try std.testing.expect(isMoneyMarketSymbol("VMFXX")); try std.testing.expect(isMoneyMarketSymbol("SPAXX")); try std.testing.expect(isMoneyMarketSymbol("FDRXX")); // Case-insensitive try std.testing.expect(isMoneyMarketSymbol("swvxx")); try std.testing.expect(isMoneyMarketSymbol("Swvxx")); } test "isMoneyMarketSymbol: non-MM tickers reject" { try std.testing.expect(!isMoneyMarketSymbol("AAPL")); try std.testing.expect(!isMoneyMarketSymbol("VTI")); try std.testing.expect(!isMoneyMarketSymbol("VSTCX")); // mutual fund, not MM try std.testing.expect(!isMoneyMarketSymbol("")); // Very long strings don't fit the buffer — safely rejected. try std.testing.expect(!isMoneyMarketSymbol("THIS_IS_NOT_A_TICKER_AT_ALL")); } 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); }