zfin/src/analytics/valuation.zig
Emil Lerch 34d37e32a8
All checks were successful
Generic zig build / build (push) Successful in 5m35s
Generic zig build / publish-macos (push) Successful in 12s
Generic zig build / deploy (push) Successful in 20s
remove extra pre-multiply on manual snapshot price data
2026-08-27 15:43:36 -07:00

2616 lines
115 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). WHETHER
/// `price_ratio` HAS BEEN APPLIED DEPENDS ON `price_ratio` ITSELF -
/// read this together with that field, never alone:
///
/// - `price_ratio != 1.0` (UNMERGED): this is the EFFECTIVE price,
/// `pos.effectivePrice(raw, is_manual)`, ratio already applied.
/// - `price_ratio == 1.0`: either a plain unratioed position, or a
/// group that `mergeAllocsBySymbol` folded - in which case this
/// is the RAW base-ticker price (`total_mv / norm_shares`) and
/// `shares` are in base-ticker-equivalent units.
///
/// Either way `shares * current_price == market_value` holds, which
/// is what makes the position row self-consistent.
///
/// Never feed this into a per-lot calculation: a lot row that
/// multiplies its own raw shares by this price is off by the lot's
/// ratio in the merged case, and squares it if you "fix" that by
/// keying on `is_manual_price` instead. Per-lot display sites go
/// through `views/portfolio_sections.zig:effectivePriceFor`, which
/// discriminates on `price_ratio`. See the "Per-LOT display rows"
/// section of the pricing-model block in `models/portfolio.zig`.
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,
/// True when `mergeAllocsBySymbol` folded two or more ratio variants
/// of one ticker into this row. Distinguishes the two ways
/// `price_ratio == 1.0` can arise - a plain unratioed position versus
/// a merged group - which `price_ratio` alone cannot express and
/// which determines whether `current_price` is the lot's effective
/// price or the raw base-ticker price.
///
/// Consumers that only need "is `current_price` raw?" can keep using
/// `price_ratio == 1.0`; this exists for consumers that must know the
/// price's UNITS, e.g. deciding whether it is comparable against a
/// snapshot's ratio-scaled per-lot price.
merged: bool = false,
};
/// 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
.merged = true,
});
}
// 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",
};
};
/// A single session's move in the portfolio's equity holdings - the
/// brokerage "Day Change" figure.
///
/// ## Which session
///
/// `priced_date` is the date the CURRENT prices belong to, and the whole
/// figure describes that day's move. It is deliberately NOT "today":
///
/// - Streaming live on a Tuesday: current prices are live ticks, so
/// `priced_date` is Tuesday and this is Tuesday's move so far.
/// - Not streaming, Tuesday mid-session: the newest candle is Monday's,
/// so current prices ARE Monday's closes, `priced_date` is Monday, and
/// this is Monday's completed move.
/// - Saturday: the newest candle is Friday's, so this is Friday's move.
///
/// Keying on the priced date instead of today is what keeps the figure
/// correct outside live mode. Anchoring the previous close to `today - 1`
/// instead collapses to zero every weekend, because Friday's bar is both
/// "the current price" and "the last close before today".
///
/// Use `recency()` to label it - a renderer should say "Day" only when the
/// priced date really is today.
///
/// ## What is included
///
/// Equity positions only. Cash, CDs, options and illiquid assets have no
/// daily price move and contribute zero, matching how
/// `adjustForNonStockAssets` keeps them out of `unrealized_gain_loss`.
/// They DO belong in the percentage's denominator - see `pctOfAccount`.
pub const DayChange = struct {
/// The session this describes. See the type doc.
priced_date: Date,
/// `curr_stock_value - prev_stock_value`. Signed.
change: f64,
/// Covered positions valued at the previous session's closes.
prev_stock_value: f64,
/// The same positions at current prices.
curr_stock_value: f64,
/// Positions with BOTH a current price and a previous close.
positions_covered: usize,
/// Positions that COULD have a daily series - everything except the
/// ones the user prices by hand.
///
/// A holding with an explicit `price::` and no candle history can never
/// be day-changed: the only price zfin will ever have for it is the one
/// typed in, and no amount of fixing changes that. Counting it as a gap
/// means the shortfall marker is permanently lit, which trains the
/// reader to ignore it. So those are excluded here, and
/// `positions_total - positions_priceable` is the impossible remainder.
///
/// `covered < positions_priceable` is therefore the only interesting
/// condition: a security that SHOULD have priced and didn't.
positions_priceable: usize,
/// All equity positions considered, impossible ones included.
positions_total: usize,
/// Market value of the priceable-but-uncovered positions - how much the
/// dollar figure is short by. Reported instead of a count because two
/// missing securities could be $3k or $3M, and only the former is
/// ignorable.
///
/// Valued at the current price when there is one, else at `avg_cost`
/// (the same last-resort the summary itself falls back to), so this
/// stays meaningful even on the first paint where the price map holds
/// candle closes only.
uncovered_value: f64,
/// The move as a fraction of YESTERDAY'S WHOLE ACCOUNT value, cash and
/// CDs and options included.
///
/// `total_value_now` is the summary's `total_value`; since the non-stock
/// part cannot move intraday, `total_value_now - change` is exactly the
/// prior session's account value. Dilutes by the non-equity weight -
/// the same dilution `unrealized_return` already carries, and the same
/// basis a brokerage reports an account-level day change on, so this
/// figure is comparable with the Gain/Loss percentage beside it.
///
/// Positions excluded from `change` sit in this denominator, which
/// implicitly treats them as flat. Measured on a real portfolio the
/// difference against excluding them was 0.1277% vs 0.1323% - identical
/// once rendered to one decimal - so the simpler whole-account basis
/// wins.
///
/// Zero when the prior value works out to zero.
pub fn pctOfAccount(self: DayChange, total_value_now: f64) f64 {
const prev_total = total_value_now - self.change;
if (prev_total == 0) return 0;
return self.change / prev_total;
}
/// True when every position that COULD be day-changed was. Impossible
/// ones don't count against it - see `positions_priceable`.
pub fn complete(self: DayChange) bool {
return self.positions_covered == self.positions_priceable;
}
/// Whether anything was priced at all. A renderer should suppress the
/// figure entirely when this is false rather than show `+$0.00`.
pub fn hasData(self: DayChange) bool {
return self.positions_covered > 0;
}
pub const Recency = enum { today, yesterday, older };
/// How `priced_date` relates to `today`, so a renderer can choose
/// between "Day", "Yesterday" and naming the date outright.
pub fn recency(self: DayChange, today: Date) Recency {
if (self.priced_date.eql(today)) return .today;
if (self.priced_date.eql(today.addDays(-1))) return .yesterday;
return .older;
}
};
/// Whether the user prices this symbol by hand, i.e. some lot carries an
/// explicit `price::`.
///
/// Deliberately keyed on `lot.price`, NOT on the `manual_prices` set from
/// `buildFallbackPrices`: that set also collects avg-cost fallbacks, which
/// are the opposite case - a security that should have priced and didn't.
/// Using it here would silently hide exactly the failures worth surfacing.
fn userPricedSymbol(lots: []const portfolio_mod.Lot, price_symbol: []const u8) bool {
for (lots) |lot| {
if (lot.security_type != .stock) continue;
if (lot.price == null) continue;
if (std.mem.eql(u8, lot.priceSymbol(), price_symbol)) return true;
}
return false;
}
/// Compute the equity portfolio's move over the session ending at
/// `priced_date`.
///
/// `current_prices` maps symbol -> price. Entries are **RAW base-ticker
/// prices** except those named in `manual_prices`, which are already in the
/// lot's own share class; the ratio is applied here, so mislabelling one as
/// raw squares it.
///
/// `lots` is used only to tell an IMPOSSIBLE gap from a FIXABLE one: a
/// hand-priced holding with no candle history can never be day-changed and
/// is left out of `positions_priceable`, whereas a security that should
/// have priced and didn't is counted so the caller can flag it. See
/// `DayChange.positions_priceable`.
///
/// The previous close comes from `candleCloseOnOrBefore(candles,
/// priced_date - 1)`, which snaps backward over weekends and holidays and
/// - crucially - excludes `priced_date`'s own bar, so the same call is
/// correct whether or not today's candle has been written yet.
pub fn computeDayChange(
priced_date: Date,
positions: []const portfolio_mod.Position,
lots: []const portfolio_mod.Lot,
current_prices: std.StringHashMap(f64),
manual_prices: ?std.StringHashMap(void),
candle_map: std.StringHashMap([]const Candle),
) DayChange {
var prev_value: f64 = 0;
var curr_value: f64 = 0;
var uncovered: f64 = 0;
var covered: usize = 0;
var priceable: usize = 0;
var total: usize = 0;
const prev_target = priced_date.addDays(-1);
for (positions) |pos| {
if (pos.shares <= 0) continue;
total += 1;
const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false;
const maybe_curr = current_prices.get(pos.symbol);
// This position's contribution to the shortfall if it turns out to
// be uncovered. Falls back to avg_cost the same way
// `Portfolio.totalForAccount` does, so the figure survives the
// first paint, where the price map holds candle closes only.
const pos_value = if (maybe_curr) |p|
pos.marketValue(p, is_manual)
else
pos.marketValue(pos.avg_cost, true);
const candles = candle_map.get(pos.symbol);
// Impossible rather than missing: hand-priced with no daily series.
// Not counted as priceable, so it can never light the marker.
if (candles == null and userPricedSymbol(lots, pos.symbol)) continue;
priceable += 1;
const raw_curr = maybe_curr orelse {
uncovered += pos_value;
continue;
};
const cs = candles orelse {
uncovered += pos_value;
continue;
};
const prev = candleCloseOnOrBefore(cs, prev_target) orelse {
uncovered += pos_value;
continue;
};
curr_value += pos.marketValue(raw_curr, is_manual);
// Candle closes are always raw, so the ratio applies.
prev_value += pos.marketValue(prev.close, false);
covered += 1;
}
return .{
.priced_date = priced_date,
.change = curr_value - prev_value,
.prev_stock_value = prev_value,
.curr_stock_value = curr_value,
.positions_covered = covered,
.positions_priceable = priceable,
.positions_total = total,
.uncovered_value = uncovered,
};
}
/// 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 -> price. Entries are **RAW base-ticker
/// prices** except those named in `manual_prices`, which are already in the
/// lot's own share class. NEVER pass
/// `summary.allocations[].current_price`: for an unmerged allocation that
/// already has the lot's `price_ratio` folded in and would get it applied a
/// second time below, squaring it. That shipped in the TUI path and silently
/// broke every percentage on the `Historical:` line for a lone `ticker::` +
/// `price_ratio::` lot.
///
/// `manual_prices` may be null when the caller's map is known to hold candle
/// closes only - e.g. `PortfolioData.revalue_base_prices`, captured before
/// `buildFallbackPrices` folds in overrides. Pass the set whenever the map
/// went through that function.
///
/// Only equity positions are considered.
pub fn computeHistoricalSnapshots(
as_of: Date,
positions: []const portfolio_mod.Position,
current_prices: std.StringHashMap(f64),
manual_prices: ?std.StringHashMap(void),
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;
// The historical side always comes from candle history, so it is
// raw and the share-class ratio applies. The current side is raw
// too UNLESS it is a manual override, which is already in the
// lot's own share class.
const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false;
hist_value += pos.marketValue(hist_price, false);
curr_value += pos.marketValue(curr_price, is_manual);
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 };
}
fn dcPos(symbol: []const u8, shares: f64, ratio: f64) portfolio_mod.Position {
return .{
.symbol = symbol,
.shares = shares,
.avg_cost = 0,
.total_cost = 0,
.open_lots = 1,
.closed_lots = 0,
.realized_gain_loss = 0,
.price_ratio = ratio,
};
}
test "computeDayChange: previous close is the session before the priced date" {
const a = std.testing.allocator;
// Fri 2024-06-07 close 100, Mon 2024-06-10 close 110.
const candles = [_]Candle{
makeCandle(Date.fromYmd(2024, 6, 7), 100),
makeCandle(Date.fromYmd(2024, 6, 10), 110),
};
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ABC", &candles);
const positions = [_]portfolio_mod.Position{dcPos("ABC", 10, 1.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 110); // current = Monday's close
// Priced date is Monday: the previous close is FRIDAY's, snapping back
// over the weekend. 10 * (110 - 100) = +$100.
const dc = computeDayChange(Date.fromYmd(2024, 6, 10), &positions, &.{}, prices, null, cm);
try std.testing.expectApproxEqAbs(@as(f64, 1000), dc.prev_stock_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1100), dc.curr_stock_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 100), dc.change, 0.01);
try std.testing.expectEqual(@as(usize, 1), dc.positions_covered);
try std.testing.expect(dc.complete());
try std.testing.expect(dc.hasData());
}
test "computeDayChange: a weekend view reports Friday's move, not zero" {
// The bug the priced-date design exists to avoid. It is Saturday; the
// newest candle is Friday's, so the current price IS Friday's close.
// Anchoring the previous close to `today - 1` (= Friday) would match
// Friday's own bar and report a $0.00 day. Anchoring to
// `priced_date - 1` correctly picks Thursday.
const a = std.testing.allocator;
const thu = Date.fromYmd(2024, 6, 6);
const fri = Date.fromYmd(2024, 6, 7);
const sat = Date.fromYmd(2024, 6, 8);
const candles = [_]Candle{ makeCandle(thu, 100), makeCandle(fri, 105) };
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ABC", &candles);
const positions = [_]portfolio_mod.Position{dcPos("ABC", 10, 1.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 105);
const good = computeDayChange(fri, &positions, &.{}, prices, null, cm);
try std.testing.expectApproxEqAbs(@as(f64, 50), good.change, 0.01);
// ...and it is labelled as Friday's move, not today's.
try std.testing.expectEqual(DayChange.Recency.yesterday, good.recency(sat));
// What anchoring on `today` would have produced.
const bad = computeDayChange(sat, &positions, &.{}, prices, null, cm);
try std.testing.expectApproxEqAbs(@as(f64, 0), bad.change, 0.01);
}
test "computeDayChange: live intraday prices against the prior close" {
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
// Only Monday's bar exists - Tuesday's has not been written yet.
const candles = [_]Candle{makeCandle(mon, 100)};
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ABC", &candles);
const positions = [_]portfolio_mod.Position{dcPos("ABC", 10, 1.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 103); // streamed tick
const dc = computeDayChange(tue, &positions, &.{}, prices, null, cm);
try std.testing.expectApproxEqAbs(@as(f64, 30), dc.change, 0.01);
try std.testing.expectEqual(DayChange.Recency.today, dc.recency(tue));
}
test "computeDayChange: the share-class ratio is applied exactly once" {
// Same trap as computeHistoricalSnapshots: both sides take RAW prices.
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
const candles = [_]Candle{makeCandle(mon, 20)};
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("VTTHX", &candles);
const positions = [_]portfolio_mod.Position{dcPos("VTTHX", 100, 5.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("VTTHX", 22); // raw; institutional NAV is 110
const dc = computeDayChange(tue, &positions, &.{}, prices, null, cm);
// 100 * 20 * 5 = 10,000 -> 100 * 22 * 5 = 11,000. Ratio once, not twice.
try std.testing.expectApproxEqAbs(@as(f64, 10_000), dc.prev_stock_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 11_000), dc.curr_stock_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1000), dc.change, 0.01);
}
test "computeDayChange: a manual price is preadjusted, ratio not reapplied" {
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
const candles = [_]Candle{makeCandle(mon, 100)};
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ORCX", &candles);
const positions = [_]portfolio_mod.Position{dcPos("ORCX", 10, 5.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ORCX", 110); // user-entered NAV, already in lot terms
var manual = std.StringHashMap(void).init(a);
defer manual.deinit();
try manual.put("ORCX", {});
const dc = computeDayChange(tue, &positions, &.{}, prices, manual, cm);
// Current: 10 * 110 (no ratio). Previous: 10 * 100 * 5 (raw candle).
try std.testing.expectApproxEqAbs(@as(f64, 1100), dc.curr_stock_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 5000), dc.prev_stock_value, 0.01);
}
test "computeDayChange: positions without candles are counted but excluded" {
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
const candles = [_]Candle{makeCandle(mon, 100)};
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ABC", &candles);
// FUNDX has a price but no candle history - a mutual fund the quote
// providers cannot price. Its dollars are missing from the figure, so
// the renderer must be able to say the total is short.
const positions = [_]portfolio_mod.Position{
dcPos("ABC", 10, 1.0),
dcPos("FUNDX", 100, 1.0),
};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 110);
try prices.put("FUNDX", 50);
const dc = computeDayChange(tue, &positions, &.{}, prices, null, cm);
try std.testing.expectEqual(@as(usize, 1), dc.positions_covered);
try std.testing.expectEqual(@as(usize, 2), dc.positions_total);
try std.testing.expect(!dc.complete());
try std.testing.expectApproxEqAbs(@as(f64, 100), dc.change, 0.01);
}
test "buildFallbackPrices: a pre-inserted manual price loses its flag" {
// Why pre-multiplying a manual override into the shared map is unsound,
// demonstrated on the pure function rather than asserted in prose.
//
// The gap-fill is guarded by `if (!prices.contains(sym))`, so an entry
// someone else already inserted is skipped - and therefore never lands
// in `manual_price_set`. Downstream `effectivePrice(price, is_manual)`
// then sees `is_manual == false` and applies `price_ratio` to a value
// that already had it folded in, squaring it.
//
// `commands/snapshot.zig` did exactly that until the pre-multiply loop
// was deleted; `commands/audit.zig` still does, and cannot stop until
// the reconcile path grows a preadjusted set. See "The pre-multiply
// anti-pattern" in models/portfolio.zig.
const a = std.testing.allocator;
const ratio: f64 = 5.0;
const typed: f64 = 144.04; // what the user reads off the statement
const lots = [_]portfolio_mod.Lot{.{
.symbol = "02315N600",
.ticker = "VTTHX",
.price_ratio = ratio,
.price = typed,
.shares = 100,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 106.99,
}};
const positions = [_]portfolio_mod.Position{dcPos("VTTHX", 100, ratio)};
// CORRECT: hand it an empty map. The override goes in untouched and the
// symbol is flagged, so the ratio is skipped and the value is exact.
{
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
var manual = try buildFallbackPrices(a, &lots, &positions, &prices);
defer manual.deinit();
try std.testing.expect(manual.contains("VTTHX"));
const p = prices.get("VTTHX").?;
try std.testing.expectApproxEqAbs(typed, p, 0.001); // stored RAW
const value = positions[0].marketValue(p, manual.contains("VTTHX"));
try std.testing.expectApproxEqAbs(@as(f64, 14_404.0), value, 0.01);
}
// BROKEN: pre-multiply the same override in first. The gap-fill skips
// it, the flag never appears, and the ratio lands twice.
{
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("VTTHX", typed * ratio); // the pre-multiply
var manual = try buildFallbackPrices(a, &lots, &positions, &prices);
defer manual.deinit();
try std.testing.expect(!manual.contains("VTTHX")); // flag lost
const p = prices.get("VTTHX").?;
const value = positions[0].marketValue(p, manual.contains("VTTHX"));
// shares * (p * ratio) * ratio = 100 * 144.04 * 25 = 360,100.
// The error factor is the ratio SQUARED - 25x here, not 5x.
try std.testing.expectApproxEqAbs(@as(f64, 360_100.0), value, 0.01);
}
}
test "buildFallbackPrices: a candle price is never overridden or flagged" {
// The guard's legitimate purpose: a live close already in the map wins
// over a manual override, and must NOT be flagged preadjusted - candle
// closes are raw and do need the ratio.
const a = std.testing.allocator;
const lots = [_]portfolio_mod.Lot{.{
.symbol = "02315N600",
.ticker = "VTTHX",
.price_ratio = 5.0,
.price = 999.0, // stale override, should lose
.shares = 100,
.open_date = Date.fromYmd(2026, 2, 26),
.open_price = 106.99,
}};
const positions = [_]portfolio_mod.Position{dcPos("VTTHX", 100, 5.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("VTTHX", 28.808); // raw candle close
var manual = try buildFallbackPrices(a, &lots, &positions, &prices);
defer manual.deinit();
try std.testing.expect(!manual.contains("VTTHX"));
try std.testing.expectApproxEqAbs(@as(f64, 28.808), prices.get("VTTHX").?, 0.001);
// Ratio applied once: 100 * 28.808 * 5 = 14,404.
const value = positions[0].marketValue(prices.get("VTTHX").?, false);
try std.testing.expectApproxEqAbs(@as(f64, 14_404.0), value, 0.01);
}
test "computeDayChange: a hand-priced holding is IMPOSSIBLE, not a gap" {
// The headline behaviour. A 529 fund the user prices by hand on Saturdays
// has no candle history and never will: the only price zfin will ever
// hold for it is the typed one. Counting it as a shortfall would keep the
// marker permanently lit and train the reader to ignore it.
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
const candles = [_]Candle{ makeCandle(mon, 100), makeCandle(tue, 110) };
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ABC", &candles);
const positions = [_]portfolio_mod.Position{
dcPos("ABC", 10, 1.0),
dcPos("ORCX", 100, 1.0), // hand-priced, no candles
};
// The `price::` on the lot is the signal - see `userPricedSymbol`.
const lots = [_]portfolio_mod.Lot{
.{ .symbol = "ABC", .shares = 10, .open_date = mon, .open_price = 100 },
.{ .symbol = "ORCX", .shares = 100, .open_date = mon, .open_price = 18, .price = 19.01 },
};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 110);
try prices.put("ORCX", 19.01);
const dc = computeDayChange(tue, &positions, &lots, prices, null, cm);
try std.testing.expectEqual(@as(usize, 2), dc.positions_total);
// ORCX is not even a candidate, so coverage is 1 of 1 POSSIBLE.
try std.testing.expectEqual(@as(usize, 1), dc.positions_priceable);
try std.testing.expectEqual(@as(usize, 1), dc.positions_covered);
try std.testing.expect(dc.complete()); // -> renderer shows no marker
try std.testing.expectApproxEqAbs(@as(f64, 0), dc.uncovered_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 100), dc.change, 0.01);
}
test "computeDayChange: a security that SHOULD have priced is a real gap" {
// No `price::` on the lot, no candles - something went wrong (fetch
// failure, negative cache entry, brand-new symbol). That is fixable and
// must be surfaced, valued so the reader can judge materiality.
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
const candles = [_]Candle{ makeCandle(mon, 100), makeCandle(tue, 110) };
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("ABC", &candles);
const positions = [_]portfolio_mod.Position{
dcPos("ABC", 10, 1.0),
dcPos("BROKEN", 100, 1.0),
};
const lots = [_]portfolio_mod.Lot{
.{ .symbol = "ABC", .shares = 10, .open_date = mon, .open_price = 100 },
.{ .symbol = "BROKEN", .shares = 100, .open_date = mon, .open_price = 40 },
};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 110);
try prices.put("BROKEN", 50);
const dc = computeDayChange(tue, &positions, &lots, prices, null, cm);
try std.testing.expectEqual(@as(usize, 2), dc.positions_priceable);
try std.testing.expectEqual(@as(usize, 1), dc.positions_covered);
try std.testing.expect(!dc.complete()); // -> renderer shows the marker
// 100 shares at the current 50 = $5,000 missing from the figure.
try std.testing.expectApproxEqAbs(@as(f64, 5000), dc.uncovered_value, 0.01);
}
test "computeDayChange: an avg-cost fallback is FIXABLE, not hidden" {
// THE TRAP. `buildFallbackPrices` puts BOTH hand-typed overrides and
// avg-cost fallbacks into its `manual_prices` set, so keying the
// impossible/fixable split on that set would silently hide genuine
// pricing failures. The split keys on `lot.price` instead.
//
// Here BROKEN has no `price::` but IS in `manual_prices` (second pass,
// priced at avg_cost). It must still count as a real gap.
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
const positions = [_]portfolio_mod.Position{dcPos("BROKEN", 100, 1.0)};
const lots = [_]portfolio_mod.Lot{
.{ .symbol = "BROKEN", .shares = 100, .open_date = mon, .open_price = 40 },
};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("BROKEN", 40); // avg_cost fallback
var manual = std.StringHashMap(void).init(a);
defer manual.deinit();
try manual.put("BROKEN", {}); // flagged by the SECOND pass
const dc = computeDayChange(tue, &positions, &lots, prices, manual, cm);
try std.testing.expectEqual(@as(usize, 1), dc.positions_priceable);
try std.testing.expectEqual(@as(usize, 0), dc.positions_covered);
try std.testing.expect(!dc.complete());
try std.testing.expectApproxEqAbs(@as(f64, 4000), dc.uncovered_value, 0.01);
}
test "computeDayChange: a hand-priced lot WITH candles is day-changeable" {
// `ticker::` + `price::` together: `stockSymbols` still fetches candles
// for it, and `buildFallbackPrices` leaves the override unused because a
// candle price already exists. So it is priceable and covered normally -
// the `price::` alone must not exempt it.
const a = std.testing.allocator;
const mon = Date.fromYmd(2024, 6, 10);
const tue = Date.fromYmd(2024, 6, 11);
const candles = [_]Candle{ makeCandle(mon, 20), makeCandle(tue, 22) };
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("VTTHX", &candles);
const positions = [_]portfolio_mod.Position{dcPos("VTTHX", 100, 5.0)};
const lots = [_]portfolio_mod.Lot{
.{ .symbol = "02315N600", .ticker = "VTTHX", .price_ratio = 5.0, .price = 999.0, .shares = 100, .open_date = mon, .open_price = 106.99 },
};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("VTTHX", 22); // candle close won; the override is inert
const dc = computeDayChange(tue, &positions, &lots, prices, null, cm);
try std.testing.expectEqual(@as(usize, 1), dc.positions_priceable);
try std.testing.expectEqual(@as(usize, 1), dc.positions_covered);
try std.testing.expect(dc.complete());
// Ratio once on each side: 100*20*5 -> 100*22*5.
try std.testing.expectApproxEqAbs(@as(f64, 1000), dc.change, 0.01);
}
test "computeHistoricalSnapshots: a manual price is not re-ratioed" {
// Contract hardening. The current-price map is raw EXCEPT entries named
// in `manual_prices`, which are already in the lot's own share class.
// Only reachable with a back-dated as_of (candles present but starting
// after the target), but the contract should hold regardless.
const a = std.testing.allocator;
const as_of = Date.fromYmd(2024, 6, 3);
const candles = [_]Candle{
makeCandle(as_of.subtractMonths(1), 20),
makeCandle(as_of, 22),
};
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
try cm.put("VTTHX", &candles);
const positions = [_]portfolio_mod.Position{dcPos("VTTHX", 100, 5.0)};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("VTTHX", 110); // hand-typed institutional NAV
var manual = std.StringHashMap(void).init(a);
defer manual.deinit();
try manual.put("VTTHX", {});
const m1 = computeHistoricalSnapshots(as_of, &positions, prices, manual, cm)[0];
// Historical side is a raw candle -> ratio applies: 100 * 20 * 5.
try std.testing.expectApproxEqAbs(@as(f64, 10_000), m1.historical_value, 0.01);
// Current side is preadjusted -> ratio must NOT apply: 100 * 110.
try std.testing.expectApproxEqAbs(@as(f64, 11_000), m1.current_value, 0.01);
// Passing null instead would square it to 55,000.
const wrong = computeHistoricalSnapshots(as_of, &positions, prices, null, cm)[0];
try std.testing.expectApproxEqAbs(@as(f64, 55_000), wrong.current_value, 0.01);
}
test "computeDayChange: nothing priced yields hasData false" {
const a = std.testing.allocator;
var cm = std.StringHashMap([]const Candle).init(a);
defer cm.deinit();
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
const positions = [_]portfolio_mod.Position{dcPos("ABC", 10, 1.0)};
const dc = computeDayChange(Date.fromYmd(2024, 6, 11), &positions, &.{}, prices, null, cm);
try std.testing.expect(!dc.hasData());
try std.testing.expectEqual(@as(usize, 0), dc.positions_covered);
try std.testing.expectEqual(@as(usize, 1), dc.positions_total);
}
test "DayChange.pctOfAccount: dilutes by the non-equity weight" {
// $100k account, $95k of it equities that rose 1% ($950), $5k cash.
const dc = DayChange{
.priced_date = Date.fromYmd(2024, 6, 11),
.change = 950,
.prev_stock_value = 95_000,
.curr_stock_value = 95_950,
.positions_covered = 1,
.positions_priceable = 1,
.positions_total = 1,
.uncovered_value = 0,
};
// Equities alone moved 1.0%...
try std.testing.expectApproxEqAbs(@as(f64, 0.01), dc.change / dc.prev_stock_value, 1e-9);
// ...but the ACCOUNT moved 950 / 100,000 = 0.95%, which is the figure
// shown beside a whole-account Gain/Loss percentage.
try std.testing.expectApproxEqAbs(@as(f64, 0.0095), dc.pctOfAccount(100_950), 1e-9);
}
test "DayChange.pctOfAccount: zero prior account value doesn't divide" {
const dc = DayChange{
.priced_date = Date.fromYmd(2024, 6, 11),
.change = 100,
.prev_stock_value = 0,
.curr_stock_value = 100,
.positions_covered = 1,
.positions_priceable = 1,
.positions_total = 1,
.uncovered_value = 0,
};
try std.testing.expectEqual(@as(f64, 0), dc.pctOfAccount(100));
}
test "DayChange.recency: today, yesterday, older" {
const today = Date.fromYmd(2024, 6, 11);
const mk = struct {
fn f(d: Date) DayChange {
return .{ .priced_date = d, .change = 0, .prev_stock_value = 0, .curr_stock_value = 0, .positions_covered = 1, .positions_priceable = 1, .positions_total = 1, .uncovered_value = 0 };
}
}.f;
try std.testing.expectEqual(DayChange.Recency.today, mk(today).recency(today));
try std.testing.expectEqual(DayChange.Recency.yesterday, mk(today.addDays(-1)).recency(today));
try std.testing.expectEqual(DayChange.Recency.older, mk(today.addDays(-4)).recency(today));
}
test "computeHistoricalSnapshots: current_prices must be RAW, ratio applied once" {
// The regression. A lone `ticker::` + `price_ratio::` lot produces an
// UNMERGED allocation whose `current_price` already has the ratio folded
// in. Feeding that in here made `pos.marketValue(.., false)` apply the
// ratio a SECOND time, squaring it - while the historical side, coming
// from raw candles, stayed correct. So the ratio between the two sides
// was wrong and every percentage on the TUI's `Historical:` line broke.
//
// Contract: pass RAW base-ticker prices. Then a pure price move of
// +10% must read as +10% regardless of the share-class ratio.
const a = std.testing.allocator;
const as_of = Date.fromYmd(2024, 6, 3);
const raw_then: f64 = 20.0;
const raw_now: f64 = 22.0; // +10%
const ratio: f64 = 5.0;
// One month back is the first period in `HistoricalPeriod.all`.
const candles = [_]Candle{
makeCandle(as_of.subtractMonths(1), raw_then),
makeCandle(as_of, raw_now),
};
var candle_map = std.StringHashMap([]const Candle).init(a);
defer candle_map.deinit();
try candle_map.put("VTTHX", &candles);
const positions = [_]portfolio_mod.Position{.{
.symbol = "VTTHX",
.shares = 100,
.avg_cost = 90,
.total_cost = 9000,
.open_lots = 1,
.closed_lots = 0,
.realized_gain_loss = 0,
.price_ratio = ratio,
}};
var raw_prices = std.StringHashMap(f64).init(a);
defer raw_prices.deinit();
try raw_prices.put("VTTHX", raw_now);
const snaps = computeHistoricalSnapshots(as_of, &positions, raw_prices, null, candle_map);
const m1 = snaps[0]; // 1M
try std.testing.expectEqual(@as(usize, 1), m1.position_count);
// Ratio applied exactly once on each side: 100 * 20 * 5 and 100 * 22 * 5.
try std.testing.expectApproxEqAbs(@as(f64, 10_000), m1.historical_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 11_000), m1.current_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 10.0), m1.changePct(), 1e-9);
// What the buggy caller did: hand in the EFFECTIVE price instead.
// The ratio gets squared, the percentage inflates wildly, and the
// dollar figures become nonsense.
var effective_prices = std.StringHashMap(f64).init(a);
defer effective_prices.deinit();
try effective_prices.put("VTTHX", raw_now * ratio);
const bad = computeHistoricalSnapshots(as_of, &positions, effective_prices, null, candle_map)[0];
try std.testing.expectApproxEqAbs(@as(f64, 55_000), bad.current_value, 0.01); // 5x too big
try std.testing.expect(bad.changePct() > 400.0); // vs the true +10%
}
test "computeHistoricalSnapshots: an unratioed position is unaffected either way" {
// Blast-radius guard: the fix must not move the needle for ordinary
// holdings, where raw and effective prices are the same number.
const a = std.testing.allocator;
const as_of = Date.fromYmd(2024, 6, 3);
const candles = [_]Candle{
makeCandle(as_of.subtractMonths(1), 100),
makeCandle(as_of, 110),
};
var candle_map = std.StringHashMap([]const Candle).init(a);
defer candle_map.deinit();
try candle_map.put("ABC", &candles);
const positions = [_]portfolio_mod.Position{.{
.symbol = "ABC",
.shares = 10,
.avg_cost = 90,
.total_cost = 900,
.open_lots = 1,
.closed_lots = 0,
.realized_gain_loss = 0,
}};
var prices = std.StringHashMap(f64).init(a);
defer prices.deinit();
try prices.put("ABC", 110);
const m1 = computeHistoricalSnapshots(as_of, &positions, prices, null, candle_map)[0];
try std.testing.expectApproxEqAbs(@as(f64, 1000), m1.historical_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1100), m1.current_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 10.0), m1.changePct(), 1e-9);
}
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);
}
}
}