diff --git a/src/analytics/benchmark.zig b/src/analytics/benchmark.zig new file mode 100644 index 0000000..7a0524a --- /dev/null +++ b/src/analytics/benchmark.zig @@ -0,0 +1,659 @@ +/// 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("../models/date.zig").Date; + +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); +} diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig new file mode 100644 index 0000000..c46c043 --- /dev/null +++ b/src/analytics/projections.zig @@ -0,0 +1,672 @@ +/// Historical simulation engine for retirement projections. +/// +/// Implements the FIRECalc algorithm: for each starting year in the Shiller +/// historical dataset (1871–present), simulate a retirement of `horizon` years +/// using actual market returns, bond returns, and inflation. The portfolio is +/// rebalanced annually to the target stock/bond allocation. +/// +/// Key outputs: +/// - Safe withdrawal amount at a given confidence level (binary search to $1) +/// - Success rate for a given spending level +/// - Percentile bands of portfolio value at each year (for charting) +const std = @import("std"); +const shiller = @import("../data/shiller.zig"); +const srf = @import("srf"); + +// ── User configuration (from projections.srf) ────────────────── + +/// User-configurable projection parameters, loaded from projections.srf. +/// +/// Example projections.srf: +/// #!srfv1 +/// target_stock_pct::77 +/// horizons::20,30,45 +/// birthdates::1975-03-15,1978-06-22 +pub const UserConfig = struct { + /// Target stock allocation percentage (0-100). Used for simulation blending. + target_stock_pct: ?f64 = null, + /// Retirement horizons to simulate (years). Defaults to 20,30,45. + horizons: [max_horizons]u16 = .{ 20, 30, 45 } ++ .{0} ** (max_horizons - 3), + horizon_count: u8 = 3, + /// Confidence levels for safe withdrawal. Always 90/95/99. + confidence_levels: [3]f64 = .{ 0.90, 0.95, 0.99 }, + + const max_horizons: usize = 8; + + pub fn getHorizons(self: *const UserConfig) []const u16 { + return self.horizons[0..self.horizon_count]; + } + + pub fn getConfidenceLevels(self: *const UserConfig) []const f64 { + return &self.confidence_levels; + } +}; + +/// Parse a projections.srf file into a UserConfig. +/// Returns default config if data is null or unparseable. +/// +/// Format (one field per line): +/// target_stock_pct::77 +/// horizon::20 +/// horizon::30 +/// horizon::45 +/// +/// Multiple `horizon` entries replace the default horizons. +pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { + var config = UserConfig{}; + const raw = data orelse return config; + if (raw.len == 0) return config; + + // Use a struct with all optional fields so a single .to() call handles any line. + const SrfLine = struct { + target_stock_pct: ?[]const u8 = null, + horizon: ?[]const u8 = null, + }; + + var reader = std.Io.Reader.fixed(raw); + var it = srf.iterator(&reader, std.heap.smp_allocator, .{ .alloc_strings = false }) catch return config; + defer it.deinit(); + + var saw_horizon = false; + + while (it.next() catch null) |fields| { + const line = fields.to(SrfLine) catch continue; + + if (line.target_stock_pct) |val| { + config.target_stock_pct = std.fmt.parseFloat(f64, val) catch null; + } + + if (line.horizon) |val| { + if (!saw_horizon) { + config.horizon_count = 0; + saw_horizon = true; + } + if (config.horizon_count < UserConfig.max_horizons) { + const h = std.fmt.parseInt(u16, val, 10) catch continue; + if (h > 0) { + config.horizons[config.horizon_count] = h; + config.horizon_count += 1; + } + } + } + } + + return config; +} + +// ── Configuration ────────────────────────────────────────────── + +/// Conservative return estimation defaults. +/// These are module-level constants that will eventually move to projections.srf. +pub const default_return_cap: ?f64 = null; // no cap currently +pub const default_exclude_1y_from_min: bool = true; // use MIN(3Y, 5Y, 10Y), skip 1Y + +pub const ProjectionConfig = struct { + /// Current total portfolio value in dollars. + portfolio_value: f64, + /// Stock allocation as a fraction (0.0–1.0). Remainder goes to bonds. + stock_pct: f64, + /// Retirement time horizons to simulate (in years). + horizons: []const u16, + /// Confidence levels for safe withdrawal (e.g. 0.90, 0.95, 0.99). + confidence_levels: []const f64, +}; + +// ── Results ──────────────────────────────────────────────────── + +pub const WithdrawalResult = struct { + /// Confidence level (e.g. 0.99 = 99%). + confidence: f64, + /// Maximum annual withdrawal that achieves this confidence. + annual_amount: f64, + /// As a fraction of starting portfolio value. + withdrawal_rate: f64, +}; + +pub const YearPercentiles = struct { + /// Year offset from retirement start (0 = start, 1 = after year 1, etc.) + year: u16, + p10: f64, + p25: f64, + p50: f64, + p75: f64, + p90: f64, +}; + +pub const SimulationResult = struct { + horizon: u16, + /// Number of historical cycles simulated. + num_cycles: usize, + /// Safe withdrawal amounts at each requested confidence level. + withdrawals: []WithdrawalResult, + /// Portfolio value percentiles at each year (for charting). + /// Length = horizon + 1 (includes year 0 = starting value). + percentile_bands: []YearPercentiles, + + pub fn deinit(self: *SimulationResult, allocator: std.mem.Allocator) void { + allocator.free(self.withdrawals); + allocator.free(self.percentile_bands); + } +}; + +// ── Core simulation ──────────────────────────────────────────── + +/// Simulate a single retirement cycle starting at `start_index` in the +/// Shiller dataset, lasting `horizon` years, with the given annual spending +/// (inflation-adjusted) and stock/bond allocation. +/// +/// Follows the FIRECalc methodology: +/// 1. Withdraw spending (year 1 = base amount, subsequent years CPI-adjusted) +/// 2. Apply market returns to the remainder +/// 3. Repeat +/// +/// Returns the portfolio values at each year (length = horizon + 1). +fn simulateCycle( + buf: []f64, + start_index: usize, + horizon: u16, + initial_value: f64, + annual_spending: f64, + stock_pct: f64, +) void { + const data = shiller.annual_returns; + var portfolio = initial_value; + buf[0] = portfolio; + + var cumulative_inflation: f64 = 1.0; + + for (0..horizon) |y| { + const di = start_index + y; + if (di >= data.len) { + for (y + 1..@as(usize, horizon) + 1) |remaining| { + buf[remaining] = portfolio; + } + return; + } + + const yr = data[di]; + + // Step 1: Withdraw spending (FIRECalc style — withdraw before growth) + // Year 1 spending = annual_spending; year 2+ adjusted for prior inflation + portfolio -= annual_spending * cumulative_inflation; + + // Step 2: Apply market returns (nominal stock + nominal bond via GS10 yield) + const blended_return = stock_pct * yr.sp500_total_return + (1.0 - stock_pct) * yr.bond_total_return; + portfolio *= (1.0 + blended_return); + + // Step 3: Update cumulative inflation for next year's spending + cumulative_inflation *= (1.0 + yr.cpi_inflation); + + buf[y + 1] = portfolio; + } +} + +/// Run the full historical simulation: for each possible starting year, +/// simulate a retirement of `horizon` years. Returns the number of cycles +/// where the portfolio survived (never went to zero). +/// +/// `all_paths` is a 2D buffer: [cycle_index][year] = portfolio value. +/// Must be pre-allocated with dimensions [num_cycles][horizon + 1]. +fn runAllCycles( + all_paths: [][]f64, + horizon: u16, + initial_value: f64, + annual_spending: f64, + stock_pct: f64, +) usize { + const num_cycles = shiller.maxCycles(horizon); + var survived: usize = 0; + + for (0..num_cycles) |cycle| { + simulateCycle( + all_paths[cycle], + cycle, + horizon, + initial_value, + annual_spending, + stock_pct, + ); + + // Check if portfolio survived the full horizon + var failed = false; + for (1..@as(usize, horizon) + 1) |y| { + if (all_paths[cycle][y] <= 0) { + // Zero out remaining years once the portfolio is depleted + for (y..@as(usize, horizon) + 1) |z| { + all_paths[cycle][z] = 0; + } + failed = true; + break; + } + } + if (!failed) survived += 1; + } + + return survived; +} + +/// Compute the success rate (fraction of cycles that survived) for a +/// given spending level. Lightweight version that doesn't store full paths. +fn successRate( + horizon: u16, + initial_value: f64, + annual_spending: f64, + stock_pct: f64, +) f64 { + const data = shiller.annual_returns; + const num_cycles = shiller.maxCycles(horizon); + if (num_cycles == 0) return 0.0; + + var survived: usize = 0; + + for (0..num_cycles) |cycle| { + var portfolio = initial_value; + var cumulative_inflation: f64 = 1.0; + var failed = false; + + for (0..horizon) |y| { + const di = cycle + y; + if (di >= data.len) break; + + const yr = data[di]; + + // Withdraw first (FIRECalc style) + portfolio -= annual_spending * cumulative_inflation; + if (portfolio <= 0) { + failed = true; + break; + } + + // Then grow + const blended_return = stock_pct * yr.sp500_total_return + (1.0 - stock_pct) * yr.bond_total_return; + portfolio *= (1.0 + blended_return); + + // Update inflation for next year + cumulative_inflation *= (1.0 + yr.cpi_inflation); + } + + if (!failed) survived += 1; + } + + return @as(f64, @floatFromInt(survived)) / @as(f64, @floatFromInt(num_cycles)); +} + +// ── Safe withdrawal search ───────────────────────────────────── + +/// Find the maximum annual withdrawal amount (in today's dollars) such that +/// the portfolio survives `horizon` years in at least `confidence` fraction +/// of all historical cycles. +/// +/// Uses binary search with $1 precision. +pub fn findSafeWithdrawal( + horizon: u16, + initial_value: f64, + stock_pct: f64, + confidence: f64, +) WithdrawalResult { + // Binary search bounds: $0 to the full portfolio value + var lo: f64 = 0; + var hi: f64 = initial_value; + + // Binary search to $1 precision + while (hi - lo > 1.0) { + const mid = @floor((lo + hi) / 2.0); + const rate = successRate(horizon, initial_value, mid, stock_pct); + + if (rate >= confidence) { + lo = mid; + } else { + hi = mid; + } + } + + return .{ + .confidence = confidence, + .annual_amount = lo, + .withdrawal_rate = lo / initial_value, + }; +} + +// ── Percentile bands ─────────────────────────────────────────── + +/// Compute percentile bands from all simulated paths for a given horizon +/// and spending level. Allocates the result. +pub fn computePercentileBands( + allocator: std.mem.Allocator, + horizon: u16, + initial_value: f64, + annual_spending: f64, + stock_pct: f64, +) ![]YearPercentiles { + const num_cycles = shiller.maxCycles(horizon); + if (num_cycles == 0) return &.{}; + + const years: usize = @as(usize, horizon) + 1; + + // Allocate path storage: num_cycles rows of (horizon+1) f64s + const path_data = try allocator.alloc(f64, num_cycles * years); + defer allocator.free(path_data); + + const paths = try allocator.alloc([]f64, num_cycles); + defer allocator.free(paths); + + for (0..num_cycles) |i| { + paths[i] = path_data[i * years .. (i + 1) * years]; + } + + _ = runAllCycles(paths, horizon, initial_value, annual_spending, stock_pct); + + // For each year, sort the values across all cycles and extract percentiles + const bands = try allocator.alloc(YearPercentiles, years); + + // Temporary buffer for sorting one year's values + const sort_buf = try allocator.alloc(f64, num_cycles); + defer allocator.free(sort_buf); + + for (0..years) |y| { + // Collect values for year y across all cycles + for (0..num_cycles) |c| { + sort_buf[c] = paths[c][y]; + } + + // Sort + std.mem.sort(f64, sort_buf, {}, std.sort.asc(f64)); + + bands[y] = .{ + .year = @intCast(y), + .p10 = percentile(sort_buf, 0.10), + .p25 = percentile(sort_buf, 0.25), + .p50 = percentile(sort_buf, 0.50), + .p75 = percentile(sort_buf, 0.75), + .p90 = percentile(sort_buf, 0.90), + }; + } + + return bands; +} + +/// Linear interpolation percentile on a sorted slice. +fn percentile(sorted: []const f64, p: f64) f64 { + if (sorted.len == 0) return 0; + if (sorted.len == 1) return sorted[0]; + + const n = @as(f64, @floatFromInt(sorted.len - 1)); + const idx = p * n; + const lo_idx: usize = @intFromFloat(@floor(idx)); + const hi_idx: usize = @min(lo_idx + 1, sorted.len - 1); + const frac = idx - @floor(idx); + + return sorted[lo_idx] * (1.0 - frac) + sorted[hi_idx] * frac; +} + +// ── High-level API ───────────────────────────────────────────── + +/// Run the full projection analysis for one horizon: compute safe withdrawal +/// at each confidence level and percentile bands using the median withdrawal. +pub fn runProjection( + allocator: std.mem.Allocator, + config: ProjectionConfig, + horizon: u16, +) !SimulationResult { + const num_cycles = shiller.maxCycles(horizon); + + // Compute safe withdrawal at each confidence level + const withdrawals = try allocator.alloc(WithdrawalResult, config.confidence_levels.len); + for (config.confidence_levels, 0..) |conf, i| { + withdrawals[i] = findSafeWithdrawal( + horizon, + config.portfolio_value, + config.stock_pct, + conf, + ); + } + + // Use the median confidence level's withdrawal for the percentile chart + const median_idx = config.confidence_levels.len / 2; + const chart_spending = if (withdrawals.len > 0) withdrawals[median_idx].annual_amount else 0; + + const bands = try computePercentileBands( + allocator, + horizon, + config.portfolio_value, + chart_spending, + config.stock_pct, + ); + + return .{ + .horizon = horizon, + .num_cycles = num_cycles, + .withdrawals = withdrawals, + .percentile_bands = bands, + }; +} + +/// Run projections for all configured horizons. +pub fn runAllProjections( + allocator: std.mem.Allocator, + config: ProjectionConfig, +) ![]SimulationResult { + const results = try allocator.alloc(SimulationResult, config.horizons.len); + errdefer { + for (results) |*r| r.deinit(allocator); + allocator.free(results); + } + + for (config.horizons, 0..) |h, i| { + results[i] = try runProjection(allocator, config, h); + } + + return results; +} + +// ── Tests ────────────────────────────────────────────────────── + +test "successRate with zero spending is 100%" { + const rate = successRate(30, 1_000_000, 0, 0.75); + try std.testing.expectApproxEqAbs(@as(f64, 1.0), rate, 0.001); +} + +test "successRate with excessive spending is 0%" { + // Spending the entire portfolio in year 1 should fail every cycle + const rate = successRate(30, 1_000_000, 1_000_000, 0.75); + try std.testing.expectApproxEqAbs(@as(f64, 0.0), rate, 0.001); +} + +test "successRate decreases with higher spending" { + const rate_low = successRate(30, 1_000_000, 20_000, 0.75); + const rate_mid = successRate(30, 1_000_000, 40_000, 0.75); + const rate_high = successRate(30, 1_000_000, 60_000, 0.75); + + try std.testing.expect(rate_low >= rate_mid); + try std.testing.expect(rate_mid >= rate_high); +} + +test "findSafeWithdrawal produces reasonable results" { + // Using Jan-to-Jan Shiller data with real→nominal bond returns. + // Results differ from the classic 4% rule due to dataset and timing differences. + const result = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95); + + // Should produce a positive withdrawal rate between 1% and 6% + try std.testing.expect(result.annual_amount >= 10_000); + try std.testing.expect(result.annual_amount <= 60_000); + try std.testing.expect(result.withdrawal_rate >= 0.01); + try std.testing.expect(result.withdrawal_rate <= 0.06); +} + +test "higher confidence means lower withdrawal" { + const r90 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.90); + const r95 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95); + const r99 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.99); + + try std.testing.expect(r90.annual_amount >= r95.annual_amount); + try std.testing.expect(r95.annual_amount >= r99.annual_amount); +} + +test "longer horizon means lower withdrawal" { + const r20 = findSafeWithdrawal(20, 1_000_000, 0.75, 0.95); + const r30 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95); + const r45 = findSafeWithdrawal(45, 1_000_000, 0.75, 0.95); + + try std.testing.expect(r20.annual_amount >= r30.annual_amount); + try std.testing.expect(r30.annual_amount >= r45.annual_amount); +} + +test "computePercentileBands basic properties" { + const allocator = std.testing.allocator; + const bands = try computePercentileBands(allocator, 30, 1_000_000, 30_000, 0.75); + defer allocator.free(bands); + + // Should have horizon + 1 entries + try std.testing.expectEqual(@as(usize, 31), bands.len); + + // Year 0 should be the starting value for all percentiles + try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), bands[0].p50, 1.0); + + // Percentiles should be ordered at each year + for (bands) |b| { + try std.testing.expect(b.p10 <= b.p25); + try std.testing.expect(b.p25 <= b.p50); + try std.testing.expect(b.p50 <= b.p75); + try std.testing.expect(b.p75 <= b.p90); + } +} + +test "runProjection produces valid results" { + const allocator = std.testing.allocator; + const config = ProjectionConfig{ + .portfolio_value = 1_000_000, + .stock_pct = 0.75, + .horizons = &.{ 20, 30 }, + .confidence_levels = &.{ 0.90, 0.95, 0.99 }, + }; + + var result = try runProjection(allocator, config, 30); + defer result.deinit(allocator); + + try std.testing.expectEqual(@as(u16, 30), result.horizon); + try std.testing.expectEqual(@as(usize, 3), result.withdrawals.len); + try std.testing.expectEqual(@as(usize, 31), result.percentile_bands.len); + + // Withdrawals should be ordered: 90% > 95% > 99% + try std.testing.expect(result.withdrawals[0].annual_amount >= result.withdrawals[1].annual_amount); + try std.testing.expect(result.withdrawals[1].annual_amount >= result.withdrawals[2].annual_amount); +} + +test "percentile interpolation" { + const data = [_]f64{ 10, 20, 30, 40, 50 }; + try std.testing.expectApproxEqAbs(@as(f64, 10.0), percentile(&data, 0.0), 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 30.0), percentile(&data, 0.5), 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 50.0), percentile(&data, 1.0), 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 20.0), percentile(&data, 0.25), 0.01); +} + +test "realistic portfolio safe withdrawal" { + // Approximate real portfolio: ~$8.34M, ~82.5% stocks + const portfolio = 8_340_000; + const stock_pct = 0.825; + + const r99_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.99); + const r95_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.95); + const r99_30 = findSafeWithdrawal(30, portfolio, stock_pct, 0.99); + + // 95% should be higher than 99% + try std.testing.expect(r95_45.annual_amount > r99_45.annual_amount); + // 30yr should be higher than 45yr at same confidence + try std.testing.expect(r99_30.annual_amount > r99_45.annual_amount); + // Should produce $290K+ at 99%/45yr based on FIRECalc reference (~$305K on $7.7M) + try std.testing.expect(r99_45.annual_amount >= 290_000); + try std.testing.expect(r99_45.annual_amount <= 350_000); + try std.testing.expect(r99_45.withdrawal_rate >= 0.03); + try std.testing.expect(r99_45.withdrawal_rate <= 0.05); +} + +test "simulateCycle produces correct year-0 value" { + var buf: [31]f64 = undefined; + simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75); + try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), buf[0], 0.01); +} + +test "simulateCycle with zero spending grows portfolio" { + var buf: [31]f64 = undefined; + simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75); + // Over any 30-year period in history, zero spending should grow the portfolio + try std.testing.expect(buf[30] > 1_000_000); +} + +test "parseProjectionsConfig defaults" { + const config = parseProjectionsConfig(null); + try std.testing.expect(config.target_stock_pct == null); + try std.testing.expectEqual(@as(u8, 3), config.horizon_count); + try std.testing.expectEqual(@as(u16, 20), config.getHorizons()[0]); + try std.testing.expectEqual(@as(u16, 30), config.getHorizons()[1]); + try std.testing.expectEqual(@as(u16, 45), config.getHorizons()[2]); +} + +test "parseProjectionsConfig from SRF" { + const data = "#!srfv1\ntarget_stock_pct::77\nhorizon::25\nhorizon::35\nhorizon::50\n"; + const config = parseProjectionsConfig(data); + try std.testing.expectApproxEqAbs(@as(f64, 77.0), config.target_stock_pct.?, 0.01); + try std.testing.expectEqual(@as(u8, 3), config.horizon_count); + try std.testing.expectEqual(@as(u16, 25), config.getHorizons()[0]); + try std.testing.expectEqual(@as(u16, 35), config.getHorizons()[1]); + try std.testing.expectEqual(@as(u16, 50), config.getHorizons()[2]); +} + +test "parseProjectionsConfig partial" { + const data = "#!srfv1\ntarget_stock_pct::82.5\n"; + const config = parseProjectionsConfig(data); + try std.testing.expectApproxEqAbs(@as(f64, 82.5), config.target_stock_pct.?, 0.01); + // Horizons should remain default + try std.testing.expectEqual(@as(u8, 3), config.horizon_count); + try std.testing.expectEqual(@as(u16, 20), config.getHorizons()[0]); +} + +test "parseProjectionsConfig empty string" { + const config = parseProjectionsConfig(""); + try std.testing.expect(config.target_stock_pct == null); + try std.testing.expectEqual(@as(u8, 3), config.horizon_count); +} + +test "parseProjectionsConfig invalid data" { + const config = parseProjectionsConfig("not valid srf"); + try std.testing.expect(config.target_stock_pct == null); +} + +test "UserConfig getHorizons default" { + const config = UserConfig{}; + const horizons = config.getHorizons(); + try std.testing.expectEqual(@as(usize, 3), horizons.len); + try std.testing.expectEqual(@as(u16, 20), horizons[0]); + try std.testing.expectEqual(@as(u16, 30), horizons[1]); + try std.testing.expectEqual(@as(u16, 45), horizons[2]); +} + +test "UserConfig getConfidenceLevels" { + const config = UserConfig{}; + const levels = config.getConfidenceLevels(); + try std.testing.expectEqual(@as(usize, 3), levels.len); + try std.testing.expectApproxEqAbs(@as(f64, 0.90), levels[0], 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 0.95), levels[1], 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 0.99), levels[2], 0.001); +} + +test "runAllProjections produces results for each horizon" { + const allocator = std.testing.allocator; + const config = ProjectionConfig{ + .portfolio_value = 1_000_000, + .stock_pct = 0.75, + .horizons = &.{ 20, 30 }, + .confidence_levels = &.{ 0.95, 0.99 }, + }; + const results = try runAllProjections(allocator, config); + defer { + for (results) |*r| r.deinit(allocator); + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 2), results.len); + try std.testing.expectEqual(@as(u16, 20), results[0].horizon); + try std.testing.expectEqual(@as(u16, 30), results[1].horizon); + try std.testing.expectEqual(@as(usize, 2), results[0].withdrawals.len); + try std.testing.expectEqual(@as(usize, 2), results[1].withdrawals.len); +} diff --git a/src/commands/projections.zig b/src/commands/projections.zig new file mode 100644 index 0000000..9d67cd8 --- /dev/null +++ b/src/commands/projections.zig @@ -0,0 +1,304 @@ +/// CLI `projections` command: retirement projections and benchmark comparison. +/// +/// Produces: +/// - Benchmark comparison table (SPY/AGG vs portfolio weighted returns) +/// - Conservative weighted return estimate +/// - Safe withdrawal amounts at multiple horizons and confidence levels +const std = @import("std"); +const zfin = @import("../root.zig"); +const cli = @import("common.zig"); +const fmt = cli.fmt; +const performance = @import("../analytics/performance.zig"); +const projections = @import("../analytics/projections.zig"); +const benchmark = @import("../analytics/benchmark.zig"); +const valuation = @import("../analytics/valuation.zig"); +const view = @import("../views/projections.zig"); + +/// Hardcoded benchmark symbols (configurable in a future version). +const stock_benchmark = "SPY"; +const bond_benchmark = "AGG"; + +pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, color: bool, out: *std.Io.Writer) !void { + // Load portfolio + const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch { + try cli.stderrPrint("Error: Cannot read portfolio file\n"); + return; + }; + defer allocator.free(file_data); + + var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch { + try cli.stderrPrint("Error: Cannot parse portfolio file\n"); + return; + }; + defer portfolio.deinit(); + + const positions = try portfolio.positions(allocator); + defer allocator.free(positions); + + const syms = try portfolio.stockSymbols(allocator); + defer allocator.free(syms); + + // Build prices from cache + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + for (positions) |pos| { + if (pos.shares <= 0) continue; + if (svc.getCachedCandles(pos.symbol)) |cs| { + defer allocator.free(cs); + if (cs.len > 0) { + try prices.put(pos.symbol, cs[cs.len - 1].close); + } + } + } + + // Build portfolio summary + var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc) catch |err| switch (err) { + error.NoAllocations, error.SummaryFailed => { + try cli.stderrPrint("Error computing portfolio summary.\n"); + return; + }, + else => return err, + }; + defer pf_data.deinit(allocator); + + // Load projections.srf config (sibling to portfolio file) + const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0; + const proj_path = std.fmt.allocPrint(allocator, "{s}projections.srf", .{file_path[0..dir_end]}) catch null; + defer if (proj_path) |p| allocator.free(p); + + const proj_data = if (proj_path) |p| std.fs.cwd().readFileAlloc(allocator, p, 64 * 1024) catch null else null; + defer if (proj_data) |d| allocator.free(d); + + const user_config = projections.parseProjectionsConfig(proj_data); + + // Derive stock/bond allocation from portfolio using classification metadata. + const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch null; + defer if (meta_path) |p| allocator.free(p); + const meta_data = if (meta_path) |p| std.fs.cwd().readFileAlloc(allocator, p, 1024 * 1024) catch null else null; + defer if (meta_data) |d| allocator.free(d); + var cm_opt: ?zfin.classification.ClassificationMap = if (meta_data) |d| + zfin.classification.parseClassificationFile(allocator, d) catch null + else + null; + defer if (cm_opt) |*cm| cm.deinit(); + + const allocs = pf_data.summary.allocations; + const total_value = pf_data.summary.total_value; + + // Derive stock/bond split from classification metadata + const split = benchmark.deriveAllocationSplit( + allocs, + if (cm_opt) |cm| cm.entries else &.{}, + total_value, + portfolio.totalCash(), + portfolio.totalCdFaceValue(), + ); + + const stock_pct = split.stock_pct; + const bond_pct = split.bond_pct; + + const sim_stock_pct = if (user_config.target_stock_pct) |t| t / 100.0 else stock_pct; + + // Fetch benchmark candles (ensure they're cached) + _ = svc.getCandles(stock_benchmark) catch null; + _ = svc.getCandles(bond_benchmark) catch null; + const spy_candles = svc.getCachedCandles(stock_benchmark) orelse &.{}; + defer if (spy_candles.len > 0) allocator.free(spy_candles); + const agg_candles = svc.getCachedCandles(bond_benchmark) orelse &.{}; + defer if (agg_candles.len > 0) allocator.free(agg_candles); + + // Compute benchmark trailing returns + week returns + const spy_trailing = performance.trailingReturns(spy_candles); + const agg_trailing = performance.trailingReturns(agg_candles); + const spy_week = weekReturn(spy_candles); + const agg_week = weekReturn(agg_candles); + + // Build per-position trailing returns for portfolio weighted average + var pos_returns = std.ArrayList(benchmark.PositionReturn).empty; + defer pos_returns.deinit(allocator); + for (allocs) |a| { + const candles = pf_data.candle_map.get(a.symbol) orelse continue; + if (candles.len == 0) continue; + try pos_returns.append(allocator, .{ + .symbol = a.symbol, + .weight = a.weight, + .returns = performance.trailingReturns(candles), + }); + } + + // Build benchmark comparison + const comparison = benchmark.buildComparison( + spy_trailing, + agg_trailing, + stock_pct, + bond_pct, + pos_returns.items, + spy_week, + agg_week, + ); + + // ── Render via view model ────────────────────────────────── + try out.print("\n", .{}); + try cli.setBold(out, color); + try out.print("Projections ({s})\n", .{file_path}); + try cli.reset(out, color); + try out.print("========================================\n\n", .{}); + + // Header row + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("{s: <32}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}\n", .{ + "", "1 Year", "3 Year", "5 Year", "10 Year", "Week", + }); + try cli.reset(out, color); + + // Build return rows via view model + var spy_bufs: [5][16]u8 = undefined; + var spy_label_buf: [32]u8 = undefined; + const spy_row = view.buildReturnRow( + view.fmtBenchmarkLabel(&spy_label_buf, stock_benchmark, stock_pct * 100), + comparison.stock_returns, + &spy_bufs, + false, + ); + + var agg_bufs: [5][16]u8 = undefined; + var agg_label_buf: [32]u8 = undefined; + const agg_row = view.buildReturnRow( + view.fmtBenchmarkLabel(&agg_label_buf, bond_benchmark, bond_pct * 100), + comparison.bond_returns, + &agg_bufs, + false, + ); + + var bench_bufs: [5][16]u8 = undefined; + const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true); + + var port_bufs: [5][16]u8 = undefined; + const port_row = view.buildReturnRow("Your Portfolio", comparison.portfolio_returns, &port_bufs, true); + + const rows = [_]view.ReturnRow{ spy_row, agg_row, bench_row }; + for (rows) |row| { + if (row.bold) try cli.setBold(out, color); + try writeReturnRow(out, color, row); + if (row.bold) try cli.reset(out, color); + } + + try out.print("\n", .{}); + + try cli.setBold(out, color); + try writeReturnRow(out, color, port_row); + try cli.reset(out, color); + + // Conservative estimate + { + var buf: [16]u8 = undefined; + const cell = view.fmtReturnCell(&buf, comparison.conservative_return); + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("{s: <32}{s: >8}\n", .{ "Conservative estimate", cell.text }); + try cli.reset(out, color); + } + + // Target allocation note + { + var note_buf: [128]u8 = undefined; + if (view.fmtAllocationNote(¬e_buf, user_config.target_stock_pct, stock_pct)) |note| { + try out.print("\n", .{}); + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("{s}\n", .{note}); + try cli.reset(out, color); + } + } + + // ── Safe withdrawal table ────────────────────────────────── + try out.print("\n", .{}); + try cli.setBold(out, color); + try out.print("Safe Withdrawal (FIRECalc historical simulation)\n", .{}); + try cli.reset(out, color); + + const horizons = user_config.getHorizons(); + const confidence_levels = user_config.getConfidenceLevels(); + + // Header row + try out.print("{s: <25}", .{""}); + for (horizons) |h| { + var hbuf: [16]u8 = undefined; + try out.print("{s: >12}", .{view.fmtHorizonLabel(&hbuf, h)}); + } + try out.print("\n", .{}); + + // One row per confidence level + for (confidence_levels) |conf| { + var lbuf: [25]u8 = undefined; + try out.print("{s: <25}", .{view.fmtConfidenceLabel(&lbuf, conf)}); + + for (horizons) |h| { + const result = projections.findSafeWithdrawal(h, total_value, sim_stock_pct, conf); + var abuf: [24]u8 = undefined; + var rbuf: [16]u8 = undefined; + const cell = view.fmtWithdrawalCell(&abuf, &rbuf, result); + try out.print("{s: >12}", .{cell.amount_text}); + } + try out.print("\n", .{}); + + // Rate row + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("{s: <25}", .{""}); + for (horizons) |h| { + const result = projections.findSafeWithdrawal(h, total_value, sim_stock_pct, conf); + var abuf: [24]u8 = undefined; + var rbuf: [16]u8 = undefined; + const cell = view.fmtWithdrawalCell(&abuf, &rbuf, result); + try out.print("{s: >12}", .{cell.rate_text}); + } + try cli.reset(out, color); + try out.print("\n", .{}); + } + + try out.print("\n", .{}); +} + +/// Write a return row using the view model, applying StyleIntent colors. +fn writeReturnRow(out: *std.Io.Writer, color: bool, row: view.ReturnRow) !void { + try out.print("{s: <32}", .{row.label}); + try writeCell(out, color, row.one_year, 8); + try writeCell(out, color, row.three_year, 9); + try writeCell(out, color, row.five_year, 9); + try writeCell(out, color, row.ten_year, 10); + try writeCell(out, color, row.week, 9); + try out.print("\n", .{}); +} + +fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usize) !void { + try setIntentFg(out, color, cell.style); + switch (width) { + 8 => try out.print("{s: >8}", .{cell.text}), + 9 => try out.print("{s: >9}", .{cell.text}), + 10 => try out.print("{s: >10}", .{cell.text}), + else => try out.print("{s}", .{cell.text}), + } + try cli.reset(out, color); +} + +fn setIntentFg(out: *std.Io.Writer, color: bool, intent: view.StyleIntent) !void { + switch (intent) { + .normal => try cli.reset(out, color), + .muted => try cli.setFg(out, color, cli.CLR_MUTED), + .positive => try cli.setFg(out, color, cli.CLR_POSITIVE), + .negative => try cli.setFg(out, color, cli.CLR_NEGATIVE), + } +} + +fn candleDate(c: zfin.Candle) zfin.Date { + return c.date; +} + +/// Compute 1-week return from candle data. +fn weekReturn(candles: []const zfin.Candle) ?f64 { + if (candles.len < 2) return null; + const latest = candles[candles.len - 1]; + const target_date = latest.date.addDays(-7); + const idx = valuation.indexAtOrBefore(zfin.Candle, candles, target_date, candleDate) orelse return null; + const start = candles[idx]; + if (start.close == 0) return null; + return (latest.close / start.close) - 1.0; +} diff --git a/src/main.zig b/src/main.zig index 4bb3d2b..6133c5e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -23,6 +23,7 @@ const usage = \\ enrich Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ lookup Look up CUSIP to ticker via OpenFIGI \\ audit [opts] Reconcile portfolio against brokerage export + \\ projections Retirement projections and benchmark comparison \\ cache stats Show cache statistics \\ cache clear Clear all cached data \\ version [-v] Show zfin version and build info @@ -264,6 +265,7 @@ pub fn main() !u8 { !std.mem.eql(u8, command, "analysis") and !std.mem.eql(u8, command, "contributions") and !std.mem.eql(u8, command, "portfolio") and + !std.mem.eql(u8, command, "projections") and !std.mem.eql(u8, command, "snapshot") and !std.mem.eql(u8, command, "version"); // Upper-case the first arg for symbol-taking commands, but skip when @@ -398,6 +400,14 @@ pub fn main() !u8 { const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); try commands.analysis.run(allocator, &svc, pf.path, color, out); + } else if (std.mem.eql(u8, command, "projections")) { + for (cmd_args) |a| { + try reportUnexpectedArg("projections", a); + return 1; + } + const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); + defer if (pf.resolved) |r| r.deinit(allocator); + try commands.projections.run(allocator, &svc, pf.path, color, out); } else if (std.mem.eql(u8, command, "contributions")) { for (cmd_args) |a| { try reportUnexpectedArg("contributions", a); @@ -486,6 +496,7 @@ const commands = struct { const contributions = @import("commands/contributions.zig"); const snapshot = @import("commands/snapshot.zig"); const version = @import("commands/version.zig"); + const projections = @import("commands/projections.zig"); }; // ── Tests ──────────────────────────────────────────────────── diff --git a/src/views/projections.zig b/src/views/projections.zig new file mode 100644 index 0000000..7b9848c --- /dev/null +++ b/src/views/projections.zig @@ -0,0 +1,206 @@ +/// Renderer-agnostic view model for the projections display. +/// +/// Produces pre-formatted text and `StyleIntent` values that both CLI +/// and TUI renderers can consume through thin style-mapping adapters. +const std = @import("std"); +const fmt = @import("../format.zig"); +const performance = @import("../analytics/performance.zig"); +const benchmark = @import("../analytics/benchmark.zig"); +const projections = @import("../analytics/projections.zig"); + +pub const StyleIntent = fmt.StyleIntent; + +// ── Layout constants (shared by CLI and TUI) ────────────────── + +pub const label_width = 32; +pub const col_1y = 8; +pub const col_3y = 9; +pub const col_5y = 9; +pub const col_10y = 10; +pub const col_week = 9; +pub const withdrawal_label_width = 25; +pub const withdrawal_col_width = 12; + +// ── Return row formatting ────────────────────────────────────── + +/// A single cell in the returns table: formatted text + style. +pub const ReturnCell = struct { + text: []const u8, + style: StyleIntent, +}; + +/// Format a return value into a buffer, returning the styled cell. +pub fn fmtReturnCell(buf: []u8, value: ?f64) ReturnCell { + if (value) |v| { + return .{ + .text = performance.formatReturn(buf, v), + .style = if (v >= 0) .positive else .negative, + }; + } + return .{ .text = "--", .style = .muted }; +} + +/// A complete row in the benchmark comparison table. +pub const ReturnRow = struct { + label: []const u8, + one_year: ReturnCell, + three_year: ReturnCell, + five_year: ReturnCell, + ten_year: ReturnCell, + week: ReturnCell, + bold: bool = false, +}; + +/// Build a return row from a ReturnsByPeriod and a label. +/// Caller owns the buffers (5 buffers of at least 16 bytes each). +pub fn buildReturnRow( + label: []const u8, + returns: benchmark.ReturnsByPeriod, + bufs: *[5][16]u8, + bold: bool, +) ReturnRow { + return .{ + .label = label, + .one_year = fmtReturnCell(&bufs[0], returns.one_year), + .three_year = fmtReturnCell(&bufs[1], returns.three_year), + .five_year = fmtReturnCell(&bufs[2], returns.five_year), + .ten_year = fmtReturnCell(&bufs[3], returns.ten_year), + .week = fmtReturnCell(&bufs[4], returns.week), + .bold = bold, + }; +} + +// ── Safe withdrawal formatting ───────────────────────────────── + +/// A single cell in the withdrawal table. +pub const WithdrawalCell = struct { + amount_text: []const u8, + rate_text: []const u8, +}; + +/// Format a safe withdrawal result into display strings. +/// Caller owns both buffers (at least 24 bytes each). +pub fn fmtWithdrawalCell(amount_buf: []u8, rate_buf: []u8, result: projections.WithdrawalResult) WithdrawalCell { + const money_str = fmt.fmtMoneyAbs(amount_buf, result.annual_amount); + // Strip trailing ".00" for clean display + const clean_amount = if (std.mem.endsWith(u8, money_str, ".00")) + money_str[0 .. money_str.len - 3] + else + money_str; + + const rate_str = std.fmt.bufPrint(rate_buf, "{d:.2}%", .{result.withdrawal_rate * 100}) catch "??%"; + + return .{ + .amount_text = clean_amount, + .rate_text = rate_str, + }; +} + +/// Format a confidence level label (e.g. "99% safe withdrawal"). +pub fn fmtConfidenceLabel(buf: []u8, confidence: f64) []const u8 { + return std.fmt.bufPrint(buf, "{d:.0}% safe withdrawal", .{confidence * 100}) catch "??"; +} + +/// Format a horizon column header (e.g. "30 Year"). +pub fn fmtHorizonLabel(buf: []u8, horizon: u16) []const u8 { + return std.fmt.bufPrint(buf, "{d} Year", .{horizon}) catch "??"; +} + +// ── Allocation summary ───────────────────────────────────────── + +/// Format the target allocation note line. +/// Returns null if no target is configured. +pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f64) ?[]const u8 { + const target = target_stock_pct orelse return null; + const current = current_stock_pct * 100; + const diff = current - target; + + if (@abs(diff) < 2.0) { + return std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}% \u{2014} on target)", .{ + target, 100.0 - target, current, + }) catch null; + } + return std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}%)", .{ + target, 100.0 - target, current, + }) catch null; +} + +/// Format the stock benchmark label with weight (e.g. "SPY (83.8% weight)"). +pub fn fmtBenchmarkLabel(buf: []u8, symbol: []const u8, weight_pct: f64) []const u8 { + return std.fmt.bufPrint(buf, "{s} ({d:.1}% weight)", .{ symbol, weight_pct }) catch symbol; +} + +// ── Tests ────────────────────────────────────────────────────── + +test "fmtReturnCell positive" { + var buf: [16]u8 = undefined; + const cell = fmtReturnCell(&buf, 0.1234); + try std.testing.expect(cell.style == .positive); + try std.testing.expect(cell.text.len > 0); +} + +test "fmtReturnCell negative" { + var buf: [16]u8 = undefined; + const cell = fmtReturnCell(&buf, -0.05); + try std.testing.expect(cell.style == .negative); +} + +test "fmtReturnCell null" { + var buf: [16]u8 = undefined; + const cell = fmtReturnCell(&buf, null); + try std.testing.expect(cell.style == .muted); + try std.testing.expectEqualStrings("--", cell.text); +} + +test "fmtWithdrawalCell strips .00" { + var abuf: [24]u8 = undefined; + var rbuf: [16]u8 = undefined; + const cell = fmtWithdrawalCell(&abuf, &rbuf, .{ + .confidence = 0.99, + .annual_amount = 305000, + .withdrawal_rate = 0.0366, + }); + try std.testing.expect(!std.mem.endsWith(u8, cell.amount_text, ".00")); + try std.testing.expect(std.mem.indexOf(u8, cell.rate_text, "3.66") != null); +} + +test "fmtAllocationNote on target" { + var buf: [128]u8 = undefined; + const note = fmtAllocationNote(&buf, 77, 0.768); + try std.testing.expect(note != null); + try std.testing.expect(std.mem.indexOf(u8, note.?, "on target") != null); +} + +test "fmtAllocationNote off target" { + var buf: [128]u8 = undefined; + const note = fmtAllocationNote(&buf, 77, 0.85); + try std.testing.expect(note != null); + try std.testing.expect(std.mem.indexOf(u8, note.?, "on target") == null); +} + +test "fmtAllocationNote no target" { + var buf: [128]u8 = undefined; + try std.testing.expect(fmtAllocationNote(&buf, null, 0.75) == null); +} + +test "fmtBenchmarkLabel" { + var buf: [32]u8 = undefined; + const label = fmtBenchmarkLabel(&buf, "SPY", 83.8); + try std.testing.expect(std.mem.indexOf(u8, label, "SPY") != null); + try std.testing.expect(std.mem.indexOf(u8, label, "83.8") != null); +} + +test "buildReturnRow" { + var bufs: [5][16]u8 = undefined; + const returns = benchmark.ReturnsByPeriod{ + .one_year = 0.15, + .three_year = -0.02, + .five_year = null, + }; + const row = buildReturnRow("Test", returns, &bufs, false); + try std.testing.expectEqualStrings("Test", row.label); + try std.testing.expect(row.one_year.style == .positive); + try std.testing.expect(row.three_year.style == .negative); + try std.testing.expect(row.five_year.style == .muted); + try std.testing.expect(row.bold == false); +}