2110 lines
89 KiB
Zig
2110 lines
89 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 srf = @import("srf");
|
||
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");
|
||
|
||
/// 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)
|
||
};
|
||
|
||
/// 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)",
|
||
};
|
||
}
|
||
};
|
||
|
||
/// 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,
|
||
};
|
||
|
||
/// 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.
|
||
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";
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
|
||
/// Return all entries matching a given institution.
|
||
pub fn entriesForInstitution(self: AccountMap, institution: []const u8) []const AccountTaxEntry {
|
||
var count: usize = 0;
|
||
for (self.entries) |e| {
|
||
if (e.institution) |inst| {
|
||
if (std.mem.eql(u8, inst, institution)) count += 1;
|
||
}
|
||
}
|
||
if (count == 0) return &.{};
|
||
return self.entries;
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
};
|
||
|
||
/// Parse an accounts.srf file into an AccountMap.
|
||
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>]
|
||
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, .{}) catch continue;
|
||
try entries.append(allocator, .{
|
||
.account = try allocator.dupe(u8, entry.account),
|
||
.tax_type = entry.tax_type,
|
||
.institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null,
|
||
.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null,
|
||
.update_cadence = entry.update_cadence,
|
||
.cash_is_contribution = entry.cash_is_contribution,
|
||
.direct_indexing = entry.direct_indexing,
|
||
.shielded = entry.shielded,
|
||
});
|
||
}
|
||
|
||
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.
|
||
/// - Else if `entry.tax_type == .taxable`, NOT shielded.
|
||
/// - Else (Traditional / Roth / HSA), shielded by default.
|
||
///
|
||
/// 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 is_shielded = accountIsShielded(item.label, account_map);
|
||
if (is_shielded) {
|
||
shielded += item.value;
|
||
} else {
|
||
exposed += item.value;
|
||
}
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
/// Look up the shielding decision for one account name.
|
||
/// Exposed (returns false) when:
|
||
/// - Account is not in the map (defensive default).
|
||
/// - Explicit `shielded::false` override.
|
||
/// - tax_type is `taxable` and no override.
|
||
fn accountIsShielded(account: []const u8, account_map: AccountMap) bool {
|
||
for (account_map.entries) |e| {
|
||
if (!std.mem.eql(u8, e.account, account)) continue;
|
||
if (e.shielded) |explicit| return explicit;
|
||
return e.tax_type != .taxable;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ── 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. The income-feels-like-bonds intuition belongs in
|
||
// a separate yield-weighted analysis (see TODO.md
|
||
// "Dividend equity / income-shaped equity"), not in the
|
||
// asset-class taxonomy.
|
||
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;
|
||
// "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;
|
||
|
||
// Everything else: derivatives, real property, sentinels
|
||
// (TODO/Unknown/empty), unrecognized future labels.
|
||
return bucket_other;
|
||
}
|
||
|
||
// ── Sector display granularity ───────────────────────────────
|
||
|
||
/// Granularity tier for the Sector breakdown display. Lets the
|
||
/// user toggle between coarse bucket-level summary and the raw
|
||
/// fine-grained NPORT-P decomposition.
|
||
pub const Granularity = enum {
|
||
/// Four buckets: Equity / Fixed Income / Cash / Other.
|
||
/// Same labels as the Asset Category breakdown.
|
||
coarse,
|
||
/// ~12-16 buckets: collapses NPORT-P sub-flavors but keeps
|
||
/// GICS sectors distinct. Default for most users — answers
|
||
/// "how exposed am I to bonds vs stocks vs cash, and which
|
||
/// sectors within stocks?".
|
||
mid,
|
||
/// Raw NPORT-P strings: every Debt / X variant, every
|
||
/// Asset-Backed / Y variant, every Derivative / Z variant
|
||
/// is its own row. Useful for spotting fine-grained
|
||
/// concentrations within the bond sleeve (e.g. "am I overweight
|
||
/// US Treasury vs Municipal?").
|
||
fine,
|
||
};
|
||
|
||
// Mid-granularity bucket labels. Static literals so they can be
|
||
// used as stable HashMap keys without duping.
|
||
pub const mid_bonds: []const u8 = "Bonds";
|
||
pub const mid_equity_corporate: []const u8 = "Equity / Corporate";
|
||
pub const mid_equity_preferred: []const u8 = "Equity Preferred";
|
||
pub const mid_cash_equivalents: []const u8 = "Cash & Equivalents";
|
||
pub const mid_derivatives: []const u8 = "Derivatives";
|
||
pub const mid_other: []const u8 = "Other";
|
||
|
||
/// 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 (or, at fine granularity, the input slice
|
||
/// itself) suitable for use as a stable HashMap key.
|
||
///
|
||
/// Granularity tiers:
|
||
///
|
||
/// - **coarse**: delegates to `bucketSector` — Equity / Fixed Income
|
||
/// / Cash / Other (4 buckets).
|
||
/// - **mid**: collapses NPORT-P sub-flavors (all Debt / * → Bonds,
|
||
/// all Asset-Backed / * → Bonds, all STIV / * → Cash & Equivalents,
|
||
/// all Derivative / * → Derivatives) while keeping GICS sectors
|
||
/// distinct (Technology, Healthcare, etc.).
|
||
/// - **fine**: passthrough — returns the input unchanged.
|
||
pub fn collapseSector(sector: []const u8, granularity: Granularity) []const u8 {
|
||
return switch (granularity) {
|
||
.fine => sector,
|
||
.coarse => bucketSector(sector),
|
||
.mid => midBucket(sector),
|
||
};
|
||
}
|
||
|
||
/// Mid-granularity bucket lookup. Pure data, no allocation.
|
||
fn midBucket(sector: []const u8) []const u8 {
|
||
// Bond-shaped (NPORT-P): all Debt / *, Loan / *, Asset-Backed / *.
|
||
if (std.mem.startsWith(u8, sector, "Debt")) return mid_bonds;
|
||
if (std.mem.startsWith(u8, sector, "Loan")) return mid_bonds;
|
||
if (std.mem.startsWith(u8, sector, "Asset-Backed")) return mid_bonds;
|
||
// Plain-English bond label from legacy hand-written metadata.
|
||
if (std.mem.eql(u8, sector, "Bonds")) return mid_bonds;
|
||
|
||
// Cash-shaped (NPORT-P): STIV variants + repurchase agreement.
|
||
if (std.mem.startsWith(u8, sector, "Short-Term Investment Vehicle")) return mid_cash_equivalents;
|
||
if (std.mem.startsWith(u8, sector, "Repurchase Agreement")) return mid_cash_equivalents;
|
||
if (std.mem.eql(u8, sector, "Cash")) return mid_cash_equivalents;
|
||
if (std.mem.eql(u8, sector, "Cash & CDs")) return mid_cash_equivalents;
|
||
|
||
// Equity Preferred is distinct at mid because it's a hybrid.
|
||
if (std.mem.startsWith(u8, sector, "Equity Preferred")) return mid_equity_preferred;
|
||
|
||
// Generic Equity / * collapses to a single Equity / Corporate
|
||
// bucket. Specifically "Equity / Corporate" / "Equity / Other"
|
||
// / "Equity / Registered Fund" all merge here.
|
||
if (std.mem.startsWith(u8, sector, "Equity")) return mid_equity_corporate;
|
||
|
||
// Derivatives.
|
||
if (std.mem.startsWith(u8, sector, "Derivative")) return mid_derivatives;
|
||
|
||
// GICS sector names pass through unchanged. Same exact-match
|
||
// list as `bucketSector`.
|
||
const gics = [_][]const u8{
|
||
"Technology",
|
||
"Healthcare",
|
||
"Financial Services",
|
||
"Financials",
|
||
"Consumer Cyclical",
|
||
"Consumer Defensive",
|
||
"Energy",
|
||
"Utilities",
|
||
"Real Estate",
|
||
"Industrials",
|
||
"Basic Materials",
|
||
"Communication Services",
|
||
"Diversified",
|
||
};
|
||
for (gics) |g| if (std.mem.eql(u8, sector, g)) return sector;
|
||
|
||
// Direct Real Property, Direct Credit Risk, sentinels — Other.
|
||
return mid_other;
|
||
}
|
||
|
||
/// 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
|
||
// Try both the raw symbol and display_symbol
|
||
var found = false;
|
||
for (classifications.entries) |entry| {
|
||
if (std.mem.eql(u8, entry.symbol, alloc.symbol) or
|
||
std.mem.eql(u8, entry.symbol, alloc.display_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
|
||
.option => @abs(lot.shares) * lot.open_price,
|
||
.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: map each account's total to its tax type
|
||
if (account_map) |am| {
|
||
var acct_iter = acct_map.iterator();
|
||
while (acct_iter.next()) |kv| {
|
||
const tt = am.taxTypeFor(kv.key_ptr.*);
|
||
const prev = tax_map.get(tt) orelse 0;
|
||
try tax_map.put(tt, prev + kv.value_ptr.*);
|
||
}
|
||
}
|
||
|
||
// Convert maps to sorted slices
|
||
const total = if (total_portfolio_value > 0) total_portfolio_value else 1.0;
|
||
|
||
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 = try mapToSortedBreakdown(allocator, acct_map, total),
|
||
.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: 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.?);
|
||
}
|
||
|
||
// ── 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 "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());
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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 and unrecognized → 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("Fintech"));
|
||
try std.testing.expectEqualStrings(bucket_other, bucketSector("Some Future Label"));
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
test "collapseSector .mid: all Debt / * collapse to Bonds" {
|
||
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(mid_bonds, collapseSector(s, .mid));
|
||
}
|
||
}
|
||
|
||
test "collapseSector .mid: all Asset-Backed and Loan variants collapse to Bonds" {
|
||
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed / Corporate Mortgage", .mid));
|
||
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed CBO/CDO / Corporate", .mid));
|
||
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Asset-Backed Other / Corporate", .mid));
|
||
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Loan / Corporate", .mid));
|
||
try std.testing.expectEqualStrings(mid_bonds, collapseSector("Bonds", .mid));
|
||
}
|
||
|
||
test "collapseSector .mid: all STIV / Repurchase collapse to Cash & Equivalents" {
|
||
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Corporate", .mid));
|
||
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Registered Fund", .mid));
|
||
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Short-Term Investment Vehicle / Private Fund", .mid));
|
||
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Repurchase Agreement / Other", .mid));
|
||
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Cash", .mid));
|
||
try std.testing.expectEqualStrings(mid_cash_equivalents, collapseSector("Cash & CDs", .mid));
|
||
}
|
||
|
||
test "collapseSector .mid: Equity Preferred is its own bucket" {
|
||
// Hybrid security — distinct from generic equity at mid.
|
||
try std.testing.expectEqualStrings(mid_equity_preferred, collapseSector("Equity Preferred / Corporate", .mid));
|
||
}
|
||
|
||
test "collapseSector .mid: Equity / * (non-Preferred) collapses to Equity / Corporate" {
|
||
try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Corporate", .mid));
|
||
try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Other", .mid));
|
||
try std.testing.expectEqualStrings(mid_equity_corporate, collapseSector("Equity / Registered Fund", .mid));
|
||
}
|
||
|
||
test "collapseSector .mid: GICS sectors stay distinct" {
|
||
// The whole point of mid: collapse NPORT-P sub-flavors but
|
||
// keep GICS sector breakdown so users see stock concentrations.
|
||
try std.testing.expectEqualStrings("Technology", collapseSector("Technology", .mid));
|
||
try std.testing.expectEqualStrings("Healthcare", collapseSector("Healthcare", .mid));
|
||
try std.testing.expectEqualStrings("Financial Services", collapseSector("Financial Services", .mid));
|
||
try std.testing.expectEqualStrings("Financials", collapseSector("Financials", .mid));
|
||
try std.testing.expectEqualStrings("Diversified", collapseSector("Diversified", .mid));
|
||
try std.testing.expectEqualStrings("Energy", collapseSector("Energy", .mid));
|
||
}
|
||
|
||
test "collapseSector .mid: Derivative variants collapse to Derivatives" {
|
||
try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative / Corporate", .mid));
|
||
try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative / Other", .mid));
|
||
try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative-FX / Other", .mid));
|
||
try std.testing.expectEqualStrings(mid_derivatives, collapseSector("Derivative-FX / Corporate", .mid));
|
||
}
|
||
|
||
test "collapseSector .mid: real property and unrecognized -> Other" {
|
||
try std.testing.expectEqualStrings(mid_other, collapseSector("Direct Real Property / Other", .mid));
|
||
try std.testing.expectEqualStrings(mid_other, collapseSector("Direct Credit Risk / Other", .mid));
|
||
try std.testing.expectEqualStrings(mid_other, collapseSector("TODO", .mid));
|
||
try std.testing.expectEqualStrings(mid_other, collapseSector("Unknown", .mid));
|
||
try std.testing.expectEqualStrings(mid_other, collapseSector("", .mid));
|
||
try std.testing.expectEqualStrings(mid_other, collapseSector("Some Future Label", .mid));
|
||
}
|
||
|
||
test "collapseSector .mid: returns same pointer for same bucket (static-string property)" {
|
||
// Stable HashMap keys without duping.
|
||
const a = collapseSector("Debt / Corporate", .mid);
|
||
const b = collapseSector("Debt / US Treasury", .mid);
|
||
try std.testing.expectEqual(@intFromPtr(a.ptr), @intFromPtr(b.ptr));
|
||
try std.testing.expectEqual(@intFromPtr(mid_bonds.ptr), @intFromPtr(a.ptr));
|
||
}
|
||
|
||
// ── collapseBreakdownAtGranularity ────────────────────────────
|
||
|
||
test "collapseBreakdownAtGranularity: VBTLX-shape Debt sleeves collapse to Bonds at mid" {
|
||
// VBTLX has six different Debt / X rows. At mid granularity
|
||
// they should all sum into one Bonds row.
|
||
const allocator = std.testing.allocator;
|
||
const items = [_]BreakdownItem{
|
||
.{ .label = "Debt / Corporate", .weight = 0.40, .value = 40_000.0 },
|
||
.{ .label = "Debt / US Treasury", .weight = 0.30, .value = 30_000.0 },
|
||
.{ .label = "Debt / Non-US Sovereign", .weight = 0.10, .value = 10_000.0 },
|
||
.{ .label = "Debt / Municipal", .weight = 0.05, .value = 5_000.0 },
|
||
.{ .label = "Debt / US Gov Agency", .weight = 0.04, .value = 4_000.0 },
|
||
.{ .label = "Debt / US GSE", .weight = 0.01, .value = 1_000.0 },
|
||
.{ .label = "Short-Term Investment Vehicle / Registered Fund", .weight = 0.10, .value = 10_000.0 },
|
||
};
|
||
const result = try collapseBreakdownAtGranularity(allocator, &items, .mid, 100_000.0);
|
||
defer allocator.free(result);
|
||
|
||
// Two output buckets: Bonds (sum of all Debt/*) and
|
||
// Cash & Equivalents (the STIV row).
|
||
try std.testing.expectEqual(@as(usize, 2), result.len);
|
||
var bonds_value: f64 = 0;
|
||
var cash_value: f64 = 0;
|
||
for (result) |item| {
|
||
if (std.mem.eql(u8, item.label, mid_bonds)) bonds_value = item.value;
|
||
if (std.mem.eql(u8, item.label, mid_cash_equivalents)) cash_value = item.value;
|
||
}
|
||
try std.testing.expectApproxEqAbs(@as(f64, 90_000), bonds_value, 1.0);
|
||
try std.testing.expectApproxEqAbs(@as(f64, 10_000), cash_value, 1.0);
|
||
}
|
||
|
||
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, .mid, 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, .mid, 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: 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(""));
|
||
}
|