add CLI command for projections
This commit is contained in:
parent
648d3308d2
commit
a8d78ee3c6
5 changed files with 1852 additions and 0 deletions
659
src/analytics/benchmark.zig
Normal file
659
src/analytics/benchmark.zig
Normal 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.0–1.0).
|
||||||
|
stock_pct: f64,
|
||||||
|
/// Fraction of portfolio in bonds + cash + CDs (0.0–1.0).
|
||||||
|
bond_pct: f64,
|
||||||
|
/// Total market value classified as bonds.
|
||||||
|
bond_value: f64,
|
||||||
|
/// Total cash + CD face value.
|
||||||
|
cash_cd_value: f64,
|
||||||
|
/// Total market value that could not be classified (no metadata entry).
|
||||||
|
unclassified_value: f64,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Derive the stock/bond allocation split from portfolio allocations and
|
||||||
|
/// classification metadata.
|
||||||
|
///
|
||||||
|
/// Positions are classified using `classifications`:
|
||||||
|
/// - asset_class == "Bonds" → bond
|
||||||
|
/// - Everything else with a classification entry → stock
|
||||||
|
/// - No classification entry → unclassified
|
||||||
|
///
|
||||||
|
/// Cash and CDs are always counted as bonds (fixed-income side).
|
||||||
|
/// Unclassified positions are reported separately so the caller can
|
||||||
|
/// decide how to handle them (e.g. treat as stock, warn, etc.).
|
||||||
|
pub fn deriveAllocationSplit(
|
||||||
|
allocations: []const Allocation,
|
||||||
|
classifications: []const ClassificationEntry,
|
||||||
|
total_value: f64,
|
||||||
|
cash_value: f64,
|
||||||
|
cd_value: f64,
|
||||||
|
) AllocationSplit {
|
||||||
|
var bond_value: f64 = 0;
|
||||||
|
var classified_value: f64 = 0;
|
||||||
|
|
||||||
|
for (allocations) |a| {
|
||||||
|
var found = false;
|
||||||
|
for (classifications) |entry| {
|
||||||
|
if (std.mem.eql(u8, entry.symbol, a.symbol)) {
|
||||||
|
found = true;
|
||||||
|
if (entry.asset_class) |ac| {
|
||||||
|
if (std.mem.eql(u8, ac, "Bonds")) {
|
||||||
|
bond_value += a.market_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) {
|
||||||
|
classified_value += a.market_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cash_cd_value = cash_value + cd_value;
|
||||||
|
const bond_plus_cash = bond_value + cash_cd_value;
|
||||||
|
// Unclassified = allocations not found in classifications (options, new positions, etc.)
|
||||||
|
// Note: cash/CDs are not in allocations, so total_value includes them separately.
|
||||||
|
const unclassified_value = total_value - classified_value - cash_cd_value;
|
||||||
|
|
||||||
|
const stock_pct = if (total_value > 0) (total_value - bond_plus_cash - @max(unclassified_value, 0)) / total_value else 0.75;
|
||||||
|
const bond_pct = if (total_value > 0) bond_plus_cash / total_value else 0.25;
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.stock_pct = stock_pct,
|
||||||
|
.bond_pct = bond_pct,
|
||||||
|
.bond_value = bond_value,
|
||||||
|
.cash_cd_value = cash_cd_value,
|
||||||
|
.unclassified_value = @max(unclassified_value, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Computation functions ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// Extract the annualized return from a PerformanceResult, if available.
|
||||||
|
fn annualized(pr: ?PerformanceResult) ?f64 {
|
||||||
|
const r = pr orelse return null;
|
||||||
|
return r.annualized_return orelse r.total_return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert TrailingReturns to simple f64 ReturnsByPeriod.
|
||||||
|
pub fn toReturnsByPeriod(tr: TrailingReturns) ReturnsByPeriod {
|
||||||
|
return .{
|
||||||
|
.one_year = annualized(tr.one_year),
|
||||||
|
.three_year = annualized(tr.three_year),
|
||||||
|
.five_year = annualized(tr.five_year),
|
||||||
|
.ten_year = annualized(tr.ten_year),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the weighted average of two ReturnsByPeriod values.
|
||||||
|
fn blendReturns(a: ReturnsByPeriod, a_weight: f64, b: ReturnsByPeriod, b_weight: f64) ReturnsByPeriod {
|
||||||
|
return .{
|
||||||
|
.one_year = blendOptional(a.one_year, a_weight, b.one_year, b_weight),
|
||||||
|
.three_year = blendOptional(a.three_year, a_weight, b.three_year, b_weight),
|
||||||
|
.five_year = blendOptional(a.five_year, a_weight, b.five_year, b_weight),
|
||||||
|
.ten_year = blendOptional(a.ten_year, a_weight, b.ten_year, b_weight),
|
||||||
|
.week = blendOptional(a.week, a_weight, b.week, b_weight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blendOptional(a: ?f64, a_w: f64, b: ?f64, b_w: f64) ?f64 {
|
||||||
|
if (a != null and b != null)
|
||||||
|
return a.? * a_w + b.? * b_w;
|
||||||
|
if (a != null) return a.? * a_w;
|
||||||
|
if (b != null) return b.? * b_w;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute portfolio weighted average returns across all positions.
|
||||||
|
/// Each position's return is weighted by its portfolio weight.
|
||||||
|
pub fn portfolioWeightedReturns(positions: []const PositionReturn) ReturnsByPeriod {
|
||||||
|
var result = ReturnsByPeriod{};
|
||||||
|
var sum_1y: f64 = 0;
|
||||||
|
var w_1y: f64 = 0;
|
||||||
|
var sum_3y: f64 = 0;
|
||||||
|
var w_3y: f64 = 0;
|
||||||
|
var sum_5y: f64 = 0;
|
||||||
|
var w_5y: f64 = 0;
|
||||||
|
var sum_10y: f64 = 0;
|
||||||
|
var w_10y: f64 = 0;
|
||||||
|
|
||||||
|
for (positions) |pos| {
|
||||||
|
if (annualized(pos.returns.one_year)) |r| {
|
||||||
|
sum_1y += pos.weight * r;
|
||||||
|
w_1y += pos.weight;
|
||||||
|
}
|
||||||
|
if (annualized(pos.returns.three_year)) |r| {
|
||||||
|
sum_3y += pos.weight * r;
|
||||||
|
w_3y += pos.weight;
|
||||||
|
}
|
||||||
|
if (annualized(pos.returns.five_year)) |r| {
|
||||||
|
sum_5y += pos.weight * r;
|
||||||
|
w_5y += pos.weight;
|
||||||
|
}
|
||||||
|
if (annualized(pos.returns.ten_year)) |r| {
|
||||||
|
sum_10y += pos.weight * r;
|
||||||
|
w_10y += pos.weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize by total weight for each period (handles positions with missing data)
|
||||||
|
if (w_1y > 0) result.one_year = sum_1y / w_1y;
|
||||||
|
if (w_3y > 0) result.three_year = sum_3y / w_3y;
|
||||||
|
if (w_5y > 0) result.five_year = sum_5y / w_5y;
|
||||||
|
if (w_10y > 0) result.ten_year = sum_10y / w_10y;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the conservative weighted return estimate.
|
||||||
|
///
|
||||||
|
/// For each position: take MIN(3Y, 5Y, 10Y) annualized return (skipping 1Y).
|
||||||
|
/// Apply optional cap. Multiply by weight. Sum across all positions.
|
||||||
|
/// Normalize by total weight of positions that had at least one valid period.
|
||||||
|
pub fn conservativeWeightedReturn(
|
||||||
|
positions: []const PositionReturn,
|
||||||
|
return_cap: ?f64,
|
||||||
|
exclude_1y: bool,
|
||||||
|
) f64 {
|
||||||
|
var total: f64 = 0;
|
||||||
|
var total_weight: f64 = 0;
|
||||||
|
|
||||||
|
for (positions) |pos| {
|
||||||
|
var min_return: ?f64 = null;
|
||||||
|
|
||||||
|
// Include 1Y only if not excluded
|
||||||
|
if (!exclude_1y) {
|
||||||
|
if (annualized(pos.returns.one_year)) |r| {
|
||||||
|
min_return = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (annualized(pos.returns.three_year)) |r| {
|
||||||
|
min_return = if (min_return) |m| @min(m, r) else r;
|
||||||
|
}
|
||||||
|
if (annualized(pos.returns.five_year)) |r| {
|
||||||
|
min_return = if (min_return) |m| @min(m, r) else r;
|
||||||
|
}
|
||||||
|
if (annualized(pos.returns.ten_year)) |r| {
|
||||||
|
min_return = if (min_return) |m| @min(m, r) else r;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (min_return) |mr| {
|
||||||
|
// Apply cap if configured
|
||||||
|
const capped = if (return_cap) |cap| @min(mr, cap) else mr;
|
||||||
|
total += pos.weight * capped;
|
||||||
|
total_weight += pos.weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total_weight > 0) return total / total_weight;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a full benchmark comparison from component data.
|
||||||
|
pub fn buildComparison(
|
||||||
|
stock_trailing: TrailingReturns,
|
||||||
|
bond_trailing: TrailingReturns,
|
||||||
|
stock_pct: f64,
|
||||||
|
bond_pct: f64,
|
||||||
|
positions: []const PositionReturn,
|
||||||
|
stock_week: ?f64,
|
||||||
|
bond_week: ?f64,
|
||||||
|
) BenchmarkComparison {
|
||||||
|
var stock_r = toReturnsByPeriod(stock_trailing);
|
||||||
|
stock_r.week = stock_week;
|
||||||
|
|
||||||
|
var bond_r = toReturnsByPeriod(bond_trailing);
|
||||||
|
bond_r.week = bond_week;
|
||||||
|
|
||||||
|
const benchmark = blendReturns(stock_r, stock_pct, bond_r, bond_pct);
|
||||||
|
const portfolio = portfolioWeightedReturns(positions);
|
||||||
|
const conservative = conservativeWeightedReturn(positions, default_return_cap, default_exclude_1y_from_min);
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.stock_returns = stock_r,
|
||||||
|
.bond_returns = bond_r,
|
||||||
|
.benchmark_returns = benchmark,
|
||||||
|
.portfolio_returns = portfolio,
|
||||||
|
.conservative_return = conservative,
|
||||||
|
.stock_pct = stock_pct,
|
||||||
|
.bond_pct = bond_pct,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const Date = @import("../models/date.zig").Date;
|
||||||
|
|
||||||
|
fn makePR(total: f64, ann: ?f64) PerformanceResult {
|
||||||
|
return .{
|
||||||
|
.total_return = total,
|
||||||
|
.annualized_return = ann,
|
||||||
|
.from = Date.fromYmd(2020, 1, 1),
|
||||||
|
.to = Date.fromYmd(2024, 1, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test "toReturnsByPeriod extracts annualized returns" {
|
||||||
|
const tr = TrailingReturns{
|
||||||
|
.one_year = makePR(0.10, 0.10),
|
||||||
|
.three_year = makePR(0.30, 0.09),
|
||||||
|
.five_year = makePR(0.50, 0.085),
|
||||||
|
.ten_year = null,
|
||||||
|
};
|
||||||
|
const r = toReturnsByPeriod(tr);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.10), r.one_year.?, 0.001);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.09), r.three_year.?, 0.001);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.085), r.five_year.?, 0.001);
|
||||||
|
try std.testing.expect(r.ten_year == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "blendReturns weighted average" {
|
||||||
|
const a = ReturnsByPeriod{ .one_year = 0.20, .three_year = 0.15 };
|
||||||
|
const b = ReturnsByPeriod{ .one_year = 0.05, .three_year = 0.04 };
|
||||||
|
const result = blendReturns(a, 0.75, b, 0.25);
|
||||||
|
// 0.75 * 0.20 + 0.25 * 0.05 = 0.1625
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.1625), result.one_year.?, 0.0001);
|
||||||
|
// 0.75 * 0.15 + 0.25 * 0.04 = 0.1225
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.1225), result.three_year.?, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "blendReturns handles null" {
|
||||||
|
const a = ReturnsByPeriod{ .one_year = 0.20, .three_year = null };
|
||||||
|
const b = ReturnsByPeriod{ .one_year = null, .three_year = 0.04 };
|
||||||
|
const result = blendReturns(a, 0.75, b, 0.25);
|
||||||
|
// Only a has 1Y: 0.20 * 0.75 = 0.15
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.15), result.one_year.?, 0.0001);
|
||||||
|
// Only b has 3Y: 0.04 * 0.25 = 0.01
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.01), result.three_year.?, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "portfolioWeightedReturns basic" {
|
||||||
|
const positions = [_]PositionReturn{
|
||||||
|
.{ .symbol = "SPY", .weight = 0.60, .returns = .{
|
||||||
|
.one_year = makePR(0.20, 0.20),
|
||||||
|
.three_year = makePR(0.50, 0.15),
|
||||||
|
} },
|
||||||
|
.{ .symbol = "AGG", .weight = 0.40, .returns = .{
|
||||||
|
.one_year = makePR(0.05, 0.05),
|
||||||
|
.three_year = makePR(0.12, 0.04),
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
const r = portfolioWeightedReturns(&positions);
|
||||||
|
// 1Y: (0.60 * 0.20 + 0.40 * 0.05) / (0.60 + 0.40) = 0.14
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.14), r.one_year.?, 0.001);
|
||||||
|
// 3Y: (0.60 * 0.15 + 0.40 * 0.04) / (0.60 + 0.40) = 0.106
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.106), r.three_year.?, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "portfolioWeightedReturns normalizes by available weight" {
|
||||||
|
// Position B has no 3Y data — should normalize by weight of those that do
|
||||||
|
const positions = [_]PositionReturn{
|
||||||
|
.{ .symbol = "SPY", .weight = 0.60, .returns = .{
|
||||||
|
.one_year = makePR(0.20, 0.20),
|
||||||
|
.three_year = makePR(0.50, 0.15),
|
||||||
|
} },
|
||||||
|
.{ .symbol = "NEW", .weight = 0.40, .returns = .{
|
||||||
|
.one_year = makePR(0.30, 0.30),
|
||||||
|
.three_year = null,
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
const r = portfolioWeightedReturns(&positions);
|
||||||
|
// 1Y: (0.60 * 0.20 + 0.40 * 0.30) / 1.0 = 0.24
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.24), r.one_year.?, 0.001);
|
||||||
|
// 3Y: (0.60 * 0.15) / 0.60 = 0.15 (only SPY contributes)
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.15), r.three_year.?, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "conservativeWeightedReturn uses MIN of 3Y/5Y/10Y" {
|
||||||
|
const positions = [_]PositionReturn{
|
||||||
|
.{
|
||||||
|
.symbol = "NVDA",
|
||||||
|
.weight = 0.05,
|
||||||
|
.returns = .{
|
||||||
|
.one_year = makePR(0.32, 0.32), // excluded from MIN
|
||||||
|
.three_year = makePR(4.0, 1.28), // 128% (highest)
|
||||||
|
.five_year = makePR(10.0, 0.69), // 69% (lowest)
|
||||||
|
.ten_year = makePR(50.0, 0.74), // 74%
|
||||||
|
},
|
||||||
|
},
|
||||||
|
.{ .symbol = "SPY", .weight = 0.95, .returns = .{
|
||||||
|
.three_year = makePR(0.50, 0.15),
|
||||||
|
.five_year = makePR(0.80, 0.12),
|
||||||
|
.ten_year = makePR(2.0, 0.14),
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
const result = conservativeWeightedReturn(&positions, null, true);
|
||||||
|
// NVDA: MIN(1.28, 0.69, 0.74) = 0.69, weight 0.05
|
||||||
|
// SPY: MIN(0.15, 0.12, 0.14) = 0.12, weight 0.95
|
||||||
|
// Total: (0.05 * 0.69 + 0.95 * 0.12) / (0.05 + 0.95) = 0.0345 + 0.114 = 0.1485
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.1485), result, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "conservativeWeightedReturn with cap" {
|
||||||
|
const positions = [_]PositionReturn{
|
||||||
|
.{ .symbol = "NVDA", .weight = 1.0, .returns = .{
|
||||||
|
.three_year = makePR(4.0, 1.28),
|
||||||
|
.five_year = makePR(10.0, 0.69),
|
||||||
|
.ten_year = makePR(50.0, 0.74),
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
// Without cap: MIN(1.28, 0.69, 0.74) = 0.69
|
||||||
|
const uncapped = conservativeWeightedReturn(&positions, null, true);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.69), uncapped, 0.001);
|
||||||
|
|
||||||
|
// With 30% cap: MIN(1.28, 0.69, 0.74) = 0.69, then capped to 0.30
|
||||||
|
const capped = conservativeWeightedReturn(&positions, 0.30, true);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.30), capped, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "conservativeWeightedReturn includes 1Y when not excluded" {
|
||||||
|
const positions = [_]PositionReturn{
|
||||||
|
.{
|
||||||
|
.symbol = "SPY",
|
||||||
|
.weight = 1.0,
|
||||||
|
.returns = .{
|
||||||
|
.one_year = makePR(0.05, 0.05), // lowest
|
||||||
|
.three_year = makePR(0.50, 0.15),
|
||||||
|
.five_year = makePR(0.80, 0.12),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// With exclude_1y=true: MIN(0.15, 0.12) = 0.12
|
||||||
|
const excluded = conservativeWeightedReturn(&positions, null, true);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.12), excluded, 0.001);
|
||||||
|
|
||||||
|
// With exclude_1y=false: MIN(0.05, 0.15, 0.12) = 0.05
|
||||||
|
const included = conservativeWeightedReturn(&positions, null, false);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.05), included, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildComparison produces consistent results" {
|
||||||
|
const stock_tr = TrailingReturns{
|
||||||
|
.one_year = makePR(0.23, 0.23),
|
||||||
|
.three_year = makePR(0.60, 0.17),
|
||||||
|
};
|
||||||
|
const bond_tr = TrailingReturns{
|
||||||
|
.one_year = makePR(0.04, 0.04),
|
||||||
|
.three_year = makePR(0.10, 0.03),
|
||||||
|
};
|
||||||
|
const positions = [_]PositionReturn{
|
||||||
|
.{ .symbol = "VTI", .weight = 0.80, .returns = .{
|
||||||
|
.one_year = makePR(0.22, 0.22),
|
||||||
|
.three_year = makePR(0.55, 0.16),
|
||||||
|
.five_year = makePR(0.80, 0.13),
|
||||||
|
} },
|
||||||
|
.{ .symbol = "BND", .weight = 0.20, .returns = .{
|
||||||
|
.one_year = makePR(0.03, 0.03),
|
||||||
|
.three_year = makePR(0.09, 0.03),
|
||||||
|
.five_year = makePR(0.10, 0.02),
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
const result = buildComparison(stock_tr, bond_tr, 0.77, 0.23, &positions, null, null);
|
||||||
|
|
||||||
|
// Benchmark 1Y: 0.77 * 0.23 + 0.23 * 0.04 = 0.1771 + 0.0092 = 0.1863
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.1863), result.benchmark_returns.one_year.?, 0.001);
|
||||||
|
// Portfolio 1Y: (0.80 * 0.22 + 0.20 * 0.03) / 1.0 = 0.182
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.182), result.portfolio_returns.one_year.?, 0.001);
|
||||||
|
// Conservative: VTI MIN(0.16, 0.13) = 0.13 * 0.80 + BND MIN(0.03, 0.02) = 0.02 * 0.20
|
||||||
|
// = 0.104 + 0.004 = 0.108
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.108), result.conservative_return, 0.001);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.77), result.stock_pct, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "conservativeWeightedReturn with no valid periods" {
|
||||||
|
const positions = [_]PositionReturn{
|
||||||
|
.{ .symbol = "NEW", .weight = 1.0, .returns = .{
|
||||||
|
.one_year = makePR(0.10, 0.10),
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
// exclude_1y=true, and only 1Y data available → no valid periods
|
||||||
|
const result = conservativeWeightedReturn(&positions, null, true);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "portfolioWeightedReturns empty positions" {
|
||||||
|
const positions = [_]PositionReturn{};
|
||||||
|
const r = portfolioWeightedReturns(&positions);
|
||||||
|
try std.testing.expect(r.one_year == null);
|
||||||
|
try std.testing.expect(r.three_year == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "annualized falls back to total_return" {
|
||||||
|
// When annualized_return is null, should use total_return
|
||||||
|
const pr = PerformanceResult{
|
||||||
|
.total_return = 0.15,
|
||||||
|
.annualized_return = null,
|
||||||
|
.from = Date.fromYmd(2024, 1, 1),
|
||||||
|
.to = Date.fromYmd(2024, 6, 1),
|
||||||
|
};
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.15), annualized(pr).?, 0.001);
|
||||||
|
try std.testing.expect(annualized(null) == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "blendReturns both null" {
|
||||||
|
const a = ReturnsByPeriod{};
|
||||||
|
const b = ReturnsByPeriod{};
|
||||||
|
const result = blendReturns(a, 0.5, b, 0.5);
|
||||||
|
try std.testing.expect(result.one_year == null);
|
||||||
|
try std.testing.expect(result.five_year == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "conservativeWeightedReturn single position single period" {
|
||||||
|
const positions = [_]PositionReturn{
|
||||||
|
.{ .symbol = "SPY", .weight = 1.0, .returns = .{
|
||||||
|
.five_year = makePR(0.80, 0.125),
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
// Only 5Y available → MIN is just 0.125
|
||||||
|
const result = conservativeWeightedReturn(&positions, null, true);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.125), result, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildComparison with week returns" {
|
||||||
|
const stock_tr = TrailingReturns{
|
||||||
|
.one_year = makePR(0.20, 0.20),
|
||||||
|
};
|
||||||
|
const bond_tr = TrailingReturns{
|
||||||
|
.one_year = makePR(0.03, 0.03),
|
||||||
|
};
|
||||||
|
const positions = [_]PositionReturn{};
|
||||||
|
const result = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, -0.01, 0.005);
|
||||||
|
|
||||||
|
// Week returns should be set
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, -0.01), result.stock_returns.week.?, 0.0001);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.005), result.bond_returns.week.?, 0.0001);
|
||||||
|
// Benchmark week: 0.80 * -0.01 + 0.20 * 0.005 = -0.007
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, -0.007), result.benchmark_returns.week.?, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "portfolioWeightedReturns all periods populated" {
|
||||||
|
const positions = [_]PositionReturn{
|
||||||
|
.{ .symbol = "VTI", .weight = 0.50, .returns = .{
|
||||||
|
.one_year = makePR(0.20, 0.20),
|
||||||
|
.three_year = makePR(0.50, 0.15),
|
||||||
|
.five_year = makePR(0.80, 0.13),
|
||||||
|
.ten_year = makePR(2.0, 0.12),
|
||||||
|
} },
|
||||||
|
.{ .symbol = "BND", .weight = 0.50, .returns = .{
|
||||||
|
.one_year = makePR(0.04, 0.04),
|
||||||
|
.three_year = makePR(0.10, 0.03),
|
||||||
|
.five_year = makePR(0.15, 0.03),
|
||||||
|
.ten_year = makePR(0.30, 0.03),
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
const r = portfolioWeightedReturns(&positions);
|
||||||
|
// 10Y: (0.50 * 0.12 + 0.50 * 0.03) / 1.0 = 0.075
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.075), r.ten_year.?, 0.001);
|
||||||
|
// 5Y: (0.50 * 0.13 + 0.50 * 0.03) / 1.0 = 0.08
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.08), r.five_year.?, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn makeAlloc(symbol: []const u8, mv: f64, weight: f64) Allocation {
|
||||||
|
return .{
|
||||||
|
.symbol = symbol,
|
||||||
|
.display_symbol = symbol,
|
||||||
|
.shares = 10,
|
||||||
|
.avg_cost = 100,
|
||||||
|
.current_price = mv / 10,
|
||||||
|
.market_value = mv,
|
||||||
|
.cost_basis = 1000,
|
||||||
|
.weight = weight,
|
||||||
|
.unrealized_gain_loss = mv - 1000,
|
||||||
|
.unrealized_return = 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test "deriveAllocationSplit basic stock/bond split" {
|
||||||
|
const allocs = [_]Allocation{
|
||||||
|
makeAlloc("SPY", 700_000, 0.70),
|
||||||
|
makeAlloc("AAPL", 100_000, 0.10),
|
||||||
|
makeAlloc("BND", 150_000, 0.15),
|
||||||
|
};
|
||||||
|
const classes = [_]ClassificationEntry{
|
||||||
|
.{ .symbol = "SPY", .asset_class = "US Large Cap" },
|
||||||
|
.{ .symbol = "AAPL", .asset_class = "US Large Cap" },
|
||||||
|
.{ .symbol = "BND", .asset_class = "Bonds" },
|
||||||
|
};
|
||||||
|
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 40_000, 10_000);
|
||||||
|
|
||||||
|
// Bonds: BND $150K + cash $40K + CD $10K = $200K → 20%
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 150_000), result.bond_value, 1.0);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 50_000), result.cash_cd_value, 1.0);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.bond_pct, 0.01);
|
||||||
|
// Stock: $800K → 80% (no unclassified since all are in metadata)
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.80), result.stock_pct, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.unclassified_value, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "deriveAllocationSplit with unclassified positions" {
|
||||||
|
const allocs = [_]Allocation{
|
||||||
|
makeAlloc("SPY", 600_000, 0.60),
|
||||||
|
makeAlloc("MYSTERY", 100_000, 0.10),
|
||||||
|
};
|
||||||
|
const classes = [_]ClassificationEntry{
|
||||||
|
.{ .symbol = "SPY", .asset_class = "US Large Cap" },
|
||||||
|
// MYSTERY has no classification entry
|
||||||
|
};
|
||||||
|
const result = deriveAllocationSplit(&allocs, &classes, 800_000, 50_000, 50_000);
|
||||||
|
|
||||||
|
// Bonds: $0 + cash $50K + CD $50K = $100K
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.cash_cd_value, 1.0);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.bond_value, 1.0);
|
||||||
|
// Unclassified: MYSTERY $100K
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.unclassified_value, 1.0);
|
||||||
|
// Stock: $800K - $100K bonds - $100K unclassified = $600K → 75%
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.75), result.stock_pct, 0.01);
|
||||||
|
// Bond pct: $100K / $800K = 12.5%
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.125), result.bond_pct, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "deriveAllocationSplit empty portfolio" {
|
||||||
|
const allocs = [_]Allocation{};
|
||||||
|
const classes = [_]ClassificationEntry{};
|
||||||
|
const result = deriveAllocationSplit(&allocs, &classes, 0, 0, 0);
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.75), result.stock_pct, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.25), result.bond_pct, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "deriveAllocationSplit no metadata" {
|
||||||
|
const allocs = [_]Allocation{
|
||||||
|
makeAlloc("SPY", 500_000, 0.50),
|
||||||
|
makeAlloc("BND", 300_000, 0.30),
|
||||||
|
};
|
||||||
|
const classes = [_]ClassificationEntry{}; // no metadata at all
|
||||||
|
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 100_000, 100_000);
|
||||||
|
|
||||||
|
// Everything is unclassified except cash/CDs
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 200_000), result.cash_cd_value, 1.0);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 800_000), result.unclassified_value, 1.0);
|
||||||
|
// Stock = total - bonds - unclassified = $1M - $200K - $800K = $0 → 0%
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), result.stock_pct, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.bond_pct, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "deriveAllocationSplit stock and bond pct sum with unclassified" {
|
||||||
|
const allocs = [_]Allocation{
|
||||||
|
makeAlloc("SPY", 500_000, 0.50),
|
||||||
|
makeAlloc("BND", 200_000, 0.20),
|
||||||
|
makeAlloc("NEW", 50_000, 0.05),
|
||||||
|
};
|
||||||
|
const classes = [_]ClassificationEntry{
|
||||||
|
.{ .symbol = "SPY", .asset_class = "US Large Cap" },
|
||||||
|
.{ .symbol = "BND", .asset_class = "Bonds" },
|
||||||
|
};
|
||||||
|
const result = deriveAllocationSplit(&allocs, &classes, 1_000_000, 200_000, 50_000);
|
||||||
|
|
||||||
|
// stock + bond + unclassified/total should account for everything
|
||||||
|
const unclass_pct = result.unclassified_value / 1_000_000;
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 1.0), result.stock_pct + result.bond_pct + unclass_pct, 0.01);
|
||||||
|
}
|
||||||
672
src/analytics/projections.zig
Normal file
672
src/analytics/projections.zig
Normal file
|
|
@ -0,0 +1,672 @@
|
||||||
|
/// Historical simulation engine for retirement projections.
|
||||||
|
///
|
||||||
|
/// Implements the FIRECalc algorithm: for each starting year in the Shiller
|
||||||
|
/// historical dataset (1871–present), simulate a retirement of `horizon` years
|
||||||
|
/// using actual market returns, bond returns, and inflation. The portfolio is
|
||||||
|
/// rebalanced annually to the target stock/bond allocation.
|
||||||
|
///
|
||||||
|
/// Key outputs:
|
||||||
|
/// - Safe withdrawal amount at a given confidence level (binary search to $1)
|
||||||
|
/// - Success rate for a given spending level
|
||||||
|
/// - Percentile bands of portfolio value at each year (for charting)
|
||||||
|
const std = @import("std");
|
||||||
|
const shiller = @import("../data/shiller.zig");
|
||||||
|
const srf = @import("srf");
|
||||||
|
|
||||||
|
// ── User configuration (from projections.srf) ──────────────────
|
||||||
|
|
||||||
|
/// User-configurable projection parameters, loaded from projections.srf.
|
||||||
|
///
|
||||||
|
/// Example projections.srf:
|
||||||
|
/// #!srfv1
|
||||||
|
/// target_stock_pct::77
|
||||||
|
/// horizons::20,30,45
|
||||||
|
/// birthdates::1975-03-15,1978-06-22
|
||||||
|
pub const UserConfig = struct {
|
||||||
|
/// Target stock allocation percentage (0-100). Used for simulation blending.
|
||||||
|
target_stock_pct: ?f64 = null,
|
||||||
|
/// Retirement horizons to simulate (years). Defaults to 20,30,45.
|
||||||
|
horizons: [max_horizons]u16 = .{ 20, 30, 45 } ++ .{0} ** (max_horizons - 3),
|
||||||
|
horizon_count: u8 = 3,
|
||||||
|
/// Confidence levels for safe withdrawal. Always 90/95/99.
|
||||||
|
confidence_levels: [3]f64 = .{ 0.90, 0.95, 0.99 },
|
||||||
|
|
||||||
|
const max_horizons: usize = 8;
|
||||||
|
|
||||||
|
pub fn getHorizons(self: *const UserConfig) []const u16 {
|
||||||
|
return self.horizons[0..self.horizon_count];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn getConfidenceLevels(self: *const UserConfig) []const f64 {
|
||||||
|
return &self.confidence_levels;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parse a projections.srf file into a UserConfig.
|
||||||
|
/// Returns default config if data is null or unparseable.
|
||||||
|
///
|
||||||
|
/// Format (one field per line):
|
||||||
|
/// target_stock_pct::77
|
||||||
|
/// horizon::20
|
||||||
|
/// horizon::30
|
||||||
|
/// horizon::45
|
||||||
|
///
|
||||||
|
/// Multiple `horizon` entries replace the default horizons.
|
||||||
|
pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig {
|
||||||
|
var config = UserConfig{};
|
||||||
|
const raw = data orelse return config;
|
||||||
|
if (raw.len == 0) return config;
|
||||||
|
|
||||||
|
// Use a struct with all optional fields so a single .to() call handles any line.
|
||||||
|
const SrfLine = struct {
|
||||||
|
target_stock_pct: ?[]const u8 = null,
|
||||||
|
horizon: ?[]const u8 = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
var reader = std.Io.Reader.fixed(raw);
|
||||||
|
var it = srf.iterator(&reader, std.heap.smp_allocator, .{ .alloc_strings = false }) catch return config;
|
||||||
|
defer it.deinit();
|
||||||
|
|
||||||
|
var saw_horizon = false;
|
||||||
|
|
||||||
|
while (it.next() catch null) |fields| {
|
||||||
|
const line = fields.to(SrfLine) catch continue;
|
||||||
|
|
||||||
|
if (line.target_stock_pct) |val| {
|
||||||
|
config.target_stock_pct = std.fmt.parseFloat(f64, val) catch null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.horizon) |val| {
|
||||||
|
if (!saw_horizon) {
|
||||||
|
config.horizon_count = 0;
|
||||||
|
saw_horizon = true;
|
||||||
|
}
|
||||||
|
if (config.horizon_count < UserConfig.max_horizons) {
|
||||||
|
const h = std.fmt.parseInt(u16, val, 10) catch continue;
|
||||||
|
if (h > 0) {
|
||||||
|
config.horizons[config.horizon_count] = h;
|
||||||
|
config.horizon_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Configuration ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Conservative return estimation defaults.
|
||||||
|
/// These are module-level constants that will eventually move to projections.srf.
|
||||||
|
pub const default_return_cap: ?f64 = null; // no cap currently
|
||||||
|
pub const default_exclude_1y_from_min: bool = true; // use MIN(3Y, 5Y, 10Y), skip 1Y
|
||||||
|
|
||||||
|
pub const ProjectionConfig = struct {
|
||||||
|
/// Current total portfolio value in dollars.
|
||||||
|
portfolio_value: f64,
|
||||||
|
/// Stock allocation as a fraction (0.0–1.0). Remainder goes to bonds.
|
||||||
|
stock_pct: f64,
|
||||||
|
/// Retirement time horizons to simulate (in years).
|
||||||
|
horizons: []const u16,
|
||||||
|
/// Confidence levels for safe withdrawal (e.g. 0.90, 0.95, 0.99).
|
||||||
|
confidence_levels: []const f64,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Results ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const WithdrawalResult = struct {
|
||||||
|
/// Confidence level (e.g. 0.99 = 99%).
|
||||||
|
confidence: f64,
|
||||||
|
/// Maximum annual withdrawal that achieves this confidence.
|
||||||
|
annual_amount: f64,
|
||||||
|
/// As a fraction of starting portfolio value.
|
||||||
|
withdrawal_rate: f64,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const YearPercentiles = struct {
|
||||||
|
/// Year offset from retirement start (0 = start, 1 = after year 1, etc.)
|
||||||
|
year: u16,
|
||||||
|
p10: f64,
|
||||||
|
p25: f64,
|
||||||
|
p50: f64,
|
||||||
|
p75: f64,
|
||||||
|
p90: f64,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const SimulationResult = struct {
|
||||||
|
horizon: u16,
|
||||||
|
/// Number of historical cycles simulated.
|
||||||
|
num_cycles: usize,
|
||||||
|
/// Safe withdrawal amounts at each requested confidence level.
|
||||||
|
withdrawals: []WithdrawalResult,
|
||||||
|
/// Portfolio value percentiles at each year (for charting).
|
||||||
|
/// Length = horizon + 1 (includes year 0 = starting value).
|
||||||
|
percentile_bands: []YearPercentiles,
|
||||||
|
|
||||||
|
pub fn deinit(self: *SimulationResult, allocator: std.mem.Allocator) void {
|
||||||
|
allocator.free(self.withdrawals);
|
||||||
|
allocator.free(self.percentile_bands);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Core simulation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Simulate a single retirement cycle starting at `start_index` in the
|
||||||
|
/// Shiller dataset, lasting `horizon` years, with the given annual spending
|
||||||
|
/// (inflation-adjusted) and stock/bond allocation.
|
||||||
|
///
|
||||||
|
/// Follows the FIRECalc methodology:
|
||||||
|
/// 1. Withdraw spending (year 1 = base amount, subsequent years CPI-adjusted)
|
||||||
|
/// 2. Apply market returns to the remainder
|
||||||
|
/// 3. Repeat
|
||||||
|
///
|
||||||
|
/// Returns the portfolio values at each year (length = horizon + 1).
|
||||||
|
fn simulateCycle(
|
||||||
|
buf: []f64,
|
||||||
|
start_index: usize,
|
||||||
|
horizon: u16,
|
||||||
|
initial_value: f64,
|
||||||
|
annual_spending: f64,
|
||||||
|
stock_pct: f64,
|
||||||
|
) void {
|
||||||
|
const data = shiller.annual_returns;
|
||||||
|
var portfolio = initial_value;
|
||||||
|
buf[0] = portfolio;
|
||||||
|
|
||||||
|
var cumulative_inflation: f64 = 1.0;
|
||||||
|
|
||||||
|
for (0..horizon) |y| {
|
||||||
|
const di = start_index + y;
|
||||||
|
if (di >= data.len) {
|
||||||
|
for (y + 1..@as(usize, horizon) + 1) |remaining| {
|
||||||
|
buf[remaining] = portfolio;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yr = data[di];
|
||||||
|
|
||||||
|
// Step 1: Withdraw spending (FIRECalc style — withdraw before growth)
|
||||||
|
// Year 1 spending = annual_spending; year 2+ adjusted for prior inflation
|
||||||
|
portfolio -= annual_spending * cumulative_inflation;
|
||||||
|
|
||||||
|
// Step 2: Apply market returns (nominal stock + nominal bond via GS10 yield)
|
||||||
|
const blended_return = stock_pct * yr.sp500_total_return + (1.0 - stock_pct) * yr.bond_total_return;
|
||||||
|
portfolio *= (1.0 + blended_return);
|
||||||
|
|
||||||
|
// Step 3: Update cumulative inflation for next year's spending
|
||||||
|
cumulative_inflation *= (1.0 + yr.cpi_inflation);
|
||||||
|
|
||||||
|
buf[y + 1] = portfolio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the full historical simulation: for each possible starting year,
|
||||||
|
/// simulate a retirement of `horizon` years. Returns the number of cycles
|
||||||
|
/// where the portfolio survived (never went to zero).
|
||||||
|
///
|
||||||
|
/// `all_paths` is a 2D buffer: [cycle_index][year] = portfolio value.
|
||||||
|
/// Must be pre-allocated with dimensions [num_cycles][horizon + 1].
|
||||||
|
fn runAllCycles(
|
||||||
|
all_paths: [][]f64,
|
||||||
|
horizon: u16,
|
||||||
|
initial_value: f64,
|
||||||
|
annual_spending: f64,
|
||||||
|
stock_pct: f64,
|
||||||
|
) usize {
|
||||||
|
const num_cycles = shiller.maxCycles(horizon);
|
||||||
|
var survived: usize = 0;
|
||||||
|
|
||||||
|
for (0..num_cycles) |cycle| {
|
||||||
|
simulateCycle(
|
||||||
|
all_paths[cycle],
|
||||||
|
cycle,
|
||||||
|
horizon,
|
||||||
|
initial_value,
|
||||||
|
annual_spending,
|
||||||
|
stock_pct,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if portfolio survived the full horizon
|
||||||
|
var failed = false;
|
||||||
|
for (1..@as(usize, horizon) + 1) |y| {
|
||||||
|
if (all_paths[cycle][y] <= 0) {
|
||||||
|
// Zero out remaining years once the portfolio is depleted
|
||||||
|
for (y..@as(usize, horizon) + 1) |z| {
|
||||||
|
all_paths[cycle][z] = 0;
|
||||||
|
}
|
||||||
|
failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!failed) survived += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return survived;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the success rate (fraction of cycles that survived) for a
|
||||||
|
/// given spending level. Lightweight version that doesn't store full paths.
|
||||||
|
fn successRate(
|
||||||
|
horizon: u16,
|
||||||
|
initial_value: f64,
|
||||||
|
annual_spending: f64,
|
||||||
|
stock_pct: f64,
|
||||||
|
) f64 {
|
||||||
|
const data = shiller.annual_returns;
|
||||||
|
const num_cycles = shiller.maxCycles(horizon);
|
||||||
|
if (num_cycles == 0) return 0.0;
|
||||||
|
|
||||||
|
var survived: usize = 0;
|
||||||
|
|
||||||
|
for (0..num_cycles) |cycle| {
|
||||||
|
var portfolio = initial_value;
|
||||||
|
var cumulative_inflation: f64 = 1.0;
|
||||||
|
var failed = false;
|
||||||
|
|
||||||
|
for (0..horizon) |y| {
|
||||||
|
const di = cycle + y;
|
||||||
|
if (di >= data.len) break;
|
||||||
|
|
||||||
|
const yr = data[di];
|
||||||
|
|
||||||
|
// Withdraw first (FIRECalc style)
|
||||||
|
portfolio -= annual_spending * cumulative_inflation;
|
||||||
|
if (portfolio <= 0) {
|
||||||
|
failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then grow
|
||||||
|
const blended_return = stock_pct * yr.sp500_total_return + (1.0 - stock_pct) * yr.bond_total_return;
|
||||||
|
portfolio *= (1.0 + blended_return);
|
||||||
|
|
||||||
|
// Update inflation for next year
|
||||||
|
cumulative_inflation *= (1.0 + yr.cpi_inflation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!failed) survived += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return @as(f64, @floatFromInt(survived)) / @as(f64, @floatFromInt(num_cycles));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Safe withdrawal search ─────────────────────────────────────
|
||||||
|
|
||||||
|
/// Find the maximum annual withdrawal amount (in today's dollars) such that
|
||||||
|
/// the portfolio survives `horizon` years in at least `confidence` fraction
|
||||||
|
/// of all historical cycles.
|
||||||
|
///
|
||||||
|
/// Uses binary search with $1 precision.
|
||||||
|
pub fn findSafeWithdrawal(
|
||||||
|
horizon: u16,
|
||||||
|
initial_value: f64,
|
||||||
|
stock_pct: f64,
|
||||||
|
confidence: f64,
|
||||||
|
) WithdrawalResult {
|
||||||
|
// Binary search bounds: $0 to the full portfolio value
|
||||||
|
var lo: f64 = 0;
|
||||||
|
var hi: f64 = initial_value;
|
||||||
|
|
||||||
|
// Binary search to $1 precision
|
||||||
|
while (hi - lo > 1.0) {
|
||||||
|
const mid = @floor((lo + hi) / 2.0);
|
||||||
|
const rate = successRate(horizon, initial_value, mid, stock_pct);
|
||||||
|
|
||||||
|
if (rate >= confidence) {
|
||||||
|
lo = mid;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.confidence = confidence,
|
||||||
|
.annual_amount = lo,
|
||||||
|
.withdrawal_rate = lo / initial_value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Percentile bands ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Compute percentile bands from all simulated paths for a given horizon
|
||||||
|
/// and spending level. Allocates the result.
|
||||||
|
pub fn computePercentileBands(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
horizon: u16,
|
||||||
|
initial_value: f64,
|
||||||
|
annual_spending: f64,
|
||||||
|
stock_pct: f64,
|
||||||
|
) ![]YearPercentiles {
|
||||||
|
const num_cycles = shiller.maxCycles(horizon);
|
||||||
|
if (num_cycles == 0) return &.{};
|
||||||
|
|
||||||
|
const years: usize = @as(usize, horizon) + 1;
|
||||||
|
|
||||||
|
// Allocate path storage: num_cycles rows of (horizon+1) f64s
|
||||||
|
const path_data = try allocator.alloc(f64, num_cycles * years);
|
||||||
|
defer allocator.free(path_data);
|
||||||
|
|
||||||
|
const paths = try allocator.alloc([]f64, num_cycles);
|
||||||
|
defer allocator.free(paths);
|
||||||
|
|
||||||
|
for (0..num_cycles) |i| {
|
||||||
|
paths[i] = path_data[i * years .. (i + 1) * years];
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = runAllCycles(paths, horizon, initial_value, annual_spending, stock_pct);
|
||||||
|
|
||||||
|
// For each year, sort the values across all cycles and extract percentiles
|
||||||
|
const bands = try allocator.alloc(YearPercentiles, years);
|
||||||
|
|
||||||
|
// Temporary buffer for sorting one year's values
|
||||||
|
const sort_buf = try allocator.alloc(f64, num_cycles);
|
||||||
|
defer allocator.free(sort_buf);
|
||||||
|
|
||||||
|
for (0..years) |y| {
|
||||||
|
// Collect values for year y across all cycles
|
||||||
|
for (0..num_cycles) |c| {
|
||||||
|
sort_buf[c] = paths[c][y];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
std.mem.sort(f64, sort_buf, {}, std.sort.asc(f64));
|
||||||
|
|
||||||
|
bands[y] = .{
|
||||||
|
.year = @intCast(y),
|
||||||
|
.p10 = percentile(sort_buf, 0.10),
|
||||||
|
.p25 = percentile(sort_buf, 0.25),
|
||||||
|
.p50 = percentile(sort_buf, 0.50),
|
||||||
|
.p75 = percentile(sort_buf, 0.75),
|
||||||
|
.p90 = percentile(sort_buf, 0.90),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return bands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear interpolation percentile on a sorted slice.
|
||||||
|
fn percentile(sorted: []const f64, p: f64) f64 {
|
||||||
|
if (sorted.len == 0) return 0;
|
||||||
|
if (sorted.len == 1) return sorted[0];
|
||||||
|
|
||||||
|
const n = @as(f64, @floatFromInt(sorted.len - 1));
|
||||||
|
const idx = p * n;
|
||||||
|
const lo_idx: usize = @intFromFloat(@floor(idx));
|
||||||
|
const hi_idx: usize = @min(lo_idx + 1, sorted.len - 1);
|
||||||
|
const frac = idx - @floor(idx);
|
||||||
|
|
||||||
|
return sorted[lo_idx] * (1.0 - frac) + sorted[hi_idx] * frac;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── High-level API ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Run the full projection analysis for one horizon: compute safe withdrawal
|
||||||
|
/// at each confidence level and percentile bands using the median withdrawal.
|
||||||
|
pub fn runProjection(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
config: ProjectionConfig,
|
||||||
|
horizon: u16,
|
||||||
|
) !SimulationResult {
|
||||||
|
const num_cycles = shiller.maxCycles(horizon);
|
||||||
|
|
||||||
|
// Compute safe withdrawal at each confidence level
|
||||||
|
const withdrawals = try allocator.alloc(WithdrawalResult, config.confidence_levels.len);
|
||||||
|
for (config.confidence_levels, 0..) |conf, i| {
|
||||||
|
withdrawals[i] = findSafeWithdrawal(
|
||||||
|
horizon,
|
||||||
|
config.portfolio_value,
|
||||||
|
config.stock_pct,
|
||||||
|
conf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the median confidence level's withdrawal for the percentile chart
|
||||||
|
const median_idx = config.confidence_levels.len / 2;
|
||||||
|
const chart_spending = if (withdrawals.len > 0) withdrawals[median_idx].annual_amount else 0;
|
||||||
|
|
||||||
|
const bands = try computePercentileBands(
|
||||||
|
allocator,
|
||||||
|
horizon,
|
||||||
|
config.portfolio_value,
|
||||||
|
chart_spending,
|
||||||
|
config.stock_pct,
|
||||||
|
);
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.horizon = horizon,
|
||||||
|
.num_cycles = num_cycles,
|
||||||
|
.withdrawals = withdrawals,
|
||||||
|
.percentile_bands = bands,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run projections for all configured horizons.
|
||||||
|
pub fn runAllProjections(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
config: ProjectionConfig,
|
||||||
|
) ![]SimulationResult {
|
||||||
|
const results = try allocator.alloc(SimulationResult, config.horizons.len);
|
||||||
|
errdefer {
|
||||||
|
for (results) |*r| r.deinit(allocator);
|
||||||
|
allocator.free(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (config.horizons, 0..) |h, i| {
|
||||||
|
results[i] = try runProjection(allocator, config, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test "successRate with zero spending is 100%" {
|
||||||
|
const rate = successRate(30, 1_000_000, 0, 0.75);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 1.0), rate, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "successRate with excessive spending is 0%" {
|
||||||
|
// Spending the entire portfolio in year 1 should fail every cycle
|
||||||
|
const rate = successRate(30, 1_000_000, 1_000_000, 0.75);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), rate, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "successRate decreases with higher spending" {
|
||||||
|
const rate_low = successRate(30, 1_000_000, 20_000, 0.75);
|
||||||
|
const rate_mid = successRate(30, 1_000_000, 40_000, 0.75);
|
||||||
|
const rate_high = successRate(30, 1_000_000, 60_000, 0.75);
|
||||||
|
|
||||||
|
try std.testing.expect(rate_low >= rate_mid);
|
||||||
|
try std.testing.expect(rate_mid >= rate_high);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "findSafeWithdrawal produces reasonable results" {
|
||||||
|
// Using Jan-to-Jan Shiller data with real→nominal bond returns.
|
||||||
|
// Results differ from the classic 4% rule due to dataset and timing differences.
|
||||||
|
const result = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95);
|
||||||
|
|
||||||
|
// Should produce a positive withdrawal rate between 1% and 6%
|
||||||
|
try std.testing.expect(result.annual_amount >= 10_000);
|
||||||
|
try std.testing.expect(result.annual_amount <= 60_000);
|
||||||
|
try std.testing.expect(result.withdrawal_rate >= 0.01);
|
||||||
|
try std.testing.expect(result.withdrawal_rate <= 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "higher confidence means lower withdrawal" {
|
||||||
|
const r90 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.90);
|
||||||
|
const r95 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95);
|
||||||
|
const r99 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.99);
|
||||||
|
|
||||||
|
try std.testing.expect(r90.annual_amount >= r95.annual_amount);
|
||||||
|
try std.testing.expect(r95.annual_amount >= r99.annual_amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "longer horizon means lower withdrawal" {
|
||||||
|
const r20 = findSafeWithdrawal(20, 1_000_000, 0.75, 0.95);
|
||||||
|
const r30 = findSafeWithdrawal(30, 1_000_000, 0.75, 0.95);
|
||||||
|
const r45 = findSafeWithdrawal(45, 1_000_000, 0.75, 0.95);
|
||||||
|
|
||||||
|
try std.testing.expect(r20.annual_amount >= r30.annual_amount);
|
||||||
|
try std.testing.expect(r30.annual_amount >= r45.annual_amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "computePercentileBands basic properties" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const bands = try computePercentileBands(allocator, 30, 1_000_000, 30_000, 0.75);
|
||||||
|
defer allocator.free(bands);
|
||||||
|
|
||||||
|
// Should have horizon + 1 entries
|
||||||
|
try std.testing.expectEqual(@as(usize, 31), bands.len);
|
||||||
|
|
||||||
|
// Year 0 should be the starting value for all percentiles
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), bands[0].p50, 1.0);
|
||||||
|
|
||||||
|
// Percentiles should be ordered at each year
|
||||||
|
for (bands) |b| {
|
||||||
|
try std.testing.expect(b.p10 <= b.p25);
|
||||||
|
try std.testing.expect(b.p25 <= b.p50);
|
||||||
|
try std.testing.expect(b.p50 <= b.p75);
|
||||||
|
try std.testing.expect(b.p75 <= b.p90);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "runProjection produces valid results" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const config = ProjectionConfig{
|
||||||
|
.portfolio_value = 1_000_000,
|
||||||
|
.stock_pct = 0.75,
|
||||||
|
.horizons = &.{ 20, 30 },
|
||||||
|
.confidence_levels = &.{ 0.90, 0.95, 0.99 },
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = try runProjection(allocator, config, 30);
|
||||||
|
defer result.deinit(allocator);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(u16, 30), result.horizon);
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), result.withdrawals.len);
|
||||||
|
try std.testing.expectEqual(@as(usize, 31), result.percentile_bands.len);
|
||||||
|
|
||||||
|
// Withdrawals should be ordered: 90% > 95% > 99%
|
||||||
|
try std.testing.expect(result.withdrawals[0].annual_amount >= result.withdrawals[1].annual_amount);
|
||||||
|
try std.testing.expect(result.withdrawals[1].annual_amount >= result.withdrawals[2].annual_amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "percentile interpolation" {
|
||||||
|
const data = [_]f64{ 10, 20, 30, 40, 50 };
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 10.0), percentile(&data, 0.0), 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 30.0), percentile(&data, 0.5), 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 50.0), percentile(&data, 1.0), 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 20.0), percentile(&data, 0.25), 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "realistic portfolio safe withdrawal" {
|
||||||
|
// Approximate real portfolio: ~$8.34M, ~82.5% stocks
|
||||||
|
const portfolio = 8_340_000;
|
||||||
|
const stock_pct = 0.825;
|
||||||
|
|
||||||
|
const r99_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.99);
|
||||||
|
const r95_45 = findSafeWithdrawal(45, portfolio, stock_pct, 0.95);
|
||||||
|
const r99_30 = findSafeWithdrawal(30, portfolio, stock_pct, 0.99);
|
||||||
|
|
||||||
|
// 95% should be higher than 99%
|
||||||
|
try std.testing.expect(r95_45.annual_amount > r99_45.annual_amount);
|
||||||
|
// 30yr should be higher than 45yr at same confidence
|
||||||
|
try std.testing.expect(r99_30.annual_amount > r99_45.annual_amount);
|
||||||
|
// Should produce $290K+ at 99%/45yr based on FIRECalc reference (~$305K on $7.7M)
|
||||||
|
try std.testing.expect(r99_45.annual_amount >= 290_000);
|
||||||
|
try std.testing.expect(r99_45.annual_amount <= 350_000);
|
||||||
|
try std.testing.expect(r99_45.withdrawal_rate >= 0.03);
|
||||||
|
try std.testing.expect(r99_45.withdrawal_rate <= 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "simulateCycle produces correct year-0 value" {
|
||||||
|
var buf: [31]f64 = undefined;
|
||||||
|
simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 1_000_000), buf[0], 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "simulateCycle with zero spending grows portfolio" {
|
||||||
|
var buf: [31]f64 = undefined;
|
||||||
|
simulateCycle(&buf, 0, 30, 1_000_000, 0, 0.75);
|
||||||
|
// Over any 30-year period in history, zero spending should grow the portfolio
|
||||||
|
try std.testing.expect(buf[30] > 1_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseProjectionsConfig defaults" {
|
||||||
|
const config = parseProjectionsConfig(null);
|
||||||
|
try std.testing.expect(config.target_stock_pct == null);
|
||||||
|
try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
|
||||||
|
try std.testing.expectEqual(@as(u16, 20), config.getHorizons()[0]);
|
||||||
|
try std.testing.expectEqual(@as(u16, 30), config.getHorizons()[1]);
|
||||||
|
try std.testing.expectEqual(@as(u16, 45), config.getHorizons()[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseProjectionsConfig from SRF" {
|
||||||
|
const data = "#!srfv1\ntarget_stock_pct::77\nhorizon::25\nhorizon::35\nhorizon::50\n";
|
||||||
|
const config = parseProjectionsConfig(data);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 77.0), config.target_stock_pct.?, 0.01);
|
||||||
|
try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
|
||||||
|
try std.testing.expectEqual(@as(u16, 25), config.getHorizons()[0]);
|
||||||
|
try std.testing.expectEqual(@as(u16, 35), config.getHorizons()[1]);
|
||||||
|
try std.testing.expectEqual(@as(u16, 50), config.getHorizons()[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseProjectionsConfig partial" {
|
||||||
|
const data = "#!srfv1\ntarget_stock_pct::82.5\n";
|
||||||
|
const config = parseProjectionsConfig(data);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 82.5), config.target_stock_pct.?, 0.01);
|
||||||
|
// Horizons should remain default
|
||||||
|
try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
|
||||||
|
try std.testing.expectEqual(@as(u16, 20), config.getHorizons()[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseProjectionsConfig empty string" {
|
||||||
|
const config = parseProjectionsConfig("");
|
||||||
|
try std.testing.expect(config.target_stock_pct == null);
|
||||||
|
try std.testing.expectEqual(@as(u8, 3), config.horizon_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseProjectionsConfig invalid data" {
|
||||||
|
const config = parseProjectionsConfig("not valid srf");
|
||||||
|
try std.testing.expect(config.target_stock_pct == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "UserConfig getHorizons default" {
|
||||||
|
const config = UserConfig{};
|
||||||
|
const horizons = config.getHorizons();
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), horizons.len);
|
||||||
|
try std.testing.expectEqual(@as(u16, 20), horizons[0]);
|
||||||
|
try std.testing.expectEqual(@as(u16, 30), horizons[1]);
|
||||||
|
try std.testing.expectEqual(@as(u16, 45), horizons[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "UserConfig getConfidenceLevels" {
|
||||||
|
const config = UserConfig{};
|
||||||
|
const levels = config.getConfidenceLevels();
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), levels.len);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.90), levels[0], 0.001);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.95), levels[1], 0.001);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.99), levels[2], 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "runAllProjections produces results for each horizon" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const config = ProjectionConfig{
|
||||||
|
.portfolio_value = 1_000_000,
|
||||||
|
.stock_pct = 0.75,
|
||||||
|
.horizons = &.{ 20, 30 },
|
||||||
|
.confidence_levels = &.{ 0.95, 0.99 },
|
||||||
|
};
|
||||||
|
const results = try runAllProjections(allocator, config);
|
||||||
|
defer {
|
||||||
|
for (results) |*r| r.deinit(allocator);
|
||||||
|
allocator.free(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), results.len);
|
||||||
|
try std.testing.expectEqual(@as(u16, 20), results[0].horizon);
|
||||||
|
try std.testing.expectEqual(@as(u16, 30), results[1].horizon);
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), results[0].withdrawals.len);
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), results[1].withdrawals.len);
|
||||||
|
}
|
||||||
304
src/commands/projections.zig
Normal file
304
src/commands/projections.zig
Normal 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(¬e_buf, user_config.target_stock_pct, stock_pct)) |note| {
|
||||||
|
try out.print("\n", .{});
|
||||||
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
|
try out.print("{s}\n", .{note});
|
||||||
|
try cli.reset(out, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Safe withdrawal table ──────────────────────────────────
|
||||||
|
try out.print("\n", .{});
|
||||||
|
try cli.setBold(out, color);
|
||||||
|
try out.print("Safe Withdrawal (FIRECalc historical simulation)\n", .{});
|
||||||
|
try cli.reset(out, color);
|
||||||
|
|
||||||
|
const horizons = user_config.getHorizons();
|
||||||
|
const confidence_levels = user_config.getConfidenceLevels();
|
||||||
|
|
||||||
|
// Header row
|
||||||
|
try out.print("{s: <25}", .{""});
|
||||||
|
for (horizons) |h| {
|
||||||
|
var hbuf: [16]u8 = undefined;
|
||||||
|
try out.print("{s: >12}", .{view.fmtHorizonLabel(&hbuf, h)});
|
||||||
|
}
|
||||||
|
try out.print("\n", .{});
|
||||||
|
|
||||||
|
// One row per confidence level
|
||||||
|
for (confidence_levels) |conf| {
|
||||||
|
var lbuf: [25]u8 = undefined;
|
||||||
|
try out.print("{s: <25}", .{view.fmtConfidenceLabel(&lbuf, conf)});
|
||||||
|
|
||||||
|
for (horizons) |h| {
|
||||||
|
const result = projections.findSafeWithdrawal(h, total_value, sim_stock_pct, conf);
|
||||||
|
var abuf: [24]u8 = undefined;
|
||||||
|
var rbuf: [16]u8 = undefined;
|
||||||
|
const cell = view.fmtWithdrawalCell(&abuf, &rbuf, result);
|
||||||
|
try out.print("{s: >12}", .{cell.amount_text});
|
||||||
|
}
|
||||||
|
try out.print("\n", .{});
|
||||||
|
|
||||||
|
// Rate row
|
||||||
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||||
|
try out.print("{s: <25}", .{""});
|
||||||
|
for (horizons) |h| {
|
||||||
|
const result = projections.findSafeWithdrawal(h, total_value, sim_stock_pct, conf);
|
||||||
|
var abuf: [24]u8 = undefined;
|
||||||
|
var rbuf: [16]u8 = undefined;
|
||||||
|
const cell = view.fmtWithdrawalCell(&abuf, &rbuf, result);
|
||||||
|
try out.print("{s: >12}", .{cell.rate_text});
|
||||||
|
}
|
||||||
|
try cli.reset(out, color);
|
||||||
|
try out.print("\n", .{});
|
||||||
|
}
|
||||||
|
|
||||||
|
try out.print("\n", .{});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a return row using the view model, applying StyleIntent colors.
|
||||||
|
fn writeReturnRow(out: *std.Io.Writer, color: bool, row: view.ReturnRow) !void {
|
||||||
|
try out.print("{s: <32}", .{row.label});
|
||||||
|
try writeCell(out, color, row.one_year, 8);
|
||||||
|
try writeCell(out, color, row.three_year, 9);
|
||||||
|
try writeCell(out, color, row.five_year, 9);
|
||||||
|
try writeCell(out, color, row.ten_year, 10);
|
||||||
|
try writeCell(out, color, row.week, 9);
|
||||||
|
try out.print("\n", .{});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usize) !void {
|
||||||
|
try setIntentFg(out, color, cell.style);
|
||||||
|
switch (width) {
|
||||||
|
8 => try out.print("{s: >8}", .{cell.text}),
|
||||||
|
9 => try out.print("{s: >9}", .{cell.text}),
|
||||||
|
10 => try out.print("{s: >10}", .{cell.text}),
|
||||||
|
else => try out.print("{s}", .{cell.text}),
|
||||||
|
}
|
||||||
|
try cli.reset(out, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setIntentFg(out: *std.Io.Writer, color: bool, intent: view.StyleIntent) !void {
|
||||||
|
switch (intent) {
|
||||||
|
.normal => try cli.reset(out, color),
|
||||||
|
.muted => try cli.setFg(out, color, cli.CLR_MUTED),
|
||||||
|
.positive => try cli.setFg(out, color, cli.CLR_POSITIVE),
|
||||||
|
.negative => try cli.setFg(out, color, cli.CLR_NEGATIVE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn candleDate(c: zfin.Candle) zfin.Date {
|
||||||
|
return c.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute 1-week return from candle data.
|
||||||
|
fn weekReturn(candles: []const zfin.Candle) ?f64 {
|
||||||
|
if (candles.len < 2) return null;
|
||||||
|
const latest = candles[candles.len - 1];
|
||||||
|
const target_date = latest.date.addDays(-7);
|
||||||
|
const idx = valuation.indexAtOrBefore(zfin.Candle, candles, target_date, candleDate) orelse return null;
|
||||||
|
const start = candles[idx];
|
||||||
|
if (start.close == 0) return null;
|
||||||
|
return (latest.close / start.close) - 1.0;
|
||||||
|
}
|
||||||
11
src/main.zig
11
src/main.zig
|
|
@ -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
206
src/views/projections.zig
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue