2616 lines
115 KiB
Zig
2616 lines
115 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). 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);
|
||
}
|
||
}
|
||
}
|