zfin/src/analytics/valuation.zig

1784 lines
78 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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