3320 lines
144 KiB
Zig
3320 lines
144 KiB
Zig
/// Portfolio analysis engine.
|
||
///
|
||
/// Takes portfolio allocations (with market values) and classification metadata,
|
||
/// produces breakdowns by asset class, sector, geographic region, account, and tax type.
|
||
const std = @import("std");
|
||
const builtin = @import("builtin");
|
||
const srf = @import("srf");
|
||
const srf_opts = @import("../srf_opts.zig");
|
||
const Allocation = @import("valuation.zig").Allocation;
|
||
const ClassificationMap = @import("../models/classification.zig").ClassificationMap;
|
||
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;
|
||
const Portfolio = @import("../models/portfolio.zig").Portfolio;
|
||
const Date = @import("../Date.zig");
|
||
const fmt = @import("../format.zig");
|
||
|
||
const log = std.log.scoped(.accounts);
|
||
|
||
/// A single slice of a breakdown (e.g., "Technology" -> 25.3%)
|
||
pub const BreakdownItem = struct {
|
||
label: []const u8,
|
||
value: f64, // dollar amount
|
||
weight: f64, // fraction of total (0.0 - 1.0)
|
||
/// Optional compact suffix rendered after the dollar value.
|
||
/// Only the "By Account" breakdown populates it (see
|
||
/// `annotateAccountBreakdown`); every other breakdown leaves it
|
||
/// null.
|
||
annotation: ?Annotation = null,
|
||
};
|
||
|
||
/// Pre-rendered compact annotation for a breakdown row.
|
||
///
|
||
/// A value type on purpose: `BreakdownItem.label` is *borrowed* (it
|
||
/// points into the aggregation map's keys, which point into lot /
|
||
/// accounts.srf strings) and `AnalysisResult.deinit` frees only the
|
||
/// item slices, never any strings. An allocated annotation would break
|
||
/// that invariant and force an asymmetric free loop over one of the
|
||
/// five breakdown slices. Inlining the bytes keeps "BreakdownItem owns
|
||
/// nothing" true.
|
||
pub const Annotation = struct {
|
||
/// Inline capacity in bytes. Sized for the longest annotation any
|
||
/// breakdown produces today - `format.harvest_annotation_max_len`
|
||
/// ("(999.9M 12/31)", 14 bytes) - rounded up with a little
|
||
/// headroom. `annotateAccountBreakdown` asserts the fit at comptime,
|
||
/// so widening a producer's format without widening this is a build
|
||
/// error rather than a silently-dropped annotation.
|
||
pub const capacity = 16;
|
||
|
||
buf: [capacity]u8,
|
||
len: u8,
|
||
|
||
/// Build from a rendered string. Returns null for an empty string
|
||
/// (the "nothing to render" signal from the formatters) so callers
|
||
/// can assign the result straight into `BreakdownItem.annotation`.
|
||
/// Also returns null rather than truncating when `s` doesn't fit.
|
||
pub fn from(s: []const u8) ?Annotation {
|
||
if (s.len == 0 or s.len > capacity) return null;
|
||
var a: Annotation = .{ .buf = @splat(0), .len = @intCast(s.len) };
|
||
@memcpy(a.buf[0..s.len], s);
|
||
return a;
|
||
}
|
||
|
||
pub fn slice(self: *const Annotation) []const u8 {
|
||
return self.buf[0..self.len];
|
||
}
|
||
};
|
||
|
||
/// Tax type classification for accounts.
|
||
pub const TaxType = enum {
|
||
taxable,
|
||
roth,
|
||
traditional,
|
||
hsa,
|
||
|
||
pub fn label(self: TaxType) []const u8 {
|
||
return switch (self) {
|
||
.taxable => "Taxable",
|
||
.roth => "Roth (Post-Tax)",
|
||
.traditional => "Traditional (Pre-Tax)",
|
||
.hsa => "HSA (Triple Tax-Free)",
|
||
};
|
||
}
|
||
};
|
||
|
||
/// How one account's value is distributed across tax types.
|
||
///
|
||
/// The overwhelmingly common case is a single type at weight 1.0. That
|
||
/// is what an account with no `tax_mix_*` carve-outs produces, and it
|
||
/// makes every consumer behave exactly as it did before mixed
|
||
/// treatment existed.
|
||
///
|
||
/// The motivating case for anything else is an employer 401(k) that
|
||
/// reports one balance for a sleeve holding pre-tax deferrals, the
|
||
/// employer match, in-plan Roth, and after-tax/backdoor Roth money all
|
||
/// at once. The plan will not break the balance out per source, so
|
||
/// declaring "this account is 22.4% Roth" is the only way to get an
|
||
/// honest pre-tax vs post-tax picture out of it.
|
||
///
|
||
/// Weights sum to 1.0 (up to float rounding).
|
||
pub const TaxMix = struct {
|
||
/// Indexed by `@intFromEnum(TaxType)`.
|
||
weights: [type_count]f64,
|
||
|
||
pub const type_count = @typeInfo(TaxType).@"enum".fields.len;
|
||
|
||
/// The whole account is one tax type - the no-carve-outs default.
|
||
pub fn single(t: TaxType) TaxMix {
|
||
var w = [_]f64{0} ** type_count;
|
||
w[@intFromEnum(t)] = 1.0;
|
||
return .{ .weights = w };
|
||
}
|
||
|
||
pub fn weightOf(self: TaxMix, t: TaxType) f64 {
|
||
return self.weights[@intFromEnum(t)];
|
||
}
|
||
|
||
/// Share of the account that is not `.taxable`. This is the
|
||
/// umbrella-exposure default rule ("anything but taxable is
|
||
/// judgment-shielded") generalized to a fraction.
|
||
pub fn shieldedWeight(self: TaxMix) f64 {
|
||
return 1.0 - self.weightOf(.taxable);
|
||
}
|
||
};
|
||
|
||
/// Why a declared `tax_mix_*` set was rejected.
|
||
///
|
||
/// The parser logs these and `zfin doctor` reports them. In both cases
|
||
/// the account falls back to its bare `tax_type`, so a bad declaration
|
||
/// degrades to the pre-mixed-treatment behavior rather than silently
|
||
/// mis-splitting a balance.
|
||
pub const TaxMixProblem = enum {
|
||
/// A carve-out is zero or negative. Zero means "omit the field";
|
||
/// negative is meaningless.
|
||
non_positive,
|
||
/// A carve-out is nan or inf - only reachable by hand-typing it.
|
||
not_finite,
|
||
/// A carve-out names the account's own `tax_type`. The primary
|
||
/// type's share is always the residual, so naming it explicitly is
|
||
/// self-contradictory (and invites a set that sums to 90).
|
||
redundant_primary,
|
||
/// Carve-outs sum to 100 or more, leaving the primary `tax_type` no
|
||
/// share of the account at all - so `tax_type` would be a lie.
|
||
over_allocated,
|
||
|
||
/// Short human-readable cause, for parser warnings and `doctor`.
|
||
pub fn label(self: TaxMixProblem) []const u8 {
|
||
return switch (self) {
|
||
.non_positive => "every tax_mix_* carve-out must be > 0",
|
||
.not_finite => "every tax_mix_* carve-out must be a finite number",
|
||
.redundant_primary => "tax_mix_* must not name the account's own tax_type",
|
||
.over_allocated => "tax_mix_* carve-outs must sum to less than 100",
|
||
};
|
||
}
|
||
};
|
||
|
||
/// A `TaxMix` plus the reason it fell back, if it did.
|
||
pub const CheckedTaxMix = struct {
|
||
mix: TaxMix,
|
||
problem: ?TaxMixProblem,
|
||
};
|
||
|
||
/// Account tax type classification entry, parsed from accounts.srf.
|
||
pub const AccountTaxEntry = struct {
|
||
account: []const u8,
|
||
tax_type: TaxType,
|
||
institution: ?[]const u8 = null,
|
||
account_number: ?[]const u8 = null,
|
||
update_cadence: UpdateCadence = .weekly,
|
||
/// When true, raw cash-balance changes (`cash_delta` in the
|
||
/// contributions diff) on this account roll up into the
|
||
/// attribution total as real contributions.
|
||
///
|
||
/// Defaults to false because most cash accounts generate
|
||
/// `cash_delta` entries from internal movement - interest posting,
|
||
/// dividend credit, CD coupon, settlement sweeps - that would
|
||
/// inflate the attribution number if counted. Set to true only
|
||
/// for accounts whose cash movement is dominated by external
|
||
/// contributions (payroll ESPP accrual, direct 401k cash
|
||
/// deposits). See TODO.md for the design history.
|
||
cash_is_contribution: bool = false,
|
||
/// When true, marks the account as a direct-indexing proxy
|
||
/// (lots track a benchmark with tracking-error drift rather
|
||
/// than holding the benchmark directly). Two behaviors:
|
||
///
|
||
/// 1. Contributions (`zfin contributions` / `zfin compare`
|
||
/// attribution): the edit-detection residual tolerance is
|
||
/// loosened from 0.01% (noise floor) to 1% - tracking-
|
||
/// error share reconciliation no longer lands in
|
||
/// `rollup_delta` / `drip_negative` and the attribution
|
||
/// total stays clean.
|
||
///
|
||
/// 2. Audit (`zfin audit` ratio-suggestions section): lots
|
||
/// with `price_ratio == 1.0` in this account get a
|
||
/// suggested ratio to bridge the brokerage vs. portfolio
|
||
/// value gap. Default audit behavior skips ratio == 1.0
|
||
/// lots since there's nothing to adjust; direct-indexing
|
||
/// accounts opt out of that skip.
|
||
///
|
||
/// Not a general "ignore drift" flag - use only for accounts
|
||
/// whose underlying lots explicitly track a benchmark (e.g. a
|
||
/// basket of 500 individual stocks tracked as SPY via `ticker::`
|
||
/// alias).
|
||
direct_indexing: bool = false,
|
||
/// Optional umbrella-insurance shielding override. When null,
|
||
/// the umbrella-exposure calculation defaults to "tax_type !=
|
||
/// taxable means shielded" (a rough proxy for retirement-account
|
||
/// status). Set explicitly when the default is wrong:
|
||
///
|
||
/// - `shielded:bool:false` for pre-tax accounts that are NOT
|
||
/// ERISA-protected (e.g. deferred-comp plans like Fidelity
|
||
/// DCP, non-qualified annuities) - tax_type is `traditional`
|
||
/// so they default to shielded, but they're not protected
|
||
/// against civil judgments.
|
||
/// - `shielded:bool:true` to mark a taxable account as
|
||
/// shielded (rare; e.g. some asset-protection trusts).
|
||
///
|
||
/// IRA state-by-state protection is not modeled. Users in
|
||
/// states with weak IRA protection should set
|
||
/// `shielded:bool:false` on their IRA accounts to get a
|
||
/// correct umbrella-exposure number.
|
||
shielded: ?bool = null,
|
||
/// Optional per-account override for the dollar threshold above
|
||
/// which `zfin audit` flags a new lot in its "Large new lots -
|
||
/// confirm source" section. Null means "use the audit's built-in
|
||
/// default" (`contributions.default_audit_large_lot_threshold`, $10k).
|
||
///
|
||
/// The right knob is per-account because the noise this nudge
|
||
/// fights is account-specific: an ESPP/payroll account that
|
||
/// accrues routine large lots wants a HIGH threshold to stay
|
||
/// quiet, while a taxable brokerage where any sizeable new lot is
|
||
/// worth a look wants the default (or lower). Set it higher to cut
|
||
/// ESPP spam, lower to catch smaller movements.
|
||
///
|
||
/// Must be positive; a zero or negative value is rejected at parse
|
||
/// time (warned + treated as unset) since zero would flag every
|
||
/// new lot and negative is meaningless.
|
||
audit_large_lot_threshold: ?f64 = null,
|
||
/// Cumulative tax-loss-harvested figure for this account, declared
|
||
/// by hand because zfin cannot derive it.
|
||
///
|
||
/// The motivating case is a synthetic direct-indexing account (see
|
||
/// `direct_indexing`): the portfolio models it as one aggregate lot
|
||
/// with a `ticker::` alias, deliberately NOT enumerating the
|
||
/// hundreds of underlying positions. Without per-position detail
|
||
/// there are no closed lots, so `Lot.realizedGainLoss` has nothing
|
||
/// to work from - yet the harvested total is the whole point of
|
||
/// such an account. This field is where you park the number you
|
||
/// read off the brokerage site.
|
||
///
|
||
/// Display-only. It never enters any total, weight, or breakdown
|
||
/// value; it is rendered as a compact annotation next to the
|
||
/// account name (`format.fmtHarvestAnnotation`). Sign-insensitive
|
||
/// on read - `harvested:num:45300` and `harvested:num:-45300` are
|
||
/// equivalent, since the annotation's parens already carry the
|
||
/// accounting "this is a loss" convention.
|
||
///
|
||
/// Requires `harvested_date` to render; see that field.
|
||
harvested: ?f64 = null,
|
||
/// The "as of" date for `harvested` - when you last copied the
|
||
/// figure from the brokerage. Required for the annotation to
|
||
/// render at all, because a hand-copied number with no date is
|
||
/// indistinguishable from a stale one.
|
||
///
|
||
/// Entries older than 12 months stop rendering: harvest data that
|
||
/// old is not decision-useful, and the cutoff is what lets the
|
||
/// annotation show a bare `M/D` with no year (within a trailing
|
||
/// year, each month/day pair occurs at most once).
|
||
harvested_date: ?Date = null,
|
||
/// Percentage of this account's value that is actually `taxable`,
|
||
/// `roth`, `traditional`, or `hsa` money despite `tax_type` saying
|
||
/// otherwise. See `TaxMix` for why this exists and `taxMixChecked`
|
||
/// for the rules.
|
||
///
|
||
/// These are *carve-outs*: `tax_type` keeps whatever percentage is
|
||
/// left over, so the common single-Roth-sleeve case needs exactly
|
||
/// one number.
|
||
///
|
||
/// Percentages rather than dollars on purpose: market appreciation
|
||
/// applies proportionally across the sleeves, so a percentage stays
|
||
/// correct through market moves and only drifts as you contribute.
|
||
/// A dollar figure would go stale daily.
|
||
tax_mix_taxable: ?f64 = null,
|
||
tax_mix_roth: ?f64 = null,
|
||
tax_mix_traditional: ?f64 = null,
|
||
tax_mix_hsa: ?f64 = null,
|
||
/// The "as of" date for the `tax_mix_*` carve-outs - when you last
|
||
/// read the source breakdown off the plan's site.
|
||
///
|
||
/// Unlike `harvested_date`, this is advisory only: a missing or
|
||
/// stale date never suppresses the split, because the mix feeds
|
||
/// real breakdown totals rather than a display annotation. Silently
|
||
/// changing someone's pre-tax vs post-tax picture because a date
|
||
/// aged out would be far worse than showing a slightly stale one.
|
||
/// `zfin audit` nags instead; see `audit/hygiene.zig`.
|
||
tax_mix_date: ?Date = null,
|
||
|
||
/// The carve-out percentage declared for `t`, if any. Exhaustive on
|
||
/// purpose: adding a `TaxType` variant should fail to compile here
|
||
/// until a matching `tax_mix_*` field exists.
|
||
fn carveOut(self: AccountTaxEntry, t: TaxType) ?f64 {
|
||
return switch (t) {
|
||
.taxable => self.tax_mix_taxable,
|
||
.roth => self.tax_mix_roth,
|
||
.traditional => self.tax_mix_traditional,
|
||
.hsa => self.tax_mix_hsa,
|
||
};
|
||
}
|
||
|
||
/// Does this account declare any `tax_mix_*` carve-out? True even
|
||
/// when the declaration is invalid, because callers that report
|
||
/// problems need to know the user tried.
|
||
pub fn hasTaxMix(self: AccountTaxEntry) bool {
|
||
for (std.enums.values(TaxType)) |t| {
|
||
if (self.carveOut(t) != null) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// Resolve `tax_type` plus any `tax_mix_*` carve-outs into a
|
||
/// normalized `TaxMix`, reporting the first problem found.
|
||
///
|
||
/// Rules, all of which fall back to `TaxMix.single(tax_type)`:
|
||
/// - each carve-out must be finite and strictly positive
|
||
/// - no carve-out may name `tax_type` itself
|
||
/// - carve-outs must sum to less than 100
|
||
///
|
||
/// Falling back rather than clamping is deliberate: a
|
||
/// half-understood declaration should behave like no declaration,
|
||
/// not like a guess at what the user meant.
|
||
pub fn taxMixChecked(self: AccountTaxEntry) CheckedTaxMix {
|
||
var weights = [_]f64{0} ** TaxMix.type_count;
|
||
var carved: f64 = 0;
|
||
var problem: ?TaxMixProblem = null;
|
||
|
||
for (std.enums.values(TaxType)) |t| {
|
||
const pct = self.carveOut(t) orelse continue;
|
||
if (t == self.tax_type) {
|
||
problem = .redundant_primary;
|
||
break;
|
||
}
|
||
if (!std.math.isFinite(pct)) {
|
||
problem = .not_finite;
|
||
break;
|
||
}
|
||
if (pct <= 0) {
|
||
problem = .non_positive;
|
||
break;
|
||
}
|
||
carved += pct;
|
||
weights[@intFromEnum(t)] = pct / 100.0;
|
||
}
|
||
if (problem == null and carved >= 100) problem = .over_allocated;
|
||
|
||
if (problem) |p| return .{ .mix = .single(self.tax_type), .problem = p };
|
||
if (carved == 0) return .{ .mix = .single(self.tax_type), .problem = null };
|
||
|
||
// Residual from the summed stored weights rather than from
|
||
// `carved / 100`, so the vector sums as close to 1.0 as f64
|
||
// allows.
|
||
var others: f64 = 0;
|
||
for (weights) |w| others += w;
|
||
weights[@intFromEnum(self.tax_type)] = 1.0 - others;
|
||
return .{ .mix = .{ .weights = weights }, .problem = null };
|
||
}
|
||
|
||
/// `taxMixChecked` without the diagnostic - the shape consumers want.
|
||
pub fn taxMix(self: AccountTaxEntry) TaxMix {
|
||
return self.taxMixChecked().mix;
|
||
}
|
||
};
|
||
|
||
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
|
||
/// Default is `weekly` (fail-open: every account nags until explicitly silenced).
|
||
pub const UpdateCadence = enum {
|
||
weekly,
|
||
monthly,
|
||
quarterly,
|
||
none,
|
||
|
||
/// Number of calendar days before an account is considered overdue.
|
||
pub fn thresholdDays(self: UpdateCadence) ?u32 {
|
||
return switch (self) {
|
||
.weekly => 7,
|
||
.monthly => 30,
|
||
.quarterly => 90,
|
||
.none => null,
|
||
};
|
||
}
|
||
|
||
pub fn label(self: UpdateCadence) []const u8 {
|
||
return switch (self) {
|
||
.weekly => "weekly",
|
||
.monthly => "monthly",
|
||
.quarterly => "quarterly",
|
||
.none => "none",
|
||
};
|
||
}
|
||
};
|
||
|
||
/// Parsed account metadata.
|
||
pub const AccountMap = struct {
|
||
entries: []AccountTaxEntry,
|
||
allocator: std.mem.Allocator,
|
||
|
||
pub fn deinit(self: *AccountMap) void {
|
||
for (self.entries) |e| {
|
||
self.allocator.free(e.account);
|
||
if (e.institution) |s| self.allocator.free(s);
|
||
if (e.account_number) |s| self.allocator.free(s);
|
||
}
|
||
self.allocator.free(self.entries);
|
||
}
|
||
|
||
/// Look up the tax type label for a given account name.
|
||
///
|
||
/// This is the account's *primary* type. For accounts with
|
||
/// `tax_mix_*` carve-outs it is the majority-by-construction
|
||
/// residual holder, not the whole story - callers that need to
|
||
/// apportion value across types want `taxMixFor` instead.
|
||
pub fn taxTypeFor(self: AccountMap, account: []const u8) []const u8 {
|
||
for (self.entries) |e| {
|
||
if (std.mem.eql(u8, e.account, account)) {
|
||
return e.tax_type.label();
|
||
}
|
||
}
|
||
return "Unknown";
|
||
}
|
||
|
||
/// How `account`'s value is distributed across tax types. Null when
|
||
/// the account isn't in the map, so each caller picks its own
|
||
/// "unclassified" behavior (the tax-type breakdown emits an
|
||
/// "Unknown" row; the umbrella calc assumes exposed).
|
||
pub fn taxMixFor(self: AccountMap, account: []const u8) ?TaxMix {
|
||
for (self.entries) |e| {
|
||
if (std.mem.eql(u8, e.account, account)) {
|
||
return e.taxMix();
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Find the portfolio account name for a given institution + account number.
|
||
pub fn findByInstitutionAccount(self: AccountMap, institution: []const u8, account_number: []const u8) ?[]const u8 {
|
||
for (self.entries) |e| {
|
||
if (e.institution) |inst| {
|
||
if (e.account_number) |num| {
|
||
if (std.mem.eql(u8, inst, institution) and std.mem.eql(u8, num, account_number))
|
||
return e.account;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Is cash-balance movement on `account` treated as a real
|
||
/// contribution (vs. internal noise) for the attribution total?
|
||
/// Defaults to false when the account isn't in the map.
|
||
pub fn cashIsContribution(self: AccountMap, account: []const u8) bool {
|
||
for (self.entries) |e| {
|
||
if (std.mem.eql(u8, e.account, account)) {
|
||
return e.cash_is_contribution;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// Is `account` flagged as a direct-indexing proxy? See
|
||
/// `AccountTaxEntry.direct_indexing` for the two behaviors this
|
||
/// drives. Defaults to false when the account isn't in the map.
|
||
pub fn isDirectIndexing(self: AccountMap, account: []const u8) bool {
|
||
for (self.entries) |e| {
|
||
if (std.mem.eql(u8, e.account, account)) {
|
||
return e.direct_indexing;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// Per-account override for the audit "Large new lots" dollar
|
||
/// threshold. Returns the account's configured value, or null to
|
||
/// fall back to the audit's built-in default
|
||
/// (`contributions.default_audit_large_lot_threshold`). Null both when
|
||
/// the account isn't in the map and when its entry omits the
|
||
/// field. Parse guarantees any non-null result is positive.
|
||
pub fn largeLotThresholdFor(self: AccountMap, account: []const u8) ?f64 {
|
||
for (self.entries) |e| {
|
||
if (std.mem.eql(u8, e.account, account)) {
|
||
return e.audit_large_lot_threshold;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Hand-declared tax-loss-harvested figure for `account`, plus the
|
||
/// date it was last refreshed. See `AccountTaxEntry.harvested`.
|
||
///
|
||
/// Returns null when the account isn't in the map or its entry
|
||
/// omits `harvested`. The amount is already sign-normalized by the
|
||
/// parser; `as_of` may still be null (the caller decides whether a
|
||
/// dateless figure is renderable - `format.fmtHarvestAnnotation`
|
||
/// says no).
|
||
pub fn harvestedFor(self: AccountMap, account: []const u8) ?struct { amount: f64, as_of: ?Date } {
|
||
for (self.entries) |e| {
|
||
if (std.mem.eql(u8, e.account, account)) {
|
||
const amt = e.harvested orelse return null;
|
||
return .{ .amount = amt, .as_of = e.harvested_date };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
};
|
||
|
||
/// Populate `items[*].annotation` for a "By Account" breakdown with each
|
||
/// account's hand-declared tax-loss-harvested figure. Rows for accounts
|
||
/// with no `harvested` entry (or a stale / future-dated / dateless one)
|
||
/// are left null and render nothing.
|
||
///
|
||
/// `as_of` is the reference date for the 12-month staleness window, not
|
||
/// necessarily today: a back-dated analysis should not surface a harvest
|
||
/// figure recorded after the date being analyzed.
|
||
pub fn annotateAccountBreakdown(items: []BreakdownItem, account_map: AccountMap, as_of: Date) void {
|
||
comptime std.debug.assert(fmt.harvest_annotation_max_len <= Annotation.capacity);
|
||
for (items) |*item| {
|
||
const h = account_map.harvestedFor(item.label) orelse continue;
|
||
var buf: [fmt.harvest_annotation_max_len]u8 = undefined;
|
||
item.annotation = Annotation.from(fmt.fmtHarvestAnnotation(&buf, h.amount, h.as_of, as_of));
|
||
}
|
||
}
|
||
|
||
/// Parse an accounts.srf file into an AccountMap.
|
||
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>][,<flags>]
|
||
/// where the optional flags include `audit_large_lot_threshold:num:<DOLLARS>`,
|
||
/// `harvested:num:<DOLLARS>`, `harvested_date::<YYYY-MM-DD>`,
|
||
/// `tax_mix_<TYPE>:num:<PERCENT>` and `tax_mix_date::<YYYY-MM-DD>`.
|
||
pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap {
|
||
var entries = std.ArrayList(AccountTaxEntry).empty;
|
||
errdefer {
|
||
for (entries.items) |e| {
|
||
allocator.free(e.account);
|
||
if (e.institution) |s| allocator.free(s);
|
||
if (e.account_number) |s| allocator.free(s);
|
||
}
|
||
entries.deinit(allocator);
|
||
}
|
||
|
||
var reader = std.Io.Reader.fixed(data);
|
||
var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return error.InvalidData;
|
||
defer it.deinit();
|
||
|
||
while (try it.next()) |fields| {
|
||
const entry = fields.to(AccountTaxEntry, srf_opts.user_edited) catch |err| {
|
||
// Skip the account rather than losing the whole file, but
|
||
// name the error: a dropped account loses its tax type,
|
||
// cadence and carve-outs, and every consumer then silently
|
||
// falls back to defaults. Quiet under `zig build test`,
|
||
// where fixtures feed malformed records on purpose.
|
||
if (!builtin.is_test) {
|
||
log.warn("accounts.srf: skipping malformed record: {s}", .{@errorName(err)});
|
||
}
|
||
continue;
|
||
};
|
||
|
||
// A zero/negative large-lot threshold is nonsensical (zero
|
||
// flags every new lot; negative is meaningless). Reject it and
|
||
// treat the account as unset so the audit uses its default.
|
||
const lot_threshold: ?f64 = if (entry.audit_large_lot_threshold) |t| blk: {
|
||
if (t > 0) break :blk t;
|
||
// No-op under `zig build test`: the parser's own tests feed
|
||
// invalid thresholds (0, negative) on purpose to verify they
|
||
// are rejected, and the warn spam pollutes test output.
|
||
if (!builtin.is_test)
|
||
log.warn("accounts.srf: account '{s}': audit_large_lot_threshold must be > 0 (got {d}); ignoring", .{ entry.account, t });
|
||
break :blk null;
|
||
} else null;
|
||
|
||
// `harvested` is a magnitude: the annotation's parens carry the
|
||
// "this is a loss" convention, so accept either sign from the
|
||
// user and normalize. A non-finite value can only arrive from a
|
||
// hand-typed `inf`/`nan`; there is nothing sensible to display
|
||
// for it, so drop the field rather than render garbage.
|
||
const harvested: ?f64 = if (entry.harvested) |h| blk: {
|
||
if (std.math.isFinite(h)) break :blk @abs(h);
|
||
// Silent under `zig build test`: the parser's own tests feed
|
||
// non-finite values on purpose, and the warn spam pollutes
|
||
// test output.
|
||
if (!builtin.is_test)
|
||
log.warn("accounts.srf: account '{s}': harvested must be a finite number (got {d}); ignoring", .{ entry.account, h });
|
||
break :blk null;
|
||
} else null;
|
||
|
||
// A declared tax mix that breaks the rules falls back to the
|
||
// bare `tax_type`. Warn so a typo isn't invisible; the raw
|
||
// fields are deliberately left in place so `zfin doctor` can
|
||
// report the same problem against the file the user edits.
|
||
if (entry.taxMixChecked().problem) |p| {
|
||
// Silent under `zig build test`: the parser's own tests feed
|
||
// invalid mixes on purpose to verify the fallback, and the
|
||
// warn spam pollutes test output.
|
||
if (!builtin.is_test)
|
||
log.warn("accounts.srf: account '{s}': {s}; ignoring the tax mix", .{ entry.account, p.label() });
|
||
}
|
||
|
||
// Copy the whole parsed record, then override just the fields
|
||
// that need duping or validating. Spelling out every field here
|
||
// used to be a footgun: a newly added field with a default would
|
||
// silently keep that default instead of the parsed value.
|
||
var out = entry;
|
||
out.account = try allocator.dupe(u8, entry.account);
|
||
out.institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null;
|
||
out.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null;
|
||
out.audit_large_lot_threshold = lot_threshold;
|
||
out.harvested = harvested;
|
||
try entries.append(allocator, out);
|
||
}
|
||
|
||
return .{
|
||
.entries = try entries.toOwnedSlice(allocator),
|
||
.allocator = allocator,
|
||
};
|
||
}
|
||
|
||
/// Complete portfolio analysis result.
|
||
pub const AnalysisResult = struct {
|
||
/// Coarse 4-bucket breakdown: Equity / Fixed Income / Cash / Other.
|
||
/// Built by mapping each fine-grained sector through `bucketSector`
|
||
/// before aggregation. The right field for portfolio-level
|
||
/// debt-to-equity analysis.
|
||
asset_category: []BreakdownItem,
|
||
/// Breakdown by sector bucket (Technology, US Healthcare ETF,
|
||
/// US Large Cap, etc.). Aggregates by `entry.bucket` -
|
||
/// pre-filled by parseClassificationFile via `deriveBucket`,
|
||
/// or curated by the user. Replaces the historical separate
|
||
/// "Asset Class" + "Sector" breakdowns: the bucket is a
|
||
/// single semantically-meaningful label that combines what
|
||
/// each was trying to express.
|
||
sector: []BreakdownItem,
|
||
/// Breakdown by geographic region (US, International, etc.)
|
||
geo: []BreakdownItem,
|
||
/// Breakdown by account name
|
||
account: []BreakdownItem,
|
||
/// Breakdown by tax type (Taxable, Roth, Traditional, HSA)
|
||
tax_type: []BreakdownItem,
|
||
/// Positions not covered by classification metadata
|
||
unclassified: []const []const u8,
|
||
/// Total portfolio value used as denominator
|
||
total_value: f64,
|
||
|
||
pub fn deinit(self: *AnalysisResult, allocator: std.mem.Allocator) void {
|
||
allocator.free(self.asset_category);
|
||
allocator.free(self.sector);
|
||
allocator.free(self.geo);
|
||
allocator.free(self.account);
|
||
allocator.free(self.tax_type);
|
||
allocator.free(self.unclassified);
|
||
}
|
||
};
|
||
|
||
/// One section of an analysis breakdown for renderer-agnostic
|
||
/// display. Both the CLI (`commands/analysis.zig`) and the TUI
|
||
/// (`tui/analysis_tab.zig`) walk the section list returned by
|
||
/// `breakdownSections` to build their output. The section list
|
||
/// is the single source of truth for which breakdowns appear and
|
||
/// in what order; renderers apply their own indent and styling.
|
||
pub const Section = struct {
|
||
items: []const BreakdownItem,
|
||
/// Title with no leading whitespace. Renderers indent.
|
||
title: []const u8,
|
||
};
|
||
|
||
/// Single source of truth for analysis-output breakdown
|
||
/// sections. Both the CLI display and the TUI tab call this so
|
||
/// adding/reordering a section is a one-place edit. Order is
|
||
/// from coarsest (Asset Category, 4 buckets) to finest
|
||
/// (per-account / per-tax-type).
|
||
pub fn breakdownSections(r: *const AnalysisResult) [5]Section {
|
||
return .{
|
||
.{ .items = r.asset_category, .title = "Asset Category" },
|
||
.{ .items = r.sector, .title = "Sector" },
|
||
.{ .items = r.geo, .title = "Geographic" },
|
||
.{ .items = r.account, .title = "By Account" },
|
||
.{ .items = r.tax_type, .title = "By Tax Type" },
|
||
};
|
||
}
|
||
|
||
// ── Umbrella-insurance exposure ──────────────────────────────
|
||
|
||
/// Result of computing umbrella-insurance exposure: the portion
|
||
/// of the liquid portfolio that's NOT legally shielded against
|
||
/// civil judgments / lawsuits, and therefore needs umbrella
|
||
/// coverage.
|
||
pub const UmbrellaExposure = struct {
|
||
/// Total liquid portfolio value summed from `account_breakdown`.
|
||
/// Equals shielded + exposed.
|
||
total_liquid: f64,
|
||
/// Sum of account values where shielding evaluates to true.
|
||
shielded_value: f64,
|
||
/// Sum of account values where shielding evaluates to false.
|
||
/// This is the approximate umbrella-insurance target.
|
||
exposed_value: f64,
|
||
/// `exposed_value / total_liquid`, or 0 when `total_liquid` is 0.
|
||
exposed_pct: f64,
|
||
};
|
||
|
||
/// Compute umbrella-insurance exposure from the per-account
|
||
/// breakdown and the account-tax map.
|
||
///
|
||
/// Shielding decision per account:
|
||
/// - If `entry.shielded` is explicitly set, use that for the whole
|
||
/// account - a hand-declared legal fact outranks the tax-type proxy.
|
||
/// - Else the shielded share is everything that isn't `taxable`,
|
||
/// which for an account with `tax_mix_*` carve-outs is a fraction
|
||
/// rather than all-or-nothing.
|
||
///
|
||
/// Accounts not in `account_map` default to NOT shielded
|
||
/// (defensive - if we don't know, assume the value is exposed
|
||
/// rather than overstate the user's protection).
|
||
///
|
||
/// Pure data, no allocation. The arithmetic is straightforward
|
||
/// summation; the meaningful logic is the per-account
|
||
/// shielded-or-not decision.
|
||
pub fn umbrellaExposure(
|
||
account_breakdown: []const BreakdownItem,
|
||
account_map: AccountMap,
|
||
) UmbrellaExposure {
|
||
var shielded: f64 = 0;
|
||
var exposed: f64 = 0;
|
||
|
||
for (account_breakdown) |item| {
|
||
const frac = accountShieldedFraction(item.label, account_map);
|
||
shielded += item.value * frac;
|
||
exposed += item.value * (1.0 - frac);
|
||
}
|
||
|
||
const total = shielded + exposed;
|
||
const pct = if (total > 0) exposed / total else 0;
|
||
|
||
return .{
|
||
.total_liquid = total,
|
||
.shielded_value = shielded,
|
||
.exposed_value = exposed,
|
||
.exposed_pct = pct,
|
||
};
|
||
}
|
||
|
||
/// What share of one account's value is judgment-shielded, in [0, 1].
|
||
///
|
||
/// Returns 0 (fully exposed) when the account is not in the map - the
|
||
/// defensive default. An explicit `shielded::false` / `shielded::true`
|
||
/// override applies to the whole account and short-circuits the
|
||
/// tax-type proxy, including any `tax_mix_*` carve-outs: it is a
|
||
/// statement about the account's legal protection, which a split of its
|
||
/// *tax* treatment has no business overriding.
|
||
fn accountShieldedFraction(account: []const u8, account_map: AccountMap) f64 {
|
||
for (account_map.entries) |e| {
|
||
if (!std.mem.eql(u8, e.account, account)) continue;
|
||
if (e.shielded) |explicit| return if (explicit) 1.0 else 0.0;
|
||
return e.taxMix().shieldedWeight();
|
||
}
|
||
return 0.0;
|
||
}
|
||
|
||
// ── Sector -> asset-category bucket ────────────────────────────
|
||
|
||
/// The four coarse asset-category buckets. Returned from
|
||
/// `bucketSector` as static `[]const u8` literals so callers can
|
||
/// use them as stable HashMap keys without duping.
|
||
pub const bucket_equity: []const u8 = "Equity";
|
||
pub const bucket_fixed_income: []const u8 = "Fixed Income";
|
||
pub const bucket_cash: []const u8 = "Cash";
|
||
pub const bucket_other: []const u8 = "Other";
|
||
|
||
/// Map a sector string to one of four coarse asset-category
|
||
/// buckets. Handles three input shapes:
|
||
///
|
||
/// - **NPORT-P fund-decomposition sectors** of the form
|
||
/// `"<assetCat> / <issuerCat>"` (e.g. `"Debt / US Treasury"`,
|
||
/// `"Equity / Corporate"`, `"Short-Term Investment Vehicle / Registered Fund"`).
|
||
/// These come from EDGAR fund-holdings data via `enrich`.
|
||
///
|
||
/// - **GICS-style stock sector names** (e.g. `"Technology"`,
|
||
/// `"Healthcare"`, `"Financial Services"`). These come from
|
||
/// Wikidata via `enrich`'s `canonicalizeSector`.
|
||
///
|
||
/// - **Plain-English asset-class words** (e.g. `"Bonds"`,
|
||
/// `"Diversified"`) that hand-written `metadata.srf` files
|
||
/// use for legacy entries. `"Bonds"` -> Fixed Income;
|
||
/// `"Diversified"` -> Equity (the word in practice means "S&P
|
||
/// 500 / total-market index fund holding all sectors", which
|
||
/// is overwhelmingly equity).
|
||
///
|
||
/// Returns one of `bucket_equity`, `bucket_fixed_income`,
|
||
/// `bucket_cash`, or `bucket_other`. Anything unrecognized
|
||
/// (sentinels like `"TODO"`, empty string, future label
|
||
/// changes) falls through to `bucket_other`.
|
||
///
|
||
/// Note: `Equity Preferred / *` rolls up to Equity, not Fixed
|
||
/// Income. Preferreds trade between stocks and bonds; we lean
|
||
/// equity to match how most retail asset-allocation views treat
|
||
/// them.
|
||
pub fn bucketSector(sector: []const u8) []const u8 {
|
||
// NPORT-P shapes: prefix-match on the assetCat half.
|
||
// `startsWith` covers both `Equity / *` and `Equity Preferred / *`.
|
||
//
|
||
// Note on dividend-equity ETFs (SCHD, VYM, DGRO, etc.):
|
||
// these bucket as Equity, not Fixed Income, despite their
|
||
// bond-like income shape. The Asset Category breakdown
|
||
// answers "what's exposed to equity drawdowns?" - and
|
||
// dividend funds drop with the market in a 2008-style
|
||
// crash. Their calmer risk character (lower volatility,
|
||
// shallower drawdown) is real but shows up in the
|
||
// per-holding vol/drawdown columns of `review`, not in
|
||
// the asset-class taxonomy. See
|
||
// docs/explanation/returns-and-performance.md, "Risk
|
||
// character vs. asset class".
|
||
if (std.mem.startsWith(u8, sector, "Equity")) return bucket_equity;
|
||
if (std.mem.startsWith(u8, sector, "Debt")) return bucket_fixed_income;
|
||
if (std.mem.startsWith(u8, sector, "Loan")) return bucket_fixed_income;
|
||
if (std.mem.startsWith(u8, sector, "Asset-Backed")) return bucket_fixed_income;
|
||
if (std.mem.startsWith(u8, sector, "Short-Term Investment Vehicle")) return bucket_cash;
|
||
if (std.mem.startsWith(u8, sector, "Repurchase Agreement")) return bucket_cash;
|
||
|
||
// Plain-English asset-class words (hand-written metadata).
|
||
if (std.mem.eql(u8, sector, "Bonds")) return bucket_fixed_income;
|
||
if (std.mem.eql(u8, sector, "Cash")) return bucket_cash;
|
||
if (std.mem.eql(u8, sector, "Cash & CDs")) return bucket_cash;
|
||
if (std.mem.eql(u8, sector, "Options")) return bucket_other;
|
||
if (std.mem.eql(u8, sector, "Unclassified")) return bucket_other;
|
||
// "Diversified" means "broad equity fund holding all
|
||
// sectors" - S&P 500 ETF, total-market index, etc.
|
||
if (std.mem.eql(u8, sector, "Diversified")) return bucket_equity;
|
||
|
||
// GICS stock sector names. Exact match over the canonical 11
|
||
// returned by `Wikidata.canonicalizeSector`. The legacy
|
||
// `"Financials"` (with 's') from old hand-written entries
|
||
// also maps here.
|
||
const gics = [_][]const u8{
|
||
"Technology",
|
||
"Healthcare",
|
||
"Financial Services",
|
||
"Financials",
|
||
"Consumer Cyclical",
|
||
"Consumer Defensive",
|
||
"Energy",
|
||
"Utilities",
|
||
"Real Estate",
|
||
"Industrials",
|
||
"Basic Materials",
|
||
"Communication Services",
|
||
};
|
||
for (gics) |g| if (std.mem.eql(u8, sector, g)) return bucket_equity;
|
||
|
||
// Strings containing `/` are NPORT-P shapes that didn't match
|
||
// any prefix above (e.g. "Direct Real Property / Other",
|
||
// "Direct Credit Risk / Other", "Other / Corporate"). Bucket
|
||
// these as Other - they're real-property, credit derivatives,
|
||
// and miscellaneous categories that don't fit the equity /
|
||
// fixed-income / cash trichotomy.
|
||
if (std.mem.indexOfScalar(u8, sector, '/') != null) return bucket_other;
|
||
|
||
// Empty string / explicit sentinels -> Other. Explicit
|
||
// because the curated-bucket fallback below would otherwise
|
||
// assume any non-empty unknown string is equity.
|
||
if (sector.len == 0) return bucket_other;
|
||
if (std.mem.eql(u8, sector, "TODO")) return bucket_other;
|
||
if (std.mem.eql(u8, sector, "Unknown")) return bucket_other;
|
||
|
||
// Word-content checks for composite bucket strings produced by
|
||
// `deriveBucket` (or hand-curated `bucket::` overrides):
|
||
// "US Bonds", future "International Bonds" / "EM Bonds" -> Fixed Income
|
||
// "US Cash", "Cash & CDs" (handled above) -> Cash
|
||
if (std.mem.endsWith(u8, sector, " Bonds") or std.mem.endsWith(u8, sector, " bonds")) {
|
||
return bucket_fixed_income;
|
||
}
|
||
if (std.mem.endsWith(u8, sector, " Cash") or std.mem.endsWith(u8, sector, " cash")) {
|
||
return bucket_cash;
|
||
}
|
||
|
||
// Default for any remaining no-`/` non-cruft string: equity.
|
||
// Catches curated buckets like "US Large Cap", "US Mid Cap",
|
||
// "US Small Cap", "US Dividend Equity", "US Healthcare ETF",
|
||
// "International Developed", "Emerging Markets", and any
|
||
// future user-defined bucket. The convention is: composite
|
||
// buckets describe an equity sleeve unless they explicitly
|
||
// say otherwise (Bonds/Cash/Options/Unclassified handled
|
||
// above).
|
||
return bucket_equity;
|
||
}
|
||
|
||
// ── Sector display granularity ───────────────────────────────
|
||
|
||
/// Granularity tier for the Sector breakdown display. Two
|
||
/// tiers: `coarse` (4 macro buckets - Equity / Fixed Income /
|
||
/// Cash / Other) and `fine` (the raw bucket strings the
|
||
/// classification layer produced - every "US Large Cap" / "US
|
||
/// Bonds" / GICS-sector / etc. row distinct).
|
||
///
|
||
/// History: this used to be a three-tier enum (coarse / mid /
|
||
/// fine). The middle tier collapsed NPORT-P sub-flavors (all
|
||
/// Debt / * -> "Bonds", all Asset-Backed / * -> "Bonds", etc.)
|
||
/// while keeping GICS sectors distinct. After the bucket
|
||
/// commit, classification rows expose a single curated bucket
|
||
/// label per entry - so the NPORT-P-flavor collapse the mid
|
||
/// tier did is now done at parse time. Mid and fine ended up
|
||
/// nearly identical and mid was dropped.
|
||
pub const Granularity = enum {
|
||
/// Four buckets: Equity / Fixed Income / Cash / Other.
|
||
/// Same labels as the Asset Category breakdown.
|
||
coarse,
|
||
/// One row per distinct bucket label - the raw shape of
|
||
/// what `entry.bucket` produces. Default. This is what
|
||
/// the user wants for "what are my actual positions?"
|
||
fine,
|
||
};
|
||
|
||
/// Display-friendly abbreviations for sector labels that don't fit
|
||
/// cleanly in narrow columns. Returns the input unchanged when no
|
||
/// abbreviation is registered for it; consumers that need a fixed
|
||
/// width should also pass the result through `format.truncateToCols`.
|
||
///
|
||
/// Single source of truth for both the analysis tab's sector
|
||
/// breakdown rows and the review tab's per-holding sector cells.
|
||
/// Add new abbreviations here when a sector label keeps overflowing
|
||
/// the columns it lives in.
|
||
pub fn abbreviateSector(s: []const u8) []const u8 {
|
||
if (std.mem.eql(u8, s, "Communication Services")) return "Comm. Services";
|
||
return s;
|
||
}
|
||
|
||
/// Map a sector string through the chosen granularity. Returns
|
||
/// a static literal (at coarse) or the input slice (at fine)
|
||
/// suitable for use as a stable HashMap key.
|
||
///
|
||
/// Granularity tiers:
|
||
///
|
||
/// - **coarse**: delegates to `bucketSector` - Equity / Fixed Income
|
||
/// / Cash / Other (4 buckets).
|
||
/// - **fine**: passthrough - returns the input unchanged.
|
||
pub fn collapseSector(sector: []const u8, granularity: Granularity) []const u8 {
|
||
return switch (granularity) {
|
||
.fine => sector,
|
||
.coarse => bucketSector(sector),
|
||
};
|
||
}
|
||
|
||
/// Compute portfolio analysis from allocations and classification metadata.
|
||
/// `allocations` are the stock/ETF positions with market values.
|
||
/// `classifications` is the metadata file data.
|
||
/// `portfolio` is the full portfolio (for cash/CD/illiquid totals).
|
||
/// `account_map` is optional account tax type metadata.
|
||
/// `as_of` is the date against which lot open/closed status is
|
||
/// evaluated. Pass `null` to use wall-clock today (the default for
|
||
/// interactive commands); historical snapshot backfill passes the
|
||
/// target date so lots opened/closed/matured between `as_of` and today
|
||
/// are counted correctly.
|
||
pub fn analyzePortfolio(
|
||
allocator: std.mem.Allocator,
|
||
allocations: []const Allocation,
|
||
classifications: ClassificationMap,
|
||
portfolio: Portfolio,
|
||
total_portfolio_value: f64,
|
||
account_map: ?AccountMap,
|
||
as_of: Date,
|
||
) !AnalysisResult {
|
||
// Accumulators: label -> dollar amount.
|
||
//
|
||
// sector_map and asset_cat_map are both keyed by the
|
||
// `bucket` field on ClassificationEntry (pre-filled by
|
||
// parseClassificationFile via deriveBucket). Buckets are
|
||
// either user-curated, GICS-like sectors, or composite
|
||
// "{geo} {asset_class}" labels - meaningful units for
|
||
// concentration rollup. The raw `entry.sector` is no
|
||
// longer used for either map: NPORT-P fund-decomp
|
||
// categories ("Equity / Corporate") would lump genuinely
|
||
// different funds together.
|
||
var sector_map = std.StringHashMap(f64).init(allocator);
|
||
defer sector_map.deinit();
|
||
// 4-bucket coarse breakdown (Equity/Fixed Income/Cash/Other).
|
||
// Keys are static literals from `bucketSector`, no dupe needed.
|
||
var asset_cat_map = std.StringHashMap(f64).init(allocator);
|
||
defer asset_cat_map.deinit();
|
||
var geo_map = std.StringHashMap(f64).init(allocator);
|
||
defer geo_map.deinit();
|
||
var acct_map = std.StringHashMap(f64).init(allocator);
|
||
defer acct_map.deinit();
|
||
var tax_map = std.StringHashMap(f64).init(allocator);
|
||
defer tax_map.deinit();
|
||
|
||
var unclassified_list = std.ArrayList([]const u8).empty;
|
||
errdefer unclassified_list.deinit(allocator);
|
||
|
||
// Process each equity allocation (for sector, geo, unclassified)
|
||
for (allocations) |alloc| {
|
||
const mv = alloc.market_value;
|
||
if (mv <= 0) continue;
|
||
|
||
// Find classification entries for this symbol.
|
||
//
|
||
// Match on `alloc.symbol` only - the canonical economic
|
||
// identity (priceSymbol(): the `ticker::` alias when set,
|
||
// else the raw symbol/CUSIP). `display_symbol` is a
|
||
// display-only concern (an explicit `label::`, else
|
||
// priceSymbol) and must NEVER be a classification key:
|
||
// a free-text annotation that silently changed what
|
||
// classifies would be a footgun. Keying on `alloc.symbol`
|
||
// keeps this engine, review's `bucketForSymbol`, and
|
||
// doctor's `classifiableSymbols` all matching on
|
||
// priceSymbol().
|
||
var found = false;
|
||
for (classifications.entries) |entry| {
|
||
if (std.mem.eql(u8, entry.symbol, alloc.symbol)) {
|
||
found = true;
|
||
const frac = entry.pct / 100.0;
|
||
const portion = mv * frac;
|
||
|
||
// Sector breakdown: roll up by bucket (the
|
||
// pre-filled deriveBucket result on the entry).
|
||
if (entry.bucket) |b| {
|
||
const prev = sector_map.get(b) orelse 0;
|
||
try sector_map.put(b, prev + portion);
|
||
}
|
||
// Asset Category 4-bucket coarse breakdown
|
||
// (Equity / Fixed Income / Cash / Other) keeps
|
||
// using the raw `entry.sector` as input. Reasons:
|
||
// 1. `bucketSector` recognizes the NPORT-P
|
||
// prefixes ("Equity / *", "Debt / *", etc.)
|
||
// directly. The user-facing Sector breakdown
|
||
// bucket might be "US ETF" (a composite that
|
||
// doesn't carry the asset-type signal),
|
||
// but the underlying sector still does.
|
||
// 2. The Asset Category breakdown is the
|
||
// coarse "what's exposed to equity drawdowns?"
|
||
// view - invariant to the user's bucket
|
||
// curation, since it's a fundamental property
|
||
// of the holding.
|
||
if (entry.sector) |s| {
|
||
const cat = bucketSector(s);
|
||
const cprev = asset_cat_map.get(cat) orelse 0;
|
||
try asset_cat_map.put(cat, cprev + portion);
|
||
} else if (entry.asset_class) |ac| {
|
||
const cat = bucketAssetClass(ac);
|
||
const cprev = asset_cat_map.get(cat) orelse 0;
|
||
try asset_cat_map.put(cat, cprev + portion);
|
||
}
|
||
if (entry.geo) |g| {
|
||
const prev = geo_map.get(g) orelse 0;
|
||
try geo_map.put(g, prev + portion);
|
||
}
|
||
}
|
||
}
|
||
if (!found) {
|
||
try unclassified_list.append(allocator, alloc.display_symbol);
|
||
}
|
||
}
|
||
|
||
// Build symbol -> (current_price, price_ratio) lookup from allocations.
|
||
// For unmerged allocations, current_price already includes price_ratio (preadjusted).
|
||
// For merged allocations, current_price is the base-ticker price (not preadjusted).
|
||
const PriceEntry = struct { price: f64, is_preadjusted: bool };
|
||
var price_lookup = std.StringHashMap(PriceEntry).init(allocator);
|
||
defer price_lookup.deinit();
|
||
for (allocations) |alloc| {
|
||
try price_lookup.put(alloc.symbol, .{
|
||
.price = alloc.current_price,
|
||
.is_preadjusted = alloc.price_ratio != 1.0,
|
||
});
|
||
}
|
||
|
||
// Account breakdown from individual lots (avoids "Multiple" aggregation issue).
|
||
// Use `lotIsOpenAsOf(as_of)` so backfilled snapshots correctly include/
|
||
// exclude lots based on the target date. For "live" callers the right
|
||
// thing is to pass today; the resolution happens at the call site.
|
||
for (portfolio.lots) |lot| {
|
||
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
||
const acct = lot.account orelse continue;
|
||
const value: f64 = switch (lot.security_type) {
|
||
.stock => blk: {
|
||
if (price_lookup.get(lot.priceSymbol())) |entry| {
|
||
break :blk lot.marketValue(entry.price, entry.is_preadjusted);
|
||
} else {
|
||
// Fallback to open_price (already in lot-specific terms)
|
||
break :blk lot.shares * lot.open_price;
|
||
}
|
||
},
|
||
.cash => lot.shares,
|
||
.cd => lot.shares, // face value
|
||
// Premium at open. `multiplier` (100 for standard US equity
|
||
// options) is NOT optional - omitting it counted 1/100th of
|
||
// the premium and left every option-holding account's row
|
||
// short by 99% of it, so `zfin analysis`'s By Account
|
||
// section disagreed with its own Options sector row and
|
||
// with the `kind::account` totals in every snapshot.
|
||
//
|
||
// Must stay identical to `Portfolio.nonStockValueForAccount`
|
||
// and the `.option` arm of `commands/snapshot.zig:buildSnapshot`.
|
||
// All three use `@abs`, so a WRITTEN (short) option counts as
|
||
// a positive asset rather than a liability. That is a
|
||
// deliberate shared convention, not an oversight - do not
|
||
// "fix" it at one site alone.
|
||
.option => @abs(lot.shares) * lot.open_price * lot.multiplier,
|
||
.illiquid, .watch => continue,
|
||
};
|
||
const prev = acct_map.get(acct) orelse 0;
|
||
try acct_map.put(acct, prev + value);
|
||
}
|
||
|
||
// Add non-stock holdings (cash, CDs, options) into the
|
||
// coarse asset_category breakdown. They have no entry in
|
||
// the classification map (it's keyed by ticker), so we
|
||
// route them to coarse buckets directly.
|
||
const cash_total = portfolio.totalCash(as_of);
|
||
const cd_total = portfolio.totalCdFaceValue(as_of);
|
||
const cash_cd_total = cash_total + cd_total;
|
||
if (cash_cd_total > 0) {
|
||
const gprev = geo_map.get("US") orelse 0;
|
||
try geo_map.put("US", gprev + cash_cd_total);
|
||
// Literal cash and CDs roll into the coarse Cash bucket.
|
||
const bprev = asset_cat_map.get(bucket_cash) orelse 0;
|
||
try asset_cat_map.put(bucket_cash, bprev + cash_cd_total);
|
||
// Also surface in the Sector breakdown as "Cash & CDs"
|
||
// so users with significant cash positions see the
|
||
// line. Without this, the Sector breakdown would
|
||
// silently omit cash entirely.
|
||
const sprev = sector_map.get("Cash & CDs") orelse 0;
|
||
try sector_map.put("Cash & CDs", sprev + cash_cd_total);
|
||
}
|
||
const opt_total = portfolio.totalOptionCost(as_of);
|
||
if (opt_total > 0) {
|
||
// Options are derivatives; coarse bucket is Other.
|
||
const bprev = asset_cat_map.get(bucket_other) orelse 0;
|
||
try asset_cat_map.put(bucket_other, bprev + opt_total);
|
||
// Surface in Sector breakdown too.
|
||
const sprev = sector_map.get("Options") orelse 0;
|
||
try sector_map.put("Options", sprev + opt_total);
|
||
}
|
||
|
||
// Tax type breakdown: apportion each account's total across the tax
|
||
// types it actually holds (see `TaxMix`). Most accounts are a single
|
||
// type at weight 1.0 and land on exactly one row. Accounts absent
|
||
// from accounts.srf collapse into a single "Unknown" row.
|
||
if (account_map) |am| {
|
||
var acct_iter = acct_map.iterator();
|
||
while (acct_iter.next()) |kv| {
|
||
const value = kv.value_ptr.*;
|
||
const mix = am.taxMixFor(kv.key_ptr.*) orelse {
|
||
const prev = tax_map.get("Unknown") orelse 0;
|
||
try tax_map.put("Unknown", prev + value);
|
||
continue;
|
||
};
|
||
for (std.enums.values(TaxType)) |t| {
|
||
const w = mix.weightOf(t);
|
||
if (w <= 0) continue;
|
||
const label = t.label();
|
||
const prev = tax_map.get(label) orelse 0;
|
||
try tax_map.put(label, prev + value * w);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Convert maps to sorted slices
|
||
const total = if (total_portfolio_value > 0) total_portfolio_value else 1.0;
|
||
|
||
// Hoisted so the per-account harvested annotations can be stamped
|
||
// in before the result is returned. `analyzePortfolio` already has
|
||
// both inputs the annotation needs (`account_map` and `as_of`), so
|
||
// no renderer needs to learn about accounts.srf.
|
||
const account_items = try mapToSortedBreakdown(allocator, acct_map, total);
|
||
if (account_map) |am| annotateAccountBreakdown(account_items, am, as_of);
|
||
|
||
return .{
|
||
.asset_category = try mapToSortedBreakdown(allocator, asset_cat_map, total),
|
||
.sector = try mapToSortedBreakdown(allocator, sector_map, total),
|
||
.geo = try mapToSortedBreakdown(allocator, geo_map, total),
|
||
.account = account_items,
|
||
.tax_type = try mapToSortedBreakdown(allocator, tax_map, total),
|
||
.unclassified = try unclassified_list.toOwnedSlice(allocator),
|
||
.total_value = total_portfolio_value,
|
||
};
|
||
}
|
||
|
||
/// Convert a label->value HashMap to a sorted BreakdownItem slice (descending by value).
|
||
fn mapToSortedBreakdown(
|
||
allocator: std.mem.Allocator,
|
||
map: std.StringHashMap(f64),
|
||
total: f64,
|
||
) ![]BreakdownItem {
|
||
var items = std.ArrayList(BreakdownItem).empty;
|
||
errdefer items.deinit(allocator);
|
||
|
||
var iter = map.iterator();
|
||
while (iter.next()) |kv| {
|
||
try items.append(allocator, .{
|
||
.label = kv.key_ptr.*,
|
||
.value = kv.value_ptr.*,
|
||
.weight = kv.value_ptr.* / total,
|
||
});
|
||
}
|
||
|
||
// Sort descending by value
|
||
std.mem.sort(BreakdownItem, items.items, {}, struct {
|
||
fn f(_: void, a: BreakdownItem, b: BreakdownItem) bool {
|
||
return a.value > b.value;
|
||
}
|
||
}.f);
|
||
|
||
return items.toOwnedSlice(allocator);
|
||
}
|
||
|
||
/// Re-aggregate a raw `BreakdownItem` slice through `collapseSector`
|
||
/// at the chosen granularity. Multiple input rows that map to the
|
||
/// same coarser bucket sum into one output row. Returns a new
|
||
/// allocator-owned slice; caller frees with `allocator.free`.
|
||
///
|
||
/// Use case: `analyze` produces `result.sector` at fine
|
||
/// granularity (raw NPORT-P + GICS labels). Display callers
|
||
/// (CLI `--sector-detail`, TUI hot-key) call this to re-bucket
|
||
/// at their chosen tier. At `.fine`, the function still
|
||
/// allocates a fresh slice (so callers always free the result
|
||
/// the same way) but the per-row labels and weights are
|
||
/// identical to the input.
|
||
pub fn collapseBreakdownAtGranularity(
|
||
allocator: std.mem.Allocator,
|
||
items: []const BreakdownItem,
|
||
granularity: Granularity,
|
||
total: f64,
|
||
) ![]BreakdownItem {
|
||
var map = std.StringHashMap(f64).init(allocator);
|
||
defer map.deinit();
|
||
|
||
for (items) |item| {
|
||
const label = collapseSector(item.label, granularity);
|
||
const prev = map.get(label) orelse 0;
|
||
try map.put(label, prev + item.value);
|
||
}
|
||
|
||
return mapToSortedBreakdown(allocator, map, total);
|
||
}
|
||
|
||
test "parseAccountsFile" {
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample Roth,tax_type::roth
|
||
\\account::Sample Trust,tax_type::taxable
|
||
\\account::Sample HSA,tax_type::hsa
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(usize, 3), am.entries.len);
|
||
try std.testing.expectEqualStrings("Roth (Post-Tax)", am.taxTypeFor("Sample Roth"));
|
||
try std.testing.expectEqualStrings("Taxable", am.taxTypeFor("Sample Trust"));
|
||
try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Sample HSA"));
|
||
try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent"));
|
||
}
|
||
|
||
test "parseAccountsFile: institution + account_number round-trip via findByInstitutionAccount" {
|
||
// The import command's WF resolver, the audit reconciler's
|
||
// schwab/fidelity match logic, and the snapshot writer all
|
||
// depend on `findByInstitutionAccount` finding entries that
|
||
// were parsed from `accounts.srf`. Pin the round-trip so a
|
||
// future change to either parseAccountsFile or
|
||
// findByInstitutionAccount can't silently drop the link.
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample Fidelity Brokerage,tax_type::taxable,institution::fidelity,account_number::Z123
|
||
\\account::Schwab Trust,tax_type::taxable,institution::schwab,account_number::1234
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||
try std.testing.expectEqualStrings("Sample Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?);
|
||
try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "1234").?);
|
||
// Wrong institution / wrong number -> null.
|
||
try std.testing.expect(am.findByInstitutionAccount("schwab", "Z123") == null);
|
||
try std.testing.expect(am.findByInstitutionAccount("fidelity", "ZZZ") == null);
|
||
}
|
||
|
||
test "parseAccountsFile: cash_is_contribution default false, opt-in true" {
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Riley ESPP,tax_type::taxable,cash_is_contribution:bool:true
|
||
\\account::Joint cash,tax_type::taxable
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||
// Opted-in account
|
||
try std.testing.expect(am.cashIsContribution("Riley ESPP"));
|
||
// Default-off account
|
||
try std.testing.expect(!am.cashIsContribution("Joint cash"));
|
||
// Unknown account defaults to false
|
||
try std.testing.expect(!am.cashIsContribution("Nonexistent"));
|
||
}
|
||
|
||
test "parseAccountsFile: a hand-typed string separator on a numeric field still parses" {
|
||
// `accounts.srf` is hand-edited, so `harvested::5000` instead of
|
||
// `harvested:num:5000` is a slip rather than different intent. Under
|
||
// SRF's strict default that string reaches an unchecked
|
||
// `val.?.number` - a panic in Debug and undefined behaviour in
|
||
// ReleaseFast - which is why this parser opts into
|
||
// `srf_opts.user_edited`. Pinned because the option is easy to drop
|
||
// and the failure is silent in the build zfin actually ships.
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample Brokerage,tax_type::taxable,harvested::5000,harvested_date::2026-06-01
|
||
\\account::Sample IRA,tax_type::traditional,audit_large_lot_threshold::25000
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 5000), am.entries[0].harvested.?, 0.001);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 25000), am.entries[1].audit_large_lot_threshold.?, 0.001);
|
||
}
|
||
|
||
test "parseAccountsFile: direct_indexing default false, opt-in true" {
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Tax Loss,tax_type::taxable,direct_indexing:bool:true
|
||
\\account::Regular Brokerage,tax_type::taxable
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||
try std.testing.expect(am.isDirectIndexing("Tax Loss"));
|
||
try std.testing.expect(!am.isDirectIndexing("Regular Brokerage"));
|
||
try std.testing.expect(!am.isDirectIndexing("Nonexistent"));
|
||
}
|
||
|
||
test "parseAccountsFile: shielded omitted -> null (use tax_type default)" {
|
||
// Default behavior: when no `shielded` override is given,
|
||
// the field stays null and the umbrella-exposure calculation
|
||
// uses tax_type to decide.
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample IRA,tax_type::traditional
|
||
\\account::Sample Brokerage,tax_type::taxable
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||
try std.testing.expect(am.entries[0].shielded == null);
|
||
try std.testing.expect(am.entries[1].shielded == null);
|
||
}
|
||
|
||
test "parseAccountsFile: shielded:bool:false override parses correctly" {
|
||
// Use case: pre-tax deferred-comp account that's NOT
|
||
// ERISA-protected (e.g. Fidelity DCP). tax_type stays as
|
||
// `traditional` (correct for tax purposes), `shielded` is
|
||
// overridden to false (correct for umbrella purposes).
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample DCP,tax_type::traditional,shielded:bool:false
|
||
\\account::Sample IRA,tax_type::traditional
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||
// DCP: explicit override to false.
|
||
try std.testing.expect(am.entries[0].shielded != null);
|
||
try std.testing.expect(!am.entries[0].shielded.?);
|
||
// IRA: no override, stays null.
|
||
try std.testing.expect(am.entries[1].shielded == null);
|
||
}
|
||
|
||
test "parseAccountsFile: shielded:bool:true override (rare, e.g. asset-protection trust)" {
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample Trust,tax_type::taxable,shielded:bool:true
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), am.entries.len);
|
||
try std.testing.expect(am.entries[0].shielded != null);
|
||
try std.testing.expect(am.entries[0].shielded.?);
|
||
}
|
||
|
||
test "parseAccountsFile: audit_large_lot_threshold omitted -> null (use audit default)" {
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample Roth,tax_type::roth
|
||
\\account::Sample Brokerage,tax_type::taxable
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||
// No override on either account -> lookup returns null so the
|
||
// audit falls back to its built-in default.
|
||
try std.testing.expect(am.entries[0].audit_large_lot_threshold == null);
|
||
try std.testing.expect(am.largeLotThresholdFor("Sample Roth") == null);
|
||
try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null);
|
||
}
|
||
|
||
test "parseAccountsFile: per-account audit_large_lot_threshold parses and is looked up by account" {
|
||
// Mixed: one account raises its threshold (e.g. a noisy ESPP
|
||
// account), a sibling leaves it default.
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
|
||
\\account::Sample Brokerage,tax_type::taxable
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), am.largeLotThresholdFor("Sample ESPP").?, 0.01);
|
||
// Sibling account has no override.
|
||
try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null);
|
||
// Unknown account -> null (falls back to default downstream).
|
||
try std.testing.expect(am.largeLotThresholdFor("Nonexistent") == null);
|
||
}
|
||
|
||
test "parseAccountsFile: audit_large_lot_threshold accepts a fractional value" {
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample Brokerage,tax_type::taxable,audit_large_lot_threshold:num:7500.5
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectApproxEqAbs(@as(f64, 7500.5), am.largeLotThresholdFor("Sample Brokerage").?, 0.001);
|
||
}
|
||
|
||
test "parseAccountsFile: non-positive audit_large_lot_threshold is rejected -> null" {
|
||
// Zero and negative thresholds are nonsensical (zero flags every
|
||
// new lot; negative is meaningless). They're dropped at parse
|
||
// time so the audit falls back to its built-in default. The
|
||
// surrounding account still parses.
|
||
inline for (.{ "0", "-5000" }) |bad| {
|
||
const data = "#!srfv1\naccount::Sample Brokerage,tax_type::taxable,audit_large_lot_threshold:num:" ++ bad ++ "\n";
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
try std.testing.expectEqual(@as(usize, 1), am.entries.len);
|
||
try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null);
|
||
}
|
||
}
|
||
|
||
// ── harvested / harvested_date ───────────────────────────────
|
||
|
||
test "parseAccountsFile: harvested omitted -> harvestedFor returns null" {
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample Brokerage,tax_type::taxable
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expect(am.entries[0].harvested == null);
|
||
try std.testing.expect(am.entries[0].harvested_date == null);
|
||
try std.testing.expect(am.harvestedFor("Sample Brokerage") == null);
|
||
}
|
||
|
||
test "parseAccountsFile: harvested + harvested_date parse and look up by account" {
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample Tax Loss,tax_type::taxable,direct_indexing:bool:true,harvested:num:45300,harvested_date::2026-06-24
|
||
\\account::Sample Brokerage,tax_type::taxable
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
const h = am.harvestedFor("Sample Tax Loss").?;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 45_300), h.amount, 0.001);
|
||
try std.testing.expect(h.as_of.?.eql(Date.fromYmd(2026, 6, 24)));
|
||
// Sibling account is unaffected.
|
||
try std.testing.expect(am.harvestedFor("Sample Brokerage") == null);
|
||
// Unknown account.
|
||
try std.testing.expect(am.harvestedFor("Nope") == null);
|
||
}
|
||
|
||
test "parseAccountsFile: negative harvested is normalized to a magnitude" {
|
||
// The annotation's parens carry the "this is a loss" convention, so
|
||
// a user writing the figure as a negative gets the same result.
|
||
inline for (.{ "45300", "-45300" }) |written| {
|
||
const data = "#!srfv1\naccount::Sample Tax Loss,tax_type::taxable,harvested:num:" ++ written ++ ",harvested_date::2026-06-24\n";
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
try std.testing.expectApproxEqAbs(@as(f64, 45_300), am.harvestedFor("Sample Tax Loss").?.amount, 0.001);
|
||
}
|
||
}
|
||
|
||
test "parseAccountsFile: zero harvested is kept (means nothing harvested yet)" {
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:0,harvested_date::2026-06-24
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), am.harvestedFor("Sample Tax Loss").?.amount, 0.001);
|
||
}
|
||
|
||
test "parseAccountsFile: harvested without harvested_date parses but is not renderable" {
|
||
// Kept in the map (doctor nags about the missing date); the
|
||
// annotation formatter is what refuses to render it.
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
const h = am.harvestedFor("Sample Tax Loss").?;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 45_300), h.amount, 0.001);
|
||
try std.testing.expect(h.as_of == null);
|
||
|
||
var buf: [fmt.harvest_annotation_max_len]u8 = undefined;
|
||
try std.testing.expectEqualStrings("", fmt.fmtHarvestAnnotation(&buf, h.amount, h.as_of, Date.fromYmd(2026, 7, 25)));
|
||
}
|
||
|
||
test "parseAccountsFile: harvested_date without harvested is inert" {
|
||
const data =
|
||
\\#!srfv1
|
||
\\account::Sample Tax Loss,tax_type::taxable,harvested_date::2026-06-24
|
||
;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expect(am.harvestedFor("Sample Tax Loss") == null);
|
||
try std.testing.expect(am.entries[0].harvested_date != null);
|
||
}
|
||
|
||
test "parseAccountsFile: non-finite harvested is rejected -> null, account still parses" {
|
||
inline for (.{ "nan", "inf", "-inf" }) |bad| {
|
||
const data = "#!srfv1\naccount::Sample Tax Loss,tax_type::taxable,harvested:num:" ++ bad ++ ",harvested_date::2026-06-24\n";
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, data);
|
||
defer am.deinit();
|
||
try std.testing.expectEqual(@as(usize, 1), am.entries.len);
|
||
try std.testing.expectEqual(TaxType.taxable, am.entries[0].tax_type);
|
||
try std.testing.expect(am.harvestedFor("Sample Tax Loss") == null);
|
||
}
|
||
}
|
||
|
||
test "Annotation.from: empty string yields null, round-trips otherwise" {
|
||
try std.testing.expect(Annotation.from("") == null);
|
||
// Longer than the inline buffer is dropped rather than truncated.
|
||
try std.testing.expect(Annotation.from("x" ** (Annotation.capacity + 1)) == null);
|
||
const a = Annotation.from("(45k 6/24)").?;
|
||
try std.testing.expectEqualStrings("(45k 6/24)", a.slice());
|
||
// Exactly-buffer-sized input fits.
|
||
const full = Annotation.from("x" ** Annotation.capacity).?;
|
||
try std.testing.expectEqualStrings("x" ** Annotation.capacity, full.slice());
|
||
// Every producer's widest output must fit - mirrored by the comptime
|
||
// assert in annotateAccountBreakdown.
|
||
try std.testing.expect(fmt.harvest_annotation_max_len <= Annotation.capacity);
|
||
}
|
||
|
||
test "annotateAccountBreakdown: stamps only accounts with fresh harvested data" {
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-06-24
|
||
\\account::Sample Stale,tax_type::taxable,harvested:num:12000,harvested_date::2024-01-15
|
||
\\account::Sample Dateless,tax_type::taxable,harvested:num:9000
|
||
\\account::Sample Brokerage,tax_type::taxable
|
||
);
|
||
defer am.deinit();
|
||
|
||
var items = [_]BreakdownItem{
|
||
.{ .label = "Sample Tax Loss", .value = 412_300, .weight = 0.4 },
|
||
.{ .label = "Sample Stale", .value = 100_000, .weight = 0.2 },
|
||
.{ .label = "Sample Dateless", .value = 100_000, .weight = 0.2 },
|
||
.{ .label = "Sample Brokerage", .value = 100_000, .weight = 0.1 },
|
||
.{ .label = "Not In accounts.srf", .value = 50_000, .weight = 0.1 },
|
||
};
|
||
annotateAccountBreakdown(&items, am, Date.fromYmd(2026, 7, 25));
|
||
|
||
try std.testing.expectEqualStrings("(45k 6/24)", items[0].annotation.?.slice());
|
||
try std.testing.expect(items[1].annotation == null); // >12 months old
|
||
try std.testing.expect(items[2].annotation == null); // no date
|
||
try std.testing.expect(items[3].annotation == null); // no harvested field
|
||
try std.testing.expect(items[4].annotation == null); // not in accounts.srf
|
||
}
|
||
|
||
test "annotateAccountBreakdown: as_of is the reference date, not necessarily today" {
|
||
// A back-dated analysis must not surface a harvest figure recorded
|
||
// after the date being analyzed.
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-06-24
|
||
);
|
||
defer am.deinit();
|
||
|
||
var items = [_]BreakdownItem{.{ .label = "Sample Tax Loss", .value = 1000, .weight = 1.0 }};
|
||
annotateAccountBreakdown(&items, am, Date.fromYmd(2026, 1, 1));
|
||
try std.testing.expect(items[0].annotation == null);
|
||
}
|
||
|
||
// ── umbrellaExposure ─────────────────────────────────────────
|
||
|
||
/// Helper: build an in-memory AccountMap from a literal SRF
|
||
/// string. Keeps each test compact while exercising the real
|
||
/// parsing path.
|
||
fn testParseAccountMap(comptime data: []const u8) !AccountMap {
|
||
return parseAccountsFile(std.testing.allocator, data);
|
||
}
|
||
|
||
test "umbrellaExposure: traditional/roth/hsa default to shielded; taxable to exposed" {
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::IRA,tax_type::traditional
|
||
\\account::Roth IRA,tax_type::roth
|
||
\\account::HSA,tax_type::hsa
|
||
\\account::Brokerage,tax_type::taxable
|
||
);
|
||
defer am.deinit();
|
||
|
||
const accounts = [_]BreakdownItem{
|
||
.{ .label = "IRA", .value = 1_000_000, .weight = 0.40 },
|
||
.{ .label = "Roth IRA", .value = 500_000, .weight = 0.20 },
|
||
.{ .label = "HSA", .value = 100_000, .weight = 0.04 },
|
||
.{ .label = "Brokerage", .value = 900_000, .weight = 0.36 },
|
||
};
|
||
const u = umbrellaExposure(&accounts, am);
|
||
|
||
try std.testing.expectApproxEqAbs(@as(f64, 2_500_000), u.total_liquid, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1_600_000), u.shielded_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 900_000), u.exposed_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.36), u.exposed_pct, 0.001);
|
||
}
|
||
|
||
test "umbrellaExposure: shielded:bool:false override flips traditional account to exposed" {
|
||
// The DCP case: pre-tax payroll account, traditional tax
|
||
// treatment, but NOT ERISA-shielded. Override with
|
||
// `shielded:bool:false` and the umbrella math counts it
|
||
// toward exposure.
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::IRA,tax_type::traditional
|
||
\\account::DCP,tax_type::traditional,shielded:bool:false
|
||
);
|
||
defer am.deinit();
|
||
|
||
const accounts = [_]BreakdownItem{
|
||
.{ .label = "IRA", .value = 1_000_000, .weight = 0.50 },
|
||
.{ .label = "DCP", .value = 1_000_000, .weight = 0.50 },
|
||
};
|
||
const u = umbrellaExposure(&accounts, am);
|
||
|
||
try std.testing.expectApproxEqAbs(@as(f64, 2_000_000), u.total_liquid, 1.0);
|
||
// IRA shielded (default), DCP exposed (override).
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), u.shielded_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), u.exposed_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.50), u.exposed_pct, 0.001);
|
||
}
|
||
|
||
test "umbrellaExposure: shielded:bool:true override flips taxable account to shielded" {
|
||
// Asset-protection trust, taxable for tax purposes but
|
||
// legally shielded.
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Trust,tax_type::taxable,shielded:bool:true
|
||
\\account::Brokerage,tax_type::taxable
|
||
);
|
||
defer am.deinit();
|
||
|
||
const accounts = [_]BreakdownItem{
|
||
.{ .label = "Trust", .value = 500_000, .weight = 0.50 },
|
||
.{ .label = "Brokerage", .value = 500_000, .weight = 0.50 },
|
||
};
|
||
const u = umbrellaExposure(&accounts, am);
|
||
|
||
try std.testing.expectApproxEqAbs(@as(f64, 500_000), u.shielded_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 500_000), u.exposed_value, 1.0);
|
||
}
|
||
|
||
test "umbrellaExposure: account not in map defaults to exposed (defensive)" {
|
||
// Defensive default: if an account name in the breakdown
|
||
// doesn't appear in accounts.srf, treat it as exposed
|
||
// rather than silently shielding it. The user will see the
|
||
// overstated exposure and notice the missing entry.
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::IRA,tax_type::traditional
|
||
);
|
||
defer am.deinit();
|
||
|
||
const accounts = [_]BreakdownItem{
|
||
.{ .label = "IRA", .value = 100_000, .weight = 0.50 },
|
||
.{ .label = "Mystery Account", .value = 100_000, .weight = 0.50 },
|
||
};
|
||
const u = umbrellaExposure(&accounts, am);
|
||
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100_000), u.shielded_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100_000), u.exposed_value, 1.0);
|
||
}
|
||
|
||
test "umbrellaExposure: empty breakdown returns zeros and avoids divide-by-zero" {
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::IRA,tax_type::traditional
|
||
);
|
||
defer am.deinit();
|
||
|
||
const u = umbrellaExposure(&.{}, am);
|
||
try std.testing.expectEqual(@as(f64, 0), u.total_liquid);
|
||
try std.testing.expectEqual(@as(f64, 0), u.shielded_value);
|
||
try std.testing.expectEqual(@as(f64, 0), u.exposed_value);
|
||
try std.testing.expectEqual(@as(f64, 0), u.exposed_pct);
|
||
}
|
||
|
||
test "umbrellaExposure: HSA counts as shielded" {
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::HSA,tax_type::hsa
|
||
);
|
||
defer am.deinit();
|
||
|
||
const accounts = [_]BreakdownItem{
|
||
.{ .label = "HSA", .value = 50_000, .weight = 1.0 },
|
||
};
|
||
const u = umbrellaExposure(&accounts, am);
|
||
|
||
try std.testing.expectApproxEqAbs(@as(f64, 50_000), u.shielded_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), u.exposed_value, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), u.exposed_pct, 0.001);
|
||
}
|
||
|
||
test "umbrellaExposure: realistic mixed portfolio" {
|
||
// Approximation of the user's actual portfolio shape:
|
||
// mostly Traditional 401k (shielded), some IRAs (shielded),
|
||
// taxable brokerage (exposed), and one DCP that LOOKS
|
||
// traditional but isn't ERISA-shielded.
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Sample 401k,tax_type::traditional
|
||
\\account::Sample DCP,tax_type::traditional,shielded:bool:false
|
||
\\account::Sample IRA,tax_type::traditional
|
||
\\account::Sample Roth,tax_type::roth
|
||
\\account::Sample HSA,tax_type::hsa
|
||
\\account::Sample Trust,tax_type::taxable
|
||
);
|
||
defer am.deinit();
|
||
|
||
const accounts = [_]BreakdownItem{
|
||
.{ .label = "Sample 401k", .value = 900_000, .weight = 0.30 },
|
||
.{ .label = "Sample DCP", .value = 1_500_000, .weight = 0.50 },
|
||
.{ .label = "Sample IRA", .value = 200_000, .weight = 0.067 },
|
||
.{ .label = "Sample Roth", .value = 100_000, .weight = 0.033 },
|
||
.{ .label = "Sample HSA", .value = 50_000, .weight = 0.017 },
|
||
.{ .label = "Sample Trust", .value = 250_000, .weight = 0.083 },
|
||
};
|
||
const u = umbrellaExposure(&accounts, am);
|
||
|
||
// Shielded: 401k + IRA + Roth + HSA = 900k + 200k + 100k + 50k = 1,250,000
|
||
// Exposed: DCP + Trust = 1,500,000 + 250,000 = 1,750,000
|
||
try std.testing.expectApproxEqAbs(@as(f64, 3_000_000), u.total_liquid, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1_250_000), u.shielded_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1_750_000), u.exposed_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.5833), u.exposed_pct, 0.001);
|
||
}
|
||
|
||
test "umbrellaExposure: a taxable carve-out exposes a fraction of one account" {
|
||
// A 401(k) with an after-tax non-Roth sleeve: the pre-tax and Roth
|
||
// money is ERISA-shielded, but the taxable-basis slice is not, so the
|
||
// account splits rather than landing wholly on one side.
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:20,tax_mix_taxable:num:10
|
||
\\account::Sample Brokerage,tax_type::taxable
|
||
);
|
||
defer am.deinit();
|
||
|
||
const accounts = [_]BreakdownItem{
|
||
.{ .label = "Sample 401k", .value = 1_000_000, .weight = 0.8 },
|
||
.{ .label = "Sample Brokerage", .value = 250_000, .weight = 0.2 },
|
||
};
|
||
const u = umbrellaExposure(&accounts, am);
|
||
|
||
// 401k: 90% shielded (traditional 70% + roth 20%) = 900,000
|
||
// 10% exposed (taxable carve-out) = 100,000
|
||
// Brokerage: fully exposed = 250,000
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1_250_000), u.total_liquid, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 900_000), u.shielded_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 350_000), u.exposed_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.28), u.exposed_pct, 0.0001);
|
||
}
|
||
|
||
test "umbrellaExposure: a traditional/roth mix changes nothing" {
|
||
// Both halves are shielded, so splitting a 401(k) between pre-tax and
|
||
// Roth must leave the umbrella numbers exactly where they were. This
|
||
// pins the claim that the common mix is inert here.
|
||
var mixed = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4
|
||
);
|
||
defer mixed.deinit();
|
||
var plain = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Sample 401k,tax_type::traditional
|
||
);
|
||
defer plain.deinit();
|
||
|
||
const accounts = [_]BreakdownItem{
|
||
.{ .label = "Sample 401k", .value = 455_000, .weight = 1.0 },
|
||
};
|
||
const a = umbrellaExposure(&accounts, mixed);
|
||
const b = umbrellaExposure(&accounts, plain);
|
||
try std.testing.expectEqual(b.shielded_value, a.shielded_value);
|
||
try std.testing.expectEqual(b.exposed_value, a.exposed_value);
|
||
try std.testing.expectEqual(b.exposed_pct, a.exposed_pct);
|
||
}
|
||
|
||
test "umbrellaExposure: an explicit shielded override outranks the tax mix" {
|
||
// `shielded` is a statement about legal protection; a split of the
|
||
// account's *tax* treatment has no business overriding it. So the
|
||
// override applies wholesale in both directions.
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Sample DCP,tax_type::traditional,tax_mix_roth:num:30,shielded:bool:false
|
||
\\account::Sample Trust,tax_type::taxable,tax_mix_traditional:num:40,shielded:bool:true
|
||
);
|
||
defer am.deinit();
|
||
|
||
const accounts = [_]BreakdownItem{
|
||
.{ .label = "Sample DCP", .value = 600_000, .weight = 0.6 },
|
||
.{ .label = "Sample Trust", .value = 400_000, .weight = 0.4 },
|
||
};
|
||
const u = umbrellaExposure(&accounts, am);
|
||
|
||
// DCP fully exposed despite 100% of its mix being non-taxable;
|
||
// Trust fully shielded despite 60% of its mix being taxable.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 400_000), u.shielded_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 600_000), u.exposed_value, 1.0);
|
||
}
|
||
|
||
test "umbrellaExposure: an invalid tax mix falls back without disturbing the split" {
|
||
// A rejected carve-out must behave exactly like no carve-out - not
|
||
// like a partial or clamped one.
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Sample 401k,tax_type::traditional,tax_mix_taxable:num:100
|
||
);
|
||
defer am.deinit();
|
||
|
||
const accounts = [_]BreakdownItem{
|
||
.{ .label = "Sample 401k", .value = 500_000, .weight = 1.0 },
|
||
};
|
||
const u = umbrellaExposure(&accounts, am);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 500_000), u.shielded_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0), u.exposed_value, 1.0);
|
||
}
|
||
|
||
test "TaxType.label" {
|
||
try std.testing.expectEqualStrings("Taxable", TaxType.taxable.label());
|
||
try std.testing.expectEqualStrings("Roth (Post-Tax)", TaxType.roth.label());
|
||
try std.testing.expectEqualStrings("Traditional (Pre-Tax)", TaxType.traditional.label());
|
||
try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", TaxType.hsa.label());
|
||
}
|
||
|
||
// ── TaxMix ───────────────────────────────────────────────────
|
||
|
||
/// Helper: total weight, which every valid mix must drive to 1.0.
|
||
fn testMixSum(mix: TaxMix) f64 {
|
||
var sum: f64 = 0;
|
||
for (mix.weights) |w| sum += w;
|
||
return sum;
|
||
}
|
||
|
||
test "TaxMix.shieldedWeight: excludes only the taxable share" {
|
||
// The umbrella default rule, generalized. Roth / traditional / HSA
|
||
// are all shielded, so only a taxable slice reduces the weight.
|
||
try std.testing.expectEqual(@as(f64, 1.0), TaxMix.single(.traditional).shieldedWeight());
|
||
try std.testing.expectEqual(@as(f64, 1.0), TaxMix.single(.roth).shieldedWeight());
|
||
try std.testing.expectEqual(@as(f64, 1.0), TaxMix.single(.hsa).shieldedWeight());
|
||
try std.testing.expectEqual(@as(f64, 0), TaxMix.single(.taxable).shieldedWeight());
|
||
|
||
const split: AccountTaxEntry = .{
|
||
.account = "Sample 401k",
|
||
.tax_type = .traditional,
|
||
.tax_mix_taxable = 15,
|
||
};
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.85), split.taxMix().shieldedWeight(), 1e-12);
|
||
}
|
||
|
||
test "AccountTaxEntry.taxMix: no carve-outs -> single mix on tax_type" {
|
||
// The backward-compatibility guarantee: an account that says nothing
|
||
// about a mix behaves exactly as it did before mixed treatment.
|
||
for (std.enums.values(TaxType)) |t| {
|
||
const e: AccountTaxEntry = .{ .account = "Sample Account", .tax_type = t };
|
||
const checked = e.taxMixChecked();
|
||
try std.testing.expectEqual(@as(?TaxMixProblem, null), checked.problem);
|
||
try std.testing.expectEqual(@as(f64, 1.0), checked.mix.weightOf(t));
|
||
try std.testing.expect(!e.hasTaxMix());
|
||
}
|
||
}
|
||
|
||
test "AccountTaxEntry.taxMix: one carve-out leaves the residual on tax_type" {
|
||
// The motivating case: a 401(k) reporting one balance that is really
|
||
// 77.6% pre-tax (deferrals + match) and 22.4% Roth.
|
||
const e: AccountTaxEntry = .{
|
||
.account = "Sample 401k",
|
||
.tax_type = .traditional,
|
||
.tax_mix_roth = 22.4,
|
||
};
|
||
const checked = e.taxMixChecked();
|
||
try std.testing.expectEqual(@as(?TaxMixProblem, null), checked.problem);
|
||
try std.testing.expect(e.hasTaxMix());
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.224), checked.mix.weightOf(.roth), 1e-12);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.776), checked.mix.weightOf(.traditional), 1e-12);
|
||
try std.testing.expectEqual(@as(f64, 0), checked.mix.weightOf(.taxable));
|
||
try std.testing.expectEqual(@as(f64, 0), checked.mix.weightOf(.hsa));
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1.0), testMixSum(checked.mix), 1e-12);
|
||
}
|
||
|
||
test "AccountTaxEntry.taxMix: multiple carve-outs across three types" {
|
||
// Pre-tax primary, plus an in-plan Roth sleeve and an after-tax
|
||
// non-Roth sleeve whose basis is taxable money.
|
||
const e: AccountTaxEntry = .{
|
||
.account = "Sample 401k",
|
||
.tax_type = .traditional,
|
||
.tax_mix_roth = 20,
|
||
.tax_mix_taxable = 5,
|
||
};
|
||
const mix = e.taxMix();
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.20), mix.weightOf(.roth), 1e-12);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.05), mix.weightOf(.taxable), 1e-12);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.75), mix.weightOf(.traditional), 1e-12);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1.0), testMixSum(mix), 1e-12);
|
||
}
|
||
|
||
test "AccountTaxEntry.taxMix: weights sum to 1.0 for every valid shape" {
|
||
const cases = [_]AccountTaxEntry{
|
||
.{ .account = "A", .tax_type = .traditional },
|
||
.{ .account = "B", .tax_type = .traditional, .tax_mix_roth = 22.4 },
|
||
.{ .account = "C", .tax_type = .roth, .tax_mix_traditional = 33.3333 },
|
||
.{ .account = "D", .tax_type = .taxable, .tax_mix_roth = 1, .tax_mix_hsa = 2, .tax_mix_traditional = 3 },
|
||
.{ .account = "E", .tax_type = .hsa, .tax_mix_taxable = 99.9999 },
|
||
// Rejected shapes still produce a normalized single mix.
|
||
.{ .account = "F", .tax_type = .traditional, .tax_mix_roth = 150 },
|
||
.{ .account = "G", .tax_type = .traditional, .tax_mix_traditional = 40 },
|
||
};
|
||
for (cases) |e| {
|
||
try std.testing.expectApproxEqAbs(@as(f64, 1.0), testMixSum(e.taxMix()), 1e-12);
|
||
}
|
||
}
|
||
|
||
test "AccountTaxEntry.taxMixChecked: non-positive carve-out falls back to tax_type" {
|
||
// Zero means "omit the field"; negative is meaningless. Neither is a
|
||
// guess worth honoring, so both degrade to the bare tax_type.
|
||
for ([_]f64{ 0, -10 }) |bad| {
|
||
const e: AccountTaxEntry = .{
|
||
.account = "Sample 401k",
|
||
.tax_type = .traditional,
|
||
.tax_mix_roth = bad,
|
||
};
|
||
const checked = e.taxMixChecked();
|
||
try std.testing.expectEqual(@as(?TaxMixProblem, .non_positive), checked.problem);
|
||
try std.testing.expectEqual(@as(f64, 1.0), checked.mix.weightOf(.traditional));
|
||
try std.testing.expectEqual(@as(f64, 0), checked.mix.weightOf(.roth));
|
||
// The user still declared something; doctor needs to know.
|
||
try std.testing.expect(e.hasTaxMix());
|
||
}
|
||
}
|
||
|
||
test "AccountTaxEntry.taxMixChecked: non-finite carve-out falls back to tax_type" {
|
||
for ([_]f64{ std.math.nan(f64), std.math.inf(f64), -std.math.inf(f64) }) |bad| {
|
||
const e: AccountTaxEntry = .{
|
||
.account = "Sample 401k",
|
||
.tax_type = .traditional,
|
||
.tax_mix_roth = bad,
|
||
};
|
||
const checked = e.taxMixChecked();
|
||
try std.testing.expectEqual(@as(?TaxMixProblem, .not_finite), checked.problem);
|
||
try std.testing.expectEqual(@as(f64, 1.0), checked.mix.weightOf(.traditional));
|
||
}
|
||
}
|
||
|
||
test "AccountTaxEntry.taxMixChecked: carve-out naming tax_type itself is rejected" {
|
||
// The primary's share is always the residual, so naming it is
|
||
// self-contradictory - and would invite a set that sums to 90.
|
||
const e: AccountTaxEntry = .{
|
||
.account = "Sample 401k",
|
||
.tax_type = .traditional,
|
||
.tax_mix_traditional = 77.6,
|
||
.tax_mix_roth = 22.4,
|
||
};
|
||
const checked = e.taxMixChecked();
|
||
try std.testing.expectEqual(@as(?TaxMixProblem, .redundant_primary), checked.problem);
|
||
try std.testing.expectEqual(@as(f64, 1.0), checked.mix.weightOf(.traditional));
|
||
try std.testing.expectEqual(@as(f64, 0), checked.mix.weightOf(.roth));
|
||
}
|
||
|
||
test "AccountTaxEntry.taxMixChecked: carve-outs summing to 100 or more are rejected" {
|
||
// Exactly 100 leaves the primary tax_type no share at all, which
|
||
// would make tax_type a lie rather than a residual holder.
|
||
const exact: AccountTaxEntry = .{
|
||
.account = "Sample 401k",
|
||
.tax_type = .traditional,
|
||
.tax_mix_roth = 60,
|
||
.tax_mix_taxable = 40,
|
||
};
|
||
try std.testing.expectEqual(@as(?TaxMixProblem, .over_allocated), exact.taxMixChecked().problem);
|
||
try std.testing.expectEqual(@as(f64, 1.0), exact.taxMix().weightOf(.traditional));
|
||
|
||
const over: AccountTaxEntry = .{
|
||
.account = "Sample 401k",
|
||
.tax_type = .traditional,
|
||
.tax_mix_roth = 130,
|
||
};
|
||
try std.testing.expectEqual(@as(?TaxMixProblem, .over_allocated), over.taxMixChecked().problem);
|
||
|
||
// Just under 100 is fine, however little is left for the primary.
|
||
const under: AccountTaxEntry = .{
|
||
.account = "Sample 401k",
|
||
.tax_type = .traditional,
|
||
.tax_mix_roth = 99.5,
|
||
};
|
||
try std.testing.expectEqual(@as(?TaxMixProblem, null), under.taxMixChecked().problem);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.005), under.taxMix().weightOf(.traditional), 1e-12);
|
||
}
|
||
|
||
test "parseAccountsFile: tax_mix carve-out and tax_mix_date round-trip" {
|
||
var am = try parseAccountsFile(std.testing.allocator,
|
||
\\#!srfv1
|
||
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2026-08-01
|
||
\\account::Sample Brokerage,tax_type::taxable
|
||
);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||
try std.testing.expectEqual(@as(?f64, 22.4), am.entries[0].tax_mix_roth);
|
||
try std.testing.expectEqual(@as(?Date, Date.fromYmd(2026, 8, 1)), am.entries[0].tax_mix_date);
|
||
try std.testing.expect(am.entries[0].hasTaxMix());
|
||
|
||
const mix = am.taxMixFor("Sample 401k").?;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.224), mix.weightOf(.roth), 1e-12);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.776), mix.weightOf(.traditional), 1e-12);
|
||
|
||
// An account with no carve-outs is untouched.
|
||
try std.testing.expect(!am.entries[1].hasTaxMix());
|
||
try std.testing.expectEqual(@as(?Date, null), am.entries[1].tax_mix_date);
|
||
try std.testing.expectEqual(@as(f64, 1.0), am.taxMixFor("Sample Brokerage").?.weightOf(.taxable));
|
||
}
|
||
|
||
test "parseAccountsFile: an invalid tax mix is preserved raw so doctor can report it" {
|
||
// The parser deliberately does NOT scrub a rejected carve-out: it
|
||
// warns and leaves the field alone, because `zfin doctor` reads back
|
||
// through this same parser and has to be able to see what the user
|
||
// actually wrote.
|
||
var am = try parseAccountsFile(std.testing.allocator,
|
||
\\#!srfv1
|
||
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:140
|
||
);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), am.entries.len);
|
||
try std.testing.expectEqual(@as(?f64, 140), am.entries[0].tax_mix_roth);
|
||
try std.testing.expectEqual(@as(?TaxMixProblem, .over_allocated), am.entries[0].taxMixChecked().problem);
|
||
// ...but the resolved mix falls back to the bare tax_type.
|
||
try std.testing.expectEqual(@as(f64, 1.0), am.taxMixFor("Sample 401k").?.weightOf(.traditional));
|
||
}
|
||
|
||
test "parseAccountsFile: every non-string field survives the copy-then-override append" {
|
||
// Regression guard for the append path in `parseAccountsFile`. It
|
||
// copies the parsed record wholesale and overrides only the duped /
|
||
// validated fields, precisely so a newly added field can't silently
|
||
// keep its default. Assert one value per field.
|
||
var am = try parseAccountsFile(std.testing.allocator,
|
||
\\#!srfv1
|
||
\\account::Sample Everything,tax_type::roth,institution::fidelity,account_number::1234,update_cadence::quarterly,cash_is_contribution:bool:true,direct_indexing:bool:true,shielded:bool:false,audit_large_lot_threshold:num:50000,harvested:num:-4500,harvested_date::2026-06-24,tax_mix_traditional:num:10,tax_mix_taxable:num:5,tax_mix_hsa:num:1,tax_mix_date::2026-08-01
|
||
);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), am.entries.len);
|
||
const e = am.entries[0];
|
||
try std.testing.expectEqualStrings("Sample Everything", e.account);
|
||
try std.testing.expectEqual(TaxType.roth, e.tax_type);
|
||
try std.testing.expectEqualStrings("fidelity", e.institution.?);
|
||
try std.testing.expectEqualStrings("1234", e.account_number.?);
|
||
try std.testing.expectEqual(UpdateCadence.quarterly, e.update_cadence);
|
||
try std.testing.expect(e.cash_is_contribution);
|
||
try std.testing.expect(e.direct_indexing);
|
||
try std.testing.expectEqual(@as(?bool, false), e.shielded);
|
||
try std.testing.expectEqual(@as(?f64, 50_000), e.audit_large_lot_threshold);
|
||
try std.testing.expectEqual(@as(?f64, 4500), e.harvested); // sign-normalized
|
||
try std.testing.expectEqual(@as(?Date, Date.fromYmd(2026, 6, 24)), e.harvested_date);
|
||
try std.testing.expectEqual(@as(?f64, 10), e.tax_mix_traditional);
|
||
try std.testing.expectEqual(@as(?f64, 5), e.tax_mix_taxable);
|
||
try std.testing.expectEqual(@as(?f64, 1), e.tax_mix_hsa);
|
||
try std.testing.expectEqual(@as(?f64, null), e.tax_mix_roth);
|
||
try std.testing.expectEqual(@as(?Date, Date.fromYmd(2026, 8, 1)), e.tax_mix_date);
|
||
}
|
||
|
||
test "taxMixFor: unknown account returns null, distinct from a zero mix" {
|
||
var am = try parseAccountsFile(std.testing.allocator,
|
||
\\#!srfv1
|
||
\\account::Sample Brokerage,tax_type::taxable
|
||
);
|
||
defer am.deinit();
|
||
|
||
try std.testing.expectEqual(@as(?TaxMix, null), am.taxMixFor("Sample Nowhere"));
|
||
try std.testing.expect(am.taxMixFor("Sample Brokerage") != null);
|
||
// taxTypeFor keeps its own "Unknown" sentinel for label callers.
|
||
try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Sample Nowhere"));
|
||
}
|
||
|
||
test "mapToSortedBreakdown" {
|
||
const allocator = std.testing.allocator;
|
||
var map = std.StringHashMap(f64).init(allocator);
|
||
defer map.deinit();
|
||
try map.put("Technology", 50_000);
|
||
try map.put("Healthcare", 30_000);
|
||
try map.put("Energy", 20_000);
|
||
|
||
const total = 100_000.0;
|
||
const breakdown = try mapToSortedBreakdown(allocator, map, total);
|
||
defer allocator.free(breakdown);
|
||
|
||
try std.testing.expectEqual(@as(usize, 3), breakdown.len);
|
||
// Should be sorted descending by value
|
||
try std.testing.expectEqualStrings("Technology", breakdown[0].label);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 50_000), breakdown[0].value, 0.01);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 0.5), breakdown[0].weight, 0.001);
|
||
try std.testing.expectEqualStrings("Healthcare", breakdown[1].label);
|
||
try std.testing.expectEqualStrings("Energy", breakdown[2].label);
|
||
}
|
||
|
||
test "mapToSortedBreakdown empty" {
|
||
const allocator = std.testing.allocator;
|
||
var map = std.StringHashMap(f64).init(allocator);
|
||
defer map.deinit();
|
||
const breakdown = try mapToSortedBreakdown(allocator, map, 100_000.0);
|
||
defer allocator.free(breakdown);
|
||
try std.testing.expectEqual(@as(usize, 0), breakdown.len);
|
||
}
|
||
|
||
test "parseAccountsFile empty" {
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, "#!srfv1\n");
|
||
defer am.deinit();
|
||
try std.testing.expectEqual(@as(usize, 0), am.entries.len);
|
||
}
|
||
|
||
test "parseAccountsFile missing fields" {
|
||
// Line with only account but no tax_type -> skipped via Record.to() error.
|
||
// Override log level to suppress expected srf log.err output that
|
||
// would otherwise cause the test runner to report failure.
|
||
const prev_level = std.testing.log_level;
|
||
std.testing.log_level = .err;
|
||
defer std.testing.log_level = prev_level;
|
||
const allocator = std.testing.allocator;
|
||
var am = try parseAccountsFile(allocator, "#!srfv1\naccount::Test Account\n# comment\n");
|
||
defer am.deinit();
|
||
try std.testing.expectEqual(@as(usize, 0), am.entries.len);
|
||
}
|
||
|
||
/// Helper: pull one breakdown row's dollar value out by label.
|
||
/// Returns null when no row carries that label, which lets a test
|
||
/// distinguish "row absent" from "row present with value 0".
|
||
fn testBreakdownValue(items: []const BreakdownItem, label: []const u8) ?f64 {
|
||
for (items) |it| {
|
||
if (std.mem.eql(u8, it.label, label)) return it.value;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Helper: a single-symbol portfolio priced at $100/share, with `lots`
|
||
/// distributing shares across accounts. Keeps the tax-rollup tests
|
||
/// focused on the apportionment rather than on pricing mechanics.
|
||
fn testTaxRollup(
|
||
account_map: ?AccountMap,
|
||
lots: []@import("../models/portfolio.zig").Lot,
|
||
total_value: f64,
|
||
) !AnalysisResult {
|
||
const allocator = std.testing.allocator;
|
||
const portfolio = Portfolio{ .lots = lots, .allocator = allocator };
|
||
const allocations = [_]Allocation{.{
|
||
.symbol = "SPY",
|
||
.display_symbol = "SPY",
|
||
.shares = total_value / 100,
|
||
.avg_cost = 100,
|
||
.current_price = 100,
|
||
.market_value = total_value,
|
||
.cost_basis = total_value,
|
||
.weight = 1.0,
|
||
.unrealized_gain_loss = 0,
|
||
.unrealized_return = 0,
|
||
.price_ratio = 1.0,
|
||
}};
|
||
const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
|
||
return analyzePortfolio(
|
||
allocator,
|
||
&allocations,
|
||
cm,
|
||
portfolio,
|
||
total_value,
|
||
account_map,
|
||
Date.fromYmd(2026, 8, 1),
|
||
);
|
||
}
|
||
|
||
test "analyzePortfolio: an option's account value includes the contract multiplier" {
|
||
// The regression. This arm used to be `@abs(shares) * open_price`
|
||
// with no `multiplier`, so it counted $2.05 where every other site
|
||
// counted $205.00 - leaving each option-holding account short by 99%
|
||
// of its premium, and making `zfin analysis`'s By Account section
|
||
// disagree with its own Options sector row and with the
|
||
// `kind::account` rows written into every snapshot.
|
||
const Lot = @import("../models/portfolio.zig").Lot;
|
||
var lots = [_]Lot{
|
||
.{
|
||
.symbol = "SPY",
|
||
.shares = 1000,
|
||
.open_date = Date.fromYmd(2020, 1, 1),
|
||
.open_price = 100,
|
||
.account = "Sample Brokerage",
|
||
},
|
||
// A written call: -2 contracts at $2.05 premium, multiplier 100.
|
||
// Premium = |-2| * 2.05 * 100 = $410.00, NOT $4.10.
|
||
// Opened before `testTaxRollup`'s as_of (2026-08-01) and maturing
|
||
// after it, so `lotIsOpenAsOf` includes it.
|
||
.{
|
||
.security_type = .option,
|
||
.symbol = "SPY 09/18/2026 700.00 C",
|
||
.shares = -2,
|
||
.open_date = Date.fromYmd(2026, 7, 21),
|
||
.maturity_date = Date.fromYmd(2026, 9, 18),
|
||
.open_price = 2.05,
|
||
.option_type = .call,
|
||
.underlying = "SPY",
|
||
.strike = 700,
|
||
.account = "Sample Brokerage",
|
||
},
|
||
};
|
||
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Sample Brokerage,tax_type::taxable
|
||
);
|
||
defer am.deinit();
|
||
|
||
var result = try testTaxRollup(am, &lots, 100_000);
|
||
defer result.deinit(std.testing.allocator);
|
||
|
||
const acct = testBreakdownValue(result.account, "Sample Brokerage").?;
|
||
// $100,000 of stock + $410.00 of premium. Under the bug: $100,004.10.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100_410.0), acct, 0.005);
|
||
|
||
// And it must agree with the other two copies of this formula. This is
|
||
// the invariant that actually matters - the three sites drifted once.
|
||
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
|
||
const non_stock = portfolio.nonStockValueForAccount(Date.fromYmd(2026, 8, 26), "Sample Brokerage");
|
||
try std.testing.expectApproxEqAbs(@as(f64, 410.0), non_stock, 0.005);
|
||
try std.testing.expectApproxEqAbs(acct, 100_000.0 + non_stock, 0.005);
|
||
}
|
||
|
||
test "analyzePortfolio: a mixed account splits its value across tax-type rows" {
|
||
// The headline case. One 401(k) reporting a single $400k balance that
|
||
// is really 75% pre-tax and 25% Roth, plus a $100k taxable brokerage.
|
||
// Before mixed treatment the whole $400k landed on Traditional.
|
||
const Lot = @import("../models/portfolio.zig").Lot;
|
||
var lots = [_]Lot{
|
||
.{
|
||
.symbol = "SPY",
|
||
.shares = 4000,
|
||
.open_date = Date.fromYmd(2020, 1, 1),
|
||
.open_price = 100,
|
||
.account = "Sample 401k",
|
||
},
|
||
.{
|
||
.symbol = "SPY",
|
||
.shares = 1000,
|
||
.open_date = Date.fromYmd(2020, 1, 1),
|
||
.open_price = 100,
|
||
.account = "Sample Brokerage",
|
||
},
|
||
};
|
||
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:25
|
||
\\account::Sample Brokerage,tax_type::taxable
|
||
);
|
||
defer am.deinit();
|
||
|
||
var result = try testTaxRollup(am, &lots, 500_000);
|
||
defer result.deinit(std.testing.allocator);
|
||
|
||
try std.testing.expectEqual(@as(usize, 3), result.tax_type.len);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 300_000), testBreakdownValue(result.tax_type, "Traditional (Pre-Tax)").?, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100_000), testBreakdownValue(result.tax_type, "Roth (Post-Tax)").?, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100_000), testBreakdownValue(result.tax_type, "Taxable").?, 1.0);
|
||
|
||
// The split must conserve dollars: the tax-type rows still sum to the
|
||
// portfolio total, and the account breakdown is untouched by it.
|
||
var tax_sum: f64 = 0;
|
||
for (result.tax_type) |it| tax_sum += it.value;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 500_000), tax_sum, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 400_000), testBreakdownValue(result.account, "Sample 401k").?, 1.0);
|
||
|
||
// Weights are shares of the portfolio total, not of the account.
|
||
for (result.tax_type) |it| {
|
||
try std.testing.expectApproxEqAbs(it.value / 500_000, it.weight, 1e-9);
|
||
}
|
||
}
|
||
|
||
test "analyzePortfolio: an account with no carve-outs still lands on one row" {
|
||
// Backward-compatibility regression: the same fixture without a mix
|
||
// must produce exactly two rows with the full account values.
|
||
const Lot = @import("../models/portfolio.zig").Lot;
|
||
var lots = [_]Lot{
|
||
.{ .symbol = "SPY", .shares = 4000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 100, .account = "Sample 401k" },
|
||
.{ .symbol = "SPY", .shares = 1000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 100, .account = "Sample Brokerage" },
|
||
};
|
||
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Sample 401k,tax_type::traditional
|
||
\\account::Sample Brokerage,tax_type::taxable
|
||
);
|
||
defer am.deinit();
|
||
|
||
var result = try testTaxRollup(am, &lots, 500_000);
|
||
defer result.deinit(std.testing.allocator);
|
||
|
||
try std.testing.expectEqual(@as(usize, 2), result.tax_type.len);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 400_000), testBreakdownValue(result.tax_type, "Traditional (Pre-Tax)").?, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100_000), testBreakdownValue(result.tax_type, "Taxable").?, 1.0);
|
||
try std.testing.expectEqual(@as(?f64, null), testBreakdownValue(result.tax_type, "Roth (Post-Tax)"));
|
||
}
|
||
|
||
test "analyzePortfolio: an unmapped account still lands wholly in Unknown" {
|
||
// The mix loop replaced `taxTypeFor`, which owned the "Unknown"
|
||
// sentinel. Pin that the sentinel survived the move and does not
|
||
// get apportioned.
|
||
const Lot = @import("../models/portfolio.zig").Lot;
|
||
var lots = [_]Lot{
|
||
.{ .symbol = "SPY", .shares = 3000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 100, .account = "Sample 401k" },
|
||
.{ .symbol = "SPY", .shares = 2000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 100, .account = "Sample Unlisted" },
|
||
};
|
||
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:40
|
||
);
|
||
defer am.deinit();
|
||
|
||
var result = try testTaxRollup(am, &lots, 500_000);
|
||
defer result.deinit(std.testing.allocator);
|
||
|
||
try std.testing.expectEqual(@as(usize, 3), result.tax_type.len);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 200_000), testBreakdownValue(result.tax_type, "Unknown").?, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 180_000), testBreakdownValue(result.tax_type, "Traditional (Pre-Tax)").?, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 120_000), testBreakdownValue(result.tax_type, "Roth (Post-Tax)").?, 1.0);
|
||
}
|
||
|
||
test "analyzePortfolio: two mixed accounts accumulate into shared tax-type rows" {
|
||
// Each account contributes to several rows, and several accounts
|
||
// contribute to each row. Guards the accumulate-don't-overwrite
|
||
// behavior of the nested rollup loop.
|
||
const Lot = @import("../models/portfolio.zig").Lot;
|
||
var lots = [_]Lot{
|
||
.{ .symbol = "SPY", .shares = 2000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 100, .account = "Sample 401k A" },
|
||
.{ .symbol = "SPY", .shares = 2000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 100, .account = "Sample 401k B" },
|
||
};
|
||
|
||
var am = try testParseAccountMap(
|
||
\\#!srfv1
|
||
\\account::Sample 401k A,tax_type::traditional,tax_mix_roth:num:25
|
||
\\account::Sample 401k B,tax_type::roth,tax_mix_traditional:num:10
|
||
);
|
||
defer am.deinit();
|
||
|
||
var result = try testTaxRollup(am, &lots, 400_000);
|
||
defer result.deinit(std.testing.allocator);
|
||
|
||
// A: 200k -> 150k traditional + 50k roth
|
||
// B: 200k -> 20k traditional + 180k roth
|
||
try std.testing.expectEqual(@as(usize, 2), result.tax_type.len);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 170_000), testBreakdownValue(result.tax_type, "Traditional (Pre-Tax)").?, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 230_000), testBreakdownValue(result.tax_type, "Roth (Post-Tax)").?, 1.0);
|
||
}
|
||
|
||
test "account breakdown applies price_ratio" {
|
||
const allocator = std.testing.allocator;
|
||
const Lot = @import("../models/portfolio.zig").Lot;
|
||
|
||
// Three lots across two accounts:
|
||
// - Brokerage: direct SPY (ratio 1.0)
|
||
// - 401(k): CIT mapped to SPY (ratio 0.25, merged allocation)
|
||
// - 401(k): CUSIP with ticker=VTTHX (ratio 5.0, unmerged allocation)
|
||
var lots = [_]Lot{
|
||
.{
|
||
.symbol = "SPY",
|
||
.shares = 100,
|
||
.open_date = Date.fromYmd(2020, 1, 1),
|
||
.open_price = 400,
|
||
.account = "Brokerage",
|
||
},
|
||
.{
|
||
.symbol = "CIT-SPY",
|
||
.shares = 500,
|
||
.open_date = Date.fromYmd(2020, 1, 1),
|
||
.open_price = 100,
|
||
.ticker = "SPY",
|
||
.price_ratio = 0.25,
|
||
.account = "401(k)",
|
||
},
|
||
.{
|
||
.symbol = "CUSIP123",
|
||
.shares = 200,
|
||
.open_date = Date.fromYmd(2020, 1, 1),
|
||
.open_price = 50,
|
||
.ticker = "VTTHX",
|
||
.price_ratio = 5.0,
|
||
.account = "401(k)",
|
||
},
|
||
};
|
||
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
|
||
|
||
// Allocations as produced by portfolioSummary + mergeAllocsBySymbol:
|
||
// SPY: merged (direct + CIT). current_price = base SPY price = 500, price_ratio = 1.0
|
||
// VTTHX: unmerged. current_price = 30 * 5.0 = 150 (already includes ratio), price_ratio = 5.0
|
||
const allocations = [_]Allocation{
|
||
.{
|
||
.symbol = "SPY",
|
||
.display_symbol = "SPY",
|
||
.shares = 225, // 100 + 500*0.25
|
||
.avg_cost = 300,
|
||
.current_price = 500, // base-ticker price (merged, ratio=1.0)
|
||
.market_value = 112_500,
|
||
.cost_basis = 67_500,
|
||
.weight = 0.789,
|
||
.unrealized_gain_loss = 45_000,
|
||
.unrealized_return = 0.667,
|
||
.price_ratio = 1.0, // merged
|
||
},
|
||
.{
|
||
.symbol = "VTTHX",
|
||
.display_symbol = "VTTHX",
|
||
.shares = 200,
|
||
.avg_cost = 50,
|
||
.current_price = 150, // already includes price_ratio (30 * 5.0)
|
||
.market_value = 30_000, // 200 * 150
|
||
.cost_basis = 10_000,
|
||
.weight = 0.211,
|
||
.unrealized_gain_loss = 20_000,
|
||
.unrealized_return = 2.0,
|
||
.price_ratio = 5.0, // unmerged, ratio preserved
|
||
},
|
||
};
|
||
|
||
const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
|
||
|
||
var result = try analyzePortfolio(
|
||
allocator,
|
||
&allocations,
|
||
cm,
|
||
portfolio,
|
||
142_500,
|
||
null,
|
||
Date.fromYmd(2024, 6, 1),
|
||
);
|
||
defer result.deinit(allocator);
|
||
|
||
// Expected account values:
|
||
// Brokerage: SPY direct, 100 shares * $500 * 1.0 = $50,000
|
||
// 401(k): CIT-SPY 500 shares * $500 * 0.25 = $62,500
|
||
// + CUSIP123 200 shares * $150 (already includes ratio) = $30,000
|
||
// = $92,500
|
||
// Total: $142,500
|
||
for (result.account) |item| {
|
||
if (std.mem.eql(u8, item.label, "Brokerage")) {
|
||
try std.testing.expectApproxEqAbs(@as(f64, 50_000), item.value, 1.0);
|
||
} else if (std.mem.eql(u8, item.label, "401(k)")) {
|
||
try std.testing.expectApproxEqAbs(@as(f64, 92_500), item.value, 1.0);
|
||
}
|
||
}
|
||
|
||
// Sum of accounts must equal total portfolio value
|
||
var account_sum: f64 = 0;
|
||
for (result.account) |item| {
|
||
account_sum += item.value;
|
||
}
|
||
try std.testing.expectApproxEqAbs(@as(f64, 142_500), account_sum, 1.0);
|
||
}
|
||
|
||
// ── bucketSector ──────────────────────────────────────────────
|
||
|
||
test "bucketSector: NPORT-P Debt / * -> Fixed Income" {
|
||
const cases = [_][]const u8{
|
||
"Debt / Corporate",
|
||
"Debt / US Treasury",
|
||
"Debt / Municipal",
|
||
"Debt / Non-US Sovereign",
|
||
"Debt / US Gov Agency",
|
||
"Debt / US GSE",
|
||
};
|
||
for (cases) |s| {
|
||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector(s));
|
||
}
|
||
}
|
||
|
||
test "bucketSector: NPORT-P Equity / * and Equity Preferred / * -> Equity" {
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Corporate"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Other"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity / Registered Fund"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Equity Preferred / Corporate"));
|
||
}
|
||
|
||
test "bucketSector: NPORT-P Loan / * -> Fixed Income" {
|
||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Loan / Corporate"));
|
||
}
|
||
|
||
test "bucketSector: NPORT-P Asset-Backed variants -> Fixed Income" {
|
||
// All three asset-backed prefixes should bucket the same
|
||
// way. Asset-backed securities are bond-like by structure.
|
||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed / Corporate Mortgage"));
|
||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed / US GSE Mortgage"));
|
||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed CBO/CDO / Corporate"));
|
||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Asset-Backed Other / Corporate"));
|
||
}
|
||
|
||
test "bucketSector: Short-Term Investment Vehicle / * -> Cash" {
|
||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Corporate"));
|
||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Registered Fund"));
|
||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Short-Term Investment Vehicle / Private Fund"));
|
||
}
|
||
|
||
test "bucketSector: Repurchase Agreement / * -> Cash" {
|
||
// PTY-style leverage liability sleeve. Bucket is Cash; the
|
||
// negative pct flows through honestly into bucket math.
|
||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Repurchase Agreement / Other"));
|
||
}
|
||
|
||
test "bucketSector: Derivative variants -> Other" {
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Corporate"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative / Other"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Other"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Derivative-FX / Corporate"));
|
||
}
|
||
|
||
test "bucketSector: Direct Real Property and Direct Credit Risk -> Other" {
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Real Property / Other"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Credit Risk / Other"));
|
||
}
|
||
|
||
test "bucketSector: GICS sector names -> Equity" {
|
||
const gics = [_][]const u8{
|
||
"Technology",
|
||
"Healthcare",
|
||
"Financial Services",
|
||
"Consumer Cyclical",
|
||
"Consumer Defensive",
|
||
"Energy",
|
||
"Utilities",
|
||
"Real Estate",
|
||
"Industrials",
|
||
"Basic Materials",
|
||
"Communication Services",
|
||
};
|
||
for (gics) |s| {
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector(s));
|
||
}
|
||
}
|
||
|
||
test "bucketSector: sentinels stay Other" {
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("TODO"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Unknown"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector(""));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Unclassified"));
|
||
}
|
||
|
||
test "bucketSector: curated-bucket-shaped unknown strings default to Equity" {
|
||
// After the bucket commit, `bucketSector` is called with
|
||
// either NPORT-P-shaped strings, GICS sector names, or
|
||
// composite/curated bucket labels (from `deriveBucket` or
|
||
// user-curated `bucket::` overrides). For composite-shaped
|
||
// strings that don't match any explicit Bonds/Cash/Options
|
||
// pattern, the default is Equity - composite buckets
|
||
// describe equity sleeves unless they say otherwise. This
|
||
// is the right default because:
|
||
// 1. The user's primary use of the Asset Category
|
||
// breakdown is "what fraction is exposed to equity
|
||
// drawdowns?" - a curated bucket like "US Large Cap"
|
||
// definitely IS equity.
|
||
// 2. The cost of the wrong default is asymmetric: a real
|
||
// bond bucket mis-bucketed as Equity will show in the
|
||
// 4-bucket coarse breakdown as overweight equity (very
|
||
// visible bug). A real equity bucket mis-bucketed as
|
||
// Other will silently disappear from the
|
||
// stocks/bonds/cash header (very subtle bug).
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Fintech"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Some Future Label"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Large Cap"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Mid Cap"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Small Cap"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Dividend Equity"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("US Healthcare ETF"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("International Developed"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Emerging Markets"));
|
||
}
|
||
|
||
test "bucketSector: composite Bonds buckets -> Fixed Income" {
|
||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("US Bonds"));
|
||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("International Bonds"));
|
||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("EM Bonds"));
|
||
}
|
||
|
||
test "bucketSector: composite Cash buckets -> Cash" {
|
||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash & CDs"));
|
||
}
|
||
|
||
test "bucketSector: Options keyword -> Other" {
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Options"));
|
||
}
|
||
|
||
test "bucketSector: NPORT-P fallthrough (slash without recognized prefix) -> Other" {
|
||
// Strings containing `/` that didn't match any specific
|
||
// NPORT-P prefix branch are real-property / credit-risk /
|
||
// miscellaneous categories. Bucket as Other.
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Other / Corporate"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Direct Real Property / Other"));
|
||
}
|
||
|
||
test "bucketSector: returns same pointer for repeated calls (static-string property)" {
|
||
// Both callers use the result as a HashMap key. Stability of
|
||
// the pointer (not just equality of bytes) is what makes
|
||
// this safe without any dupe.
|
||
const a = bucketSector("Debt / Corporate");
|
||
const b = bucketSector("Debt / US Treasury");
|
||
try std.testing.expectEqual(@intFromPtr(a.ptr), @intFromPtr(b.ptr));
|
||
try std.testing.expectEqual(@intFromPtr(bucketSector("Equity / Corporate").ptr), @intFromPtr(bucket_equity.ptr));
|
||
try std.testing.expectEqual(@intFromPtr(bucketSector("TODO").ptr), @intFromPtr(bucket_other.ptr));
|
||
}
|
||
|
||
test "bucketSector: case-sensitive (defensive - bad input lands in Other, not crash)" {
|
||
// We don't normalize case. "debt / corporate" doesn't match
|
||
// "Debt / Corporate" so it falls through to Other. Tests the
|
||
// contract: only canonical strings are recognized.
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("debt / corporate"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("EQUITY / CORPORATE"));
|
||
}
|
||
|
||
test "bucketSector: legacy hand-written 'Bonds' -> Fixed Income" {
|
||
// metadata.srf entries that pre-date EDGAR fund decomposition
|
||
// use the literal word `Bonds` as the sector. Map to Fixed
|
||
// Income so the Asset Category breakdown picks them up
|
||
// alongside the NPORT-P `Debt / *` rows.
|
||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketSector("Bonds"));
|
||
}
|
||
|
||
test "bucketSector: legacy hand-written 'Cash' -> Cash" {
|
||
try std.testing.expectEqualStrings(bucket_cash, bucketSector("Cash"));
|
||
}
|
||
|
||
test "bucketSector: legacy 'Diversified' -> Equity (broad equity fund)" {
|
||
// "Diversified" in practice means an S&P 500 / total-market
|
||
// index fund holding all sectors - overwhelmingly equity.
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Diversified"));
|
||
}
|
||
|
||
test "bucketSector: legacy 'Financials' (with s) -> Equity" {
|
||
// Wikidata's canonical name is "Financial Services"; older
|
||
// hand-written entries use "Financials". Both must map to
|
||
// Equity so legacy data doesn't silently land in Other.
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Financials"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketSector("Financial Services"));
|
||
}
|
||
|
||
// ── collapseSector / Granularity ──────────────────────────────
|
||
|
||
test "collapseSector .fine: passthrough - input slice returned unchanged" {
|
||
try std.testing.expectEqualStrings("Debt / US Treasury", collapseSector("Debt / US Treasury", .fine));
|
||
try std.testing.expectEqualStrings("Equity / Corporate", collapseSector("Equity / Corporate", .fine));
|
||
try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .fine));
|
||
try std.testing.expectEqualStrings("Bonds", collapseSector("Bonds", .fine));
|
||
}
|
||
|
||
test "collapseSector .coarse: delegates to bucketSector" {
|
||
try std.testing.expectEqualStrings(bucket_fixed_income, collapseSector("Debt / US Treasury", .coarse));
|
||
try std.testing.expectEqualStrings(bucket_equity, collapseSector("Equity / Corporate", .coarse));
|
||
try std.testing.expectEqualStrings(bucket_equity, collapseSector("Technology", .coarse));
|
||
try std.testing.expectEqualStrings(bucket_cash, collapseSector("Short-Term Investment Vehicle / Registered Fund", .coarse));
|
||
try std.testing.expectEqualStrings(bucket_other, collapseSector("Derivative / Other", .coarse));
|
||
}
|
||
|
||
// ── collapseBreakdownAtGranularity ────────────────────────────
|
||
|
||
test "collapseBreakdownAtGranularity: coarse collapses everything to 4 buckets" {
|
||
const allocator = std.testing.allocator;
|
||
const items = [_]BreakdownItem{
|
||
.{ .label = "Debt / Corporate", .weight = 0.50, .value = 50_000.0 },
|
||
.{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000.0 },
|
||
.{ .label = "Technology", .weight = 0.10, .value = 10_000.0 },
|
||
.{ .label = "Short-Term Investment Vehicle / Corporate", .weight = 0.05, .value = 5_000.0 },
|
||
.{ .label = "Derivative / Other", .weight = 0.05, .value = 5_000.0 },
|
||
};
|
||
const result = try collapseBreakdownAtGranularity(allocator, &items, .coarse, 100_000.0);
|
||
defer allocator.free(result);
|
||
|
||
// Equity (30k Equity/Corp + 10k Technology) = 40k
|
||
// Fixed Income = 50k Debt
|
||
// Cash = 5k STIV
|
||
// Other = 5k Derivative
|
||
var eq: f64 = 0;
|
||
var fi: f64 = 0;
|
||
var c: f64 = 0;
|
||
var o: f64 = 0;
|
||
for (result) |item| {
|
||
if (std.mem.eql(u8, item.label, bucket_equity)) eq = item.value;
|
||
if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi = item.value;
|
||
if (std.mem.eql(u8, item.label, bucket_cash)) c = item.value;
|
||
if (std.mem.eql(u8, item.label, bucket_other)) o = item.value;
|
||
}
|
||
try std.testing.expectApproxEqAbs(@as(f64, 40_000), eq, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 50_000), fi, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 5_000), c, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 5_000), o, 1.0);
|
||
}
|
||
|
||
test "collapseBreakdownAtGranularity: fine returns equivalent breakdown unchanged" {
|
||
const allocator = std.testing.allocator;
|
||
const items = [_]BreakdownItem{
|
||
.{ .label = "Debt / Corporate", .weight = 0.50, .value = 50_000.0 },
|
||
.{ .label = "Equity / Corporate", .weight = 0.30, .value = 30_000.0 },
|
||
.{ .label = "Technology", .weight = 0.20, .value = 20_000.0 },
|
||
};
|
||
const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0);
|
||
defer allocator.free(result);
|
||
|
||
// 3 input rows -> 3 output rows (no collapsing at fine).
|
||
try std.testing.expectEqual(@as(usize, 3), result.len);
|
||
// Output sorted by value descending.
|
||
try std.testing.expectEqualStrings("Debt / Corporate", result[0].label);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 50_000), result[0].value, 1.0);
|
||
}
|
||
|
||
test "collapseBreakdownAtGranularity: empty input -> empty output" {
|
||
const allocator = std.testing.allocator;
|
||
const items = [_]BreakdownItem{};
|
||
const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100_000.0);
|
||
defer allocator.free(result);
|
||
try std.testing.expectEqual(@as(usize, 0), result.len);
|
||
}
|
||
|
||
test "collapseBreakdownAtGranularity: total values preserved through collapse" {
|
||
// Sum of output values must equal sum of input values
|
||
// (modulo float rounding).
|
||
const allocator = std.testing.allocator;
|
||
const items = [_]BreakdownItem{
|
||
.{ .label = "Debt / Corporate", .weight = 0.40, .value = 40.0 },
|
||
.{ .label = "Loan / Corporate", .weight = 0.20, .value = 20.0 },
|
||
.{ .label = "Asset-Backed / Corporate Mortgage", .weight = 0.15, .value = 15.0 },
|
||
.{ .label = "Equity / Corporate", .weight = 0.20, .value = 20.0 },
|
||
.{ .label = "Technology", .weight = 0.05, .value = 5.0 },
|
||
};
|
||
const result = try collapseBreakdownAtGranularity(allocator, &items, .fine, 100.0);
|
||
defer allocator.free(result);
|
||
|
||
var total: f64 = 0;
|
||
for (result) |item| total += item.value;
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100.0), total, 0.01);
|
||
}
|
||
|
||
/// Map an `asset_class` string to one of the four asset-category
|
||
/// buckets. Used as a fallback when a classification entry has
|
||
/// no `sector` but does have an `asset_class` (legacy
|
||
/// hand-written entries for CITs / CUSIPs / blended funds where
|
||
/// the user wrote `asset_class::Bonds,pct:num:30` without
|
||
/// a sector). Returns `bucket_other` for unrecognized values.
|
||
pub fn bucketAssetClass(asset_class: []const u8) []const u8 {
|
||
if (std.mem.eql(u8, asset_class, "Bonds")) return bucket_fixed_income;
|
||
if (std.mem.eql(u8, asset_class, "Cash")) return bucket_cash;
|
||
if (std.mem.eql(u8, asset_class, "Cash & CDs")) return bucket_cash;
|
||
// US size buckets and international/EM buckets are all equity.
|
||
if (std.mem.eql(u8, asset_class, "US Large Cap")) return bucket_equity;
|
||
if (std.mem.eql(u8, asset_class, "US Mid Cap")) return bucket_equity;
|
||
if (std.mem.eql(u8, asset_class, "US Small Cap")) return bucket_equity;
|
||
if (std.mem.eql(u8, asset_class, "International Developed")) return bucket_equity;
|
||
if (std.mem.eql(u8, asset_class, "Emerging Markets")) return bucket_equity;
|
||
// Mutual Fund / ETF / Fund are too generic to bucket without
|
||
// sector data - fall through to Other rather than guess
|
||
// wrong. The companion `sector` field should already have
|
||
// bucketed these via `bucketSector`; if it didn't, that's a
|
||
// metadata-quality signal (TODO sector that needs filling
|
||
// in) and Other is the right label.
|
||
return bucket_other;
|
||
}
|
||
|
||
// ── bucketAssetClass ──────────────────────────────────────────
|
||
|
||
test "bucketAssetClass: Bonds -> Fixed Income" {
|
||
try std.testing.expectEqualStrings(bucket_fixed_income, bucketAssetClass("Bonds"));
|
||
}
|
||
|
||
test "bucketAssetClass: Cash variants -> Cash" {
|
||
try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash"));
|
||
try std.testing.expectEqualStrings(bucket_cash, bucketAssetClass("Cash & CDs"));
|
||
}
|
||
|
||
test "bucketAssetClass: US size buckets -> Equity" {
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Large Cap"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Mid Cap"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("US Small Cap"));
|
||
}
|
||
|
||
test "bucketAssetClass: international + EM -> Equity" {
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("International Developed"));
|
||
try std.testing.expectEqualStrings(bucket_equity, bucketAssetClass("Emerging Markets"));
|
||
}
|
||
|
||
test "bucketAssetClass: generic Fund/ETF/Mutual Fund -> Other (not enough info)" {
|
||
// The companion `sector` field is what disambiguates Fund-typed
|
||
// entries. If sector is missing too, calling these "Equity"
|
||
// would be a guess; Other is the honest label that signals
|
||
// a metadata-quality issue (sector::TODO needs filling in).
|
||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Fund"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("ETF"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Mutual Fund"));
|
||
}
|
||
|
||
test "bucketAssetClass: unknown / sentinels -> Other" {
|
||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass(""));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("TODO"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Unknown"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("Some Future Class"));
|
||
}
|
||
|
||
test "bucketAssetClass: case-sensitive - bad case lands in Other" {
|
||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("bonds"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketAssetClass("US LARGE CAP"));
|
||
}
|
||
|
||
test "bucketAssetClass: returns same pointer for same bucket (static-string property)" {
|
||
// Same invariant as bucketSector - result is a stable
|
||
// HashMap key without dupe.
|
||
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("US Large Cap").ptr), @intFromPtr(bucket_equity.ptr));
|
||
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Bonds").ptr), @intFromPtr(bucket_fixed_income.ptr));
|
||
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Cash").ptr), @intFromPtr(bucket_cash.ptr));
|
||
try std.testing.expectEqual(@intFromPtr(bucketAssetClass("Fund").ptr), @intFromPtr(bucket_other.ptr));
|
||
}
|
||
|
||
// ── breakdownSections ─────────────────────────────────────────
|
||
|
||
test "breakdownSections: returns 5 sections" {
|
||
var ac_cat = [_]BreakdownItem{};
|
||
var sec = [_]BreakdownItem{};
|
||
var geo = [_]BreakdownItem{};
|
||
var acct = [_]BreakdownItem{};
|
||
var tax = [_]BreakdownItem{};
|
||
const result = AnalysisResult{
|
||
.asset_category = &ac_cat,
|
||
.sector = &sec,
|
||
.geo = &geo,
|
||
.account = &acct,
|
||
.tax_type = &tax,
|
||
.unclassified = &.{},
|
||
.total_value = 0,
|
||
};
|
||
const sections = breakdownSections(&result);
|
||
try std.testing.expectEqual(@as(usize, 5), sections.len);
|
||
}
|
||
|
||
test "breakdownSections: titles in expected order, no leading whitespace, unique" {
|
||
var ac_cat = [_]BreakdownItem{};
|
||
var sec = [_]BreakdownItem{};
|
||
var geo = [_]BreakdownItem{};
|
||
var acct = [_]BreakdownItem{};
|
||
var tax = [_]BreakdownItem{};
|
||
const result = AnalysisResult{
|
||
.asset_category = &ac_cat,
|
||
.sector = &sec,
|
||
.geo = &geo,
|
||
.account = &acct,
|
||
.tax_type = &tax,
|
||
.unclassified = &.{},
|
||
.total_value = 0,
|
||
};
|
||
const sections = breakdownSections(&result);
|
||
|
||
const expected = [_][]const u8{
|
||
"Asset Category",
|
||
"Sector",
|
||
"Geographic",
|
||
"By Account",
|
||
"By Tax Type",
|
||
};
|
||
for (sections, expected) |s, want| {
|
||
try std.testing.expectEqualStrings(want, s.title);
|
||
// No leading whitespace baked into the title - renderers
|
||
// own indent.
|
||
try std.testing.expect(s.title.len > 0);
|
||
try std.testing.expect(s.title[0] != ' ');
|
||
try std.testing.expect(s.title[0] != '\t');
|
||
}
|
||
// Titles must be unique.
|
||
for (sections, 0..) |a, i| {
|
||
for (sections[i + 1 ..]) |b| {
|
||
try std.testing.expect(!std.mem.eql(u8, a.title, b.title));
|
||
}
|
||
}
|
||
}
|
||
|
||
test "breakdownSections: items.ptr points to AnalysisResult fields" {
|
||
// The single-source-of-truth promise: each section borrows
|
||
// from the corresponding AnalysisResult field. Catches anyone
|
||
// sliding in a copy or reordering the fields.
|
||
var ac_cat = [_]BreakdownItem{
|
||
.{ .label = "Equity", .weight = 1.0, .value = 100.0 },
|
||
};
|
||
var sec = [_]BreakdownItem{
|
||
.{ .label = "Technology", .weight = 0.5, .value = 50.0 },
|
||
};
|
||
var geo = [_]BreakdownItem{};
|
||
var acct = [_]BreakdownItem{};
|
||
var tax = [_]BreakdownItem{};
|
||
const result = AnalysisResult{
|
||
.asset_category = &ac_cat,
|
||
.sector = &sec,
|
||
.geo = &geo,
|
||
.account = &acct,
|
||
.tax_type = &tax,
|
||
.unclassified = &.{},
|
||
.total_value = 100,
|
||
};
|
||
const sections = breakdownSections(&result);
|
||
|
||
try std.testing.expectEqual(result.asset_category.ptr, sections[0].items.ptr);
|
||
try std.testing.expectEqual(result.sector.ptr, sections[1].items.ptr);
|
||
try std.testing.expectEqual(result.geo.ptr, sections[2].items.ptr);
|
||
try std.testing.expectEqual(result.account.ptr, sections[3].items.ptr);
|
||
try std.testing.expectEqual(result.tax_type.ptr, sections[4].items.ptr);
|
||
}
|
||
|
||
test "breakdownSections: Asset Category is first (coarse-to-fine ordering)" {
|
||
var ac_cat = [_]BreakdownItem{};
|
||
var sec = [_]BreakdownItem{};
|
||
var geo = [_]BreakdownItem{};
|
||
var acct = [_]BreakdownItem{};
|
||
var tax = [_]BreakdownItem{};
|
||
const result = AnalysisResult{
|
||
.asset_category = &ac_cat,
|
||
.sector = &sec,
|
||
.geo = &geo,
|
||
.account = &acct,
|
||
.tax_type = &tax,
|
||
.unclassified = &.{},
|
||
.total_value = 0,
|
||
};
|
||
const sections = breakdownSections(&result);
|
||
// Asset Category (4 buckets) is the coarsest view; should
|
||
// come first so the user sees the headline number before
|
||
// the finer breakdowns.
|
||
try std.testing.expectEqualStrings("Asset Category", sections[0].title);
|
||
}
|
||
|
||
// ── analyzePortfolio: asset_category aggregation ──────────────
|
||
|
||
/// Helper: minimal Allocation for asset-category tests. Only
|
||
/// the fields read by `analyzePortfolio`'s sector loop matter.
|
||
fn mkAlloc(symbol: []const u8, mv: f64) Allocation {
|
||
return .{
|
||
.symbol = symbol,
|
||
.display_symbol = symbol,
|
||
.shares = 1,
|
||
.avg_cost = mv,
|
||
.current_price = mv,
|
||
.market_value = mv,
|
||
.cost_basis = mv,
|
||
.weight = 1.0,
|
||
.unrealized_gain_loss = 0.0,
|
||
.unrealized_return = 0.0,
|
||
};
|
||
}
|
||
|
||
test "analyzePortfolio: multi-sector fund (FAGIX shape) splits asset_category buckets" {
|
||
const allocator = std.testing.allocator;
|
||
const allocations = [_]Allocation{mkAlloc("FAGIX", 100_000)};
|
||
var entries = [_]ClassificationEntry{
|
||
.{ .symbol = "FAGIX", .sector = "Debt / Corporate", .pct = 47.69 },
|
||
.{ .symbol = "FAGIX", .sector = "Equity / Corporate", .pct = 22.49 },
|
||
.{ .symbol = "FAGIX", .sector = "Short-Term Investment Vehicle / Registered Fund", .pct = 13.37 },
|
||
.{ .symbol = "FAGIX", .sector = "Loan / Corporate", .pct = 9.99 },
|
||
.{ .symbol = "FAGIX", .sector = "Equity Preferred / Corporate", .pct = 3.59 },
|
||
};
|
||
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
|
||
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
|
||
|
||
var result = try analyzePortfolio(
|
||
allocator,
|
||
&allocations,
|
||
cm,
|
||
portfolio,
|
||
100_000,
|
||
null,
|
||
Date.fromYmd(2024, 6, 1),
|
||
);
|
||
defer result.deinit(allocator);
|
||
|
||
// Find each bucket's value.
|
||
var equity_val: f64 = 0;
|
||
var fi_val: f64 = 0;
|
||
var cash_val: f64 = 0;
|
||
for (result.asset_category) |item| {
|
||
if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
|
||
if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value;
|
||
if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value;
|
||
}
|
||
// Equity = 22.49 + 3.59 = 26.08% of $100K = $26,080
|
||
try std.testing.expectApproxEqAbs(@as(f64, 26_080), equity_val, 1.0);
|
||
// Fixed Income = 47.69 + 9.99 = 57.68% of $100K = $57,680
|
||
try std.testing.expectApproxEqAbs(@as(f64, 57_680), fi_val, 1.0);
|
||
// Cash = 13.37% of $100K = $13,370
|
||
try std.testing.expectApproxEqAbs(@as(f64, 13_370), cash_val, 1.0);
|
||
}
|
||
|
||
test "analyzePortfolio: pure-stock fund (SCHD shape) lands in Equity + tiny Cash" {
|
||
const allocator = std.testing.allocator;
|
||
const allocations = [_]Allocation{mkAlloc("SCHD", 100_000)};
|
||
var entries = [_]ClassificationEntry{
|
||
.{ .symbol = "SCHD", .sector = "Equity / Corporate", .pct = 99.70 },
|
||
.{ .symbol = "SCHD", .sector = "Short-Term Investment Vehicle / Registered Fund", .pct = 0.19 },
|
||
};
|
||
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
|
||
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
|
||
|
||
var result = try analyzePortfolio(
|
||
allocator,
|
||
&allocations,
|
||
cm,
|
||
portfolio,
|
||
100_000,
|
||
null,
|
||
Date.fromYmd(2024, 6, 1),
|
||
);
|
||
defer result.deinit(allocator);
|
||
|
||
var equity_val: f64 = 0;
|
||
var cash_val: f64 = 0;
|
||
for (result.asset_category) |item| {
|
||
if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
|
||
if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value;
|
||
}
|
||
try std.testing.expectApproxEqAbs(@as(f64, 99_700), equity_val, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 190), cash_val, 1.0);
|
||
}
|
||
|
||
test "analyzePortfolio: GICS-sectored stock lands in Equity bucket" {
|
||
const allocator = std.testing.allocator;
|
||
const allocations = [_]Allocation{mkAlloc("NVDA", 50_000)};
|
||
var entries = [_]ClassificationEntry{
|
||
.{ .symbol = "NVDA", .sector = "Technology" },
|
||
};
|
||
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
|
||
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
|
||
|
||
var result = try analyzePortfolio(
|
||
allocator,
|
||
&allocations,
|
||
cm,
|
||
portfolio,
|
||
50_000,
|
||
null,
|
||
Date.fromYmd(2024, 6, 1),
|
||
);
|
||
defer result.deinit(allocator);
|
||
|
||
try std.testing.expectEqual(@as(usize, 1), result.asset_category.len);
|
||
try std.testing.expectEqualStrings(bucket_equity, result.asset_category[0].label);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.asset_category[0].value, 1.0);
|
||
}
|
||
|
||
test "analyzePortfolio: display_symbol is never a classification key" {
|
||
// Regression: a note/label-derived `display_symbol` must not
|
||
// classify. The engine keys on `alloc.symbol` (priceSymbol())
|
||
// only, so a metadata entry written against the display label
|
||
// classifies nothing - editing a label or note can't move a
|
||
// single breakdown dollar.
|
||
const allocator = std.testing.allocator;
|
||
const allocations = [_]Allocation{
|
||
.{
|
||
.symbol = "02315N600", // bare CUSIP: the economic identity
|
||
.display_symbol = "TGT2035", // human label: the would-be footgun
|
||
.shares = 1,
|
||
.avg_cost = 50_000,
|
||
.current_price = 50_000,
|
||
.market_value = 50_000,
|
||
.cost_basis = 50_000,
|
||
.weight = 1.0,
|
||
.unrealized_gain_loss = 0.0,
|
||
.unrealized_return = 0.0,
|
||
},
|
||
};
|
||
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
|
||
|
||
// Metadata keyed on the display label must NOT classify; the
|
||
// holding falls through to unclassified (where display_symbol is
|
||
// still the friendly label shown to the user).
|
||
{
|
||
var entries = [_]ClassificationEntry{
|
||
.{ .symbol = "TGT2035", .sector = "Technology" },
|
||
};
|
||
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
|
||
var result = try analyzePortfolio(allocator, &allocations, cm, portfolio, 50_000, null, Date.fromYmd(2024, 6, 1));
|
||
defer result.deinit(allocator);
|
||
try std.testing.expectEqual(@as(usize, 0), result.asset_category.len);
|
||
try std.testing.expectEqual(@as(usize, 1), result.unclassified.len);
|
||
try std.testing.expectEqualStrings("TGT2035", result.unclassified[0]);
|
||
}
|
||
|
||
// Metadata keyed on the CUSIP (the economic identity) DOES classify.
|
||
{
|
||
var entries = [_]ClassificationEntry{
|
||
.{ .symbol = "02315N600", .sector = "Technology" },
|
||
};
|
||
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
|
||
var result = try analyzePortfolio(allocator, &allocations, cm, portfolio, 50_000, null, Date.fromYmd(2024, 6, 1));
|
||
defer result.deinit(allocator);
|
||
try std.testing.expectEqual(@as(usize, 0), result.unclassified.len);
|
||
try std.testing.expectEqual(@as(usize, 1), result.asset_category.len);
|
||
try std.testing.expectEqualStrings(bucket_equity, result.asset_category[0].label);
|
||
}
|
||
}
|
||
|
||
test "analyzePortfolio: empty portfolio produces empty asset_category" {
|
||
const allocator = std.testing.allocator;
|
||
const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
|
||
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
|
||
|
||
var result = try analyzePortfolio(
|
||
allocator,
|
||
&.{},
|
||
cm,
|
||
portfolio,
|
||
0,
|
||
null,
|
||
Date.fromYmd(2024, 6, 1),
|
||
);
|
||
defer result.deinit(allocator);
|
||
|
||
try std.testing.expectEqual(@as(usize, 0), result.asset_category.len);
|
||
}
|
||
|
||
test "analyzePortfolio: PTY-shape negative repo flows honestly into Cash bucket" {
|
||
// Portfolio has only PTY. Repo line is negative; bucket math
|
||
// sums it honestly. Cash bucket value is the (negative)
|
||
// repo contribution alone, since this fund has no Cash
|
||
// SIV sleeve.
|
||
const allocator = std.testing.allocator;
|
||
const allocations = [_]Allocation{mkAlloc("PTY", 10_000)};
|
||
var entries = [_]ClassificationEntry{
|
||
.{ .symbol = "PTY", .sector = "Debt / Corporate", .pct = 41.65 },
|
||
.{ .symbol = "PTY", .sector = "Loan / Corporate", .pct = 40.05 },
|
||
.{ .symbol = "PTY", .sector = "Equity / Corporate", .pct = 5.78 },
|
||
.{ .symbol = "PTY", .sector = "Repurchase Agreement / Other", .pct = -29.72 },
|
||
};
|
||
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
|
||
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
|
||
|
||
var result = try analyzePortfolio(
|
||
allocator,
|
||
&allocations,
|
||
cm,
|
||
portfolio,
|
||
10_000,
|
||
null,
|
||
Date.fromYmd(2024, 6, 1),
|
||
);
|
||
defer result.deinit(allocator);
|
||
|
||
var cash_val: f64 = 0;
|
||
var fi_val: f64 = 0;
|
||
var equity_val: f64 = 0;
|
||
for (result.asset_category) |item| {
|
||
if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value;
|
||
if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value;
|
||
if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
|
||
}
|
||
// Cash = -29.72% × $10,000 = -$2,972 (honest negative).
|
||
try std.testing.expectApproxEqAbs(@as(f64, -2_972), cash_val, 1.0);
|
||
// Fixed Income = (41.65 + 40.05)% × $10,000 = $8,170.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 8_170), fi_val, 1.0);
|
||
// Equity = 5.78% × $10,000 = $578.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 578), equity_val, 1.0);
|
||
}
|
||
|
||
test "analyzePortfolio: asset_category includes literal cash + CD totals in Cash bucket" {
|
||
// Literal cash and CDs should add to the Cash bucket's
|
||
// value, not just Cash & CDs in the asset_class breakdown.
|
||
const allocator = std.testing.allocator;
|
||
const Lot = @import("../models/portfolio.zig").Lot;
|
||
var lots = [_]Lot{
|
||
.{
|
||
.symbol = "CASH",
|
||
.shares = 50_000,
|
||
.open_date = Date.fromYmd(2020, 1, 1),
|
||
.open_price = 1.0,
|
||
.security_type = .cash,
|
||
.account = "Brokerage",
|
||
},
|
||
.{
|
||
.symbol = "CD-1",
|
||
.shares = 10_000, // face value
|
||
.open_date = Date.fromYmd(2024, 1, 1),
|
||
.open_price = 1.0,
|
||
.security_type = .cd,
|
||
.account = "Brokerage",
|
||
.maturity_date = Date.fromYmd(2027, 1, 1),
|
||
},
|
||
};
|
||
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
|
||
const cm = ClassificationMap{ .entries = &.{}, .allocator = allocator };
|
||
|
||
var result = try analyzePortfolio(
|
||
allocator,
|
||
&.{},
|
||
cm,
|
||
portfolio,
|
||
60_000,
|
||
null,
|
||
Date.fromYmd(2024, 6, 1),
|
||
);
|
||
defer result.deinit(allocator);
|
||
|
||
var cash_val: f64 = 0;
|
||
for (result.asset_category) |item| {
|
||
if (std.mem.eql(u8, item.label, bucket_cash)) cash_val = item.value;
|
||
}
|
||
try std.testing.expectApproxEqAbs(@as(f64, 60_000), cash_val, 1.0);
|
||
}
|
||
|
||
test "analyzePortfolio: legacy entry (asset_class only, no sector) buckets via fallback" {
|
||
// Hand-written CIT/CUSIP entries in metadata.srf often have
|
||
// `asset_class::Bonds,pct:num:30` with no sector. The
|
||
// fallback path through `bucketAssetClass` must pick these
|
||
// up so they land in Fixed Income, not Other.
|
||
const allocator = std.testing.allocator;
|
||
const allocations = [_]Allocation{mkAlloc("LEGACY-CIT", 100_000)};
|
||
var entries = [_]ClassificationEntry{
|
||
.{ .symbol = "LEGACY-CIT", .asset_class = "Bonds", .pct = 60 },
|
||
.{ .symbol = "LEGACY-CIT", .asset_class = "US Large Cap", .pct = 40 },
|
||
};
|
||
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
|
||
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
|
||
|
||
var result = try analyzePortfolio(
|
||
allocator,
|
||
&allocations,
|
||
cm,
|
||
portfolio,
|
||
100_000,
|
||
null,
|
||
Date.fromYmd(2024, 6, 1),
|
||
);
|
||
defer result.deinit(allocator);
|
||
|
||
var equity_val: f64 = 0;
|
||
var fi_val: f64 = 0;
|
||
for (result.asset_category) |item| {
|
||
if (std.mem.eql(u8, item.label, bucket_equity)) equity_val = item.value;
|
||
if (std.mem.eql(u8, item.label, bucket_fixed_income)) fi_val = item.value;
|
||
}
|
||
// 60% Bonds -> Fixed Income = $60,000.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 60_000), fi_val, 1.0);
|
||
// 40% US Large Cap -> Equity = $40,000.
|
||
try std.testing.expectApproxEqAbs(@as(f64, 40_000), equity_val, 1.0);
|
||
}
|
||
|
||
test "analyzePortfolio: sector wins over asset_class when both present" {
|
||
// Defensive: we should not double-count. If both fields are
|
||
// present, only the sector-based bucket fires.
|
||
const allocator = std.testing.allocator;
|
||
const allocations = [_]Allocation{mkAlloc("FOO", 100_000)};
|
||
var entries = [_]ClassificationEntry{
|
||
// sector says Fixed Income (Debt / *), asset_class says
|
||
// Equity (US Large Cap). sector should win.
|
||
.{ .symbol = "FOO", .sector = "Debt / Corporate", .asset_class = "US Large Cap" },
|
||
};
|
||
const cm = ClassificationMap{ .entries = &entries, .allocator = allocator };
|
||
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
|
||
|
||
var result = try analyzePortfolio(
|
||
allocator,
|
||
&allocations,
|
||
cm,
|
||
portfolio,
|
||
100_000,
|
||
null,
|
||
Date.fromYmd(2024, 6, 1),
|
||
);
|
||
defer result.deinit(allocator);
|
||
|
||
// Exactly one row, in Fixed Income.
|
||
try std.testing.expectEqual(@as(usize, 1), result.asset_category.len);
|
||
try std.testing.expectEqualStrings(bucket_fixed_income, result.asset_category[0].label);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.asset_category[0].value, 1.0);
|
||
}
|
||
|
||
test "abbreviateSector: known long labels collapse, others pass through" {
|
||
try std.testing.expectEqualStrings("Comm. Services", abbreviateSector("Communication Services"));
|
||
try std.testing.expectEqualStrings("Technology", abbreviateSector("Technology"));
|
||
try std.testing.expectEqualStrings("Bonds", abbreviateSector("Bonds"));
|
||
try std.testing.expectEqualStrings("Equity / Corporate", abbreviateSector("Equity / Corporate"));
|
||
try std.testing.expectEqualStrings("", abbreviateSector(""));
|
||
}
|