add CLI command for projections
This commit is contained in:
parent
dda49efe27
commit
6debed0d69
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)
|
||||
\\ 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
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