const std = @import("std"); const Date = @import("../Date.zig"); const Candle = @import("candle.zig").Candle; const split = @import("split.zig"); const Split = split.Split; // ── 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/, and valuation.zig route through these - do // not reintroduce inline `price * price_ratio` expressions. // // ## The pre-multiply anti-pattern (do not add new instances) // // A tempting shortcut is to fold `price_ratio` into a shared `prices` map // at insert time - "pre-multiplying" a manual override - so later readers // can treat every entry uniformly. It is unsound, for two reasons: // // 1. The map is keyed by SYMBOL while `price_ratio` is per LOT. Folding // one lot's ratio into a symbol-keyed entry corrupts every other lot // sharing that ticker at a different ratio - which is exactly the // `ticker::` aliasing this whole mechanism exists to support. // 2. It collides with `buildFallbackPrices`, whose first pass gap-fills // only `if (!prices.contains(sym))`. A pre-inserted entry is skipped, // so it never lands in `manual_set`, so the downstream // `effectivePrice(raw, is_manual)` sees `is_manual == false` and // applies the ratio a SECOND time, squaring it. // // The correct shape is raw value + a companion "preadjusted" set: // `buildFallbackPrices` stores the override untouched and records the // symbol in `manual_set`; readers pass that through as `is_preadjusted`. // `commands/snapshot.zig` does this (it used to pre-multiply, and squared // the ratio for any manual-priced lot with a non-unit one). // // KNOWN GAP: `commands/audit.zig` still pre-multiplies, because the // reconcile path has no `manual_set` equivalent - // `reconcile/common.zig:resolvePositionValue` applies the ratio to // whatever is in the map with no way to opt out. Fixing it properly means // threading preadjusted-ness through the reconcile signatures. Until then // a manual `price::` combined with a non-unit `price_ratio` reads high by // the ratio in `zfin audit` only. Do not "fix" it by dividing the ratio // out at insert - that just moves the corruption onto sibling lots. // // ## 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. // // ## Per-LOT display rows // // `valuation.Allocation` is a POSITION-level view, and its // `current_price` is NOT unconditionally raw. Which it is depends on // whether `mergeAllocsBySymbol` folded the row: // // - UNMERGED (`alloc.price_ratio != 1.0`): the allocation carries the // lot's own ratio, and `portfolioSummary` already applied it - // `current_price` is the EFFECTIVE price. // - MERGED, or plainly unratioed (`alloc.price_ratio == 1.0`): // `current_price` is the RAW base-ticker price and `shares` are in // base-ticker-equivalent units. // // So a lot-detail row cannot just multiply its own raw shares by // `current_price`: in the merged case it is wrong by exactly the lot's // ratio. That shipped - a direct-indexing sleeve with // `price_ratio:num:0.2387` rendered a +$3.39M gain against a real // +$433K, and the lot rows under a position summed to three times the // position's own market value. Three sites had independently // hand-rolled the same broken expression. // // The first fix for that got the OTHER half wrong: it keyed provenance // on `alloc.is_manual_price`, which is orthogonal to merging, so every // unmerged live-priced ratio'd lot then had its ratio applied twice. // That is the single-CIT-lot case this file's `ticker` + `price_ratio` // docs describe as the primary use, and no test caught it because every // fixture left `Allocation.price_ratio` at 1.0 - indistinguishable from // a merged group. // // So: per-lot display sites MUST price through // `views/portfolio_sections.zig:effectivePriceFor(allocations, lot)`, // which resolves `close_price`, discriminates on `price_ratio`, and // applies the ratio via `Lot.effectivePrice`. Never read // `Allocation.current_price` into a per-lot calculation, and never // reintroduce `is_manual_price` as the provenance signal. Current // callers: the CLI holdings table, the TUI portfolio tab's lot rows, // and the shared column-width pass. // ── Share model (split adjustment) ────────────────────────── // // `lot.shares` and `lot.open_price` are IMMUTABLE HISTORICAL FACTS: // the count and per-share price exactly as the lot was transacted, as // written in portfolio.srf. We never rewrite them for a stock split // (that would corrupt the git history the contributions/compare/audit // commands read). Instead, `lot.split_factor` is a DERIVED multiplier, // populated on read by `enrichSplits` from the fetched split corpus: // // effective_shares = shares * split_factor // effective_open_price = open_price / split_factor // // so cost basis stays split-invariant // (`effective_shares * effective_open_price == shares * open_price`). // // `split_factor` is analogous to `price_ratio`: a derived multiplier, // default 1.0 (no adjustment), applied via accessors. It is set ONLY // by `enrichSplits`, and ONLY for symbols the user has opted in with a // per-symbol `splits_current_through::DATE` on that symbol's // metadata.srf row. Symbols without it stay at 1.0 everywhere and every // path below behaves exactly as it did before this feature existed. // // ## The rule - when to use which // // Use `effectiveShares()` / `effectiveOpenPrice()` (NOT raw // `shares`/`open_price`) anywhere a share count is: // - multiplied by a current-or-later price (market value, gain/loss // vs current price), // - summed into a current holdings/position total, or // - shown on screen. // `marketValue` already routes through `effectiveShares()`, and // `positionsAsOf`/`positionsForAccount` sum `effectiveShares()`, so // anything flowing through positions or `marketValue` is correct for // free. // // Use RAW `shares`/`open_price` (they are correct precisely because // they are split-invariant or historical) for: // - cost basis and realized P&L (`costBasis`, `realizedGainLoss`), // - the contributions diff (a split must NOT read as a contribution; // it diffs raw declared shares across git revisions), // - the frozen `.shares` field written into a snapshot record (a // historical fact; its `.value` is computed effective instead). // // If you are reading `lot.shares` directly in NEW code, you had // better be in the RAW list above - otherwise use `effectiveShares()`. // ── Money-market / stable-NAV classification ──────────────── // // Centralized so that audit/, 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", // Wells Fargo / Allspring (Allspring is the WAM rebrand; // tickers retained their classic letters for legacy holders) "WMPXX", "WFFXX", "NWGXX", "GVIXX", // 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", }; } }; /// Call or put option type. pub const OptionType = enum { call, put, }; /// 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 explicit display label - the lot's "human identity" /// for the symbol column (e.g. `label::TGT2035` on a target-date /// CUSIP). When set it overrides the symbol/ticker in display /// ONLY; it is never a pricing or classification key. The display /// counterpart to `ticker::`/`priceSymbol()`. See `displaySymbol()`. label: ?[]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, /// DERIVED split-adjustment multiplier - NOT hand-edited, NOT a /// pricing/classification key. Default 1.0 = no adjustment. /// Populated on read by `enrichSplits` (opt-in via a per-symbol /// `splits_current_through` on the symbol's metadata.srf row); see /// the "Share model" /// block at the top of this file. `effectiveShares()` multiplies by /// it; `effectiveOpenPrice()` divides by it. Left at its 1.0 default /// it is omitted from SRF serialization, so it never touches the /// user's portfolio.srf (guarded by a round-trip test in store.zig). split_factor: 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: the `ticker::` alias /// when set, else the raw `symbol`. This is the lot's **economic /// identity** - what the pipeline prices, aggregates, and /// classifies by. Its display counterpart is `displaySymbol()`. pub fn priceSymbol(self: Lot) []const u8 { return self.ticker orelse self.symbol; } /// The symbol to show in the display: an explicit `label::` when /// set, else the economic identity (`priceSymbol()`). This is the /// lot's **human identity** - purely cosmetic, never a pricing or /// classification key. The display mirror of `priceSymbol()`; /// also mirrored by `Position.displaySymbol()`. pub fn displaySymbol(self: Lot) []const u8 { return self.label orelse self.priceSymbol(); } /// Field names a user hand-maintains that a brokerage export /// never carries. `zfin import` copies each verbatim from the /// prior matching lot on re-import (see `synthesizeLots` in /// `commands/import.zig`), so hand annotations survive a refresh. /// Add a new hand-edited field here and import preserves it /// automatically; this is the single source of truth. /// /// Deliberately NOT listed: /// - `symbol`, `shares`, `account`, `security_type` come from /// the export. /// - `open_date`, `open_price`, `note` are preserved-or- /// synthesized with their own fallback logic in import. /// - option-mechanics (`underlying`, `strike`, `multiplier`, /// `option_type`) and closed-lot (`close_date`, /// `close_price`) fields: import only builds open stock/cash /// positions, so they never apply to a re-imported lot. pub const hand_edited_fields = [_][]const u8{ "ticker", "label", "price", "price_date", "price_ratio", "drip", "maturity_date", "rate", }; 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; } /// Cost basis: RAW shares x RAW open_price. Split-invariant /// (`effectiveShares * effectiveOpenPrice == shares * open_price`), /// so this deliberately stays on the raw fields. See "Share model". pub fn costBasis(self: Lot) f64 { return self.shares * self.open_price; } /// Split-adjusted share count: raw `shares` scaled by the derived /// `split_factor`. Use this (never raw `shares`) anywhere shares get /// multiplied by a current-or-later price, summed into current /// holdings, or displayed. See the "Share model" block above. pub fn effectiveShares(self: Lot) f64 { return self.shares * self.split_factor; } /// Split-adjusted per-share cost: raw `open_price` divided by /// `split_factor`. Pairs with `effectiveShares()` so cost basis is /// preserved. Use for per-share cost display on a split-spanning lot. pub fn effectiveOpenPrice(self: Lot) f64 { return self.open_price / self.split_factor; } /// 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`: /// `effectiveShares * effectivePrice` (split- and share-class-aware). pub fn marketValue(self: Lot, raw_price: f64, is_preadjusted: bool) f64 { return self.effectiveShares() * 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. Stays on RAW shares - a /// closed lot is a completed round-trip whose recorded open/close are /// consistent; `enrichSplits` leaves closed lots at `split_factor 1.0`. 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.effectiveShares() * (current_price - self.effectiveOpenPrice()); } 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.effectiveOpenPrice()) - 1.0; } }; /// Populate each open stock lot's `split_factor` from the fetched split /// `corpus` (keyed by `priceSymbol()`), in place. /// /// OPT-IN, PER SYMBOL: `cutovers` maps a symbol to the date through /// which its recorded shares are already split-adjusted (the /// `splits_current_through` field on that symbol's metadata.srf row). A /// symbol ABSENT from `cutovers` is left untouched - `split_factor` /// stays 1.0 and valuation behaves exactly as it did before splits /// existed. For a symbol present, a split is applied to a lot only if it /// occurred AFTER both the lot's purchase and the symbol's cutover (so /// already-restated legacy lots, whose splits predate the cutover, are /// left alone) and on/before `as_of`. Closed lots are skipped - their /// realized P&L stays on raw shares. Non-stock lots never split. /// /// `as_of` is the reference date: today for live valuation, the /// snapshot date for a back-dated snapshot capture. pub fn enrichSplits( lots: []Lot, corpus: *const std.StringHashMap([]const Split), cutovers: *const std.StringHashMap(Date), as_of: Date, ) void { for (lots) |*lot| { if (lot.security_type != .stock) continue; if (!lot.lotIsOpenAsOf(as_of)) continue; const cutover = cutovers.get(lot.priceSymbol()) orelse continue; // per-symbol opt-in const splits = corpus.get(lot.priceSymbol()) orelse continue; // Apply splits after BOTH the purchase and the symbol's cutover. const after = if (cutover.lessThan(lot.open_date)) lot.open_date else cutover; lot.split_factor = split.cumulativeSplitRatio(splits, after, as_of); } } /// 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, /// Explicit display label from the first lot (the lot's `label::`). /// Drives `displaySymbol()`; display-only, never a key. label: ?[]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); } /// The symbol to show in the display: an explicit `label` (from /// the lot's `label::`) when set, else `symbol` - which is /// already the economic identity (`priceSymbol()`), since /// positions are keyed by it. The aggregate mirror of /// `Lot.displaySymbol()`. pub fn displaySymbol(self: Position) []const u8 { return self.label orelse self.symbol; } }; /// 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.label) |l| self.allocator.free(l); 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, .label = lot.label, .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.effectiveShares(); 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, .label = lot.label, .price_ratio = lot.price_ratio, }); found = &result.items[result.items.len - 1]; } const pos = found.?; if (lot.isOpen(as_of)) { pos.shares += lot.effectiveShares(); 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; } /// True if `account_name` holds at least one open lot as-of - any /// real holding type (stock, cash, CD, option). Watchlist entries /// (`.watch`, share count zero) don't count: they're not held. /// /// Used by the audit reconciler to decide whether a portfolio /// account that's missing from a brokerage export is worth /// flagging. A fully-closed / zero-balance account has nothing left /// to reconcile, so it's suppressed. pub fn hasOpenLotsForAccount(self: Portfolio, as_of: Date, account_name: []const u8) bool { for (self.lots) |lot| { if (lot.security_type == .watch) continue; const lot_acct = lot.account orelse continue; if (!std.mem.eql(u8, lot_acct, account_name)) continue; if (lot.isOpen(as_of)) return true; } return false; } /// Total value of non-stock holdings (cash, CDs, options) for a single account. /// Only includes open lots (respects close_date and maturity_date). /// /// The option arm below is one of THREE copies of the same premium /// formula - the others are `analytics/analysis.zig`'s account /// breakdown and the `.option` arm of /// `commands/snapshot.zig:buildSnapshot`. They must agree; `analysis` /// once dropped `multiplier` and silently under-reported every /// option-holding account by 99% of its premium. All three use /// `@abs`, so a written (short) option counts as a positive asset /// rather than a liability - a deliberate shared convention. Change /// either detail at all three sites or none. 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| { // Live API price is in the retail share class -> ratio applies // (is_preadjusted=false). avg_cost fallback is in the lot's own // share-class terms -> ratio must NOT be applied // (is_preadjusted=true). See the "Pricing model" doc-block above. total += if (prices.get(pos.symbol)) |p| pos.marketValue(p, false) else pos.marketValue(pos.avg_cost, true); } 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); } /// Every symbol zfin fetches candles for: holdings, watch lots, the /// separate `watchlist.srf`, and the benchmark pair. /// /// This exists because "what do we keep fresh?" had **five** disjoint /// answers, and things fell through the gaps between them. Holdings came /// from `Portfolio.stockSymbols`, watch lots from a hand-rolled loop in /// each caller, `watchlist.srf` from `cli.loadWatchlist` (TUI only - the /// CLI loaded it for display and priced it from cache, so a /// watchlist-only symbol went arbitrarily stale), and the benchmark pair /// from two hardcoded `getCandles(sym, .{})` calls on a lazy path that /// only ran when someone opened projections. Observed consequences: SPCX /// sat 39 days out of date while sitting in `watchlist.srf`, and AGG was /// unreachable by `--refresh-data=force` entirely. /// /// One answer, shared by the CLI, the TUI and zfin-server, so a symbol /// cannot be tracked by one and invisible to another. /// /// **Every returned string is duplicated into `allocator`.** Unlike /// `stockSymbols`, which borrows from the portfolio, the inputs here have /// mixed and shorter lifetimes - notably a benchmark override lives in a /// `[16]u8` field inside a stack `UserConfig`, so borrowing it would /// dangle the moment that config went out of scope. (`UserConfig` is /// itself copy-safe - it stores buffer + length, not a self-slice - but a /// slice into one particular copy of it is only as long-lived as that /// copy, which is exactly why this dupes.) Caller owns the result; free /// the slices and the outer slice, or use an arena. pub fn fetchedSymbols( self: Portfolio, allocator: std.mem.Allocator, opts: struct { /// Symbols from a separate `watchlist.srf`. watchlist_syms: []const []const u8 = &.{}, /// Benchmark symbols (e.g. the projections stock/bond pair). /// Passed as plain strings so this stays free of any dependency /// on the projections config. benchmarks: []const []const u8 = &.{}, }, ) ![][]const u8 { var seen = std.StringHashMap(void).init(allocator); defer seen.deinit(); var result = std.ArrayList([]const u8).empty; errdefer { for (result.items) |s| allocator.free(s); result.deinit(allocator); } // Owns nothing until the dupe succeeds, so `seen` keys borrow from // `result` and stay valid for the whole build. const add = struct { fn f( a: std.mem.Allocator, set: *std.StringHashMap(void), list: *std.ArrayList([]const u8), sym: []const u8, ) !void { if (sym.len == 0) return; if (set.contains(sym)) return; const owned = try a.dupe(u8, sym); // The errdefer is scoped to the append and no further, on purpose. // Left armed across the `set.put` below it would double-free: // `list` already owns `owned` by then, and the caller's errdefer // frees everything in `list`. An allocation-failure test caught // exactly that as a segfault. { errdefer a.free(owned); try list.append(a, owned); } try set.put(owned, {}); } }.f; // Holdings. Skips options, CDs, cash, and manual-price-only lots - // see `stockSymbols` for why each is excluded. const held = try self.stockSymbols(allocator); defer allocator.free(held); for (held) |s| try add(allocator, &seen, &result, s); // `security_type::watch` lots inside the portfolio file. for (self.lots) |lot| { if (lot.security_type != .watch) continue; try add(allocator, &seen, &result, lot.priceSymbol()); } for (opts.watchlist_syms) |s| try add(allocator, &seen, &result, s); for (opts.benchmarks) |s| try add(allocator, &seen, &result, s); return result.toOwnedSlice(allocator); } /// Symbols to price that are NOT stock positions: `security_type::watch` lots /// in the portfolio file, plus every entry from a separate `watchlist.srf`, /// excluding anything already in `held`. /// /// Separate from `fetchedSymbols` because the price loader takes holdings and /// extras as two slices - it derives progress totals from the two counts - so a /// single flat union does not fit there. /// /// It lives here rather than inline in the command for a testability reason /// that bit once already: `commands/portfolio.zig`'s `run` needs a live /// `RunCtx`, a `DataService` and the network, so its tests only ever exercise /// `display`. Set logic embedded in `run` is untestable by construction, and /// the version that was embedded there had a bug - it never included /// `watchlist.srf` at all, leaving SPCX 39 days stale. /// /// Returned slices BORROW from `portfolio` and `watchlist_syms`; only the outer /// slice is owned by the caller. pub fn extraPriceSymbols( self: Portfolio, allocator: std.mem.Allocator, held: []const []const u8, watchlist_syms: []const []const u8, ) ![][]const u8 { var seen = std.StringHashMap(void).init(allocator); defer seen.deinit(); for (held) |s| try seen.put(s, {}); var out = std.ArrayList([]const u8).empty; errdefer out.deinit(allocator); for (self.lots) |lot| { if (lot.security_type != .watch) continue; const sym = lot.priceSymbol(); if (sym.len == 0 or seen.contains(sym)) continue; try seen.put(sym, {}); try out.append(allocator, sym); } for (watchlist_syms) |sym| { if (sym.len == 0 or seen.contains(sym)) continue; try seen.put(sym, {}); try out.append(allocator, sym); } return out.toOwnedSlice(allocator); } /// Free a `fetchedSymbols` result. pub fn freeFetchedSymbols(allocator: std.mem.Allocator, syms: [][]const u8) void { for (syms) |s| allocator.free(s); allocator.free(syms); } }; /// 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" { 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()); } 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.displaySymbol: label orelse priceSymbol" { const base = Lot{ .symbol = "02315N600", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 }; // No label, no ticker: falls back to the raw symbol (the CUSIP). try std.testing.expectEqualStrings("02315N600", base.displaySymbol()); // No label, ticker set: falls back to priceSymbol (the ticker). var aliased = base; aliased.ticker = "VTTHX"; try std.testing.expectEqualStrings("VTTHX", aliased.displaySymbol()); // Explicit label wins over both symbol and ticker. var labeled = aliased; labeled.label = "TGT2035"; try std.testing.expectEqualStrings("TGT2035", labeled.displaySymbol()); } test "Position.displaySymbol: label orelse symbol" { // Position.symbol is already priceSymbol(), so symbol is the fallback. const no_label = Position{ .symbol = "VTTHX", .shares = 1, .avg_cost = 0, .total_cost = 0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }; try std.testing.expectEqualStrings("VTTHX", no_label.displaySymbol()); const labeled = Position{ .symbol = "VTTHX", .shares = 1, .avg_cost = 0, .total_cost = 0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .label = "TGT2035" }; try std.testing.expectEqualStrings("TGT2035", labeled.displaySymbol()); } 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 = 100.0, .open_date = Date.fromYmd(2025, 2, 25), .open_price = 400.00, .account = "Sample Account" }, // Institutional S&P 500 CIT, uses SPY as ticker with a ratio .{ .symbol = "NON40OR52", .shares = 5000.0, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 90.00, .ticker = "SPY", .price_ratio = 0.25, .account = "Fidelity Riley 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, 100.0), p.shares, 0.01); try std.testing.expectEqualStrings("SPY", p.lot_symbol); } else { found_institutional = true; try std.testing.expectApproxEqAbs(@as(f64, 5000.0), p.shares, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.25), 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 "hasOpenLotsForAccount: open stock, cash; closed and watch excluded" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); var lots = [_]Lot{ // Open stock in Sample IRA. .{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Sample IRA" }, // Sample Brokerage holds only a closed stock lot. .{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 350.0, .account = "Sample Brokerage" }, // Sample Roth holds only an open cash lot. .{ .symbol = "", .shares = 2000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Roth" }, // Sample HSA holds only a watchlist entry (not a real holding). .{ .symbol = "NVDA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch, .account = "Sample HSA" }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; try std.testing.expect(portfolio.hasOpenLotsForAccount(as_of, "Sample IRA")); try std.testing.expect(portfolio.hasOpenLotsForAccount(as_of, "Sample Roth")); // Closed-only account -> no open lots. try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample Brokerage")); // Watchlist-only account -> not held. try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample HSA")); // Account with no lots at all. try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample Trust")); // Before the open date the stock isn't held yet. try std.testing.expect(!portfolio.hasOpenLotsForAccount(Date.fromYmd(2023, 1, 1), "Sample IRA")); } 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); } test "totalForAccount: institutional lot missing from prices map uses preadjusted avg_cost" { // Regression test for the price_ratio double-application bug in // Portfolio.totalForAccount. When a position misses the prices // map, the avg_cost fallback is in the LOT's share-class terms // (preadjusted) - multiplying by price_ratio would inflate the // value by the ratio. See the "Pricing model" doc-block above. const allocator = std.testing.allocator; var lots = [_]Lot{ .{ .symbol = "02315N402", .ticker = "VTTVX", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.92, .price_ratio = 6.6139, .account = "Sample 401(k)", }, }; const portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); // Empty prices map - avg_cost (= 140.92, institutional) fallback fires. // Correct: 100 × 140.92 = 14,092 (institutional value). // Buggy: 100 × 140.92 × 6.6139 ≈ 93,213. const total = portfolio.totalForAccount(Date.fromYmd(2026, 5, 8), allocator, "Sample 401(k)", prices); try std.testing.expectApproxEqAbs(@as(f64, 14092.0), total, 0.5); } // ── 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); } // ── Split-adjustment (effectiveShares / enrichSplits) tests ── test "effectiveShares/effectiveOpenPrice default to raw at factor 1.0" { const lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 180.0 }; try std.testing.expectApproxEqAbs(@as(f64, 10), lot.effectiveShares(), 0.0001); try std.testing.expectApproxEqAbs(@as(f64, 180.0), lot.effectiveOpenPrice(), 0.0001); // Cost basis is invariant under the split factor. try std.testing.expectApproxEqAbs(lot.costBasis(), lot.effectiveShares() * lot.effectiveOpenPrice(), 0.0001); } test "enrichSplits: per-symbol opt-in gate, forward split, post-split lot, legacy already-restated" { const allocator = std.testing.allocator; const as_of = Date.fromYmd(2026, 1, 1); var corpus = std.StringHashMap([]const Split).init(allocator); defer corpus.deinit(); const nvda_splits = [_]Split{.{ .date = Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }}; const amzn_splits = [_]Split{.{ .date = Date.fromYmd(2022, 6, 6), .numerator = 20, .denominator = 1 }}; const tsla_splits = [_]Split{.{ .date = Date.fromYmd(2024, 8, 1), .numerator = 3, .denominator = 1 }}; try corpus.put("NVDA", &nvda_splits); try corpus.put("AMZN", &amzn_splits); try corpus.put("TSLA", &tsla_splits); // Per-symbol opt-in: NVDA and AMZN carry a cutover; TSLA does NOT. var cutovers = std.StringHashMap(Date).init(allocator); defer cutovers.deinit(); try cutovers.put("NVDA", Date.fromYmd(2024, 1, 1)); try cutovers.put("AMZN", Date.fromYmd(2024, 1, 1)); var lots = [_]Lot{ // NVDA held across the post-cutover split -> factor 10. .{ .symbol = "NVDA", .shares = 100, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 40.0 }, // NVDA opened AFTER the split -> already post-split -> factor 1. .{ .symbol = "NVDA", .shares = 50, .open_date = Date.fromYmd(2024, 8, 1), .open_price = 110.0 }, // AMZN split predates its cutover (already restated) -> factor 1. .{ .symbol = "AMZN", .shares = 30, .open_date = Date.fromYmd(2019, 3, 1), .open_price = 90.0 }, // TSLA has a real post-purchase split but is NOT opted in -> factor 1. .{ .symbol = "TSLA", .shares = 20, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 30.0 }, // Non-stock never splits. .{ .symbol = "CASH", .shares = 5000, .open_date = Date.fromYmd(2019, 1, 1), .open_price = 1.0, .security_type = .cash }, }; enrichSplits(&lots, &corpus, &cutovers, as_of); try std.testing.expectApproxEqAbs(@as(f64, 1000), lots[0].effectiveShares(), 0.0001); try std.testing.expectApproxEqAbs(@as(f64, 4.0), lots[0].effectiveOpenPrice(), 0.0001); // 40 / 10 try std.testing.expectApproxEqAbs(lots[0].costBasis(), lots[0].effectiveShares() * lots[0].effectiveOpenPrice(), 0.0001); try std.testing.expectApproxEqAbs(@as(f64, 50), lots[1].effectiveShares(), 0.0001); try std.testing.expectApproxEqAbs(@as(f64, 30), lots[2].effectiveShares(), 0.0001); // TSLA not opted in -> untouched despite a real post-purchase split. try std.testing.expectApproxEqAbs(@as(f64, 20), lots[3].effectiveShares(), 0.0001); try std.testing.expectApproxEqAbs(@as(f64, 5000), lots[4].effectiveShares(), 0.0001); // Empty cutovers map -> every factor stays 1.0 (today's behavior). var lots2 = [_]Lot{ .{ .symbol = "NVDA", .shares = 100, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 40.0 }, }; var empty = std.StringHashMap(Date).init(allocator); defer empty.deinit(); enrichSplits(&lots2, &corpus, &empty, as_of); try std.testing.expectApproxEqAbs(@as(f64, 100), lots2[0].effectiveShares(), 0.0001); } test "positionsAsOf reflects split_factor: effective shares, invariant basis, effective market value" { const allocator = std.testing.allocator; var lots = [_]Lot{ .{ .symbol = "NVDA", .shares = 100, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 40.0, .account = "Sample Brokerage" }, }; // Simulate enrichment applying a 10:1 split. lots[0].split_factor = 10.0; const pf = Portfolio{ .lots = &lots, .allocator = allocator }; const positions = try pf.positionsAsOf(allocator, Date.fromYmd(2026, 1, 1)); defer allocator.free(positions); try std.testing.expectEqual(@as(usize, 1), positions.len); // Effective shares: 100 * 10 = 1000. try std.testing.expectApproxEqAbs(@as(f64, 1000), positions[0].shares, 0.001); // Cost basis stays raw/invariant: 100 * 40 = 4000. try std.testing.expectApproxEqAbs(@as(f64, 4000), positions[0].total_cost, 0.001); // avg_cost = total_cost / effective_shares = 4.0 (effective per-share). try std.testing.expectApproxEqAbs(@as(f64, 4.0), positions[0].avg_cost, 0.001); // Market value at the post-split price $120: 1000 * 120 = 120,000 // (the whole point - raw 100 * 120 would undercount 10x). try std.testing.expectApproxEqAbs(@as(f64, 120000), positions[0].marketValue(120.0, false), 0.01); } // ── fetchedSymbols ─────────────────────────────────────────── /// Build a Portfolio from lots for the union tests. Lots borrow from the /// caller; `fetchedSymbols` dupes everything it keeps, so that is safe. fn testPortfolio(lots: []Lot) Portfolio { return .{ .lots = lots, .allocator = std.testing.allocator }; } test "fetchedSymbols: unions all four sources and dedups across them" { const a = std.testing.allocator; var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 200, .security_type = .stock }, // A ticker alias: the price symbol is what gets fetched, which is // why SPY stayed fresh while AGG did not. .{ .symbol = "NON40OR52", .ticker = "SPY", .shares = 5, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 90, .security_type = .stock }, .{ .symbol = "QTUM", .shares = 0, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 0, .security_type = .watch }, // Excluded by stockSymbols: manual price, no ticker alias. .{ .symbol = "ORCBI", .shares = 3, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 10, .price = 11, .security_type = .stock }, // Excluded: not a stock or watch lot. .{ .symbol = "CASHX", .shares = 1, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 1, .security_type = .cash }, }; const wl = [_][]const u8{ "SPCX", "AMZN" }; // AMZN duplicates a holding const bm = [_][]const u8{ "SPY", "AGG" }; // SPY duplicates the alias above const syms = try testPortfolio(&lots).fetchedSymbols(a, .{ .watchlist_syms = &wl, .benchmarks = &bm, }); defer Portfolio.freeFetchedSymbols(a, syms); // AMZN, SPY, QTUM, SPCX, AGG - five distinct, no duplicates. try std.testing.expectEqual(@as(usize, 5), syms.len); for ([_][]const u8{ "AMZN", "SPY", "QTUM", "SPCX", "AGG" }) |want| { var found = false; for (syms) |s| if (std.mem.eql(u8, s, want)) { found = true; }; try std.testing.expect(found); } // Manual-price-only and cash lots stay out. for (syms) |s| { try std.testing.expect(!std.mem.eql(u8, s, "ORCBI")); try std.testing.expect(!std.mem.eql(u8, s, "CASHX")); } } test "fetchedSymbols: a watchlist-only symbol is included" { // THE SPCX REGRESSION. It sat in watchlist.srf 39 days out of date // because no CLI path ever put it in the fetch set - the CLI loaded // the file for display and priced it from cache. const a = std.testing.allocator; var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 200, .security_type = .stock }, }; const wl = [_][]const u8{"SPCX"}; const syms = try testPortfolio(&lots).fetchedSymbols(a, .{ .watchlist_syms = &wl }); defer Portfolio.freeFetchedSymbols(a, syms); var found = false; for (syms) |s| if (std.mem.eql(u8, s, "SPCX")) { found = true; }; try std.testing.expect(found); } test "fetchedSymbols: a benchmark symbol held nowhere is still included" { // THE AGG REGRESSION. AGG is not held and not watched - it is the bond // half of the benchmark comparison, fetched only from a lazy // projections path with hardcoded default FetchOptions, so // `--refresh-data=force` could never reach it. const a = std.testing.allocator; var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 200, .security_type = .stock }, }; const bm = [_][]const u8{ "SPY", "AGG" }; const syms = try testPortfolio(&lots).fetchedSymbols(a, .{ .benchmarks = &bm }); defer Portfolio.freeFetchedSymbols(a, syms); try std.testing.expectEqual(@as(usize, 3), syms.len); var found_agg = false; for (syms) |s| if (std.mem.eql(u8, s, "AGG")) { found_agg = true; }; try std.testing.expect(found_agg); } test "fetchedSymbols: empty and blank inputs produce no entries" { const a = std.testing.allocator; var lots = [_]Lot{}; const wl = [_][]const u8{""}; // blank line in watchlist.srf const syms = try testPortfolio(&lots).fetchedSymbols(a, .{ .watchlist_syms = &wl }); defer Portfolio.freeFetchedSymbols(a, syms); try std.testing.expectEqual(@as(usize, 0), syms.len); } test "fetchedSymbols: result outlives a stack-allocated benchmark override" { // A projections override lives in a [16]u8 INSIDE the UserConfig // struct, so borrowing it would dangle as soon as that config went out // of scope. This is why the union dupes rather than borrows. const a = std.testing.allocator; var lots = [_]Lot{}; var syms: [][]const u8 = undefined; { var buf: [16]u8 = undefined; @memcpy(buf[0..4], "VBIL"); const bm = [_][]const u8{buf[0..4]}; syms = try testPortfolio(&lots).fetchedSymbols(a, .{ .benchmarks = &bm }); @memset(&buf, 0xAA); // scribble over the source } defer Portfolio.freeFetchedSymbols(a, syms); try std.testing.expectEqual(@as(usize, 1), syms.len); try std.testing.expectEqualStrings("VBIL", syms[0]); } test "watchSymbols: watchlist.srf entries are included, holdings excluded" { // THE SPCX BUG, at the layer where it actually lived. The version embedded // in `commands/portfolio.zig` never looked at watchlist.srf at all, so a // watchlist-only symbol was displayed from whatever the cache happened to // hold - 39 days old, in SPCX's case - and never fetched. const a = std.testing.allocator; var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 200, .security_type = .stock }, .{ .symbol = "QTUM", .shares = 0, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 0, .security_type = .watch }, }; const held = [_][]const u8{"AMZN"}; const wl = [_][]const u8{ "SPCX", "QTUM", "AMZN", "" }; const out = try testPortfolio(&lots).extraPriceSymbols(a, &held, &wl); defer a.free(out); // QTUM once (watch lot, deduped against the watchlist), SPCX from the // file. AMZN is held so it belongs to the other slice, and the blank // line is dropped. try std.testing.expectEqual(@as(usize, 2), out.len); try std.testing.expectEqualStrings("QTUM", out[0]); try std.testing.expectEqualStrings("SPCX", out[1]); } test "watchSymbols: a ticker alias on a watch lot is priced by its alias" { const a = std.testing.allocator; var lots = [_]Lot{ .{ .symbol = "NON40OR52", .ticker = "SPY", .shares = 0, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 0, .security_type = .watch }, }; const out = try testPortfolio(&lots).extraPriceSymbols(a, &.{}, &.{}); defer a.free(out); try std.testing.expectEqual(@as(usize, 1), out.len); try std.testing.expectEqualStrings("SPY", out[0]); } test "watchSymbols: no watch lots and no watchlist yields an empty set" { const a = std.testing.allocator; var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 200, .security_type = .stock }, }; const held = [_][]const u8{"AMZN"}; const out = try testPortfolio(&lots).extraPriceSymbols(a, &held, &.{}); defer a.free(out); try std.testing.expectEqual(@as(usize, 0), out.len); } /// OOM-path wrapper for `checkAllAllocationFailures`. fn fetchedSymbolsOom(a: std.mem.Allocator, lots: []Lot, wl: []const []const u8, bm: []const []const u8) !void { const syms = try (Portfolio{ .lots = lots, .allocator = a }).fetchedSymbols(a, .{ .watchlist_syms = wl, .benchmarks = bm, }); Portfolio.freeFetchedSymbols(a, syms); } fn watchSymbolsOom(a: std.mem.Allocator, lots: []Lot, held: []const []const u8, wl: []const []const u8) !void { const out = try (Portfolio{ .lots = lots, .allocator = a }).extraPriceSymbols(a, held, wl); a.free(out); } test "fetchedSymbols/watchSymbols: every allocation-failure path unwinds cleanly" { // Covers the errdefer arms, which are otherwise unreachable: a partial // build must free the strings it already duped, and the inner arm must // free a dupe whose append then failed. var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 200, .security_type = .stock }, .{ .symbol = "QTUM", .shares = 0, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 0, .security_type = .watch }, }; const wl = [_][]const u8{"SPCX"}; const bm = [_][]const u8{ "SPY", "AGG" }; const held = [_][]const u8{"AMZN"}; try std.testing.checkAllAllocationFailures( std.testing.allocator, fetchedSymbolsOom, .{ &lots, @as([]const []const u8, &wl), @as([]const []const u8, &bm) }, ); try std.testing.checkAllAllocationFailures( std.testing.allocator, watchSymbolsOom, .{ &lots, @as([]const []const u8, &held), @as([]const []const u8, &wl) }, ); } test "extraPriceSymbols: order is stable - watch lots first, then the watchlist file" { // `PortfolioData.load` used to build this through a StringHashMap, so // iteration order - and therefore the "[5/28] Loading X" progress order - // varied run to run for no reason. Callers may now rely on the order. const a = std.testing.allocator; var lots = [_]Lot{ .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 100, .security_type = .stock }, .{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 0, .security_type = .watch }, .{ .symbol = "NVDA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 0, .security_type = .watch }, }; const held = [_][]const u8{"AAPL"}; const wl = [_][]const u8{ "MSFT", "QTUM" }; // Run twice: a hash-order build would be free to differ between calls. for (0..2) |_| { const out = try testPortfolio(&lots).extraPriceSymbols(a, &held, &wl); defer a.free(out); try std.testing.expectEqual(@as(usize, 4), out.len); try std.testing.expectEqualStrings("TSLA", out[0]); try std.testing.expectEqualStrings("NVDA", out[1]); try std.testing.expectEqualStrings("MSFT", out[2]); try std.testing.expectEqualStrings("QTUM", out[3]); } }