zfin/src/models/portfolio.zig

2165 lines
96 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

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

const std = @import("std");
const Date = @import("../Date.zig");
const Candle = @import("candle.zig").Candle;
const split = @import("split.zig");
const Split = split.Split;
// ── Pricing model ────────────────────────────────────────────
//
// How a lot's market value gets computed is non-obvious because several
// independent concerns overlap. Consolidated here so new readers (and
// future-us) don't have to reverse-engineer it from call sites.
//
// ## Inputs
//
// 1. `lot.shares` - signed share count. Negative = short (written
// options, short stock). Absolute value is what multiplies price for
// cost/value; the sign flows through to P&L.
//
// 2. Some "raw price" from one of these sources, in priority order:
// a. Candle close for the target date (live API - retail share
// class). This is the common path.
// b. `lot.price` manual override (`price::` in portfolio.srf). The
// user enters what they see in their brokerage statement, so this
// is in the LOT's share class already - no ratio needed.
// c. `position.avg_cost` fallback when no candle is available and no
// manual override exists. This is in the LOT's share class (user
// paid institutional-class prices to open the lot).
//
// 3. `lot.price_ratio` - share-class conversion factor. Default 1.0
// for retail-class lots. Example: VTTHX (institutional, $144) holds
// VTHR (retail, $27.78), ratio ≈ 5.185. API gives us the $27.78
// retail close; we multiply to get the $144 institutional price.
//
// ## The rule
//
// `effective_price = is_preadjusted ? raw_price : raw_price * price_ratio`
//
// Where `is_preadjusted` means "this raw price is already in the lot's
// share-class terms, don't apply the ratio." Sources (2b) and (2c) are
// preadjusted; source (2a) is not.
//
// `market_value = shares * effective_price`
//
// See `Lot.effectivePrice`, `Lot.marketValue`, and the matching methods
// on `Position` for the canonical implementation. All callers in
// snapshot.zig, audit/, and valuation.zig route through these - do
// not reintroduce inline `price * price_ratio` expressions.
//
// ## The pre-multiply anti-pattern (do not add new instances)
//
// A tempting shortcut is to fold `price_ratio` into a shared `prices` map
// at insert time - "pre-multiplying" a manual override - so later readers
// can treat every entry uniformly. It is unsound, for two reasons:
//
// 1. The map is keyed by SYMBOL while `price_ratio` is per LOT. Folding
// one lot's ratio into a symbol-keyed entry corrupts every other lot
// sharing that ticker at a different ratio - which is exactly the
// `ticker::` aliasing this whole mechanism exists to support.
// 2. It collides with `buildFallbackPrices`, whose first pass gap-fills
// only `if (!prices.contains(sym))`. A pre-inserted entry is skipped,
// so it never lands in `manual_set`, so the downstream
// `effectivePrice(raw, is_manual)` sees `is_manual == false` and
// applies the ratio a SECOND time, squaring it.
//
// The correct shape is raw value + a companion "preadjusted" set:
// `buildFallbackPrices` stores the override untouched and records the
// symbol in `manual_set`; readers pass that through as `is_preadjusted`.
// `commands/snapshot.zig` does this (it used to pre-multiply, and squared
// the ratio for any manual-priced lot with a non-unit one).
//
// KNOWN GAP: `commands/audit.zig` still pre-multiplies, because the
// reconcile path has no `manual_set` equivalent -
// `reconcile/common.zig:resolvePositionValue` applies the ratio to
// whatever is in the map with no way to opt out. Fixing it properly means
// threading preadjusted-ness through the reconcile signatures. Until then
// a manual `price::` combined with a non-unit `price_ratio` reads high by
// the ratio in `zfin audit` only. Do not "fix" it by dividing the ratio
// out at insert - that just moves the corruption onto sibling lots.
//
// ## avg_cost fallback
//
// When a symbol has no live price AND no manual override, callers fall
// back to `position.avg_cost` (the weighted average lot open-price).
// That value is already in the lot's share-class terms - the user paid
// institutional-class prices to open the lot - so `is_preadjusted = true`.
// Both snapshot and audit honor this: snapshot via `buildFallbackPrices`
// + `manual_set`, audit via inline `prices.get(sym) orelse avg_cost`
// with a matching `is_preadjusted` flag per branch.
//
// ## Per-LOT display rows
//
// `valuation.Allocation` is a POSITION-level view, and its
// `current_price` is NOT unconditionally raw. Which it is depends on
// whether `mergeAllocsBySymbol` folded the row:
//
// - UNMERGED (`alloc.price_ratio != 1.0`): the allocation carries the
// lot's own ratio, and `portfolioSummary` already applied it -
// `current_price` is the EFFECTIVE price.
// - MERGED, or plainly unratioed (`alloc.price_ratio == 1.0`):
// `current_price` is the RAW base-ticker price and `shares` are in
// base-ticker-equivalent units.
//
// So a lot-detail row cannot just multiply its own raw shares by
// `current_price`: in the merged case it is wrong by exactly the lot's
// ratio. That shipped - a direct-indexing sleeve with
// `price_ratio:num:0.2387` rendered a +$3.39M gain against a real
// +$433K, and the lot rows under a position summed to three times the
// position's own market value. Three sites had independently
// hand-rolled the same broken expression.
//
// The first fix for that got the OTHER half wrong: it keyed provenance
// on `alloc.is_manual_price`, which is orthogonal to merging, so every
// unmerged live-priced ratio'd lot then had its ratio applied twice.
// That is the single-CIT-lot case this file's `ticker` + `price_ratio`
// docs describe as the primary use, and no test caught it because every
// fixture left `Allocation.price_ratio` at 1.0 - indistinguishable from
// a merged group.
//
// So: per-lot display sites MUST price through
// `views/portfolio_sections.zig:effectivePriceFor(allocations, lot)`,
// which resolves `close_price`, discriminates on `price_ratio`, and
// applies the ratio via `Lot.effectivePrice`. Never read
// `Allocation.current_price` into a per-lot calculation, and never
// reintroduce `is_manual_price` as the provenance signal. Current
// callers: the CLI holdings table, the TUI portfolio tab's lot rows,
// and the shared column-width pass.
// ── Share model (split adjustment) ──────────────────────────
//
// `lot.shares` and `lot.open_price` are IMMUTABLE HISTORICAL FACTS:
// the count and per-share price exactly as the lot was transacted, as
// written in portfolio.srf. We never rewrite them for a stock split
// (that would corrupt the git history the contributions/compare/audit
// commands read). Instead, `lot.split_factor` is a DERIVED multiplier,
// populated on read by `enrichSplits` from the fetched split corpus:
//
// effective_shares = shares * split_factor
// effective_open_price = open_price / split_factor
//
// so cost basis stays split-invariant
// (`effective_shares * effective_open_price == shares * open_price`).
//
// `split_factor` is analogous to `price_ratio`: a derived multiplier,
// default 1.0 (no adjustment), applied via accessors. It is set ONLY
// by `enrichSplits`, and ONLY for symbols the user has opted in with a
// per-symbol `splits_current_through::DATE` on that symbol's
// metadata.srf row. Symbols without it stay at 1.0 everywhere and every
// path below behaves exactly as it did before this feature existed.
//
// ## The rule - when to use which
//
// Use `effectiveShares()` / `effectiveOpenPrice()` (NOT raw
// `shares`/`open_price`) anywhere a share count is:
// - multiplied by a current-or-later price (market value, gain/loss
// vs current price),
// - summed into a current holdings/position total, or
// - shown on screen.
// `marketValue` already routes through `effectiveShares()`, and
// `positionsAsOf`/`positionsForAccount` sum `effectiveShares()`, so
// anything flowing through positions or `marketValue` is correct for
// free.
//
// Use RAW `shares`/`open_price` (they are correct precisely because
// they are split-invariant or historical) for:
// - cost basis and realized P&L (`costBasis`, `realizedGainLoss`),
// - the contributions diff (a split must NOT read as a contribution;
// it diffs raw declared shares across git revisions),
// - the frozen `.shares` field written into a snapshot record (a
// historical fact; its `.value` is computed effective instead).
//
// If you are reading `lot.shares` directly in NEW code, you had
// better be in the RAW list above - otherwise use `effectiveShares()`.
// ── Money-market / stable-NAV classification ────────────────
//
// Centralized so that audit/, the Fidelity/Schwab parsers, and the
// planned snapshot writer all agree on which symbols are fixed-$1-NAV
// instruments. Prior to this the classification lived in three places
// with three different heuristics that disagreed on edge cases.
/// Well-known US money-market fund tickers. Schwab (SWVXX/SWTXX),
/// Vanguard (VMFXX/VMRXX/VUSXX/VMSXX/VYFXX), Fidelity
/// (SPAXX/SPRXX/FDRXX/FDLXX/FZFXX/FZDXX/FTEXX), and a handful of
/// BlackRock / Federated / JPM common tickers. Extend as new funds
/// appear.
pub const money_market_symbols = [_][]const u8{
// Schwab
"SWVXX", "SWTXX", "SNAXX", "SNVXX", "SNOXX", "SNSXX",
// Vanguard
"VMFXX", "VMRXX", "VUSXX", "VMSXX", "VYFXX",
// Fidelity prime/gov/treasury
"SPAXX",
"SPRXX", "FDRXX", "FDLXX", "FZFXX", "FZDXX", "FTEXX",
"FDIXX",
// Wells Fargo / Allspring (Allspring is the WAM rebrand;
// tickers retained their classic letters for legacy holders)
"WMPXX", "WFFXX", "NWGXX", "GVIXX",
// Federated, BlackRock, JPM common tickers
"GOFXX",
"TSCXX", "MJLXX",
};
/// Returns true when `symbol` appears in the well-known money-market
/// ticker list (case-insensitive). Use this anywhere you need to know
/// "is this a fixed-$1-NAV cash equivalent?" based on the ticker alone.
///
/// Symbols not in the list (including unknown MM funds) return false.
/// Callers that have candle data on hand can supplement this with a
/// trailing-$1-close check of their own if they need to catch funds
/// missing from the whitelist.
pub fn isMoneyMarketSymbol(symbol: []const u8) bool {
if (symbol.len == 0) return false;
// All tickers in `money_market_symbols` are uppercase; upper-case the
// input once into a fixed-size buffer for the comparison.
var buf: [16]u8 = undefined;
if (symbol.len > buf.len) return false;
for (symbol, 0..) |c, i| buf[i] = std.ascii.toUpper(c);
const up = buf[0..symbol.len];
for (money_market_symbols) |mm| {
if (std.mem.eql(u8, up, mm)) return true;
}
return false;
}
/// Synthesize a stable-NAV (= $1) candle for a given date. Used when
/// historical price data for a money-market fund doesn't reach back as
/// far as the period under analysis - the close is known to be $1 by
/// construction, so we can extrapolate backward without inventing data.
pub fn stableNavCandle(date: Date) Candle {
return .{ .date = date, .open = 1, .high = 1, .low = 1, .close = 1, .adj_close = 1, .volume = 0 };
}
/// Type of holding in a portfolio lot.
pub const LotType = enum {
stock, // stocks and ETFs (default)
option, // option contracts
cd, // certificates of deposit
cash, // cash/money market
illiquid, // illiquid assets (real estate, vehicles, etc.)
watch, // watchlist item (no position, just track price)
pub fn label(self: LotType) []const u8 {
return switch (self) {
.stock => "Stock",
.option => "Option",
.cd => "CD",
.cash => "Cash",
.illiquid => "Illiquid",
.watch => "Watch",
};
}
};
/// Call or put option type.
pub const OptionType = enum {
call,
put,
};
/// A single lot in a portfolio -- one purchase/sale event.
/// Open lots have no close_date/close_price.
/// Closed lots have both.
pub const Lot = struct {
symbol: []const u8 = "",
shares: f64,
open_date: Date,
open_price: f64,
close_date: ?Date = null,
close_price: ?f64 = null,
/// Optional note/tag for the lot
note: ?[]const u8 = null,
/// Optional explicit display label - the lot's "human identity"
/// for the symbol column (e.g. `label::TGT2035` on a target-date
/// CUSIP). When set it overrides the symbol/ticker in display
/// ONLY; it is never a pricing or classification key. The display
/// counterpart to `ticker::`/`priceSymbol()`. See `displaySymbol()`.
label: ?[]const u8 = null,
/// Optional account identifier (e.g. "Roth IRA", "Brokerage")
account: ?[]const u8 = null,
/// Type of holding (stock, option, cd, cash)
security_type: LotType = .stock,
/// Maturity date (for CDs)
maturity_date: ?Date = null,
/// Interest rate (for CDs, as percentage e.g. 3.8 = 3.8%)
rate: ?f64 = null,
/// Whether this lot is from dividend reinvestment (DRIP).
/// DRIP lots are summarized as ST/LT groups instead of shown individually.
drip: bool = false,
/// Ticker alias for price fetching (e.g. CUSIP symbol with ticker::VTTHX).
/// When set, this ticker is used for API calls instead of the symbol field.
ticker: ?[]const u8 = null,
/// Manual price override (e.g. for mutual funds not covered by data providers).
/// Used as fallback when API price fetch fails.
price: ?f64 = null,
/// Date of the manual price (for display/staleness tracking).
price_date: ?Date = null,
/// Price ratio for institutional share classes. When set, the fetched price
/// (from the `ticker` symbol) is multiplied by this ratio to get the actual
/// institutional NAV. E.g. if VTTHX (investor) is $27.78 and the institutional
/// class trades at $144.04, price_ratio = 144.04 / 27.78 ≈ 5.185.
price_ratio: f64 = 1.0,
/// DERIVED split-adjustment multiplier - NOT hand-edited, NOT a
/// pricing/classification key. Default 1.0 = no adjustment.
/// Populated on read by `enrichSplits` (opt-in via a per-symbol
/// `splits_current_through` on the symbol's metadata.srf row); see
/// the "Share model"
/// block at the top of this file. `effectiveShares()` multiplies by
/// it; `effectiveOpenPrice()` divides by it. Left at its 1.0 default
/// it is omitted from SRF serialization, so it never touches the
/// user's portfolio.srf (guarded by a round-trip test in store.zig).
split_factor: f64 = 1.0,
/// Underlying stock symbol for option lots (e.g. "AMZN").
underlying: ?[]const u8 = null,
/// Strike price for option lots.
strike: ?f64 = null,
/// Contract multiplier (shares per contract). Default 100 for standard US equity options.
multiplier: f64 = 100.0,
/// Call or put (for option lots).
option_type: OptionType = .call,
/// The symbol to use for price fetching: the `ticker::` alias
/// when set, else the raw `symbol`. This is the lot's **economic
/// identity** - what the pipeline prices, aggregates, and
/// classifies by. Its display counterpart is `displaySymbol()`.
pub fn priceSymbol(self: Lot) []const u8 {
return self.ticker orelse self.symbol;
}
/// The symbol to show in the display: an explicit `label::` when
/// set, else the economic identity (`priceSymbol()`). This is the
/// lot's **human identity** - purely cosmetic, never a pricing or
/// classification key. The display mirror of `priceSymbol()`;
/// also mirrored by `Position.displaySymbol()`.
pub fn displaySymbol(self: Lot) []const u8 {
return self.label orelse self.priceSymbol();
}
/// Field names a user hand-maintains that a brokerage export
/// never carries. `zfin import` copies each verbatim from the
/// prior matching lot on re-import (see `synthesizeLots` in
/// `commands/import.zig`), so hand annotations survive a refresh.
/// Add a new hand-edited field here and import preserves it
/// automatically; this is the single source of truth.
///
/// Deliberately NOT listed:
/// - `symbol`, `shares`, `account`, `security_type` come from
/// the export.
/// - `open_date`, `open_price`, `note` are preserved-or-
/// synthesized with their own fallback logic in import.
/// - option-mechanics (`underlying`, `strike`, `multiplier`,
/// `option_type`) and closed-lot (`close_date`,
/// `close_price`) fields: import only builds open stock/cash
/// positions, so they never apply to a re-imported lot.
pub const hand_edited_fields = [_][]const u8{
"ticker",
"label",
"price",
"price_date",
"price_ratio",
"drip",
"maturity_date",
"rate",
};
pub fn isOpen(self: Lot, as_of: Date) bool {
return self.lotIsOpenAsOf(as_of);
}
/// Was the lot held at end-of-day on `as_of`?
///
/// Used by historical snapshot backfill (`zfin snapshot --as-of`)
/// where "open" must be evaluated against the target date rather
/// than wall-clock today. `isOpen()` delegates to this with
/// today as `as_of`.
///
/// End-of-day semantics (see tests):
/// - `open_date > as_of` -> not yet bought -> false
/// - `close_date` on/before as_of -> sold that day or earlier -> false
/// - `maturity_date` on/before as_of -> matured that day or earlier -> false
/// - otherwise -> true
pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool {
// Not yet bought on `as_of`.
if (as_of.lessThan(self.open_date)) return false;
// Sold on or before `as_of`.
if (self.close_date) |cd| {
if (!as_of.lessThan(cd)) return false;
}
// Matured on or before `as_of` (options, CDs).
if (self.maturity_date) |mat| {
if (!as_of.lessThan(mat)) return false;
}
return true;
}
/// Cost basis: RAW shares x RAW open_price. Split-invariant
/// (`effectiveShares * effectiveOpenPrice == shares * open_price`),
/// so this deliberately stays on the raw fields. See "Share model".
pub fn costBasis(self: Lot) f64 {
return self.shares * self.open_price;
}
/// Split-adjusted share count: raw `shares` scaled by the derived
/// `split_factor`. Use this (never raw `shares`) anywhere shares get
/// multiplied by a current-or-later price, summed into current
/// holdings, or displayed. See the "Share model" block above.
pub fn effectiveShares(self: Lot) f64 {
return self.shares * self.split_factor;
}
/// Split-adjusted per-share cost: raw `open_price` divided by
/// `split_factor`. Pairs with `effectiveShares()` so cost basis is
/// preserved. Use for per-share cost display on a split-spanning lot.
pub fn effectiveOpenPrice(self: Lot) f64 {
return self.open_price / self.split_factor;
}
/// Apply the share-class `price_ratio` to `raw_price`. See the
/// "Pricing model" block at the top of this file for the full
/// semantics of `is_preadjusted`.
pub fn effectivePrice(self: Lot, raw_price: f64, is_preadjusted: bool) f64 {
return if (is_preadjusted) raw_price else raw_price * self.price_ratio;
}
/// Market value of the lot at `raw_price`:
/// `effectiveShares * effectivePrice` (split- and share-class-aware).
pub fn marketValue(self: Lot, raw_price: f64, is_preadjusted: bool) f64 {
return self.effectiveShares() * self.effectivePrice(raw_price, is_preadjusted);
}
/// Realized gain/loss for a closed lot: shares * (close_price - open_price).
/// Returns null if the lot is still open. Stays on RAW shares - a
/// closed lot is a completed round-trip whose recorded open/close are
/// consistent; `enrichSplits` leaves closed lots at `split_factor 1.0`.
pub fn realizedGainLoss(self: Lot) ?f64 {
const cp = self.close_price orelse return null;
return self.shares * (cp - self.open_price);
}
/// Unrealized gain/loss for an open lot at the given market price.
pub fn unrealizedGainLoss(self: Lot, current_price: f64) f64 {
return self.effectiveShares() * (current_price - self.effectiveOpenPrice());
}
pub fn returnPct(self: Lot, current_price: f64) f64 {
if (self.open_price == 0) return 0;
const price = if (self.close_price) |cp| cp else current_price;
return (price / self.effectiveOpenPrice()) - 1.0;
}
};
/// Populate each open stock lot's `split_factor` from the fetched split
/// `corpus` (keyed by `priceSymbol()`), in place.
///
/// OPT-IN, PER SYMBOL: `cutovers` maps a symbol to the date through
/// which its recorded shares are already split-adjusted (the
/// `splits_current_through` field on that symbol's metadata.srf row). A
/// symbol ABSENT from `cutovers` is left untouched - `split_factor`
/// stays 1.0 and valuation behaves exactly as it did before splits
/// existed. For a symbol present, a split is applied to a lot only if it
/// occurred AFTER both the lot's purchase and the symbol's cutover (so
/// already-restated legacy lots, whose splits predate the cutover, are
/// left alone) and on/before `as_of`. Closed lots are skipped - their
/// realized P&L stays on raw shares. Non-stock lots never split.
///
/// `as_of` is the reference date: today for live valuation, the
/// snapshot date for a back-dated snapshot capture.
pub fn enrichSplits(
lots: []Lot,
corpus: *const std.StringHashMap([]const Split),
cutovers: *const std.StringHashMap(Date),
as_of: Date,
) void {
for (lots) |*lot| {
if (lot.security_type != .stock) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
const cutover = cutovers.get(lot.priceSymbol()) orelse continue; // per-symbol opt-in
const splits = corpus.get(lot.priceSymbol()) orelse continue;
// Apply splits after BOTH the purchase and the symbol's cutover.
const after = if (cutover.lessThan(lot.open_date)) lot.open_date else cutover;
lot.split_factor = split.cumulativeSplitRatio(splits, after, as_of);
}
}
/// Aggregated position for a single symbol across multiple lots.
pub const Position = struct {
symbol: []const u8,
/// Original lot symbol before ticker aliasing (e.g. CUSIP "02315N600").
/// Same as `symbol` when no ticker alias is set.
lot_symbol: []const u8 = "",
/// Total open shares
shares: f64,
/// Weighted average cost basis per share (open lots only)
avg_cost: f64,
/// Total cost basis of open lots
total_cost: f64,
/// Number of open lots
open_lots: u32,
/// Number of closed lots
closed_lots: u32,
/// Total realized P&L from closed lots
realized_gain_loss: f64,
/// Account name (shared across lots, or "Multiple" if mixed).
account: []const u8 = "",
/// Note from the first lot (e.g. "VANGUARD TARGET 2035").
note: ?[]const u8 = null,
/// Explicit display label from the first lot (the lot's `label::`).
/// Drives `displaySymbol()`; display-only, never a key.
label: ?[]const u8 = null,
/// Price ratio for institutional share classes (from lot).
/// positionsAsOf() groups by (priceSymbol, price_ratio), so lots with
/// different ratios sharing the same ticker produce separate positions.
/// portfolioSummary() then merges them back into a single rolled-up
/// allocation with normalized (base-ticker-equivalent) shares.
price_ratio: f64 = 1.0,
/// Apply the share-class `price_ratio` to `raw_price` - the
/// Position-aggregate mirror of `Lot.effectivePrice`. See the
/// "Pricing model" block at the top of this file.
pub fn effectivePrice(self: Position, raw_price: f64, is_preadjusted: bool) f64 {
return if (is_preadjusted) raw_price else raw_price * self.price_ratio;
}
/// Market value of the position at `raw_price`: `shares * effectivePrice`.
pub fn marketValue(self: Position, raw_price: f64, is_preadjusted: bool) f64 {
return self.shares * self.effectivePrice(raw_price, is_preadjusted);
}
/// The symbol to show in the display: an explicit `label` (from
/// the lot's `label::`) when set, else `symbol` - which is
/// already the economic identity (`priceSymbol()`), since
/// positions are keyed by it. The aggregate mirror of
/// `Lot.displaySymbol()`.
pub fn displaySymbol(self: Position) []const u8 {
return self.label orelse self.symbol;
}
};
/// A portfolio is a collection of lots.
pub const Portfolio = struct {
lots: []Lot,
allocator: std.mem.Allocator,
pub fn deinit(self: *Portfolio) void {
for (self.lots) |lot| {
self.allocator.free(lot.symbol);
if (lot.note) |n| self.allocator.free(n);
if (lot.label) |l| self.allocator.free(l);
if (lot.account) |a| self.allocator.free(a);
if (lot.ticker) |t| self.allocator.free(t);
if (lot.underlying) |u| self.allocator.free(u);
}
self.allocator.free(self.lots);
}
/// Get all unique symbols in the portfolio (all types).
pub fn symbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 {
var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit();
for (self.lots) |lot| {
try seen.put(lot.symbol, {});
}
var result = std.ArrayList([]const u8).empty;
errdefer result.deinit(allocator);
var iter = seen.keyIterator();
while (iter.next()) |key| {
try result.append(allocator, key.*);
}
return result.toOwnedSlice(allocator);
}
/// Get unique symbols for stock/ETF lots only (skips options, CDs, cash).
/// Returns the price symbol (ticker alias if set, otherwise raw symbol).
/// Excludes manual-price-only lots (price:: set, no ticker::) since those
/// have no API coverage and should never be fetched.
pub fn stockSymbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 {
var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit();
for (self.lots) |lot| {
if (lot.security_type == .stock) {
// Skip lots that have a manual price but no ticker alias -
// these are securities without API coverage (e.g. 401k CIT shares).
if (lot.price != null and lot.ticker == null) continue;
try seen.put(lot.priceSymbol(), {});
}
}
var result = std.ArrayList([]const u8).empty;
errdefer result.deinit(allocator);
var iter = seen.keyIterator();
while (iter.next()) |key| {
try result.append(allocator, key.*);
}
return result.toOwnedSlice(allocator);
}
/// Get all lots for a given symbol.
pub fn lotsForSymbol(self: Portfolio, allocator: std.mem.Allocator, symbol: []const u8) ![]Lot {
var result = std.ArrayList(Lot).empty;
errdefer result.deinit(allocator);
for (self.lots) |lot| {
if (std.mem.eql(u8, lot.symbol, symbol)) {
try result.append(allocator, lot);
}
}
return result.toOwnedSlice(allocator);
}
/// Get all lots of a given security type (allocated copy).
pub fn lotsOfTypeAlloc(self: Portfolio, allocator: std.mem.Allocator, sec_type: LotType) ![]Lot {
var result = std.ArrayList(Lot).empty;
errdefer result.deinit(allocator);
for (self.lots) |lot| {
if (lot.security_type == sec_type) {
try result.append(allocator, lot);
}
}
return result.toOwnedSlice(allocator);
}
/// Aggregate stock/ETF lots into positions by symbol (skips options, CDs, cash).
/// Keys by priceSymbol() so CUSIP lots with ticker aliases aggregate under the ticker.
///
/// Uses wall-clock today for the open/closed determination. For
/// historical snapshot backfill where "today" is not the right
/// reference, use `positionsAsOf(allocator, as_of)`.
pub fn positions(self: Portfolio, as_of: Date, allocator: std.mem.Allocator) ![]Position {
return self.positionsAsOf(allocator, as_of);
}
/// Like `positions` but evaluates lot open/closed against `as_of`
/// rather than wall-clock today. See `Lot.lotIsOpenAsOf` for
/// semantics. Used by historical snapshot backfill so a lot closed
/// after `as_of` still contributes its shares on that date, and
/// a lot opened after `as_of` does not.
pub fn positionsAsOf(self: Portfolio, allocator: std.mem.Allocator, as_of: Date) ![]Position {
var result = std.ArrayList(Position).empty;
errdefer result.deinit(allocator);
for (self.lots) |lot| {
if (lot.security_type != .stock) continue;
const sym = lot.priceSymbol();
// Find existing position matching both symbol AND price_ratio.
// Lots with different ratios (e.g. direct SPY vs institutional CIT
// using ticker::SPY) must produce separate positions to ensure
// correct valuation.
var found: ?*Position = null;
for (result.items) |*pos| {
if (std.mem.eql(u8, pos.symbol, sym) and pos.price_ratio == lot.price_ratio) {
found = pos;
break;
}
}
if (found == null) {
try result.append(allocator, .{
.symbol = sym,
.lot_symbol = lot.symbol,
.shares = 0,
.avg_cost = 0,
.total_cost = 0,
.open_lots = 0,
.closed_lots = 0,
.realized_gain_loss = 0,
.account = lot.account orelse "",
.note = lot.note,
.label = lot.label,
.price_ratio = lot.price_ratio,
});
found = &result.items[result.items.len - 1];
} else {
// Track account: if lots have different accounts, mark as "Multiple"
const existing = found.?.account;
const new_acct = lot.account orelse "";
if (existing.len > 0 and !std.mem.eql(u8, existing, "Multiple") and !std.mem.eql(u8, existing, new_acct)) {
found.?.account = "Multiple";
}
}
const pos = found.?;
if (lot.lotIsOpenAsOf(as_of)) {
pos.shares += lot.effectiveShares();
pos.total_cost += lot.costBasis();
pos.open_lots += 1;
} else {
const not_yet_opened = as_of.lessThan(lot.open_date);
if (!not_yet_opened) {
pos.closed_lots += 1;
pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
}
}
}
// Compute avg_cost
for (result.items) |*pos| {
if (pos.shares > 0) {
pos.avg_cost = pos.total_cost / pos.shares;
}
}
return result.toOwnedSlice(allocator);
}
/// Aggregate stock/ETF lots into positions for a single account.
/// Same logic as positions() but filtered to lots matching `account_name`.
/// Only includes positions with at least one open lot (closed-only symbols are excluded).
pub fn positionsForAccount(self: Portfolio, as_of: Date, allocator: std.mem.Allocator, account_name: []const u8) ![]Position {
var result = std.ArrayList(Position).empty;
errdefer result.deinit(allocator);
for (self.lots) |lot| {
if (lot.security_type != .stock) continue;
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, account_name)) continue;
const sym = lot.priceSymbol();
// Find existing position matching both symbol AND price_ratio.
var found: ?*Position = null;
for (result.items) |*pos| {
if (std.mem.eql(u8, pos.symbol, sym) and pos.price_ratio == lot.price_ratio) {
found = pos;
break;
}
}
if (found == null) {
try result.append(allocator, .{
.symbol = sym,
.lot_symbol = lot.symbol,
.shares = 0,
.avg_cost = 0,
.total_cost = 0,
.open_lots = 0,
.closed_lots = 0,
.realized_gain_loss = 0,
.account = lot_acct,
.note = lot.note,
.label = lot.label,
.price_ratio = lot.price_ratio,
});
found = &result.items[result.items.len - 1];
}
const pos = found.?;
if (lot.isOpen(as_of)) {
pos.shares += lot.effectiveShares();
pos.total_cost += lot.costBasis();
pos.open_lots += 1;
} else {
pos.closed_lots += 1;
pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
}
}
// Compute avg_cost and filter to open-only
var final = std.ArrayList(Position).empty;
errdefer final.deinit(allocator);
for (result.items) |*pos| {
if (pos.open_lots == 0) continue;
if (pos.shares > 0) {
pos.avg_cost = pos.total_cost / pos.shares;
}
try final.append(allocator, pos.*);
}
result.deinit(allocator);
return final.toOwnedSlice(allocator);
}
/// Total cash for a single account.
pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type != .cash) continue;
const lot_acct = lot.account orelse continue;
if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares;
}
return total;
}
/// True if `account_name` holds at least one open lot as-of - any
/// real holding type (stock, cash, CD, option). Watchlist entries
/// (`.watch`, share count zero) don't count: they're not held.
///
/// Used by the audit reconciler to decide whether a portfolio
/// account that's missing from a brokerage export is worth
/// flagging. A fully-closed / zero-balance account has nothing left
/// to reconcile, so it's suppressed.
pub fn hasOpenLotsForAccount(self: Portfolio, as_of: Date, account_name: []const u8) bool {
for (self.lots) |lot| {
if (lot.security_type == .watch) continue;
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, account_name)) continue;
if (lot.isOpen(as_of)) return true;
}
return false;
}
/// Total value of non-stock holdings (cash, CDs, options) for a single account.
/// Only includes open lots (respects close_date and maturity_date).
///
/// The option arm below is one of THREE copies of the same premium
/// formula - the others are `analytics/analysis.zig`'s account
/// breakdown and the `.option` arm of
/// `commands/snapshot.zig:buildSnapshot`. They must agree; `analysis`
/// once dropped `multiplier` and silently under-reported every
/// option-holding account by 99% of its premium. All three use
/// `@abs`, so a written (short) option counts as a positive asset
/// rather than a liability - a deliberate shared convention. Change
/// either detail at all three sites or none.
pub fn nonStockValueForAccount(self: Portfolio, as_of: Date, account_name: []const u8) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (!lot.isOpen(as_of)) continue;
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, account_name)) continue;
switch (lot.security_type) {
.cash => total += lot.shares,
.cd => total += lot.shares,
.option => total += @abs(lot.shares) * lot.open_price * lot.multiplier,
else => {},
}
}
return total;
}
/// Total value of an account: stocks (priced from the given map, falling back to avg_cost)
/// plus cash, CDs, and options. Only includes open lots.
pub fn totalForAccount(self: Portfolio, as_of: Date, allocator: std.mem.Allocator, account_name: []const u8, prices: std.StringHashMap(f64)) f64 {
var total: f64 = 0;
const acct_positions = self.positionsForAccount(as_of, allocator, account_name) catch return self.nonStockValueForAccount(as_of, account_name);
defer allocator.free(acct_positions);
for (acct_positions) |pos| {
// Live API price is in the retail share class -> ratio applies
// (is_preadjusted=false). avg_cost fallback is in the lot's own
// share-class terms -> ratio must NOT be applied
// (is_preadjusted=true). See the "Pricing model" doc-block above.
total += if (prices.get(pos.symbol)) |p|
pos.marketValue(p, false)
else
pos.marketValue(pos.avg_cost, true);
}
total += self.nonStockValueForAccount(as_of, account_name);
return total;
}
/// Total cost basis of all open stock lots.
pub fn totalCostBasis(self: Portfolio, as_of: Date) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.isOpen(as_of) and lot.security_type == .stock) total += lot.costBasis();
}
return total;
}
/// Total realized P&L from all closed stock lots.
pub fn totalRealizedGainLoss(self: Portfolio) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type == .stock) {
if (lot.realizedGainLoss()) |pnl| total += pnl;
}
}
return total;
}
/// Total cash across all accounts (open lots only).
pub fn totalCash(self: Portfolio, as_of: Date) f64 {
return self.totalCashAsOf(as_of);
}
/// `totalCash` evaluated against an arbitrary date - used by
/// historical snapshot backfill. See `Lot.lotIsOpenAsOf`.
pub fn totalCashAsOf(self: Portfolio, as_of: Date) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type != .cash) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
total += lot.shares;
}
return total;
}
/// Total illiquid asset value across all accounts (open lots only).
pub fn totalIlliquid(self: Portfolio, as_of: Date) f64 {
return self.totalIlliquidAsOf(as_of);
}
/// `totalIlliquid` evaluated against an arbitrary date.
pub fn totalIlliquidAsOf(self: Portfolio, as_of: Date) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type != .illiquid) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
total += lot.shares;
}
return total;
}
/// Total CD face value across all accounts (open lots only -
/// matured CDs are excluded).
pub fn totalCdFaceValue(self: Portfolio, as_of: Date) f64 {
return self.totalCdFaceValueAsOf(as_of);
}
/// `totalCdFaceValue` evaluated against an arbitrary date.
pub fn totalCdFaceValueAsOf(self: Portfolio, as_of: Date) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type != .cd) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
total += lot.shares;
}
return total;
}
/// Total option cost basis (|shares| * open_price * multiplier) -
/// open lots only. Closed/matured options are excluded.
pub fn totalOptionCost(self: Portfolio, as_of: Date) f64 {
return self.totalOptionCostAsOf(as_of);
}
/// `totalOptionCost` evaluated against an arbitrary date.
pub fn totalOptionCostAsOf(self: Portfolio, as_of: Date) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type != .option) continue;
if (!lot.lotIsOpenAsOf(as_of)) continue;
// open_price is per-share option price; multiply by contract size
total += @abs(lot.shares) * lot.open_price * lot.multiplier;
}
return total;
}
/// Check if portfolio has any lots of a given type.
pub fn hasType(self: Portfolio, sec_type: LotType) bool {
for (self.lots) |lot| {
if (lot.security_type == sec_type) return true;
}
return false;
}
/// Get watchlist symbols (from watch lots in the portfolio).
pub fn watchSymbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 {
var result = std.ArrayList([]const u8).empty;
errdefer result.deinit(allocator);
for (self.lots) |lot| {
if (lot.security_type == .watch) {
try result.append(allocator, lot.symbol);
}
}
return result.toOwnedSlice(allocator);
}
/// Every symbol zfin fetches candles for: holdings, watch lots, the
/// separate `watchlist.srf`, and the benchmark pair.
///
/// This exists because "what do we keep fresh?" had **five** disjoint
/// answers, and things fell through the gaps between them. Holdings came
/// from `Portfolio.stockSymbols`, watch lots from a hand-rolled loop in
/// each caller, `watchlist.srf` from `cli.loadWatchlist` (TUI only - the
/// CLI loaded it for display and priced it from cache, so a
/// watchlist-only symbol went arbitrarily stale), and the benchmark pair
/// from two hardcoded `getCandles(sym, .{})` calls on a lazy path that
/// only ran when someone opened projections. Observed consequences: SPCX
/// sat 39 days out of date while sitting in `watchlist.srf`, and AGG was
/// unreachable by `--refresh-data=force` entirely.
///
/// One answer, shared by the CLI, the TUI and zfin-server, so a symbol
/// cannot be tracked by one and invisible to another.
///
/// **Every returned string is duplicated into `allocator`.** Unlike
/// `stockSymbols`, which borrows from the portfolio, the inputs here have
/// mixed and shorter lifetimes - notably a benchmark override lives in a
/// `[16]u8` field inside a stack `UserConfig`, so borrowing it would
/// dangle the moment that config went out of scope. (`UserConfig` is
/// itself copy-safe - it stores buffer + length, not a self-slice - but a
/// slice into one particular copy of it is only as long-lived as that
/// copy, which is exactly why this dupes.) Caller owns the result; free
/// the slices and the outer slice, or use an arena.
pub fn fetchedSymbols(
self: Portfolio,
allocator: std.mem.Allocator,
opts: struct {
/// Symbols from a separate `watchlist.srf`.
watchlist_syms: []const []const u8 = &.{},
/// Benchmark symbols (e.g. the projections stock/bond pair).
/// Passed as plain strings so this stays free of any dependency
/// on the projections config.
benchmarks: []const []const u8 = &.{},
},
) ![][]const u8 {
var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit();
var result = std.ArrayList([]const u8).empty;
errdefer {
for (result.items) |s| allocator.free(s);
result.deinit(allocator);
}
// Owns nothing until the dupe succeeds, so `seen` keys borrow from
// `result` and stay valid for the whole build.
const add = struct {
fn f(
a: std.mem.Allocator,
set: *std.StringHashMap(void),
list: *std.ArrayList([]const u8),
sym: []const u8,
) !void {
if (sym.len == 0) return;
if (set.contains(sym)) return;
const owned = try a.dupe(u8, sym);
// The errdefer is scoped to the append and no further, on purpose.
// Left armed across the `set.put` below it would double-free:
// `list` already owns `owned` by then, and the caller's errdefer
// frees everything in `list`. An allocation-failure test caught
// exactly that as a segfault.
{
errdefer a.free(owned);
try list.append(a, owned);
}
try set.put(owned, {});
}
}.f;
// Holdings. Skips options, CDs, cash, and manual-price-only lots -
// see `stockSymbols` for why each is excluded.
const held = try self.stockSymbols(allocator);
defer allocator.free(held);
for (held) |s| try add(allocator, &seen, &result, s);
// `security_type::watch` lots inside the portfolio file.
for (self.lots) |lot| {
if (lot.security_type != .watch) continue;
try add(allocator, &seen, &result, lot.priceSymbol());
}
for (opts.watchlist_syms) |s| try add(allocator, &seen, &result, s);
for (opts.benchmarks) |s| try add(allocator, &seen, &result, s);
return result.toOwnedSlice(allocator);
}
/// Symbols to price that are NOT stock positions: `security_type::watch` lots
/// in the portfolio file, plus every entry from a separate `watchlist.srf`,
/// excluding anything already in `held`.
///
/// Separate from `fetchedSymbols` because the price loader takes holdings and
/// extras as two slices - it derives progress totals from the two counts - so a
/// single flat union does not fit there.
///
/// It lives here rather than inline in the command for a testability reason
/// that bit once already: `commands/portfolio.zig`'s `run` needs a live
/// `RunCtx`, a `DataService` and the network, so its tests only ever exercise
/// `display`. Set logic embedded in `run` is untestable by construction, and
/// the version that was embedded there had a bug - it never included
/// `watchlist.srf` at all, leaving SPCX 39 days stale.
///
/// Returned slices BORROW from `portfolio` and `watchlist_syms`; only the outer
/// slice is owned by the caller.
pub fn extraPriceSymbols(
self: Portfolio,
allocator: std.mem.Allocator,
held: []const []const u8,
watchlist_syms: []const []const u8,
) ![][]const u8 {
var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit();
for (held) |s| try seen.put(s, {});
var out = std.ArrayList([]const u8).empty;
errdefer out.deinit(allocator);
for (self.lots) |lot| {
if (lot.security_type != .watch) continue;
const sym = lot.priceSymbol();
if (sym.len == 0 or seen.contains(sym)) continue;
try seen.put(sym, {});
try out.append(allocator, sym);
}
for (watchlist_syms) |sym| {
if (sym.len == 0 or seen.contains(sym)) continue;
try seen.put(sym, {});
try out.append(allocator, sym);
}
return out.toOwnedSlice(allocator);
}
/// Free a `fetchedSymbols` result.
pub fn freeFetchedSymbols(allocator: std.mem.Allocator, syms: [][]const u8) void {
for (syms) |s| allocator.free(s);
allocator.free(syms);
}
};
/// Check if a string looks like a CUSIP (9 alphanumeric characters).
/// CUSIPs have 6 alphanumeric issuer chars + 2 issue chars + 1 check digit.
/// This is a heuristic -- it won't catch all CUSIPs and may have false positives.
pub fn isCusipLike(s: []const u8) bool {
if (s.len != 9) return false;
// Must contain at least one digit (all-alpha would be a ticker)
var has_digit = false;
for (s) |c| {
if (!std.ascii.isAlphanumeric(c)) return false;
if (std.ascii.isDigit(c)) has_digit = true;
}
return has_digit;
}
test "lot basics" {
const lot = Lot{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 1, 15),
.open_price = 150.0,
};
try std.testing.expect(lot.isOpen(Date.fromYmd(2026, 5, 8)));
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), lot.costBasis(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 2000.0), lot.marketValue(200.0, true), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.unrealizedGainLoss(200.0), 0.01);
try std.testing.expect(lot.realizedGainLoss() == null);
}
test "closed lot" {
const lot = Lot{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 1, 15),
.open_price = 150.0,
.close_date = Date.fromYmd(2024, 6, 15),
.close_price = 200.0,
};
try std.testing.expect(!lot.isOpen(Date.fromYmd(2026, 5, 8)));
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLoss().?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0), 0.001);
}
test "portfolio positions" {
const allocator = std.testing.allocator;
var lots = [_]Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 },
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 3, 1), .open_price = 160.0 },
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 220.0 },
.{ .symbol = "AAPL", .shares = 3, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 130.0, .close_date = Date.fromYmd(2024, 2, 1), .close_price = 155.0 },
};
var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
// Don't call deinit since these are stack-allocated test strings
const pos = try portfolio.positions(Date.fromYmd(2026, 5, 8), allocator);
defer allocator.free(pos);
try std.testing.expectEqual(@as(usize, 2), pos.len);
// Find AAPL position
var aapl: ?Position = null;
for (pos) |p| {
if (std.mem.eql(u8, p.symbol, "AAPL")) aapl = p;
}
try std.testing.expect(aapl != null);
try std.testing.expectApproxEqAbs(@as(f64, 15.0), aapl.?.shares, 0.01);
try std.testing.expectEqual(@as(u32, 2), aapl.?.open_lots);
try std.testing.expectEqual(@as(u32, 1), aapl.?.closed_lots);
try std.testing.expectApproxEqAbs(@as(f64, 75.0), aapl.?.realized_gain_loss, 0.01); // 3 * (155-130)
}
test "LotType label" {
try std.testing.expectEqualStrings("Stock", LotType.stock.label());
try std.testing.expectEqualStrings("Option", LotType.option.label());
try std.testing.expectEqualStrings("CD", LotType.cd.label());
try std.testing.expectEqualStrings("Cash", LotType.cash.label());
try std.testing.expectEqualStrings("Illiquid", LotType.illiquid.label());
try std.testing.expectEqualStrings("Watch", LotType.watch.label());
}
test "Lot.priceSymbol" {
const with_ticker = Lot{ .symbol = "9128283H2", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .ticker = "VTTHX" };
try std.testing.expectEqualStrings("VTTHX", with_ticker.priceSymbol());
const without_ticker = Lot{ .symbol = "AAPL", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 };
try std.testing.expectEqualStrings("AAPL", without_ticker.priceSymbol());
}
test "Lot.displaySymbol: label orelse priceSymbol" {
const base = Lot{ .symbol = "02315N600", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 };
// No label, no ticker: falls back to the raw symbol (the CUSIP).
try std.testing.expectEqualStrings("02315N600", base.displaySymbol());
// No label, ticker set: falls back to priceSymbol (the ticker).
var aliased = base;
aliased.ticker = "VTTHX";
try std.testing.expectEqualStrings("VTTHX", aliased.displaySymbol());
// Explicit label wins over both symbol and ticker.
var labeled = aliased;
labeled.label = "TGT2035";
try std.testing.expectEqualStrings("TGT2035", labeled.displaySymbol());
}
test "Position.displaySymbol: label orelse symbol" {
// Position.symbol is already priceSymbol(), so symbol is the fallback.
const no_label = Position{ .symbol = "VTTHX", .shares = 1, .avg_cost = 0, .total_cost = 0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 };
try std.testing.expectEqualStrings("VTTHX", no_label.displaySymbol());
const labeled = Position{ .symbol = "VTTHX", .shares = 1, .avg_cost = 0, .total_cost = 0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0, .label = "TGT2035" };
try std.testing.expectEqualStrings("TGT2035", labeled.displaySymbol());
}
test "Lot.returnPct" {
// Open lot: uses current_price param
const open_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 };
try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150), 0.001);
// Closed lot: uses close_price, ignores current_price
const closed_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 120 };
try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999), 0.001);
// Zero open_price: returns 0
const zero_lot = Lot{ .symbol = "X", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0 };
try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100), 0.001);
}
test "Portfolio totals" {
var lots = [_]Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .security_type = .stock },
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140, .security_type = .stock, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 160 },
.{ .symbol = "Savings", .shares = 50000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cash },
.{ .symbol = "CD-1Y", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cd },
.{ .symbol = "House", .shares = 500000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 0, .security_type = .illiquid },
.{ .symbol = "SPY_CALL", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.50, .security_type = .option },
.{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch },
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
// totalCostBasis: only open stock lots -> 10 * 150 = 1500
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), portfolio.totalCostBasis(Date.fromYmd(2026, 5, 8)), 0.01);
// totalRealizedGainLoss: closed stock lots -> 5 * (160-140) = 100
try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(), 0.01);
// totalCash
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCash(Date.fromYmd(2026, 5, 8)), 0.01);
// totalIlliquid
try std.testing.expectApproxEqAbs(@as(f64, 500000.0), portfolio.totalIlliquid(Date.fromYmd(2026, 5, 8)), 0.01);
// totalCdFaceValue
try std.testing.expectApproxEqAbs(@as(f64, 10000.0), portfolio.totalCdFaceValue(Date.fromYmd(2026, 5, 8)), 0.01);
// totalOptionCost: |2| * 5.50 * 100 = 1100
try std.testing.expectApproxEqAbs(@as(f64, 1100.0), portfolio.totalOptionCost(Date.fromYmd(2026, 5, 8)), 0.01);
// hasType
try std.testing.expect(portfolio.hasType(.stock));
try std.testing.expect(portfolio.hasType(.cash));
try std.testing.expect(portfolio.hasType(.cd));
try std.testing.expect(portfolio.hasType(.illiquid));
try std.testing.expect(portfolio.hasType(.option));
try std.testing.expect(portfolio.hasType(.watch));
}
// ── Portfolio totals: open-lot filtering ──────────────────────
//
// The four non-stock totals (cash, cd, illiquid, option) now filter
// by `lotIsOpenAsOf` rather than counting every lot of the given type.
// Motivating scenario: user leaves a matured CD in portfolio.srf with
// `maturity_date` set (for historical context). Pre-fix, totalCdFaceValue
// would include it and over-report cash-equivalents. Post-fix, the
// matured CD is correctly excluded from "right now" totals.
test "Portfolio.totalOptionCost: excludes closed options" {
var lots = [_]Lot{
.{
.symbol = "CALL_OPEN",
.shares = -5,
.open_date = Date.fromYmd(2026, 3, 1),
.open_price = 2.00,
.security_type = .option,
.maturity_date = Date.fromYmd(2099, 1, 1),
},
.{
.symbol = "CALL_CLOSED",
.shares = -3,
.open_date = Date.fromYmd(2026, 3, 1),
.open_price = 4.00,
.security_type = .option,
.close_date = Date.fromYmd(2026, 3, 15),
.close_price = 0.01,
.maturity_date = Date.fromYmd(2099, 1, 1),
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
// Only CALL_OPEN contributes: |-5| * 2.00 * 100 = 1000.
// Pre-fix would have been 1000 + |-3| * 4.00 * 100 = 2200.
try std.testing.expectApproxEqAbs(@as(f64, 1000.0), portfolio.totalOptionCost(Date.fromYmd(2026, 5, 8)), 0.01);
}
test "Portfolio.totalOptionCost: excludes matured options" {
var lots = [_]Lot{
.{
.symbol = "CALL_OPEN",
.shares = -5,
.open_date = Date.fromYmd(2026, 3, 1),
.open_price = 2.00,
.security_type = .option,
.maturity_date = Date.fromYmd(2099, 1, 1),
},
.{
.symbol = "CALL_MATURED",
.shares = -3,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 4.00,
.security_type = .option,
.maturity_date = Date.fromYmd(2024, 6, 1), // long expired
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
try std.testing.expectApproxEqAbs(@as(f64, 1000.0), portfolio.totalOptionCost(Date.fromYmd(2026, 5, 8)), 0.01);
}
test "Portfolio.totalCdFaceValue: excludes matured CDs" {
var lots = [_]Lot{
.{
.symbol = "CD_ACTIVE",
.shares = 50000,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 1.00,
.security_type = .cd,
.maturity_date = Date.fromYmd(2099, 1, 1),
},
.{
.symbol = "CD_MATURED",
.shares = 75000,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 1.00,
.security_type = .cd,
.maturity_date = Date.fromYmd(2025, 12, 31),
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
// Pre-fix would have been 50000 + 75000 = 125000.
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCdFaceValue(Date.fromYmd(2026, 5, 8)), 0.01);
}
test "Portfolio.totalCash: excludes closed cash lots" {
var lots = [_]Lot{
.{
.symbol = "ACTIVE_CASH",
.shares = 10000,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 1.00,
.security_type = .cash,
},
.{
.symbol = "MOVED_CASH",
.shares = 25000,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 1.00,
.security_type = .cash,
.close_date = Date.fromYmd(2026, 1, 15), // cash was swept out
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
try std.testing.expectApproxEqAbs(@as(f64, 10000.0), portfolio.totalCash(Date.fromYmd(2026, 5, 8)), 0.01);
}
test "Portfolio.totalIlliquidAsOf: respects as_of for backfill" {
// Illiquid lots rarely "close," but a property sale would set
// close_date. Backfill to before the sale should include it;
// backfill to after should not.
var lots = [_]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), // sold
},
.{
.symbol = "Other",
.shares = 200000,
.open_date = Date.fromYmd(2022, 1, 1),
.open_price = 0,
.security_type = .illiquid,
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
// Before the sale: both count.
try std.testing.expectApproxEqAbs(
@as(f64, 1_000_000.0),
portfolio.totalIlliquidAsOf(Date.fromYmd(2026, 1, 1)),
0.01,
);
// After the sale: only Other counts.
try std.testing.expectApproxEqAbs(
@as(f64, 200_000.0),
portfolio.totalIlliquidAsOf(Date.fromYmd(2026, 4, 1)),
0.01,
);
}
test "Portfolio totals: AsOf excludes not-yet-opened lots" {
// Backfill to a date before a lot's open_date should exclude it.
var lots = [_]Lot{
.{
.symbol = "EarlyCash",
.shares = 1000,
.open_date = Date.fromYmd(2026, 1, 1),
.open_price = 1.00,
.security_type = .cash,
},
.{
.symbol = "LateCash",
.shares = 5000,
.open_date = Date.fromYmd(2026, 4, 1),
.open_price = 1.00,
.security_type = .cash,
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
// 2026-02-15 is after EarlyCash's open but before LateCash's.
try std.testing.expectApproxEqAbs(
@as(f64, 1000.0),
portfolio.totalCashAsOf(Date.fromYmd(2026, 2, 15)),
0.01,
);
// 2026-04-15 is after both.
try std.testing.expectApproxEqAbs(
@as(f64, 6000.0),
portfolio.totalCashAsOf(Date.fromYmd(2026, 4, 15)),
0.01,
);
}
test "Portfolio watchSymbols" {
const allocator = std.testing.allocator;
var lots = [_]Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 },
.{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch },
.{ .symbol = "NVDA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch },
};
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
const watch = try portfolio.watchSymbols(allocator);
defer allocator.free(watch);
try std.testing.expectEqual(@as(usize, 2), watch.len);
}
test "positions propagates price_ratio from lot" {
const allocator = std.testing.allocator;
var lots = [_]Lot{
// Two institutional lots for the same CUSIP, both with ticker alias and price_ratio
.{ .symbol = "02315N600", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .ticker = "VTTHX", .price_ratio = 5.185 },
.{ .symbol = "02315N600", .shares = 50, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 142.0, .ticker = "VTTHX", .price_ratio = 5.185 },
// Regular stock lot - no price_ratio
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 },
};
var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
const pos = try portfolio.positions(Date.fromYmd(2026, 5, 8), allocator);
defer allocator.free(pos);
try std.testing.expectEqual(@as(usize, 2), pos.len);
for (pos) |p| {
if (std.mem.eql(u8, p.symbol, "VTTHX")) {
try std.testing.expectApproxEqAbs(@as(f64, 150.0), p.shares, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 5.185), p.price_ratio, 0.001);
} else {
try std.testing.expectEqualStrings("AAPL", p.symbol);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), p.price_ratio, 0.001);
}
}
}
test "positions separates lots with different price_ratio" {
const allocator = std.testing.allocator;
var lots = [_]Lot{
// Direct SPY holding, price_ratio = 1.0 (default)
.{ .symbol = "SPY", .shares = 100.0, .open_date = Date.fromYmd(2025, 2, 25), .open_price = 400.00, .account = "Sample Account" },
// Institutional S&P 500 CIT, uses SPY as ticker with a ratio
.{ .symbol = "NON40OR52", .shares = 5000.0, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 90.00, .ticker = "SPY", .price_ratio = 0.25, .account = "Fidelity Riley 401(k)" },
};
var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
const pos = try portfolio.positions(Date.fromYmd(2026, 5, 8), allocator);
defer allocator.free(pos);
// Should produce 2 separate positions, not 1 merged position
try std.testing.expectEqual(@as(usize, 2), pos.len);
var found_direct = false;
var found_institutional = false;
for (pos) |p| {
if (p.price_ratio == 1.0) {
found_direct = true;
try std.testing.expectApproxEqAbs(@as(f64, 100.0), p.shares, 0.01);
try std.testing.expectEqualStrings("SPY", p.lot_symbol);
} else {
found_institutional = true;
try std.testing.expectApproxEqAbs(@as(f64, 5000.0), p.shares, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.25), p.price_ratio, 0.0001);
try std.testing.expectEqualStrings("NON40OR52", p.lot_symbol);
}
}
try std.testing.expect(found_direct);
try std.testing.expect(found_institutional);
}
test "positionsForAccount excludes closed-only symbols" {
const allocator = std.testing.allocator;
var lots = [_]Lot{
// Open lot in account A
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
// Closed lot in account A (was sold)
.{ .symbol = "XLV", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 150.0, .account = "Acct A" },
// Open lot for same symbol in a different account
.{ .symbol = "XLV", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .account = "Acct B" },
};
var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
// Account A: should only see AAPL (XLV is fully closed there)
const pos_a = try portfolio.positionsForAccount(Date.fromYmd(2026, 5, 8), allocator, "Acct A");
defer allocator.free(pos_a);
try std.testing.expectEqual(@as(usize, 1), pos_a.len);
try std.testing.expectEqualStrings("AAPL", pos_a[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 10.0), pos_a[0].shares, 0.01);
// Account B: should see XLV with 50 shares
const pos_b = try portfolio.positionsForAccount(Date.fromYmd(2026, 5, 8), allocator, "Acct B");
defer allocator.free(pos_b);
try std.testing.expectEqual(@as(usize, 1), pos_b.len);
try std.testing.expectEqualStrings("XLV", pos_b[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 50.0), pos_b[0].shares, 0.01);
}
test "isOpen respects maturity_date" {
const past = Date.fromYmd(2024, 1, 1);
const future = Date.fromYmd(2099, 12, 31);
const expired_option = Lot{
.symbol = "AAPL 01/01/2024 150 C",
.shares = -1,
.open_date = Date.fromYmd(2023, 6, 1),
.open_price = 5.0,
.security_type = .option,
.maturity_date = past,
};
try std.testing.expect(!expired_option.isOpen(Date.fromYmd(2026, 5, 8)));
const active_option = Lot{
.symbol = "AAPL 12/31/2099 150 C",
.shares = -1,
.open_date = Date.fromYmd(2023, 6, 1),
.open_price = 5.0,
.security_type = .option,
.maturity_date = future,
};
try std.testing.expect(active_option.isOpen(Date.fromYmd(2026, 5, 8)));
const closed_option = Lot{
.symbol = "AAPL 12/31/2099 150 C",
.shares = -1,
.open_date = Date.fromYmd(2023, 6, 1),
.open_price = 5.0,
.security_type = .option,
.maturity_date = future,
.close_date = Date.fromYmd(2024, 6, 1),
};
try std.testing.expect(!closed_option.isOpen(Date.fromYmd(2026, 5, 8)));
const stock = Lot{
.symbol = "AAPL",
.shares = 100,
.open_date = Date.fromYmd(2023, 1, 1),
.open_price = 150.0,
};
try std.testing.expect(stock.isOpen(Date.fromYmd(2026, 5, 8)));
}
// ── lotIsOpenAsOf ────────────────────────────────────────────
//
// `isOpen()` asks "is this lot held right now (wall-clock today)?"
// `lotIsOpenAsOf(as_of)` asks "was this lot held at end-of-day on
// `as_of`?" - needed for historical snapshot backfill where wall-clock
// `today` is not the relevant reference date.
//
// Rules (end-of-day semantics):
// - open_date > as_of -> not yet bought -> CLOSED
// - close_date set and <= as_of -> sold on/before -> CLOSED
// - maturity_date set and <= as_of -> matured on/before -> CLOSED
// - otherwise -> open
//
// "Closed on D excluded from D snapshot" is deliberate (end-of-day
// semantics: a lot sold on D is not held at day-end). Symmetric: "opened
// on D included in D snapshot" - you bought it that day, you hold it at
// day-end.
test "lotIsOpenAsOf: open_date after as_of excludes" {
const lot = Lot{
.symbol = "X",
.shares = 10,
.open_date = Date.fromYmd(2026, 4, 9),
.open_price = 100.0,
};
try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6)));
try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 9))); // opened that day
try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 10)));
}
test "lotIsOpenAsOf: close_date on or before as_of excludes" {
const lot = Lot{
.symbol = "X",
.shares = 10,
.open_date = Date.fromYmd(2026, 1, 1),
.open_price = 100.0,
.close_date = Date.fromYmd(2026, 4, 6),
.close_price = 110.0,
};
try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 5))); // still open
try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6))); // sold that day
try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2026, 4, 7)));
}
test "lotIsOpenAsOf: maturity relative to as_of, not wall clock" {
// Option opened 03-16, matured 04-17. Asking about 04-06 should
// return true - open, maturity hasn't happened yet on 04-06.
// This was the real bug: isOpen() used wall-clock today, so
// backfilling any date before today but after maturity wrongly
// excluded the lot.
const opt = Lot{
.symbol = "NVDA 04/17/2026 200 C",
.shares = -5,
.open_date = Date.fromYmd(2026, 3, 16),
.open_price = 2.79,
.security_type = .option,
.maturity_date = Date.fromYmd(2026, 4, 17),
};
try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6)));
try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 16)));
try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 17))); // matured that day
try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 18)));
}
test "lotIsOpenAsOf: close wins over maturity (closed early)" {
// Option opened 03-16, closed early 04-09, nominal maturity 04-17.
// On 04-06 (before both): open.
// On 04-09 (closed that day): not open.
// On 04-15 (between close and maturity): not open (already closed).
const opt = Lot{
.symbol = "NVDA 04/17/2026 200 C",
.shares = -5,
.open_date = Date.fromYmd(2026, 3, 16),
.open_price = 2.79,
.security_type = .option,
.close_date = Date.fromYmd(2026, 4, 9),
.close_price = 0.09,
.maturity_date = Date.fromYmd(2026, 4, 17),
};
try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 6)));
try std.testing.expect(opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 8)));
try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 9)));
try std.testing.expect(!opt.lotIsOpenAsOf(Date.fromYmd(2026, 4, 15)));
}
test "lotIsOpenAsOf: plain stock with no close, no maturity" {
const lot = Lot{
.symbol = "AAPL",
.shares = 100,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 150.0,
};
try std.testing.expect(!lot.lotIsOpenAsOf(Date.fromYmd(2023, 12, 31)));
try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2024, 1, 1)));
try std.testing.expect(lot.lotIsOpenAsOf(Date.fromYmd(2100, 1, 1)));
}
test "lotIsOpenAsOf: isOpen() stays compatible via today" {
// Regression guard: isOpen() should still behave as before -
// equivalent to lotIsOpenAsOf(today). Test with a lot whose
// status doesn't depend on date to keep this deterministic.
const stock = Lot{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 1, 15),
.open_price = 150.0,
};
try std.testing.expectEqual(stock.isOpen(Date.fromYmd(2026, 5, 8)), stock.lotIsOpenAsOf(Date.fromYmd(2026, 5, 8)));
const closed = Lot{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 1, 15),
.open_price = 150.0,
.close_date = Date.fromYmd(2024, 6, 15),
.close_price = 200.0,
};
try std.testing.expectEqual(closed.isOpen(Date.fromYmd(2026, 5, 8)), closed.lotIsOpenAsOf(Date.fromYmd(2026, 5, 8)));
}
test "nonStockValueForAccount" {
const allocator = std.testing.allocator;
const future = Date.fromYmd(2099, 12, 31);
const past = Date.fromYmd(2024, 1, 1);
var lots = [_]Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "IRA" },
.{ .symbol = "", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
.{ .symbol = "CD123", .shares = 50000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = future },
.{ .symbol = "AAPL 12/31/2099 200 C", .shares = -2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 3.50, .security_type = .option, .account = "IRA", .maturity_date = future, .multiplier = 100 },
.{ .symbol = "AAPL 01/01/2024 180 C", .shares = -1, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 4.0, .security_type = .option, .account = "IRA", .maturity_date = past, .multiplier = 100 },
.{ .symbol = "", .shares = 1000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Other" },
};
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
// cash(5000) + cd(50000) + open option(2*3.50*100=700) = 55700
// expired option excluded
const ns = portfolio.nonStockValueForAccount(Date.fromYmd(2026, 5, 8), "IRA");
try std.testing.expectApproxEqAbs(@as(f64, 55700.0), ns, 0.01);
const ns_other = portfolio.nonStockValueForAccount(Date.fromYmd(2026, 5, 8), "Other");
try std.testing.expectApproxEqAbs(@as(f64, 1000.0), ns_other, 0.01);
}
test "hasOpenLotsForAccount: open stock, cash; closed and watch excluded" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 5, 8);
var lots = [_]Lot{
// Open stock in Sample IRA.
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Sample IRA" },
// Sample Brokerage holds only a closed stock lot.
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 350.0, .account = "Sample Brokerage" },
// Sample Roth holds only an open cash lot.
.{ .symbol = "", .shares = 2000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Roth" },
// Sample HSA holds only a watchlist entry (not a real holding).
.{ .symbol = "NVDA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch, .account = "Sample HSA" },
};
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
try std.testing.expect(portfolio.hasOpenLotsForAccount(as_of, "Sample IRA"));
try std.testing.expect(portfolio.hasOpenLotsForAccount(as_of, "Sample Roth"));
// Closed-only account -> no open lots.
try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample Brokerage"));
// Watchlist-only account -> not held.
try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample HSA"));
// Account with no lots at all.
try std.testing.expect(!portfolio.hasOpenLotsForAccount(as_of, "Sample Trust"));
// Before the open date the stock isn't held yet.
try std.testing.expect(!portfolio.hasOpenLotsForAccount(Date.fromYmd(2023, 1, 1), "Sample IRA"));
}
test "totalForAccount" {
const allocator = std.testing.allocator;
const future = Date.fromYmd(2099, 12, 31);
var lots = [_]Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "IRA" },
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "IRA" },
.{ .symbol = "", .shares = 2000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
.{ .symbol = "CD456", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = future },
.{ .symbol = "AAPL C", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .option, .account = "IRA", .maturity_date = future, .multiplier = 100 },
};
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 170.0);
// MSFT not in prices - should fall back to avg_cost (300.0)
// stocks: AAPL(100*170=17000) + MSFT(50*300=15000) = 32000
// non-stock: cash(2000) + cd(10000) + option(1*5*100=500) = 12500
// total = 44500
const total = portfolio.totalForAccount(Date.fromYmd(2026, 5, 8), allocator, "IRA", prices);
try std.testing.expectApproxEqAbs(@as(f64, 44500.0), total, 0.01);
}
test "totalForAccount: institutional lot missing from prices map uses preadjusted avg_cost" {
// Regression test for the price_ratio double-application bug in
// Portfolio.totalForAccount. When a position misses the prices
// map, the avg_cost fallback is in the LOT's share-class terms
// (preadjusted) - multiplying by price_ratio would inflate the
// value by the ratio. See the "Pricing model" doc-block above.
const allocator = std.testing.allocator;
var lots = [_]Lot{
.{
.symbol = "02315N402",
.ticker = "VTTVX",
.shares = 100,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 140.92,
.price_ratio = 6.6139,
.account = "Sample 401(k)",
},
};
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// Empty prices map - avg_cost (= 140.92, institutional) fallback fires.
// Correct: 100 × 140.92 = 14,092 (institutional value).
// Buggy: 100 × 140.92 × 6.6139 ≈ 93,213.
const total = portfolio.totalForAccount(Date.fromYmd(2026, 5, 8), allocator, "Sample 401(k)", prices);
try std.testing.expectApproxEqAbs(@as(f64, 14092.0), total, 0.5);
}
// ── Money-market predicate tests ─────────────────────────────
test "isMoneyMarketSymbol: known Schwab and Fidelity tickers" {
try std.testing.expect(isMoneyMarketSymbol("SWVXX"));
try std.testing.expect(isMoneyMarketSymbol("VMFXX"));
try std.testing.expect(isMoneyMarketSymbol("SPAXX"));
try std.testing.expect(isMoneyMarketSymbol("FDRXX"));
// Case-insensitive
try std.testing.expect(isMoneyMarketSymbol("swvxx"));
try std.testing.expect(isMoneyMarketSymbol("Swvxx"));
}
test "isMoneyMarketSymbol: non-MM tickers reject" {
try std.testing.expect(!isMoneyMarketSymbol("AAPL"));
try std.testing.expect(!isMoneyMarketSymbol("VTI"));
try std.testing.expect(!isMoneyMarketSymbol("VSTCX")); // mutual fund, not MM
try std.testing.expect(!isMoneyMarketSymbol(""));
// Very long strings don't fit the buffer - safely rejected.
try std.testing.expect(!isMoneyMarketSymbol("THIS_IS_NOT_A_TICKER_AT_ALL"));
}
test "stableNavCandle: fills all fields at $1" {
const c = stableNavCandle(Date.fromYmd(2026, 4, 1));
try std.testing.expectEqual(@as(f64, 1), c.close);
try std.testing.expectEqual(@as(f64, 1), c.open);
try std.testing.expectEqual(@as(f64, 1), c.high);
try std.testing.expectEqual(@as(f64, 1), c.low);
try std.testing.expectEqual(@as(f64, 1), c.adj_close);
try std.testing.expectEqual(@as(u64, 0), c.volume);
}
// ── Split-adjustment (effectiveShares / enrichSplits) tests ──
test "effectiveShares/effectiveOpenPrice default to raw at factor 1.0" {
const lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 180.0 };
try std.testing.expectApproxEqAbs(@as(f64, 10), lot.effectiveShares(), 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, 180.0), lot.effectiveOpenPrice(), 0.0001);
// Cost basis is invariant under the split factor.
try std.testing.expectApproxEqAbs(lot.costBasis(), lot.effectiveShares() * lot.effectiveOpenPrice(), 0.0001);
}
test "enrichSplits: per-symbol opt-in gate, forward split, post-split lot, legacy already-restated" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 1, 1);
var corpus = std.StringHashMap([]const Split).init(allocator);
defer corpus.deinit();
const nvda_splits = [_]Split{.{ .date = Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }};
const amzn_splits = [_]Split{.{ .date = Date.fromYmd(2022, 6, 6), .numerator = 20, .denominator = 1 }};
const tsla_splits = [_]Split{.{ .date = Date.fromYmd(2024, 8, 1), .numerator = 3, .denominator = 1 }};
try corpus.put("NVDA", &nvda_splits);
try corpus.put("AMZN", &amzn_splits);
try corpus.put("TSLA", &tsla_splits);
// Per-symbol opt-in: NVDA and AMZN carry a cutover; TSLA does NOT.
var cutovers = std.StringHashMap(Date).init(allocator);
defer cutovers.deinit();
try cutovers.put("NVDA", Date.fromYmd(2024, 1, 1));
try cutovers.put("AMZN", Date.fromYmd(2024, 1, 1));
var lots = [_]Lot{
// NVDA held across the post-cutover split -> factor 10.
.{ .symbol = "NVDA", .shares = 100, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 40.0 },
// NVDA opened AFTER the split -> already post-split -> factor 1.
.{ .symbol = "NVDA", .shares = 50, .open_date = Date.fromYmd(2024, 8, 1), .open_price = 110.0 },
// AMZN split predates its cutover (already restated) -> factor 1.
.{ .symbol = "AMZN", .shares = 30, .open_date = Date.fromYmd(2019, 3, 1), .open_price = 90.0 },
// TSLA has a real post-purchase split but is NOT opted in -> factor 1.
.{ .symbol = "TSLA", .shares = 20, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 30.0 },
// Non-stock never splits.
.{ .symbol = "CASH", .shares = 5000, .open_date = Date.fromYmd(2019, 1, 1), .open_price = 1.0, .security_type = .cash },
};
enrichSplits(&lots, &corpus, &cutovers, as_of);
try std.testing.expectApproxEqAbs(@as(f64, 1000), lots[0].effectiveShares(), 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, 4.0), lots[0].effectiveOpenPrice(), 0.0001); // 40 / 10
try std.testing.expectApproxEqAbs(lots[0].costBasis(), lots[0].effectiveShares() * lots[0].effectiveOpenPrice(), 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, 50), lots[1].effectiveShares(), 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, 30), lots[2].effectiveShares(), 0.0001);
// TSLA not opted in -> untouched despite a real post-purchase split.
try std.testing.expectApproxEqAbs(@as(f64, 20), lots[3].effectiveShares(), 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, 5000), lots[4].effectiveShares(), 0.0001);
// Empty cutovers map -> every factor stays 1.0 (today's behavior).
var lots2 = [_]Lot{
.{ .symbol = "NVDA", .shares = 100, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 40.0 },
};
var empty = std.StringHashMap(Date).init(allocator);
defer empty.deinit();
enrichSplits(&lots2, &corpus, &empty, as_of);
try std.testing.expectApproxEqAbs(@as(f64, 100), lots2[0].effectiveShares(), 0.0001);
}
test "positionsAsOf reflects split_factor: effective shares, invariant basis, effective market value" {
const allocator = std.testing.allocator;
var lots = [_]Lot{
.{ .symbol = "NVDA", .shares = 100, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 40.0, .account = "Sample Brokerage" },
};
// Simulate enrichment applying a 10:1 split.
lots[0].split_factor = 10.0;
const pf = Portfolio{ .lots = &lots, .allocator = allocator };
const positions = try pf.positionsAsOf(allocator, Date.fromYmd(2026, 1, 1));
defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), positions.len);
// Effective shares: 100 * 10 = 1000.
try std.testing.expectApproxEqAbs(@as(f64, 1000), positions[0].shares, 0.001);
// Cost basis stays raw/invariant: 100 * 40 = 4000.
try std.testing.expectApproxEqAbs(@as(f64, 4000), positions[0].total_cost, 0.001);
// avg_cost = total_cost / effective_shares = 4.0 (effective per-share).
try std.testing.expectApproxEqAbs(@as(f64, 4.0), positions[0].avg_cost, 0.001);
// Market value at the post-split price $120: 1000 * 120 = 120,000
// (the whole point - raw 100 * 120 would undercount 10x).
try std.testing.expectApproxEqAbs(@as(f64, 120000), positions[0].marketValue(120.0, false), 0.01);
}
// ── fetchedSymbols ───────────────────────────────────────────
/// Build a Portfolio from lots for the union tests. Lots borrow from the
/// caller; `fetchedSymbols` dupes everything it keeps, so that is safe.
fn testPortfolio(lots: []Lot) Portfolio {
return .{ .lots = lots, .allocator = std.testing.allocator };
}
test "fetchedSymbols: unions all four sources and dedups across them" {
const a = std.testing.allocator;
var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 200, .security_type = .stock },
// A ticker alias: the price symbol is what gets fetched, which is
// why SPY stayed fresh while AGG did not.
.{ .symbol = "NON40OR52", .ticker = "SPY", .shares = 5, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 90, .security_type = .stock },
.{ .symbol = "QTUM", .shares = 0, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 0, .security_type = .watch },
// Excluded by stockSymbols: manual price, no ticker alias.
.{ .symbol = "ORCBI", .shares = 3, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 10, .price = 11, .security_type = .stock },
// Excluded: not a stock or watch lot.
.{ .symbol = "CASHX", .shares = 1, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 1, .security_type = .cash },
};
const wl = [_][]const u8{ "SPCX", "AMZN" }; // AMZN duplicates a holding
const bm = [_][]const u8{ "SPY", "AGG" }; // SPY duplicates the alias above
const syms = try testPortfolio(&lots).fetchedSymbols(a, .{
.watchlist_syms = &wl,
.benchmarks = &bm,
});
defer Portfolio.freeFetchedSymbols(a, syms);
// AMZN, SPY, QTUM, SPCX, AGG - five distinct, no duplicates.
try std.testing.expectEqual(@as(usize, 5), syms.len);
for ([_][]const u8{ "AMZN", "SPY", "QTUM", "SPCX", "AGG" }) |want| {
var found = false;
for (syms) |s| if (std.mem.eql(u8, s, want)) {
found = true;
};
try std.testing.expect(found);
}
// Manual-price-only and cash lots stay out.
for (syms) |s| {
try std.testing.expect(!std.mem.eql(u8, s, "ORCBI"));
try std.testing.expect(!std.mem.eql(u8, s, "CASHX"));
}
}
test "fetchedSymbols: a watchlist-only symbol is included" {
// THE SPCX REGRESSION. It sat in watchlist.srf 39 days out of date
// because no CLI path ever put it in the fetch set - the CLI loaded
// the file for display and priced it from cache.
const a = std.testing.allocator;
var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 200, .security_type = .stock },
};
const wl = [_][]const u8{"SPCX"};
const syms = try testPortfolio(&lots).fetchedSymbols(a, .{ .watchlist_syms = &wl });
defer Portfolio.freeFetchedSymbols(a, syms);
var found = false;
for (syms) |s| if (std.mem.eql(u8, s, "SPCX")) {
found = true;
};
try std.testing.expect(found);
}
test "fetchedSymbols: a benchmark symbol held nowhere is still included" {
// THE AGG REGRESSION. AGG is not held and not watched - it is the bond
// half of the benchmark comparison, fetched only from a lazy
// projections path with hardcoded default FetchOptions, so
// `--refresh-data=force` could never reach it.
const a = std.testing.allocator;
var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 200, .security_type = .stock },
};
const bm = [_][]const u8{ "SPY", "AGG" };
const syms = try testPortfolio(&lots).fetchedSymbols(a, .{ .benchmarks = &bm });
defer Portfolio.freeFetchedSymbols(a, syms);
try std.testing.expectEqual(@as(usize, 3), syms.len);
var found_agg = false;
for (syms) |s| if (std.mem.eql(u8, s, "AGG")) {
found_agg = true;
};
try std.testing.expect(found_agg);
}
test "fetchedSymbols: empty and blank inputs produce no entries" {
const a = std.testing.allocator;
var lots = [_]Lot{};
const wl = [_][]const u8{""}; // blank line in watchlist.srf
const syms = try testPortfolio(&lots).fetchedSymbols(a, .{ .watchlist_syms = &wl });
defer Portfolio.freeFetchedSymbols(a, syms);
try std.testing.expectEqual(@as(usize, 0), syms.len);
}
test "fetchedSymbols: result outlives a stack-allocated benchmark override" {
// A projections override lives in a [16]u8 INSIDE the UserConfig
// struct, so borrowing it would dangle as soon as that config went out
// of scope. This is why the union dupes rather than borrows.
const a = std.testing.allocator;
var lots = [_]Lot{};
var syms: [][]const u8 = undefined;
{
var buf: [16]u8 = undefined;
@memcpy(buf[0..4], "VBIL");
const bm = [_][]const u8{buf[0..4]};
syms = try testPortfolio(&lots).fetchedSymbols(a, .{ .benchmarks = &bm });
@memset(&buf, 0xAA); // scribble over the source
}
defer Portfolio.freeFetchedSymbols(a, syms);
try std.testing.expectEqual(@as(usize, 1), syms.len);
try std.testing.expectEqualStrings("VBIL", syms[0]);
}
test "watchSymbols: watchlist.srf entries are included, holdings excluded" {
// THE SPCX BUG, at the layer where it actually lived. The version embedded
// in `commands/portfolio.zig` never looked at watchlist.srf at all, so a
// watchlist-only symbol was displayed from whatever the cache happened to
// hold - 39 days old, in SPCX's case - and never fetched.
const a = std.testing.allocator;
var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 200, .security_type = .stock },
.{ .symbol = "QTUM", .shares = 0, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 0, .security_type = .watch },
};
const held = [_][]const u8{"AMZN"};
const wl = [_][]const u8{ "SPCX", "QTUM", "AMZN", "" };
const out = try testPortfolio(&lots).extraPriceSymbols(a, &held, &wl);
defer a.free(out);
// QTUM once (watch lot, deduped against the watchlist), SPCX from the
// file. AMZN is held so it belongs to the other slice, and the blank
// line is dropped.
try std.testing.expectEqual(@as(usize, 2), out.len);
try std.testing.expectEqualStrings("QTUM", out[0]);
try std.testing.expectEqualStrings("SPCX", out[1]);
}
test "watchSymbols: a ticker alias on a watch lot is priced by its alias" {
const a = std.testing.allocator;
var lots = [_]Lot{
.{ .symbol = "NON40OR52", .ticker = "SPY", .shares = 0, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 0, .security_type = .watch },
};
const out = try testPortfolio(&lots).extraPriceSymbols(a, &.{}, &.{});
defer a.free(out);
try std.testing.expectEqual(@as(usize, 1), out.len);
try std.testing.expectEqualStrings("SPY", out[0]);
}
test "watchSymbols: no watch lots and no watchlist yields an empty set" {
const a = std.testing.allocator;
var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 200, .security_type = .stock },
};
const held = [_][]const u8{"AMZN"};
const out = try testPortfolio(&lots).extraPriceSymbols(a, &held, &.{});
defer a.free(out);
try std.testing.expectEqual(@as(usize, 0), out.len);
}
/// OOM-path wrapper for `checkAllAllocationFailures`.
fn fetchedSymbolsOom(a: std.mem.Allocator, lots: []Lot, wl: []const []const u8, bm: []const []const u8) !void {
const syms = try (Portfolio{ .lots = lots, .allocator = a }).fetchedSymbols(a, .{
.watchlist_syms = wl,
.benchmarks = bm,
});
Portfolio.freeFetchedSymbols(a, syms);
}
fn watchSymbolsOom(a: std.mem.Allocator, lots: []Lot, held: []const []const u8, wl: []const []const u8) !void {
const out = try (Portfolio{ .lots = lots, .allocator = a }).extraPriceSymbols(a, held, wl);
a.free(out);
}
test "fetchedSymbols/watchSymbols: every allocation-failure path unwinds cleanly" {
// Covers the errdefer arms, which are otherwise unreachable: a partial
// build must free the strings it already duped, and the inner arm must
// free a dupe whose append then failed.
var lots = [_]Lot{
.{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 200, .security_type = .stock },
.{ .symbol = "QTUM", .shares = 0, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 0, .security_type = .watch },
};
const wl = [_][]const u8{"SPCX"};
const bm = [_][]const u8{ "SPY", "AGG" };
const held = [_][]const u8{"AMZN"};
try std.testing.checkAllAllocationFailures(
std.testing.allocator,
fetchedSymbolsOom,
.{ &lots, @as([]const []const u8, &wl), @as([]const []const u8, &bm) },
);
try std.testing.checkAllAllocationFailures(
std.testing.allocator,
watchSymbolsOom,
.{ &lots, @as([]const []const u8, &held), @as([]const []const u8, &wl) },
);
}
test "extraPriceSymbols: order is stable - watch lots first, then the watchlist file" {
// `PortfolioData.load` used to build this through a StringHashMap, so
// iteration order - and therefore the "[5/28] Loading X" progress order -
// varied run to run for no reason. Callers may now rely on the order.
const a = std.testing.allocator;
var lots = [_]Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 100, .security_type = .stock },
.{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 0, .security_type = .watch },
.{ .symbol = "NVDA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 0, .security_type = .watch },
};
const held = [_][]const u8{"AAPL"};
const wl = [_][]const u8{ "MSFT", "QTUM" };
// Run twice: a hash-order build would be free to differ between calls.
for (0..2) |_| {
const out = try testPortfolio(&lots).extraPriceSymbols(a, &held, &wl);
defer a.free(out);
try std.testing.expectEqual(@as(usize, 4), out.len);
try std.testing.expectEqualStrings("TSLA", out[0]);
try std.testing.expectEqualStrings("NVDA", out[1]);
try std.testing.expectEqualStrings("MSFT", out[2]);
try std.testing.expectEqualStrings("QTUM", out[3]);
}
}