const std = @import("std"); const Candle = @import("../models/candle.zig").Candle; const Date = @import("../Date.zig"); const portfolio_mod = @import("../models/portfolio.zig"); /// Portfolio-level metrics computed from weighted position data. pub const PortfolioSummary = struct { /// Total market value of open positions total_value: f64, /// Total cost basis of open positions total_cost: f64, /// Total unrealized P&L unrealized_gain_loss: f64, /// Total unrealized return (decimal) unrealized_return: f64, /// Total realized P&L from closed lots realized_gain_loss: f64, /// Per-symbol breakdown allocations: []Allocation, pub fn deinit(self: *PortfolioSummary, allocator: std.mem.Allocator) void { allocator.free(self.allocations); } /// Adjust the summary to include non-stock assets (cash, CDs, options) in the totals. /// Cash and CDs add equally to value and cost (no gain/loss). /// Options add at cost basis (no live pricing). /// This keeps unrealized_gain_loss correct (only stocks contribute market gains) /// but dilutes the return% against the full portfolio cost base. fn adjustForNonStockAssets(self: *PortfolioSummary, as_of: Date, portfolio: portfolio_mod.Portfolio) void { const cash_total = portfolio.totalCash(as_of); const cd_total = portfolio.totalCdFaceValue(as_of); const opt_total = portfolio.totalOptionCost(as_of); const non_stock = cash_total + cd_total + opt_total; self.total_value += non_stock; self.total_cost += non_stock; if (self.total_cost > 0) { self.unrealized_return = self.unrealized_gain_loss / self.total_cost; } // Reweight allocations against grand total if (self.total_value > 0) { for (self.allocations) |*a| { a.weight = a.market_value / self.total_value; } } } /// Adjust portfolio valuation for sold (short) call options. /// When a sold call is in-the-money (current price > strike), the covered /// shares should be valued at the strike price, not the market price. /// This reflects the realistic assignment value of the position. /// /// Coverage is matched PER ACCOUNT: a sold call can only be covered by /// shares of the underlying held in the same account (you can't deliver /// Sample IRA shares against a Sample Brokerage call). Allocations are /// account-agnostic by the time we get here - `positionsAsOf` aggregates /// lots across accounts - so we recover the per-account share counts /// straight from `lots`, cap each account's coverage at that account's /// shares, and sum the per-account reductions back onto the /// (account-agnostic) allocation. Calls written against shares sitting in /// a different account are effectively naked and cap nothing. /// /// Untagged lots follow a "null is its own bucket" rule: a lot with no /// `account::` shares one bucket with every other untagged lot, and a /// call in a named account never draws on untagged shares (or vice /// versa). See `sameAccountBucket`. /// /// Only currently-open option lots contribute to the cap. Specifically, /// we skip lots whose `maturity_date` is on or before `as_of` (the /// option has expired - was either assigned or expired worthless, /// either way it no longer covers anything) and lots whose `close_date` /// is on or before `as_of` (user manually closed the position before /// expiry, e.g. recorded an assignment by hand). `Lot.lotIsOpenAsOf` /// is the single source of truth for that check; bugs in either case /// would otherwise cap the underlying's market value FOREVER, every /// time we run a portfolio summary, even though the contract is gone. /// /// Must be called BEFORE `adjustForNonStockAssets`, which adds cash/CD/option /// totals on top of the recomputed stock totals. fn adjustForCoveredCalls(self: *PortfolioSummary, as_of: Date, lots: []const portfolio_mod.Lot, prices: std.StringHashMap(f64)) void { for (self.allocations) |*alloc| { // Underlying and option strikes are both raw market prices; the // allocation's market_value is in ratio-adjusted terms, so the // summed reduction gets the `price_ratio` multiply at the end. // (Options don't exist on institutional share classes, so the // strike-vs-market math itself stays ratio-free.) const current_price = prices.get(alloc.symbol) orelse continue; var total_reduction: f64 = 0; // Walk each distinct account bucket that has an open ITM sold // call on this symbol. We dedupe by skipping any matching call // whose bucket an earlier matching call already represented - // allocation-free, and cheap for personal portfolios. for (lots, 0..) |call_lot, i| { if (!isOpenSoldCallOn(call_lot, as_of, alloc.symbol)) continue; var bucket_seen = false; for (lots[0..i]) |prev| { if (isOpenSoldCallOn(prev, as_of, alloc.symbol) and sameAccountBucket(prev.account, call_lot.account)) { bucket_seen = true; break; } } if (bucket_seen) continue; // Sum this bucket's ITM coverage and the strike-vs-market // value reduction it implies. var covered: f64 = 0; var reduction: f64 = 0; for (lots) |l| { if (!isOpenSoldCallOn(l, as_of, alloc.symbol)) continue; if (!sameAccountBucket(l.account, call_lot.account)) continue; const strike = l.strike orelse continue; if (current_price <= strike) continue; // OTM - no adjustment const c = @abs(l.shares) * l.multiplier; covered += c; reduction += c * (current_price - strike); } if (reduction <= 0) continue; // Shares of this symbol held in the SAME account bucket - // the only shares that can be called away. Coverage beyond // them is naked and caps nothing. Clamp to non-negative so // a net-short stock bucket can't invert the reduction. var shares_in_bucket: f64 = 0; for (lots) |l| { if (l.security_type != .stock) continue; if (!l.lotIsOpenAsOf(as_of)) continue; if (!std.mem.eql(u8, l.priceSymbol(), alloc.symbol)) continue; if (!sameAccountBucket(l.account, call_lot.account)) continue; shares_in_bucket += l.shares; } if (shares_in_bucket < 0) shares_in_bucket = 0; // Scale the reduction proportionally when over-covered // (same rule as the prior portfolio-wide cap, now per bucket). const effective = if (covered > shares_in_bucket) reduction * (shares_in_bucket / covered) else reduction; total_reduction += effective; } if (total_reduction > 0) { // Apply price_ratio to the reduction since alloc.market_value is in ratio-adjusted terms alloc.market_value -= total_reduction * alloc.price_ratio; alloc.unrealized_gain_loss = alloc.market_value - alloc.cost_basis; alloc.unrealized_return = if (alloc.cost_basis > 0) (alloc.market_value / alloc.cost_basis) - 1.0 else 0; } } // Recompute summary totals from allocations. var total_value: f64 = 0; for (self.allocations) |alloc| { total_value += alloc.market_value; } self.total_value = total_value; self.unrealized_gain_loss = total_value - self.total_cost; self.unrealized_return = if (self.total_cost > 0) (total_value / self.total_cost) - 1.0 else 0; // Recompute weights if (self.total_value > 0) { for (self.allocations) |*a| { a.weight = a.market_value / self.total_value; } } } }; /// True when `lot` is an open-as-of-`as_of` sold (short) call whose /// underlying matches `symbol`. The shared predicate behind per-account /// covered-call matching - every place that decides "does this lot cap /// `symbol`'s value?" routes through here so the open/closed, call/put, /// and sign rules can't drift apart. fn isOpenSoldCallOn(lot: portfolio_mod.Lot, as_of: Date, symbol: []const u8) bool { if (lot.security_type != .option) return false; // Past maturity OR explicitly closed -> the contract no longer covers // shares. `lotIsOpenAsOf` handles both plus the "not yet opened" edge. if (!lot.lotIsOpenAsOf(as_of)) return false; if (lot.option_type != .call) return false; if (lot.shares >= 0) return false; // only sold (short) calls const underlying = lot.underlying orelse return false; return std.mem.eql(u8, underlying, symbol); } /// Account-bucket equality with null normalized to "". Implements the /// "null is its own bucket" rule: every untagged lot lands in one shared /// bucket, and a tagged call only matches shares in its own named account. /// See `adjustForCoveredCalls`. fn sameAccountBucket(a: ?[]const u8, b: ?[]const u8) bool { return std.mem.eql(u8, a orelse "", b orelse ""); } pub const Allocation = struct { /// Ticker symbol or CUSIP identifying this position. symbol: []const u8, /// Display label for the symbol column - the position's "human /// identity": an explicit `label::`, else the economic identity /// (`priceSymbol()`). Display-only; never note-derived and never a /// pricing or classification key. See `Position.displaySymbol()`. display_symbol: []const u8, /// Total shares held across all lots for this symbol. shares: f64, /// Weighted average cost per share across all lots (cost_basis / shares). avg_cost: f64, /// Latest price from API (or manual fallback), before price_ratio adjustment. current_price: f64, /// Total current value: shares * current_price * price_ratio. /// May be reduced by adjustForCoveredCalls for ITM sold calls /// that are still open as of the summary's `as_of` date - /// matured / closed contracts no longer cap the underlying. market_value: f64, /// Total cost basis: sum of (lot.shares * lot.open_price) across all lots. cost_basis: f64, /// Fraction of total portfolio value (market_value / total_value). /// Recomputed after any valuation adjustments (covered calls, non-stock assets). weight: f64, /// market_value - cost_basis. unrealized_gain_loss: f64, /// (market_value / cost_basis) - 1.0. Zero if cost_basis is zero. unrealized_return: f64, /// True if current_price came from a manual override rather than live API data. is_manual_price: bool = false, /// Account name (from lots; "Multiple" if lots span different accounts). account: []const u8 = "", /// Price ratio applied (for display context; 1.0 means no ratio). price_ratio: f64 = 1.0, }; /// Net worth = liquid (stocks + cash + CDs + options) + illiquid assets. /// /// Lives here rather than on `Portfolio` because the liquid side needs a /// fully-computed `PortfolioSummary` (current prices, covered-call /// adjustments, non-stock totals). The illiquid side is a simple sum the /// model already exposes. Every display site - CLI `portfolio` command, /// TUI portfolio tab, planned snapshot writer - should call this instead /// of re-summing inline. pub fn netWorth(as_of: Date, portfolio: portfolio_mod.Portfolio, summary: PortfolioSummary) f64 { return summary.total_value + portfolio.totalIlliquid(as_of); } /// `netWorth` evaluated against an arbitrary date - used by historical /// snapshot backfill so the illiquid component matches the target-date /// composition (e.g., before/after a property sale). `summary` is /// computed from `portfolio.positionsAsOf(as_of)` upstream, so the /// liquid side is already as-of-scoped; this helper only differs from /// `netWorth` in how it pulls the illiquid total. pub fn netWorthAsOf( portfolio: portfolio_mod.Portfolio, summary: PortfolioSummary, as_of: Date, ) f64 { return summary.total_value + portfolio.totalIlliquidAsOf(as_of); } /// Result of a date-targeted candle lookup. pub const CandleAtDate = struct { close: f64, /// The candle's actual date. Equals `target` when an exact match /// was found; earlier than `target` when we carried forward (e.g., /// target fell on a weekend/holiday or cache doesn't reach that /// far yet). date: Date, /// True iff `date < target`. stale: bool, }; /// Look up the close price on-or-before `target` in a date-sorted /// (ascending) candle slice. Returns null if every candle in the slice /// is strictly after `target`, or if the slice is empty. /// /// This is the core primitive for candle-native pricing: /// - Snapshot writes: "what was the close on `as_of_date`?" /// - Historical backfill: "what was the close on some past date?" /// /// Carry-forward semantics handle weekends and holidays naturally - /// Monday's snapshot for a Saturday `as_of_date` would use Friday's /// close with `stale = true`. /// /// Input is expected to be sorted ascending by date (the cache /// guarantees this). O(log n) via binary search. pub fn candleCloseOnOrBefore(candles: []const Candle, target: Date) ?CandleAtDate { const idx = indexAtOrBefore(Candle, candles, target, candleDateOf) orelse return null; const c = candles[idx]; return .{ .close = c.close, .date = c.date, .stale = !c.date.eql(target) }; } fn candleDateOf(c: Candle) Date { return c.date; } /// Generic "latest index ≤ target" binary search. /// /// Returns the largest index `i` such that `dateOf(items[i]) <= target`, or /// null when no such index exists (target is strictly before every entry, or /// the slice is empty). Caller supplies a `dateOf` extractor so this works /// on any slice sorted ascending by date. /// /// This is the shared "snap backward" primitive used by candle pricing /// (`findPriceAtDate`, `candleCloseOnOrBefore`) and the portfolio-timeline /// windows (`src/analytics/timeline.zig:pointAtOrBefore`). Every one of /// those callers answers the same question - "what's the latest data point /// on or before this target?" - so a single implementation keeps weekend / /// holiday / gap semantics uniform across the codebase. /// /// No slack cap. If a policy cap is needed (e.g. "reject matches more than /// 7 days old"), apply it at the call site against the returned index. pub fn indexAtOrBefore( comptime T: type, items: []const T, target: Date, comptime dateOf: fn (T) Date, ) ?usize { if (items.len == 0) return null; // Lower-bound on "date > target", then step back. var lo: usize = 0; var hi: usize = items.len; while (lo < hi) { const mid = lo + (hi - lo) / 2; const md = dateOf(items[mid]); if (md.lessThan(target) or md.eql(target)) { lo = mid + 1; } else { hi = mid; } } if (lo == 0) return null; return lo - 1; } /// Merge allocations that share the same ticker symbol but have different /// price_ratio values into a single rolled-up allocation with normalized /// (base-ticker-equivalent) shares. This lets the portfolio view show a /// combined weight for related positions (e.g. direct SPY + institutional /// CIT using ticker::SPY). /// /// For groups with a single allocation, no changes are made. /// For groups with multiple allocations: /// - shares are normalized to base-ticker units (shares * price_ratio) /// - avg_cost is recomputed from total cost / normalized shares /// - current_price is the raw ticker price (market_value / normalized shares) /// - market_value, cost_basis, gain/loss, weight are summed /// - price_ratio is set to 1.0 (shares are now in base units) /// - account is set to "Multiple" fn mergeAllocsBySymbol(allocs: *std.ArrayList(Allocation), allocator: std.mem.Allocator) !void { if (allocs.items.len <= 1) return; // Identify symbols that appear more than once var counts = std.StringHashMap(u32).init(allocator); defer counts.deinit(); for (allocs.items) |a| { const entry = try counts.getOrPut(a.symbol); if (!entry.found_existing) { entry.value_ptr.* = 1; } else { entry.value_ptr.* += 1; } } // Check if any merges are needed var needs_merge = false; var count_iter = counts.valueIterator(); while (count_iter.next()) |v| { if (v.* > 1) { needs_merge = true; break; } } if (!needs_merge) return; // Build merged result var merged = std.ArrayList(Allocation).empty; defer merged.deinit(allocator); // Track which symbols we've already merged var done = std.StringHashMap(void).init(allocator); defer done.deinit(); for (allocs.items) |a| { if (counts.get(a.symbol).? <= 1) { // Single allocation for this symbol - pass through try merged.append(allocator, a); continue; } if (done.contains(a.symbol)) continue; try done.put(a.symbol, {}); // Merge all allocations for this symbol var total_mv: f64 = 0; var total_cost: f64 = 0; var total_weight: f64 = 0; var norm_shares: f64 = 0; var is_manual = false; for (allocs.items) |b| { if (!std.mem.eql(u8, b.symbol, a.symbol)) continue; total_mv += b.market_value; total_cost += b.cost_basis; total_weight += b.weight; // Normalize: convert each lot's shares to base-ticker units norm_shares += b.shares * b.price_ratio; if (b.is_manual_price) is_manual = true; } const raw_price = if (norm_shares > 0) total_mv / norm_shares else 0; const avg_cost = if (norm_shares > 0) total_cost / norm_shares else 0; try merged.append(allocator, .{ .symbol = a.symbol, .display_symbol = a.symbol, // ticker, not CUSIP .shares = norm_shares, .avg_cost = avg_cost, .current_price = raw_price, .market_value = total_mv, .cost_basis = total_cost, .weight = total_weight, .unrealized_gain_loss = total_mv - total_cost, .unrealized_return = if (total_cost > 0) (total_mv / total_cost) - 1.0 else 0, .is_manual_price = is_manual, .account = "Multiple", .price_ratio = 1.0, // normalized to base units }); } // Replace the allocations list allocs.clearRetainingCapacity(); for (merged.items) |a| { try allocs.append(allocator, a); } } /// Compute portfolio summary given positions and current prices. /// `prices` maps symbol -> current price. /// `manual_prices` optionally marks symbols whose price came from manual override (not live API). /// Automatically adjusts for covered calls (ITM sold calls capped at strike) and /// non-stock assets (cash, CDs, options added to totals). pub fn portfolioSummary( as_of: Date, allocator: std.mem.Allocator, portfolio: portfolio_mod.Portfolio, positions: []const portfolio_mod.Position, prices: std.StringHashMap(f64), manual_prices: ?std.StringHashMap(void), ) !PortfolioSummary { var allocs = std.ArrayList(Allocation).empty; errdefer allocs.deinit(allocator); var total_value: f64 = 0; var total_cost: f64 = 0; var total_realized: f64 = 0; for (positions) |pos| { if (pos.shares <= 0) continue; const raw_price = prices.get(pos.symbol) orelse continue; const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false; const price = pos.effectivePrice(raw_price, is_manual); const mv = pos.marketValue(raw_price, is_manual); total_value += mv; total_cost += pos.total_cost; total_realized += pos.realized_gain_loss; try allocs.append(allocator, .{ .symbol = pos.symbol, .display_symbol = pos.displaySymbol(), .shares = pos.shares, .avg_cost = pos.avg_cost, .current_price = price, .market_value = mv, .cost_basis = pos.total_cost, .weight = 0, // filled below .unrealized_gain_loss = mv - pos.total_cost, .unrealized_return = if (pos.total_cost > 0) (mv / pos.total_cost) - 1.0 else 0, .is_manual_price = if (manual_prices) |mp| mp.contains(pos.symbol) else false, .account = pos.account, .price_ratio = pos.price_ratio, }); } // Fill weights if (total_value > 0) { for (allocs.items) |*a| { a.weight = a.market_value / total_value; } } // Roll up allocations that share the same ticker but have different // price_ratios (e.g. direct SPY + institutional CIT using ticker::SPY). // Normalize shares to base-ticker units so the header row shows // meaningful aggregates (SPY-equivalent shares * SPY price = total value). try mergeAllocsBySymbol(&allocs, allocator); var summary = PortfolioSummary{ .total_value = total_value, .total_cost = total_cost, .unrealized_gain_loss = total_value - total_cost, .unrealized_return = if (total_cost > 0) (total_value / total_cost) - 1.0 else 0, .realized_gain_loss = total_realized, .allocations = try allocs.toOwnedSlice(allocator), }; summary.adjustForCoveredCalls(as_of, portfolio.lots, prices); summary.adjustForNonStockAssets(as_of, portfolio); return summary; } /// Build fallback prices for symbols that failed API fetch. /// 1. Use manual `price::` from SRF if available /// 2. Otherwise use position avg_cost so the position still appears /// Populates `prices` and returns a set of symbols whose price is manual/fallback. pub fn buildFallbackPrices( allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot, positions: []const portfolio_mod.Position, prices: *std.StringHashMap(f64), ) !std.StringHashMap(void) { var manual_price_set = std.StringHashMap(void).init(allocator); errdefer manual_price_set.deinit(); // First pass: manual price:: overrides for (lots) |lot| { if (lot.security_type != .stock) continue; const sym = lot.priceSymbol(); if (lot.price) |p| { if (!prices.contains(sym)) { try prices.put(sym, p); try manual_price_set.put(sym, {}); } } } // Second pass: fall back to avg_cost for anything still missing for (positions) |pos| { if (!prices.contains(pos.symbol) and pos.shares > 0) { try prices.put(pos.symbol, pos.avg_cost); try manual_price_set.put(pos.symbol, {}); } } return manual_price_set; } // ── Historical portfolio value ─────────────────────────────── /// A lookback period anchored to `today`. Used both for: /// * `computeHistoricalSnapshots` - "current holdings at historical prices" /// (backed by candle cache via `findPriceAtDate`). /// * portfolio-timeline windows - "snapshot-value on date A vs. today's /// snapshot value" (backed by snapshot history via /// `timeline.pointAtOrBefore`). /// /// The enum only holds periods that are *relative to today*; "since first /// snapshot" ("all-time") is handled inline by the timeline renderer - /// adding it here would break the "relative to today" invariant. /// /// `all` lists the 6 periods used by the portfolio historical block (kept /// stable - `zfin portfolio` and the portfolio tab iterate it). The /// `timeline_windows` array defines the 8 periods shown in the history /// view's rolling-windows block. pub const HistoricalPeriod = enum { @"1D", @"1W", @"1M", @"3M", ytd, @"1Y", @"3Y", @"5Y", @"10Y", pub fn label(self: HistoricalPeriod) []const u8 { return switch (self) { .@"1D" => "1D", .@"1W" => "1W", .@"1M" => "1M", .@"3M" => "3M", .ytd => "YTD", .@"1Y" => "1Y", .@"3Y" => "3Y", .@"5Y" => "5Y", .@"10Y" => "10Y", }; } /// Human-friendly label for the history view's windows block. Longer /// than `label()` (which is used in compact table headers). pub fn longLabel(self: HistoricalPeriod) []const u8 { return switch (self) { .@"1D" => "1 day", .@"1W" => "1 week", .@"1M" => "1 month", .@"3M" => "3 months", .ytd => "YTD", .@"1Y" => "1 year", .@"3Y" => "3 years", .@"5Y" => "5 years", .@"10Y" => "10 years", }; } /// Compute the target date by subtracting this period from `as_of`. /// /// `1D` subtracts one calendar day. Downstream snap-backward logic /// will then pick the latest available data point on or before that /// date - so a Saturday-run view with no Saturday snapshot naturally /// compares as_of against Friday's close. /// /// `ytd` resolves to Jan 1 of `as_of`'s year. Jan 1 is always a market /// holiday; the snap primitive will fall back to the prior year's /// final trading day, which is exactly the brokerage YTD convention. pub fn targetDate(self: HistoricalPeriod, as_of: Date) Date { return switch (self) { .@"1D" => as_of.addDays(-1), .@"1W" => as_of.addDays(-7), .@"1M" => as_of.subtractMonths(1), .@"3M" => as_of.subtractMonths(3), .ytd => Date.fromYmd(as_of.year(), 1, 1), .@"1Y" => as_of.subtractYears(1), .@"3Y" => as_of.subtractYears(3), .@"5Y" => as_of.subtractYears(5), .@"10Y" => as_of.subtractYears(10), }; } /// Periods shown in `zfin portfolio`'s historical-value block and the /// portfolio tab. Stable by design - renderers iterate and format by /// index. Do not reorder without updating those callers. pub const all = [_]HistoricalPeriod{ .@"1M", .@"3M", .@"1Y", .@"3Y", .@"5Y", .@"10Y" }; /// Periods shown in the history view's rolling-windows block. Order /// matches user mental model: "today vs. recent" -> "today vs. old". /// `all_time` is rendered as a 9th row by the timeline renderer - /// not listed here because it isn't relative to `today`. pub const timeline_windows = [_]HistoricalPeriod{ .@"1D", .@"1W", .@"1M", .ytd, .@"1Y", .@"3Y", .@"5Y", .@"10Y", }; }; /// One snapshot of portfolio value at a historical date. pub const HistoricalSnapshot = struct { period: HistoricalPeriod, target_date: Date, /// Value of current holdings at historical prices (only positions with data) historical_value: f64, /// Current value of same positions (only those with historical data) current_value: f64, /// Number of positions with data at this date position_count: usize, /// Total positions attempted total_positions: usize, pub fn change(self: HistoricalSnapshot) f64 { return self.current_value - self.historical_value; } pub fn changePct(self: HistoricalSnapshot) f64 { if (self.historical_value == 0) return 0; return (self.current_value / self.historical_value - 1.0) * 100.0; } }; /// Find the closing price on or just before `target_date` in a sorted candle array. /// Returns null if no candle is within 5 trading days before the target. /// /// For snapshot/backfill usage prefer `candleCloseOnOrBefore` - it has /// no slack cap and reports the matched candle's date + staleness. fn findPriceAtDate(candles: []const Candle, target: Date) ?f64 { const idx = indexAtOrBefore(Candle, candles, target, candleDateOf) orelse return null; // Allow up to 7 calendar days slack (weekends, holidays) between the // matched candle and the target. if (target.days - candles[idx].date.days > 7) return null; return candles[idx].close; } /// Compute historical portfolio snapshots for all standard lookback periods. /// `candle_map` maps symbol -> sorted candle slice. /// `current_prices` maps symbol -> current price. /// Only equity positions are considered. pub fn computeHistoricalSnapshots( as_of: Date, positions: []const portfolio_mod.Position, current_prices: std.StringHashMap(f64), candle_map: std.StringHashMap([]const Candle), ) [HistoricalPeriod.all.len]HistoricalSnapshot { var result: [HistoricalPeriod.all.len]HistoricalSnapshot = undefined; for (HistoricalPeriod.all, 0..) |period, pi| { const target = period.targetDate(as_of); var hist_value: f64 = 0; var curr_value: f64 = 0; var count: usize = 0; for (positions) |pos| { if (pos.shares <= 0) continue; const curr_price = current_prices.get(pos.symbol) orelse continue; const candles = candle_map.get(pos.symbol) orelse continue; const hist_price = findPriceAtDate(candles, target) orelse continue; // Both prices come from candle history (live API provenance), // so apply the share-class price_ratio - `is_preadjusted = false`. hist_value += pos.marketValue(hist_price, false); curr_value += pos.marketValue(curr_price, false); count += 1; } result[pi] = .{ .period = period, .target_date = target, .historical_value = hist_value, .current_value = curr_value, .position_count = count, .total_positions = positions.len, }; } return result; } // ── Tests ──────────────────────────────────────────────────── fn makeCandle(date: Date, price: f64) Candle { return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 }; } test "findPriceAtDate exact match" { const candles = [_]Candle{ makeCandle(Date.fromYmd(2024, 1, 2), 100), makeCandle(Date.fromYmd(2024, 1, 3), 101), makeCandle(Date.fromYmd(2024, 1, 4), 102), }; const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 3)); try std.testing.expect(price != null); try std.testing.expectApproxEqAbs(@as(f64, 101), price.?, 0.01); } test "findPriceAtDate snap backward" { const candles = [_]Candle{ makeCandle(Date.fromYmd(2024, 1, 2), 100), makeCandle(Date.fromYmd(2024, 1, 3), 101), makeCandle(Date.fromYmd(2024, 1, 8), 105), // gap (weekend) }; // Target is Jan 5 (Saturday), should snap back to Jan 3 const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 5)); try std.testing.expect(price != null); try std.testing.expectApproxEqAbs(@as(f64, 101), price.?, 0.01); } test "findPriceAtDate too far back" { const candles = [_]Candle{ makeCandle(Date.fromYmd(2024, 1, 15), 100), makeCandle(Date.fromYmd(2024, 1, 16), 101), }; // Target is Jan 2, closest is Jan 15 (13 days gap > 7 days) const price = findPriceAtDate(&candles, Date.fromYmd(2024, 1, 2)); try std.testing.expect(price == null); } test "findPriceAtDate empty" { const candles: []const Candle = &.{}; try std.testing.expect(findPriceAtDate(candles, Date.fromYmd(2024, 1, 1)) == null); } test "findPriceAtDate before all candles" { const candles = [_]Candle{ .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 101, .low = 99, .close = 100.5, .adj_close = 100.5, .volume = 0 }, }; try std.testing.expect(findPriceAtDate(&candles, Date.fromYmd(2020, 1, 1)) == null); } test "candleCloseOnOrBefore: exact date match" { const candles = [_]Candle{ .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 101, .low = 99, .close = 100.5, .adj_close = 100.5, .volume = 0 }, .{ .date = Date.fromYmd(2024, 1, 3), .open = 101, .high = 102, .low = 100, .close = 101.5, .adj_close = 101.5, .volume = 0 }, .{ .date = Date.fromYmd(2024, 1, 4), .open = 102, .high = 103, .low = 101, .close = 102.5, .adj_close = 102.5, .volume = 0 }, }; const r = candleCloseOnOrBefore(&candles, Date.fromYmd(2024, 1, 3)).?; try std.testing.expectEqual(@as(f64, 101.5), r.close); try std.testing.expect(r.date.eql(Date.fromYmd(2024, 1, 3))); try std.testing.expect(!r.stale); } test "candleCloseOnOrBefore: weekend carry-forward marks stale" { // Target lands on Saturday; expect to fall back to Friday's close. const candles = [_]Candle{ .{ .date = Date.fromYmd(2024, 1, 4), .open = 0, .high = 0, .low = 0, .close = 100, .adj_close = 100, .volume = 0 }, .{ .date = Date.fromYmd(2024, 1, 5), .open = 0, .high = 0, .low = 0, .close = 101, .adj_close = 101, .volume = 0 }, // Fri }; const r = candleCloseOnOrBefore(&candles, Date.fromYmd(2024, 1, 6)).?; // Sat try std.testing.expectEqual(@as(f64, 101), r.close); try std.testing.expect(r.date.eql(Date.fromYmd(2024, 1, 5))); try std.testing.expect(r.stale); } test "candleCloseOnOrBefore: far-past carry-forward still works (no slack cap)" { // Unlike findPriceAtDate, this helper has no 7-day cap. const candles = [_]Candle{ .{ .date = Date.fromYmd(2020, 1, 1), .open = 0, .high = 0, .low = 0, .close = 50, .adj_close = 50, .volume = 0 }, }; const r = candleCloseOnOrBefore(&candles, Date.fromYmd(2026, 4, 21)).?; try std.testing.expectEqual(@as(f64, 50), r.close); try std.testing.expect(r.stale); } test "candleCloseOnOrBefore: target before all candles returns null" { const candles = [_]Candle{ .{ .date = Date.fromYmd(2024, 1, 10), .open = 0, .high = 0, .low = 0, .close = 100, .adj_close = 100, .volume = 0 }, }; try std.testing.expect(candleCloseOnOrBefore(&candles, Date.fromYmd(2024, 1, 5)) == null); } test "candleCloseOnOrBefore: empty candles returns null" { const candles: []const Candle = &.{}; try std.testing.expect(candleCloseOnOrBefore(candles, Date.fromYmd(2024, 1, 1)) == null); } test "HistoricalSnapshot change and changePct" { const snap = HistoricalSnapshot{ .period = .@"1Y", .target_date = Date.fromYmd(2023, 1, 1), .historical_value = 100_000, .current_value = 120_000, .position_count = 5, .total_positions = 5, }; try std.testing.expectApproxEqAbs(@as(f64, 20_000), snap.change(), 0.01); try std.testing.expectApproxEqAbs(@as(f64, 20.0), snap.changePct(), 0.01); // Zero historical value -> changePct returns 0 const zero = HistoricalSnapshot{ .period = .@"1M", .target_date = Date.fromYmd(2024, 1, 1), .historical_value = 0, .current_value = 100, .position_count = 0, .total_positions = 0, }; try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero.changePct(), 0.001); } test "HistoricalPeriod label and targetDate" { try std.testing.expectEqualStrings("1M", HistoricalPeriod.@"1M".label()); try std.testing.expectEqualStrings("3M", HistoricalPeriod.@"3M".label()); try std.testing.expectEqualStrings("1Y", HistoricalPeriod.@"1Y".label()); try std.testing.expectEqualStrings("10Y", HistoricalPeriod.@"10Y".label()); // targetDate: 1Y from 2025-06-15 -> 2024-06-15 const today = Date.fromYmd(2025, 6, 15); const one_year = HistoricalPeriod.@"1Y".targetDate(today); try std.testing.expectEqual(@as(i16, 2024), one_year.year()); try std.testing.expectEqual(@as(u8, 6), one_year.month()); // targetDate: 1M from 2025-03-15 -> 2025-02-15 const one_month = HistoricalPeriod.@"1M".targetDate(Date.fromYmd(2025, 3, 15)); try std.testing.expectEqual(@as(u8, 2), one_month.month()); } test "HistoricalPeriod 1D/1W/ytd targetDate + labels" { const today = Date.fromYmd(2026, 4, 22); // 1D = yesterday const d1 = HistoricalPeriod.@"1D".targetDate(today); try std.testing.expect(d1.eql(Date.fromYmd(2026, 4, 21))); // 1W = 7 days ago const w1 = HistoricalPeriod.@"1W".targetDate(today); try std.testing.expect(w1.eql(Date.fromYmd(2026, 4, 15))); // YTD = Jan 1 of current year (snap-backward in callers pulls back to // prior year's Dec 31 close, matching brokerage YTD convention) const ytd = HistoricalPeriod.ytd.targetDate(today); try std.testing.expect(ytd.eql(Date.fromYmd(2026, 1, 1))); // Labels used in compact contexts try std.testing.expectEqualStrings("1D", HistoricalPeriod.@"1D".label()); try std.testing.expectEqualStrings("1W", HistoricalPeriod.@"1W".label()); try std.testing.expectEqualStrings("YTD", HistoricalPeriod.ytd.label()); // Long labels used in the history windows block try std.testing.expectEqualStrings("1 day", HistoricalPeriod.@"1D".longLabel()); try std.testing.expectEqualStrings("1 week", HistoricalPeriod.@"1W".longLabel()); try std.testing.expectEqualStrings("1 month", HistoricalPeriod.@"1M".longLabel()); try std.testing.expectEqualStrings("YTD", HistoricalPeriod.ytd.longLabel()); try std.testing.expectEqualStrings("10 years", HistoricalPeriod.@"10Y".longLabel()); } test "HistoricalPeriod.timeline_windows: 8 periods, no all_time" { // `all_time` is intentionally handled inline by the timeline renderer. // This test pins that decision - if a future change tries to add it // here, it will break. try std.testing.expectEqual(@as(usize, 8), HistoricalPeriod.timeline_windows.len); try std.testing.expectEqual(HistoricalPeriod.@"1D", HistoricalPeriod.timeline_windows[0]); try std.testing.expectEqual(HistoricalPeriod.@"10Y", HistoricalPeriod.timeline_windows[7]); } test "indexAtOrBefore: exact / before all / after all / empty" { const dates = [_]Date{ Date.fromYmd(2026, 4, 17), Date.fromYmd(2026, 4, 18), Date.fromYmd(2026, 4, 21), }; const dateOf = struct { fn f(d: Date) Date { return d; } }.f; // Exact match -> that index try std.testing.expectEqual(@as(usize, 1), indexAtOrBefore(Date, &dates, Date.fromYmd(2026, 4, 18), dateOf).?); // Between two entries -> earlier index try std.testing.expectEqual(@as(usize, 1), indexAtOrBefore(Date, &dates, Date.fromYmd(2026, 4, 19), dateOf).?); // After all -> last index try std.testing.expectEqual(@as(usize, 2), indexAtOrBefore(Date, &dates, Date.fromYmd(2099, 1, 1), dateOf).?); // Before all -> null try std.testing.expect(indexAtOrBefore(Date, &dates, Date.fromYmd(1999, 1, 1), dateOf) == null); // Empty -> null const empty: []const Date = &.{}; try std.testing.expect(indexAtOrBefore(Date, empty, Date.fromYmd(2026, 4, 1), dateOf) == null); } test "adjustForNonStockAssets" { const Portfolio = portfolio_mod.Portfolio; const Lot = portfolio_mod.Lot; var lots = [_]Lot{ .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200 }, .{ .symbol = "Cash", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cash }, .{ .symbol = "CD1", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cd }, .{ .symbol = "OPT1", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .option }, }; const pf = Portfolio{ .lots = &lots, .allocator = std.testing.allocator }; var allocs = [_]Allocation{ .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200, .current_price = 220, .market_value = 2200, .cost_basis = 2000, .weight = 1.0, .unrealized_gain_loss = 200, .unrealized_return = 0.1 }, }; var summary = PortfolioSummary{ .total_value = 2200, .total_cost = 2000, .unrealized_gain_loss = 200, .unrealized_return = 0.1, .realized_gain_loss = 0, .allocations = &allocs, }; summary.adjustForNonStockAssets(Date.fromYmd(2026, 5, 8), pf); // non_stock = 5000 + 10000 + (2 * 5 * 100) = 16000 try std.testing.expectApproxEqAbs(@as(f64, 18200), summary.total_value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 18000), summary.total_cost, 0.01); // unrealized_gain_loss unchanged (200), unrealized_return = 200 / 18000 try std.testing.expectApproxEqAbs(@as(f64, 200), summary.unrealized_gain_loss, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 200.0 / 18000.0), summary.unrealized_return, 0.001); // Weight recomputed against new total try std.testing.expectApproxEqAbs(@as(f64, 2200.0 / 18200.0), allocs[0].weight, 0.001); } test "buildFallbackPrices" { const Lot = portfolio_mod.Lot; const Position = portfolio_mod.Position; const alloc = std.testing.allocator; var lots = [_]Lot{ .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 }, .{ .symbol = "CUSIP1", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .price = 105.5 }, }; var positions = [_]Position{ .{ .symbol = "AAPL", .shares = 10, .avg_cost = 150, .total_cost = 1500, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, .{ .symbol = "CUSIP1", .shares = 5, .avg_cost = 100, .total_cost = 500, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); // AAPL already has a live price try prices.put("AAPL", 175.0); // CUSIP1 has no live price -- should get manual price:: fallback var manual = try buildFallbackPrices(alloc, &lots, &positions, &prices); defer manual.deinit(); // AAPL should NOT be in manual set (already had live price) try std.testing.expect(!manual.contains("AAPL")); // CUSIP1 should be in manual set with price 105.5 try std.testing.expect(manual.contains("CUSIP1")); try std.testing.expectApproxEqAbs(@as(f64, 105.5), prices.get("CUSIP1").?, 0.01); } test "portfolioSummary applies price_ratio" { const Position = portfolio_mod.Position; const alloc = std.testing.allocator; var positions = [_]Position{ // VTTHX with price_ratio 5.185 (institutional share class) .{ .symbol = "VTTHX", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .price_ratio = 5.185 }, // Regular stock, no ratio .{ .symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("VTTHX", 27.78); // investor class price try prices.put("AAPL", 175.0); const empty_pf = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = alloc }; var summary = try portfolioSummary(Date.fromYmd(2026, 5, 8), alloc, empty_pf, &positions, prices, null); defer summary.deinit(alloc); try std.testing.expectEqual(@as(usize, 2), summary.allocations.len); for (summary.allocations) |a| { if (std.mem.eql(u8, a.symbol, "VTTHX")) { // Price should be adjusted: 27.78 * 5.185 ≈ 144.04 try std.testing.expectApproxEqAbs(@as(f64, 144.04), a.current_price, 0.1); // Market value: 100 * 144.04 ≈ 14404 try std.testing.expectApproxEqAbs(@as(f64, 14404.0), a.market_value, 10.0); } else { // AAPL: no ratio, price unchanged try std.testing.expectApproxEqAbs(@as(f64, 175.0), a.current_price, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 1750.0), a.market_value, 0.01); } } } test "portfolioSummary: display_symbol uses label, else priceSymbol" { const Position = portfolio_mod.Position; const alloc = std.testing.allocator; var positions = [_]Position{ // Bare CUSIP with an explicit label -> the label shows. .{ .symbol = "02315N600", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .label = "TGT2035" }, // Bare CUSIP without a label -> raw CUSIP shows (post-migration default). .{ .symbol = "02315N709", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("02315N600", 200.0); try prices.put("02315N709", 100.0); const empty_pf = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = alloc }; var summary = try portfolioSummary(Date.fromYmd(2026, 5, 8), alloc, empty_pf, &positions, prices, null); defer summary.deinit(alloc); try std.testing.expectEqual(@as(usize, 2), summary.allocations.len); for (summary.allocations) |a| { if (std.mem.eql(u8, a.symbol, "02315N600")) { // symbol (the classification key) is unchanged; display shows the label. try std.testing.expectEqualStrings("02315N600", a.symbol); try std.testing.expectEqualStrings("TGT2035", a.display_symbol); } else { // No label -> display falls back to the symbol (priceSymbol). try std.testing.expectEqualStrings("02315N709", a.display_symbol); } } } test "portfolioSummary skips price_ratio for manual/fallback prices" { const Position = portfolio_mod.Position; const alloc = std.testing.allocator; var positions = [_]Position{ // VTTHX with price_ratio - but price is a fallback (avg_cost), already institutional .{ .symbol = "VTTHX", .shares = 100, .avg_cost = 140.0, .total_cost = 14000.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .price_ratio = 5.185 }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("VTTHX", 140.0); // fallback: avg_cost, already institutional // Mark VTTHX as manual/fallback var manual = std.StringHashMap(void).init(alloc); defer manual.deinit(); try manual.put("VTTHX", {}); var summary = try portfolioSummary(Date.fromYmd(2026, 5, 8), alloc, .{ .lots = &.{}, .allocator = alloc }, &positions, prices, manual); defer summary.deinit(alloc); try std.testing.expectEqual(@as(usize, 1), summary.allocations.len); // Price should NOT be multiplied by ratio - it's already institutional try std.testing.expectApproxEqAbs(@as(f64, 140.0), summary.allocations[0].current_price, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 14000.0), summary.allocations[0].market_value, 0.01); } test "adjustForCoveredCalls ITM sold call" { const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); // AMZN at $225, with 3 sold $220 calls var allocs = [_]Allocation{ .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 }, }; var summary = PortfolioSummary{ .total_value = 112500, .total_cost = 100000, .unrealized_gain_loss = 12500, .unrealized_return = 0.125, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, .{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20) }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("AMZN", 225.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // 300 shares covered (3 contracts × 100), ITM by $5 each // Reduction = 300 * (225 - 220) = 1500 // New market value = 112500 - 1500 = 111000 try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.allocations[0].market_value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.total_value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 11000), summary.unrealized_gain_loss, 0.01); } test "adjustForCoveredCalls OTM - no adjustment" { const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); var allocs = [_]Allocation{ .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 215.0, .market_value = 107500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 7500.0, .unrealized_return = 0.075 }, }; var summary = PortfolioSummary{ .total_value = 107500, .total_cost = 100000, .unrealized_gain_loss = 7500, .unrealized_return = 0.075, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, .{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20) }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("AMZN", 215.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // OTM (215 < 220) - no adjustment try std.testing.expectApproxEqAbs(@as(f64, 107500), summary.allocations[0].market_value, 0.01); } test "adjustForCoveredCalls partial coverage" { const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); // Only 200 shares but 3 calls (300 shares covered). Should cap at 200. var allocs = [_]Allocation{ .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 200, .avg_cost = 200.0, .current_price = 225.0, .market_value = 45000.0, .cost_basis = 40000.0, .weight = 1.0, .unrealized_gain_loss = 5000.0, .unrealized_return = 0.125 }, }; var summary = PortfolioSummary{ .total_value = 45000, .total_cost = 40000, .unrealized_gain_loss = 5000, .unrealized_return = 0.125, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 200, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, .{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20) }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("AMZN", 225.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // 300 covered but only 200 shares -> scale reduction // Full reduction would be 300 * 5 = 1500, scaled to 200/300 = 1000 // New market value = 45000 - 1000 = 44000 try std.testing.expectApproxEqAbs(@as(f64, 44000), summary.allocations[0].market_value, 0.01); } test "adjustForCoveredCalls ignores puts" { const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); var allocs = [_]Allocation{ .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 }, }; var summary = PortfolioSummary{ .total_value = 112500, .total_cost = 100000, .unrealized_gain_loss = 12500, .unrealized_return = 0.125, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, .{ .symbol = "AMZN 260620P00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .put, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20) }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("AMZN", 225.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // Puts are ignored - no adjustment try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); } // ── Regression: matured / closed options must NOT cap shares ── // // The bug these tests pin: prior to the fix, `adjustForCoveredCalls` // only filtered by security_type / option_type / shares-sign / // underlying / strike / ITM. It did NOT check whether the option // was still open. So a sold call that had passed `maturity_date` // (assigned or expired worthless - either way, gone) or had been // manually closed via `close_date::` would FOREVER cap the // underlying's market value, every time we ran a portfolio // summary. // // Real example from the field: 300 shares of NVDA + 2 sold calls // covering 200 shares. After the calls expired, the user was // still seeing the market value of 200 NVDA shares capped at // strike. These tests pin the fix and prevent the regression. test "adjustForCoveredCalls: matured ITM call no longer caps the underlying" { const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); // 500 NVDA shares + 3 sold $220 calls that EXPIRED on // 2025-12-19 (well before as_of). With the bug, these still // capped 300 shares at $220 even today. With the fix, the // matured calls are skipped and market value = 500 * $225. var allocs = [_]Allocation{ .{ .symbol = "NVDA", .display_symbol = "NVDA", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 }, }; var summary = PortfolioSummary{ .total_value = 112500, .total_cost = 100000, .unrealized_gain_loss = 12500, .unrealized_return = 0.125, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ .{ .symbol = "NVDA", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, .{ .symbol = "NVDA 251219C00220000", .shares = -3, .open_date = Date.fromYmd(2025, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "NVDA", .strike = 220.0, .maturity_date = Date.fromYmd(2025, 12, 19), // EXPIRED before as_of }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("NVDA", 225.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // No cap applied - market value unchanged from the original // un-adjusted value. With the bug, this would have been // 112500 - 1500 = 111000. try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 12500), summary.unrealized_gain_loss, 0.01); } test "adjustForCoveredCalls: maturity_date == as_of treated as closed" { // Pin the end-of-day-on-expiry semantics from // `Lot.lotIsOpenAsOf`: maturity_date <= as_of means the // contract is gone. Drift between modules on this rule // would cause subtle off-by-one valuation bugs on expiry // day, so we pin the exact boundary. const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); var allocs = [_]Allocation{ .{ .symbol = "NVDA", .display_symbol = "NVDA", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 }, }; var summary = PortfolioSummary{ .total_value = 112500, .total_cost = 100000, .unrealized_gain_loss = 12500, .unrealized_return = 0.125, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ .{ .symbol = "NVDA", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, .{ .symbol = "NVDA 260508C00220000", .shares = -3, .open_date = Date.fromYmd(2025, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "NVDA", .strike = 220.0, .maturity_date = as_of, // expires on as_of itself -> closed }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("NVDA", 225.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // Treated as closed at as_of -> no cap. try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); } test "adjustForCoveredCalls: lot with close_date set does not cap" { // The user manually marked the call as closed (e.g. recorded // an early assignment by setting `close_date::` and // `close_price::` in the portfolio file). The contract no // longer exists; stop applying the cap. const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); var allocs = [_]Allocation{ .{ .symbol = "NVDA", .display_symbol = "NVDA", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 }, }; var summary = PortfolioSummary{ .total_value = 112500, .total_cost = 100000, .unrealized_gain_loss = 12500, .unrealized_return = 0.125, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ .{ .symbol = "NVDA", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, .{ .symbol = "NVDA 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2025, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "NVDA", .strike = 220.0, // maturity_date is well in the future, but... .maturity_date = Date.fromYmd(2026, 6, 20), // ...the user closed early. .close_date = Date.fromYmd(2026, 3, 15), .close_price = 7.10, }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("NVDA", 225.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // close_date is before as_of -> contract gone -> no cap. try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); } test "adjustForCoveredCalls: open call still caps - sanity counter-test" { // Counter-test for the regressions above: with everything // else the same as the matured-call test but maturity_date // moved to AFTER as_of, the cap DOES apply. This pins that // the new filter doesn't accidentally over-cull and break // the happy path. const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); var allocs = [_]Allocation{ .{ .symbol = "NVDA", .display_symbol = "NVDA", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 }, }; var summary = PortfolioSummary{ .total_value = 112500, .total_cost = 100000, .unrealized_gain_loss = 12500, .unrealized_return = 0.125, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ .{ .symbol = "NVDA", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, .{ .symbol = "NVDA 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2025, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "NVDA", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), // AFTER as_of -> still open }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("NVDA", 225.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // Cap applies: 300 shares × $5 ITM = $1500 reduction. try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.allocations[0].market_value, 0.01); } // ── Per-account covered-call coverage ───────────────────────── // // A sold call can only be covered by shares of the underlying held in // the SAME account; shares in a different account can't be delivered // against it. These tests pin that the coverage cap is computed per // account bucket rather than against the portfolio-wide share total // (the old behavior, which over-capped naked calls). test "adjustForCoveredCalls: sold call in a different account is naked - no cap" { // Sample IRA holds 500 AMZN with no calls. Sample Brokerage wrote 3 // $220 calls but holds zero AMZN. The Brokerage calls are naked - they // cannot be covered by IRA shares - so the underlying is NOT capped. // Pre-fix (portfolio-wide matching) wrongly capped 300 shares. const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); var allocs = [_]Allocation{ .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125, .account = "Sample IRA" }, }; var summary = PortfolioSummary{ .total_value = 112500, .total_cost = 100000, .unrealized_gain_loss = 12500, .unrealized_return = 0.125, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, .{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("AMZN", 225.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // Naked in Sample Brokerage (0 AMZN there) -> no cap. Pre-fix: 111000. try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.total_value, 0.01); } test "adjustForCoveredCalls: covered call in the same account still caps" { // Both the 500 AMZN shares and the 3 $220 calls live in Sample // Brokerage. Same-account coverage caps exactly as it did before the // per-account change - the common, correct case. const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); var allocs = [_]Allocation{ .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125, .account = "Sample Brokerage" }, }; var summary = PortfolioSummary{ .total_value = 112500, .total_cost = 100000, .unrealized_gain_loss = 12500, .unrealized_return = 0.125, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" }, .{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("AMZN", 225.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // 300 shares ITM by $5 -> 1500 reduction. try std.testing.expectApproxEqAbs(@as(f64, 111000), summary.allocations[0].market_value, 0.01); } test "adjustForCoveredCalls: shares split across accounts cap only same-account coverage" { // Sample IRA: 500 AMZN, no calls. Sample Brokerage: 100 AMZN + 3 $220 // calls (covering 300). Only the 100 Brokerage shares back the calls; // the other 200 contracts are naked. Reduction scales to 100/300. const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); var allocs = [_]Allocation{ .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 600, .avg_cost = 200.0, .current_price = 225.0, .market_value = 135000.0, .cost_basis = 120000.0, .weight = 1.0, .unrealized_gain_loss = 15000.0, .unrealized_return = 0.125, .account = "Multiple" }, }; var summary = PortfolioSummary{ .total_value = 135000, .total_cost = 120000, .unrealized_gain_loss = 15000, .unrealized_return = 0.125, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, .{ .symbol = "AMZN", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" }, .{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("AMZN", 225.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // Brokerage: covered 300 capped at 100 shares -> 1500 * (100/300) = 500. // Pre-fix (portfolio-wide): full 1500 -> 133500. try std.testing.expectApproxEqAbs(@as(f64, 134500), summary.allocations[0].market_value, 0.01); } test "adjustForCoveredCalls: per-account caps sum independently" { // Sample IRA: 50 AMZN + 1 call (covers 100) -> over-covered, capped at // 50 shares. Sample Brokerage: 300 AMZN + 2 calls (covers 200) -> fully // covered. Each bucket caps against its own shares and the reductions // sum. Pre-fix lumped all 300 covered against the 350 total. const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); var allocs = [_]Allocation{ .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 350, .avg_cost = 200.0, .current_price = 225.0, .market_value = 78750.0, .cost_basis = 70000.0, .weight = 1.0, .unrealized_gain_loss = 8750.0, .unrealized_return = 0.125, .account = "Multiple" }, }; var summary = PortfolioSummary{ .total_value = 78750, .total_cost = 70000, .unrealized_gain_loss = 8750, .unrealized_return = 0.125, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ .{ .symbol = "AMZN", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, .{ .symbol = "AMZN 260620C00220000", .shares = -1, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample IRA" }, .{ .symbol = "AMZN", .shares = 300, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" }, .{ .symbol = "AMZN 260620C00220000", .shares = -2, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("AMZN", 225.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // IRA: 100 covered capped at 50 -> 500 * (50/100) = 250. Brokerage: 200 // covered, 300 shares -> 1000. Total 1250 -> 77500. Pre-fix: 300 < 350 // total -> full 1500 -> 77250. try std.testing.expectApproxEqAbs(@as(f64, 77500), summary.allocations[0].market_value, 0.01); } test "adjustForCoveredCalls: null account is its own bucket - tagged call ignores untagged shares" { // 500 AMZN with NO account:: (untagged bucket). The 3 $220 calls are // tagged Sample Brokerage. Under "null is its own bucket" the tagged // call finds zero AMZN in Sample Brokerage and caps nothing. const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); var allocs = [_]Allocation{ .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125 }, }; var summary = PortfolioSummary{ .total_value = 112500, .total_cost = 100000, .unrealized_gain_loss = 12500, .unrealized_return = 0.125, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ // No account:: on the shares -> untagged bucket. .{ .symbol = "AMZN", .shares = 500, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0 }, // Call tagged to a named account. .{ .symbol = "AMZN 260620C00220000", .shares = -3, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample Brokerage" }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("AMZN", 225.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // Tagged call draws on no untagged shares -> no cap. try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); } test "adjustForCoveredCalls: net-short stock bucket caps nothing" { // Edge: a written call in an account whose stock position is net // SHORT cannot be covered - there are no deliverable shares there. // The negative per-bucket share count clamps to zero coverage. // Sample IRA is short 100 AMZN with 1 written call; the real long // 600 shares (and the positive aggregate) live in Sample Brokerage. const Lot = portfolio_mod.Lot; const alloc = std.testing.allocator; const as_of = Date.fromYmd(2026, 5, 8); var allocs = [_]Allocation{ .{ .symbol = "AMZN", .display_symbol = "AMZN", .shares = 500, .avg_cost = 200.0, .current_price = 225.0, .market_value = 112500.0, .cost_basis = 100000.0, .weight = 1.0, .unrealized_gain_loss = 12500.0, .unrealized_return = 0.125, .account = "Multiple" }, }; var summary = PortfolioSummary{ .total_value = 112500, .total_cost = 100000, .unrealized_gain_loss = 12500, .unrealized_return = 0.125, .realized_gain_loss = 0, .allocations = &allocs, }; var lots = [_]Lot{ // Net-short bucket: -100 AMZN in Sample IRA. .{ .symbol = "AMZN", .shares = -100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" }, // Written call in that same short bucket - nothing to cover. .{ .symbol = "AMZN 260620C00220000", .shares = -1, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 8.35, .security_type = .option, .option_type = .call, .underlying = "AMZN", .strike = 220.0, .maturity_date = Date.fromYmd(2026, 6, 20), .account = "Sample IRA" }, // The real long position lives elsewhere, with no calls. .{ .symbol = "AMZN", .shares = 600, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" }, }; var prices = std.StringHashMap(f64).init(alloc); defer prices.deinit(); try prices.put("AMZN", 225.0); summary.adjustForCoveredCalls(as_of, &lots, prices); // Short bucket clamps to 0 coverable shares -> no cap anywhere. try std.testing.expectApproxEqAbs(@as(f64, 112500), summary.allocations[0].market_value, 0.01); } test "netWorth / netWorthAsOf: illiquid respects target date" { // Illiquid property closed on 2026-03-15. Net worth before the sale // should include it; after shouldn't. var lots = [_]portfolio_mod.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), }, }; const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = std.testing.allocator }; // Liquid side: pretend summary says $100k. const summary: PortfolioSummary = .{ .total_value = 100_000, .total_cost = 100_000, .unrealized_gain_loss = 0, .unrealized_return = 0, .realized_gain_loss = 0, .allocations = &.{}, }; // Before sale: 100k liquid + 800k illiquid = 900k. try std.testing.expectApproxEqAbs( @as(f64, 900_000.0), netWorthAsOf(portfolio, summary, Date.fromYmd(2026, 1, 1)), 0.01, ); // After sale: illiquid excluded, net worth is just the 100k liquid. try std.testing.expectApproxEqAbs( @as(f64, 100_000.0), netWorthAsOf(portfolio, summary, Date.fromYmd(2026, 4, 1)), 0.01, ); // netWorth (wall-clock today) - today is after the sale, so the // illiquid is excluded. Asserts the no-arg form delegates correctly. try std.testing.expectApproxEqAbs( @as(f64, 100_000.0), netWorth(Date.fromYmd(2026, 5, 8), portfolio, summary), 0.01, ); } test "mergeAllocsBySymbol rolls up same-ticker different-ratio allocations" { const allocator = std.testing.allocator; var allocs = std.ArrayList(Allocation).empty; defer allocs.deinit(allocator); // Direct SPY: 717 shares at $713.94, ratio 1.0 try allocs.append(allocator, .{ .symbol = "SPY", .display_symbol = "SPY", .shares = 717.34, .avg_cost = 461.24, .current_price = 713.94, .market_value = 717.34 * 713.94, // $512,064 .cost_basis = 717.34 * 461.24, // $330,877 .weight = 0.37, .unrealized_gain_loss = (717.34 * 713.94) - (717.34 * 461.24), .unrealized_return = (713.94 / 461.24) - 1.0, .account = "Tax Loss", .price_ratio = 1.0, }); // Institutional CIT: 5070.866 shares at $169.97 (713.94 * 0.2381), ratio 0.2381 try allocs.append(allocator, .{ .symbol = "SPY", .display_symbol = "S&P500", .shares = 5070.866, .avg_cost = 97.24, .current_price = 713.94 * 0.2381, // effective price .market_value = 5070.866 * 713.94 * 0.2381, // $861,893 .cost_basis = 5070.866 * 97.24, // $493,093 .weight = 0.63, .unrealized_gain_loss = (5070.866 * 713.94 * 0.2381) - (5070.866 * 97.24), .unrealized_return = 0, .account = "Fidelity Riley 401(k)", .price_ratio = 0.2381, }); // A non-SPY allocation that should pass through unchanged try allocs.append(allocator, .{ .symbol = "AAPL", .display_symbol = "AAPL", .shares = 100, .avg_cost = 150.0, .current_price = 200.0, .market_value = 20000.0, .cost_basis = 15000.0, .weight = 0, .unrealized_gain_loss = 5000.0, .unrealized_return = 0.333, .account = "Brokerage", .price_ratio = 1.0, }); try mergeAllocsBySymbol(&allocs, allocator); // Should produce 2 allocations: merged SPY + unchanged AAPL try std.testing.expectEqual(@as(usize, 2), allocs.items.len); for (allocs.items) |a| { if (std.mem.eql(u8, a.symbol, "SPY")) { // Normalized shares: 717.34 * 1.0 + 5070.866 * 0.2381 ≈ 1924.22 const expected_norm = 717.34 + 5070.866 * 0.2381; try std.testing.expectApproxEqAbs(expected_norm, a.shares, 0.1); // Market value: sum of both const expected_mv = (717.34 * 713.94) + (5070.866 * 713.94 * 0.2381); try std.testing.expectApproxEqAbs(expected_mv, a.market_value, 1.0); // price_ratio should be normalized to 1.0 try std.testing.expectApproxEqAbs(@as(f64, 1.0), a.price_ratio, 0.001); // current_price should be raw SPY price (market_value / normalized_shares) try std.testing.expectApproxEqAbs(@as(f64, 713.94), a.current_price, 0.1); // Account should be "Multiple" try std.testing.expectEqualStrings("Multiple", a.account); } else { // AAPL passes through unchanged try std.testing.expectEqualStrings("AAPL", a.symbol); try std.testing.expectApproxEqAbs(@as(f64, 100.0), a.shares, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 1.0), a.price_ratio, 0.001); } } }