/// Benchmark comparison and portfolio weighted return calculations. /// /// Produces: /// - Weighted benchmark returns (SPY + AGG at the portfolio's stock/bond split) /// - Per-period portfolio weighted trailing returns (straight average) /// - Conservative weighted return: SUM(weight_i * MIN(3Y_i, 5Y_i, 10Y_i)) /// /// The conservative estimate uses the minimum of longer-term trailing returns /// per position, excluding 1-year (too noisy). This matches the spreadsheet /// methodology used for forward-looking projections. const std = @import("std"); const performance = @import("performance.zig"); const TrailingReturns = performance.TrailingReturns; const PerformanceResult = performance.PerformanceResult; const Allocation = @import("valuation.zig").Allocation; const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry; // ── Conservative return estimation defaults ──────────────────── // These will eventually move to projections.srf config. /// Maximum return any single position can contribute (null = no cap). pub const default_return_cap: ?f64 = null; /// Whether to exclude 1-year returns from the conservative MIN calculation. pub const default_exclude_1y_from_min: bool = true; // ── Result types ─────────────────────────────────────────────── /// Trailing returns for each standard period, as simple f64 values (annualized). pub const ReturnsByPeriod = struct { one_year: ?f64 = null, three_year: ?f64 = null, five_year: ?f64 = null, ten_year: ?f64 = null, /// Weekly return (non-annualized, just the 1-week price change). week: ?f64 = null, }; /// Full benchmark comparison result. pub const BenchmarkComparison = struct { /// Stock benchmark (e.g. SPY) returns at each period. stock_returns: ReturnsByPeriod, /// Bond benchmark (e.g. AGG) returns at each period. bond_returns: ReturnsByPeriod, /// Weighted benchmark: stock_pct * stock + bond_pct * bond. benchmark_returns: ReturnsByPeriod, /// Portfolio weighted average returns (straight weight * return per period). portfolio_returns: ReturnsByPeriod, /// Conservative estimate: SUM(weight * MIN(3Y, 5Y, 10Y)) per position. conservative_return: f64, /// Actual stock allocation (fraction). stock_pct: f64, /// Actual bond allocation (fraction, includes cash and CDs). bond_pct: f64, }; /// Per-position data needed for weighted return calculations. pub const PositionReturn = struct { symbol: []const u8, weight: f64, returns: TrailingReturns, }; // ── Allocation split ─────────────────────────────────────────── /// Result of deriving the stock/bond/unclassified allocation split. pub const AllocationSplit = struct { /// Fraction of portfolio in equities (0.0–1.0). stock_pct: f64, /// Fraction of portfolio in bonds + cash + CDs (0.0–1.0). bond_pct: f64, /// Total market value classified as bonds. bond_value: f64, /// Total cash + CD face value. cash_cd_value: f64, /// Total market value that could not be classified (no metadata entry). unclassified_value: f64, }; /// Derive the stock/bond allocation split from portfolio allocations and /// classification metadata. /// /// Positions are classified using `classifications`: /// - asset_class == "Bonds" → bond /// - Everything else with a classification entry → stock /// - No classification entry → unclassified /// /// Cash and CDs are always counted as bonds (fixed-income side). /// Unclassified positions are reported separately so the caller can /// decide how to handle them (e.g. treat as stock, warn, etc.). pub fn deriveAllocationSplit( allocations: []const Allocation, classifications: []const ClassificationEntry, total_value: f64, cash_value: f64, cd_value: f64, ) AllocationSplit { var bond_value: f64 = 0; var classified_value: f64 = 0; for (allocations) |a| { var found = false; for (classifications) |entry| { if (std.mem.eql(u8, entry.symbol, a.symbol)) { found = true; if (entry.asset_class) |ac| { if (std.mem.eql(u8, ac, "Bonds")) { bond_value += a.market_value; } } break; } } if (found) { classified_value += a.market_value; } } const cash_cd_value = cash_value + cd_value; const bond_plus_cash = bond_value + cash_cd_value; // Unclassified = allocations not found in classifications (options, new positions, etc.) // Note: cash/CDs are not in allocations, so total_value includes them separately. const unclassified_value = total_value - classified_value - cash_cd_value; const stock_pct = if (total_value > 0) (total_value - bond_plus_cash - @max(unclassified_value, 0)) / total_value else 0.75; const bond_pct = if (total_value > 0) bond_plus_cash / total_value else 0.25; return .{ .stock_pct = stock_pct, .bond_pct = bond_pct, .bond_value = bond_value, .cash_cd_value = cash_cd_value, .unclassified_value = @max(unclassified_value, 0), }; } // ── Computation functions ────────────────────────────────────── /// Extract the annualized return from a PerformanceResult, if available. fn annualized(pr: ?PerformanceResult) ?f64 { const r = pr orelse return null; return r.annualized_return orelse r.total_return; } /// Convert TrailingReturns to simple f64 ReturnsByPeriod. pub fn toReturnsByPeriod(tr: TrailingReturns) ReturnsByPeriod { return .{ .one_year = annualized(tr.one_year), .three_year = annualized(tr.three_year), .five_year = annualized(tr.five_year), .ten_year = annualized(tr.ten_year), }; } /// Compute the weighted average of two ReturnsByPeriod values. fn blendReturns(a: ReturnsByPeriod, a_weight: f64, b: ReturnsByPeriod, b_weight: f64) ReturnsByPeriod { return .{ .one_year = blendOptional(a.one_year, a_weight, b.one_year, b_weight), .three_year = blendOptional(a.three_year, a_weight, b.three_year, b_weight), .five_year = blendOptional(a.five_year, a_weight, b.five_year, b_weight), .ten_year = blendOptional(a.ten_year, a_weight, b.ten_year, b_weight), .week = blendOptional(a.week, a_weight, b.week, b_weight), }; } fn blendOptional(a: ?f64, a_w: f64, b: ?f64, b_w: f64) ?f64 { if (a != null and b != null) return a.? * a_w + b.? * b_w; if (a != null) return a.? * a_w; if (b != null) return b.? * b_w; return null; } /// Compute portfolio weighted average returns across all positions. /// Each position's return is weighted by its portfolio weight. pub fn portfolioWeightedReturns(positions: []const PositionReturn) ReturnsByPeriod { var result = ReturnsByPeriod{}; var sum_1y: f64 = 0; var w_1y: f64 = 0; var sum_3y: f64 = 0; var w_3y: f64 = 0; var sum_5y: f64 = 0; var w_5y: f64 = 0; var sum_10y: f64 = 0; var w_10y: f64 = 0; for (positions) |pos| { if (annualized(pos.returns.one_year)) |r| { sum_1y += pos.weight * r; w_1y += pos.weight; } if (annualized(pos.returns.three_year)) |r| { sum_3y += pos.weight * r; w_3y += pos.weight; } if (annualized(pos.returns.five_year)) |r| { sum_5y += pos.weight * r; w_5y += pos.weight; } if (annualized(pos.returns.ten_year)) |r| { sum_10y += pos.weight * r; w_10y += pos.weight; } } // Normalize by total weight for each period (handles positions with missing data) if (w_1y > 0) result.one_year = sum_1y / w_1y; if (w_3y > 0) result.three_year = sum_3y / w_3y; if (w_5y > 0) result.five_year = sum_5y / w_5y; if (w_10y > 0) result.ten_year = sum_10y / w_10y; return result; } /// Compute the conservative weighted return estimate. /// /// For each position: take MIN(3Y, 5Y, 10Y) annualized return (skipping 1Y). /// Apply optional cap. Multiply by weight. Sum across all positions. /// Normalize by total weight of positions that had at least one valid period. pub fn conservativeWeightedReturn( positions: []const PositionReturn, return_cap: ?f64, exclude_1y: bool, ) f64 { var total: f64 = 0; var total_weight: f64 = 0; for (positions) |pos| { var min_return: ?f64 = null; // Include 1Y only if not excluded if (!exclude_1y) { if (annualized(pos.returns.one_year)) |r| { min_return = r; } } if (annualized(pos.returns.three_year)) |r| { min_return = if (min_return) |m| @min(m, r) else r; } if (annualized(pos.returns.five_year)) |r| { min_return = if (min_return) |m| @min(m, r) else r; } if (annualized(pos.returns.ten_year)) |r| { min_return = if (min_return) |m| @min(m, r) else r; } if (min_return) |mr| { // Apply cap if configured const capped = if (return_cap) |cap| @min(mr, cap) else mr; total += pos.weight * capped; total_weight += pos.weight; } } if (total_weight > 0) return total / total_weight; return 0; } /// Build a full benchmark comparison from component data. pub fn buildComparison( stock_trailing: TrailingReturns, bond_trailing: TrailingReturns, stock_pct: f64, bond_pct: f64, positions: []const PositionReturn, stock_week: ?f64, bond_week: ?f64, ) BenchmarkComparison { var stock_r = toReturnsByPeriod(stock_trailing); stock_r.week = stock_week; var bond_r = toReturnsByPeriod(bond_trailing); bond_r.week = bond_week; const benchmark = blendReturns(stock_r, stock_pct, bond_r, bond_pct); const portfolio = portfolioWeightedReturns(positions); const conservative = conservativeWeightedReturn(positions, default_return_cap, default_exclude_1y_from_min); return .{ .stock_returns = stock_r, .bond_returns = bond_r, .benchmark_returns = benchmark, .portfolio_returns = portfolio, .conservative_return = conservative, .stock_pct = stock_pct, .bond_pct = bond_pct, }; } // ── Tests ────────────────────────────────────────────────────── const Date = @import("../Date.zig"); fn makePR(total: f64, ann: ?f64) PerformanceResult { return .{ .total_return = total, .annualized_return = ann, .from = Date.fromYmd(2020, 1, 1), .to = Date.fromYmd(2024, 1, 1), }; } test "toReturnsByPeriod extracts annualized returns" { const tr = TrailingReturns{ .one_year = makePR(0.10, 0.10), .three_year = makePR(0.30, 0.09), .five_year = makePR(0.50, 0.085), .ten_year = null, }; const r = toReturnsByPeriod(tr); try std.testing.expectApproxEqAbs(@as(f64, 0.10), r.one_year.?, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 0.09), r.three_year.?, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 0.085), r.five_year.?, 0.001); try std.testing.expect(r.ten_year == null); } test "blendReturns weighted average" { const a = ReturnsByPeriod{ .one_year = 0.20, .three_year = 0.15 }; const b = ReturnsByPeriod{ .one_year = 0.05, .three_year = 0.04 }; const result = blendReturns(a, 0.75, b, 0.25); // 0.75 * 0.20 + 0.25 * 0.05 = 0.1625 try std.testing.expectApproxEqAbs(@as(f64, 0.1625), result.one_year.?, 0.0001); // 0.75 * 0.15 + 0.25 * 0.04 = 0.1225 try std.testing.expectApproxEqAbs(@as(f64, 0.1225), result.three_year.?, 0.0001); } test "blendReturns handles null" { const a = ReturnsByPeriod{ .one_year = 0.20, .three_year = null }; const b = ReturnsByPeriod{ .one_year = null, .three_year = 0.04 }; const result = blendReturns(a, 0.75, b, 0.25); // Only a has 1Y: 0.20 * 0.75 = 0.15 try std.testing.expectApproxEqAbs(@as(f64, 0.15), result.one_year.?, 0.0001); // Only b has 3Y: 0.04 * 0.25 = 0.01 try std.testing.expectApproxEqAbs(@as(f64, 0.01), result.three_year.?, 0.0001); } test "portfolioWeightedReturns basic" { const positions = [_]PositionReturn{ .{ .symbol = "SPY", .weight = 0.60, .returns = .{ .one_year = makePR(0.20, 0.20), .three_year = makePR(0.50, 0.15), } }, .{ .symbol = "AGG", .weight = 0.40, .returns = .{ .one_year = makePR(0.05, 0.05), .three_year = makePR(0.12, 0.04), } }, }; const r = portfolioWeightedReturns(&positions); // 1Y: (0.60 * 0.20 + 0.40 * 0.05) / (0.60 + 0.40) = 0.14 try std.testing.expectApproxEqAbs(@as(f64, 0.14), r.one_year.?, 0.001); // 3Y: (0.60 * 0.15 + 0.40 * 0.04) / (0.60 + 0.40) = 0.106 try std.testing.expectApproxEqAbs(@as(f64, 0.106), r.three_year.?, 0.001); } test "portfolioWeightedReturns normalizes by available weight" { // Position B has no 3Y data — should normalize by weight of those that do const positions = [_]PositionReturn{ .{ .symbol = "SPY", .weight = 0.60, .returns = .{ .one_year = makePR(0.20, 0.20), .three_year = makePR(0.50, 0.15), } }, .{ .symbol = "NEW", .weight = 0.40, .returns = .{ .one_year = makePR(0.30, 0.30), .three_year = null, } }, }; const r = portfolioWeightedReturns(&positions); // 1Y: (0.60 * 0.20 + 0.40 * 0.30) / 1.0 = 0.24 try std.testing.expectApproxEqAbs(@as(f64, 0.24), r.one_year.?, 0.001); // 3Y: (0.60 * 0.15) / 0.60 = 0.15 (only SPY contributes) try std.testing.expectApproxEqAbs(@as(f64, 0.15), r.three_year.?, 0.001); } test "conservativeWeightedReturn uses MIN of 3Y/5Y/10Y" { const positions = [_]PositionReturn{ .{ .symbol = "NVDA", .weight = 0.05, .returns = .{ .one_year = makePR(0.32, 0.32), // excluded from MIN .three_year = makePR(4.0, 1.28), // 128% (highest) .five_year = makePR(10.0, 0.69), // 69% (lowest) .ten_year = makePR(50.0, 0.74), // 74% }, }, .{ .symbol = "SPY", .weight = 0.95, .returns = .{ .three_year = makePR(0.50, 0.15), .five_year = makePR(0.80, 0.12), .ten_year = makePR(2.0, 0.14), } }, }; const result = conservativeWeightedReturn(&positions, null, true); // NVDA: MIN(1.28, 0.69, 0.74) = 0.69, weight 0.05 // SPY: MIN(0.15, 0.12, 0.14) = 0.12, weight 0.95 // Total: (0.05 * 0.69 + 0.95 * 0.12) / (0.05 + 0.95) = 0.0345 + 0.114 = 0.1485 try std.testing.expectApproxEqAbs(@as(f64, 0.1485), result, 0.001); } test "conservativeWeightedReturn with cap" { const positions = [_]PositionReturn{ .{ .symbol = "NVDA", .weight = 1.0, .returns = .{ .three_year = makePR(4.0, 1.28), .five_year = makePR(10.0, 0.69), .ten_year = makePR(50.0, 0.74), } }, }; // Without cap: MIN(1.28, 0.69, 0.74) = 0.69 const uncapped = conservativeWeightedReturn(&positions, null, true); try std.testing.expectApproxEqAbs(@as(f64, 0.69), uncapped, 0.001); // With 30% cap: MIN(1.28, 0.69, 0.74) = 0.69, then capped to 0.30 const capped = conservativeWeightedReturn(&positions, 0.30, true); try std.testing.expectApproxEqAbs(@as(f64, 0.30), capped, 0.001); } test "conservativeWeightedReturn includes 1Y when not excluded" { const positions = [_]PositionReturn{ .{ .symbol = "SPY", .weight = 1.0, .returns = .{ .one_year = makePR(0.05, 0.05), // lowest .three_year = makePR(0.50, 0.15), .five_year = makePR(0.80, 0.12), }, }, }; // With exclude_1y=true: MIN(0.15, 0.12) = 0.12 const excluded = conservativeWeightedReturn(&positions, null, true); try std.testing.expectApproxEqAbs(@as(f64, 0.12), excluded, 0.001); // With exclude_1y=false: MIN(0.05, 0.15, 0.12) = 0.05 const included = conservativeWeightedReturn(&positions, null, false); try std.testing.expectApproxEqAbs(@as(f64, 0.05), included, 0.001); } test "buildComparison produces consistent results" { const stock_tr = TrailingReturns{ .one_year = makePR(0.23, 0.23), .three_year = makePR(0.60, 0.17), }; const bond_tr = TrailingReturns{ .one_year = makePR(0.04, 0.04), .three_year = makePR(0.10, 0.03), }; const positions = [_]PositionReturn{ .{ .symbol = "VTI", .weight = 0.80, .returns = .{ .one_year = makePR(0.22, 0.22), .three_year = makePR(0.55, 0.16), .five_year = makePR(0.80, 0.13), } }, .{ .symbol = "BND", .weight = 0.20, .returns = .{ .one_year = makePR(0.03, 0.03), .three_year = makePR(0.09, 0.03), .five_year = makePR(0.10, 0.02), } }, }; const result = buildComparison(stock_tr, bond_tr, 0.77, 0.23, &positions, null, null); // Benchmark 1Y: 0.77 * 0.23 + 0.23 * 0.04 = 0.1771 + 0.0092 = 0.1863 try std.testing.expectApproxEqAbs(@as(f64, 0.1863), result.benchmark_returns.one_year.?, 0.001); // Portfolio 1Y: (0.80 * 0.22 + 0.20 * 0.03) / 1.0 = 0.182 try std.testing.expectApproxEqAbs(@as(f64, 0.182), result.portfolio_returns.one_year.?, 0.001); // Conservative: VTI MIN(0.16, 0.13) = 0.13 * 0.80 + BND MIN(0.03, 0.02) = 0.02 * 0.20 // = 0.104 + 0.004 = 0.108 try std.testing.expectApproxEqAbs(@as(f64, 0.108), result.conservative_return, 0.001); try std.testing.expectApproxEqAbs(@as(f64, 0.77), result.stock_pct, 0.001); } test "conservativeWeightedReturn with no valid periods" { const positions = [_]PositionReturn{ .{ .symbol = "NEW", .weight = 1.0, .returns = .{ .one_year = makePR(0.10, 0.10), } }, }; // exclude_1y=true, and only 1Y data available → no valid periods const result = conservativeWeightedReturn(&positions, null, true); try std.testing.expectApproxEqAbs(@as(f64, 0.0), result, 0.001); } test "portfolioWeightedReturns empty positions" { const positions = [_]PositionReturn{}; const r = portfolioWeightedReturns(&positions); try std.testing.expect(r.one_year == null); try std.testing.expect(r.three_year == null); } test "annualized falls back to total_return" { // When annualized_return is null, should use total_return const pr = PerformanceResult{ .total_return = 0.15, .annualized_return = null, .from = Date.fromYmd(2024, 1, 1), .to = Date.fromYmd(2024, 6, 1), }; try std.testing.expectApproxEqAbs(@as(f64, 0.15), annualized(pr).?, 0.001); try std.testing.expect(annualized(null) == null); } test "blendReturns both null" { const a = ReturnsByPeriod{}; const b = ReturnsByPeriod{}; const result = blendReturns(a, 0.5, b, 0.5); try std.testing.expect(result.one_year == null); try std.testing.expect(result.five_year == null); } test "conservativeWeightedReturn single position single period" { const positions = [_]PositionReturn{ .{ .symbol = "SPY", .weight = 1.0, .returns = .{ .five_year = makePR(0.80, 0.125), } }, }; // Only 5Y available → MIN is just 0.125 const result = conservativeWeightedReturn(&positions, null, true); try std.testing.expectApproxEqAbs(@as(f64, 0.125), result, 0.001); } test "buildComparison with week returns" { const stock_tr = TrailingReturns{ .one_year = makePR(0.20, 0.20), }; const bond_tr = TrailingReturns{ .one_year = makePR(0.03, 0.03), }; const positions = [_]PositionReturn{}; const result = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, -0.01, 0.005); // Week returns should be set try std.testing.expectApproxEqAbs(@as(f64, -0.01), result.stock_returns.week.?, 0.0001); try std.testing.expectApproxEqAbs(@as(f64, 0.005), result.bond_returns.week.?, 0.0001); // Benchmark week: 0.80 * -0.01 + 0.20 * 0.005 = -0.007 try std.testing.expectApproxEqAbs(@as(f64, -0.007), result.benchmark_returns.week.?, 0.0001); } test "portfolioWeightedReturns all periods populated" { const positions = [_]PositionReturn{ .{ .symbol = "VTI", .weight = 0.50, .returns = .{ .one_year = makePR(0.20, 0.20), .three_year = makePR(0.50, 0.15), .five_year = makePR(0.80, 0.13), .ten_year = makePR(2.0, 0.12), } }, .{ .symbol = "BND", .weight = 0.50, .returns = .{ .one_year = makePR(0.04, 0.04), .three_year = makePR(0.10, 0.03), .five_year = makePR(0.15, 0.03), .ten_year = makePR(0.30, 0.03), } }, }; const r = portfolioWeightedReturns(&positions); // 10Y: (0.50 * 0.12 + 0.50 * 0.03) / 1.0 = 0.075 try std.testing.expectApproxEqAbs(@as(f64, 0.075), r.ten_year.?, 0.001); // 5Y: (0.50 * 0.13 + 0.50 * 0.03) / 1.0 = 0.08 try std.testing.expectApproxEqAbs(@as(f64, 0.08), r.five_year.?, 0.001); } fn makeAlloc(symbol: []const u8, mv: f64, weight: f64) Allocation { return .{ .symbol = symbol, .display_symbol = symbol, .shares = 10, .avg_cost = 100, .current_price = mv / 10, .market_value = mv, .cost_basis = 1000, .weight = weight, .unrealized_gain_loss = mv - 1000, .unrealized_return = 0.0, }; } test "deriveAllocationSplit basic stock/bond split" { const allocs = [_]Allocation{ makeAlloc("SPY", 700_000, 0.70), makeAlloc("AAPL", 100_000, 0.10), makeAlloc("BND", 150_000, 0.15), }; const classes = [_]ClassificationEntry{ .{ .symbol = "SPY", .asset_class = "US Large Cap" }, .{ .symbol = "AAPL", .asset_class = "US Large Cap" }, .{ .symbol = "BND", .asset_class = "Bonds" }, }; const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 40_000, 10_000); // Bonds: BND $150K + cash $40K + CD $10K = $200K → 20% try std.testing.expectApproxEqAbs(@as(f64, 150_000), result.bond_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.cash_cd_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.bond_pct, 0.01); // Stock: $800K → 80% (no unclassified since all are in metadata) try std.testing.expectApproxEqAbs(@as(f64, 0.80), result.stock_pct, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.unclassified_value, 1.0); } test "deriveAllocationSplit with unclassified positions" { const allocs = [_]Allocation{ makeAlloc("SPY", 600_000, 0.60), makeAlloc("MYSTERY", 100_000, 0.10), }; const classes = [_]ClassificationEntry{ .{ .symbol = "SPY", .asset_class = "US Large Cap" }, // MYSTERY has no classification entry }; const result = deriveAllocationSplit(&allocs, &classes, 800_000, 50_000, 50_000); // Bonds: $0 + cash $50K + CD $50K = $100K try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.cash_cd_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_value, 1.0); // Unclassified: MYSTERY $100K try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.unclassified_value, 1.0); // Stock: $800K - $100K bonds - $100K unclassified = $600K → 75% try std.testing.expectApproxEqAbs(@as(f64, 0.75), result.stock_pct, 0.01); // Bond pct: $100K / $800K = 12.5% try std.testing.expectApproxEqAbs(@as(f64, 0.125), result.bond_pct, 0.01); } test "deriveAllocationSplit empty portfolio" { const allocs = [_]Allocation{}; const classes = [_]ClassificationEntry{}; const result = deriveAllocationSplit(&allocs, &classes, 0, 0, 0); // Default fallback try std.testing.expectApproxEqAbs(@as(f64, 0.75), result.stock_pct, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.25), result.bond_pct, 0.01); } test "deriveAllocationSplit no metadata" { const allocs = [_]Allocation{ makeAlloc("SPY", 500_000, 0.50), makeAlloc("BND", 300_000, 0.30), }; const classes = [_]ClassificationEntry{}; // no metadata at all const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 100_000, 100_000); // Everything is unclassified except cash/CDs try std.testing.expectApproxEqAbs(@as(f64, 200_000), result.cash_cd_value, 1.0); try std.testing.expectApproxEqAbs(@as(f64, 800_000), result.unclassified_value, 1.0); // Stock = total - bonds - unclassified = $1M - $200K - $800K = $0 → 0% try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.stock_pct, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.bond_pct, 0.01); } test "deriveAllocationSplit stock and bond pct sum with unclassified" { const allocs = [_]Allocation{ makeAlloc("SPY", 500_000, 0.50), makeAlloc("BND", 200_000, 0.20), makeAlloc("NEW", 50_000, 0.05), }; const classes = [_]ClassificationEntry{ .{ .symbol = "SPY", .asset_class = "US Large Cap" }, .{ .symbol = "BND", .asset_class = "Bonds" }, }; const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 200_000, 50_000); // stock + bond + unclassified/total should account for everything const unclass_pct = result.unclassified_value / 1_000_000; try std.testing.expectApproxEqAbs(@as(f64, 1.0), result.stock_pct + result.bond_pct + unclass_pct, 0.01); }