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