add CLI command for projections

This commit is contained in:
Emil Lerch 2026-04-27 17:36:37 -07:00
parent dda49efe27
commit 6debed0d69
Signed by: lobo
GPG key ID: A7B62D657EF764F8
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)
\\ lookup <CUSIP> 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

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