add CLI command for projections

This commit is contained in:
Emil Lerch 2026-04-27 17:36:37 -07:00
parent 648d3308d2
commit a8d78ee3c6
5 changed files with 1852 additions and 0 deletions

659
src/analytics/benchmark.zig Normal file
View file

@ -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.01.0).
stock_pct: f64,
/// Fraction of portfolio in bonds + cash + CDs (0.01.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);
}

View file

@ -0,0 +1,672 @@
/// Historical simulation engine for retirement projections.
///
/// Implements the FIRECalc algorithm: for each starting year in the Shiller
/// historical dataset (1871present), 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.01.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 realnominal 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);
}

View file

@ -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(&note_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;
}

View file

@ -23,6 +23,7 @@ const usage =
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI \\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ audit [opts] Reconcile portfolio against brokerage export \\ audit [opts] Reconcile portfolio against brokerage export
\\ projections Retirement projections and benchmark comparison
\\ cache stats Show cache statistics \\ cache stats Show cache statistics
\\ cache clear Clear all cached data \\ cache clear Clear all cached data
\\ version [-v] Show zfin version and build info \\ 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, "analysis") and
!std.mem.eql(u8, command, "contributions") and !std.mem.eql(u8, command, "contributions") and
!std.mem.eql(u8, command, "portfolio") 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, "snapshot") and
!std.mem.eql(u8, command, "version"); !std.mem.eql(u8, command, "version");
// Upper-case the first arg for symbol-taking commands, but skip when // 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); const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator); defer if (pf.resolved) |r| r.deinit(allocator);
try commands.analysis.run(allocator, &svc, pf.path, color, out); 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")) { } else if (std.mem.eql(u8, command, "contributions")) {
for (cmd_args) |a| { for (cmd_args) |a| {
try reportUnexpectedArg("contributions", a); try reportUnexpectedArg("contributions", a);
@ -486,6 +496,7 @@ const commands = struct {
const contributions = @import("commands/contributions.zig"); const contributions = @import("commands/contributions.zig");
const snapshot = @import("commands/snapshot.zig"); const snapshot = @import("commands/snapshot.zig");
const version = @import("commands/version.zig"); const version = @import("commands/version.zig");
const projections = @import("commands/projections.zig");
}; };
// Tests // Tests

206
src/views/projections.zig Normal file
View file

@ -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);
}