/// 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(""));
}
