2156 lines
95 KiB
Zig
2156 lines
95 KiB
Zig
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.
|
||
//
|
||
// ## Caching pre-multiply pattern
|
||
//
|
||
// When manual overrides (2b) get folded into a shared `prices` map
|
||
// keyed by symbol, they're PRE-MULTIPLIED by `price_ratio` at insert
|
||
// time (see `commands/snapshot.zig:buildSnapshot` and
|
||
// `commands/audit/`). This normalizes the cached value so later
|
||
// readers can treat every entry uniformly as "price in whichever terms
|
||
// the lot needs." The `manual_set` (from `buildFallbackPrices`) then
|
||
// tells readers which entries are preadjusted.
|
||
//
|
||
// ## 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",
|
||
};
|
||
}
|
||
|
||
pub fn fromString(s: []const u8) LotType {
|
||
if (std.mem.eql(u8, s, "option")) return .option;
|
||
if (std.mem.eql(u8, s, "cd")) return .cd;
|
||
if (std.mem.eql(u8, s, "cash")) return .cash;
|
||
if (std.mem.eql(u8, s, "illiquid")) return .illiquid;
|
||
if (std.mem.eql(u8, s, "watch")) return .watch;
|
||
return .stock;
|
||
}
|
||
};
|
||
|
||
/// Call or put option type.
|
||
pub const OptionType = enum {
|
||
call,
|
||
put,
|
||
|
||
pub fn fromString(s: []const u8) OptionType {
|
||
if (std.mem.eql(u8, s, "put")) return .put;
|
||
return .call;
|
||
}
|
||
};
|
||
|
||
/// 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).
|
||
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 and fromString" {
|
||
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());
|
||
|
||
try std.testing.expectEqual(LotType.option, LotType.fromString("option"));
|
||
try std.testing.expectEqual(LotType.cd, LotType.fromString("cd"));
|
||
try std.testing.expectEqual(LotType.cash, LotType.fromString("cash"));
|
||
try std.testing.expectEqual(LotType.illiquid, LotType.fromString("illiquid"));
|
||
try std.testing.expectEqual(LotType.watch, LotType.fromString("watch"));
|
||
try std.testing.expectEqual(LotType.stock, LotType.fromString("unknown"));
|
||
try std.testing.expectEqual(LotType.stock, LotType.fromString(""));
|
||
}
|
||
|
||
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]);
|
||
}
|
||
}
|