From 1f2b6b32dec46fc3b845e7d4f79f12588e11eaa5 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 25 Jun 2026 10:37:53 -0700 Subject: [PATCH] add configurable return cap --- TODO.md | 3 -- docs/reference/config/projections-srf.md | 32 +++++++++++++ src/analytics/benchmark.zig | 42 ++++++++++++++--- src/analytics/projections.zig | 60 +++++++++++++++++++++--- src/views/projections.zig | 7 +++ 5 files changed, 127 insertions(+), 17 deletions(-) diff --git a/TODO.md b/TODO.md index 851c4c5..3ce9a53 100644 --- a/TODO.md +++ b/TODO.md @@ -7,9 +7,6 @@ ranking; unlabeled items are "someday, if the mood strikes." ## Projections: future enhancements -- **Configurable return cap per position - priority MEDIUM.** - Default: none; cap outliers like NVDA. Should route through - `projections.srf` cleanly. - **Chart vertical line at retirement boundary - priority LOW.** The accumulation-phase spec called this "mandatory" but it was explicitly deferred during implementation. The chart currently diff --git a/docs/reference/config/projections-srf.md b/docs/reference/config/projections-srf.md index bc08994..4651085 100644 --- a/docs/reference/config/projections-srf.md +++ b/docs/reference/config/projections-srf.md @@ -36,6 +36,7 @@ type::event,name::Social Security,start_age:num:70,amount:num:38400 |--------------------------------------|------|--------------------------------------------------------------------------------------------------------------------------------| | `target_stock_pct` | num | Asset-allocation target (0-100). Sets the simulation's stock/bond blend. | | `expense_ratio` | num | Annual fund expense ratio as a percent (e.g. `0.18` = 0.18%), subtracted from the blended return each year. Default `0.18` (FIRECalc's default; realistic for a fund portfolio). Override down (`0.04`) for low-cost index funds, up for active funds, or `0` for all individual stocks. | +| `return_cap` | num | Optional ceiling, as a percent (e.g. `30` = 30%), on each position's conservative trailing return before it is weighted into the displayed **Projected return**. Default: none. See [Capping outlier returns](#capping-outlier-returns). | | `horizon` | num | Distribution-phase length in years. Repeat the line for multiple horizons. | | `horizon_age` | num | Horizon expressed as an age; resolves to `target_age - oldest_current_age`. Repeatable. | | `retirement_age` | num | Age the **oldest** configured person must reach to retire. | @@ -78,6 +79,37 @@ individual stocks, bonds, and cash contribute ~0. Set the result once: [Parity with FIRECalc](../../explanation/projections-model.md#parity-with-firecalc) for how the fee interacts with the rest of the model. +### Capping outlier returns + +The **Projected return** shown by `zfin projections` (and the "Projected +return:" row in `zfin compare`) is a conservative, market-value-weighted +blend of each position's `MIN(3Y, 5Y, 10Y)` annualized trailing return. +A single position that has run hot recently -- NVDA is the canonical +example -- can carry a multi-hundred-percent trailing return that drags +the whole estimate up to a level no one would forecast forward. + +`return_cap` clamps each position's contribution to a ceiling you pick, +in percent: + +```srf +# No single position contributes more than 30%/yr to the estimate +type::config,return_cap:num:30 +``` + +With this set, NVDA's 69% trailing MIN is treated as 30% before +weighting; positions already under 30% are untouched. The default is no +cap, so the estimate uses the raw trailing returns. + +Two things to note: + +- It is a **single global ceiling applied per position**, not a + per-symbol value. Set it to the highest forward return you find + credible for *any* holding; every outlier above it clamps down. +- It only affects the displayed conservative **Projected return**. It + does **not** change the Monte Carlo percentile bands or the + safe-withdrawal grid -- those blend Shiller S&P/bond history by your + aggregate `target_stock_pct` and never look at individual positions. + ## `birthdate` fields | Field | Type | Description | diff --git a/src/analytics/benchmark.zig b/src/analytics/benchmark.zig index 350fd1f..201cd3d 100644 --- a/src/analytics/benchmark.zig +++ b/src/analytics/benchmark.zig @@ -16,10 +16,10 @@ 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; +// The per-position return cap is configured in projections.srf +// (`UserConfig.return_cap`) and threaded into `buildComparison` by the +// view layer. Excluding 1-year returns from the MIN is not yet +// user-configurable, so it stays a module-level default here. /// Whether to exclude 1-year returns from the conservative MIN calculation. pub const default_exclude_1y_from_min: bool = true; @@ -322,12 +322,19 @@ pub fn conservativeWeightedReturn( } /// Build a full benchmark comparison from component data. +/// +/// `return_cap` is the per-position ceiling (a fraction, e.g. 0.30 for +/// 30%) applied to each position's conservative MIN return before +/// weighting; `null` means no cap. It originates from +/// `projections.srf` (`UserConfig.return_cap`, stored as a percent and +/// converted to a fraction at the view boundary). pub fn buildComparison( stock_trailing: TrailingReturns, bond_trailing: TrailingReturns, stock_pct: f64, bond_pct: f64, positions: []const PositionReturn, + return_cap: ?f64, ) BenchmarkComparison { // `stock_trailing.week` and `bond_trailing.week` propagate // through `toReturnsByPeriod` automatically - see @@ -338,7 +345,7 @@ pub fn buildComparison( 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); + const conservative = conservativeWeightedReturn(positions, return_cap, default_exclude_1y_from_min); return .{ .stock_returns = stock_r, @@ -519,7 +526,7 @@ test "buildComparison produces consistent results" { .five_year = makePR(0.10, 0.02), } }, }; - const result = buildComparison(stock_tr, bond_tr, 0.77, 0.23, &positions); + const result = buildComparison(stock_tr, bond_tr, 0.77, 0.23, &positions, 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); @@ -625,7 +632,7 @@ test "buildComparison with week returns" { .week = 0.005, }; const positions = [_]PositionReturn{}; - const result = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions); + const result = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, null); // Week returns should be set try std.testing.expectApproxEqAbs(@as(f64, -0.01), result.stock_returns.week.?, 0.0001); @@ -634,6 +641,27 @@ test "buildComparison with week returns" { try std.testing.expectApproxEqAbs(@as(f64, -0.007), result.benchmark_returns.week.?, 0.0001); } +test "buildComparison applies the return cap to conservative_return" { + // A single high-flyer position (NVDA-shaped) whose MIN(3Y,5Y,10Y) + // is 0.69. With no cap the conservative return is 0.69; a 0.30 cap + // threaded through buildComparison clamps it to 0.30. + const stock_tr = TrailingReturns{ .three_year = makePR(0.50, 0.15) }; + const bond_tr = TrailingReturns{ .three_year = makePR(0.10, 0.03) }; + 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), + } }, + }; + + const uncapped = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, null); + try std.testing.expectApproxEqAbs(@as(f64, 0.69), uncapped.conservative_return, 0.001); + + const capped = buildComparison(stock_tr, bond_tr, 0.80, 0.20, &positions, 0.30); + try std.testing.expectApproxEqAbs(@as(f64, 0.30), capped.conservative_return, 0.001); +} + test "portfolioWeightedReturns all periods populated" { const positions = [_]PositionReturn{ .{ .symbol = "VTI", .weight = 0.50, .returns = .{ diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index 926a73f..b11b033 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -169,6 +169,22 @@ pub const UserConfig = struct { /// (like `target_stock_pct`); converted to the decimal the /// simulation wants (`/100`) at the view boundary. expense_ratio: f64 = 0.18, + /// Optional per-position return cap, as a percentage (e.g. `30` = + /// 30%). When set, each position's conservative MIN(3Y,5Y,10Y) + /// trailing return is clamped to this ceiling before being + /// market-value weighted into the "Projected return" estimate. This + /// keeps a single outlier (e.g. NVDA's recent run) from inflating + /// the forward-looking projected return. **Defaults to `null` (no + /// cap).** Stored as a percentage here (like `target_stock_pct` / + /// `expense_ratio`); converted to the decimal the analytics want + /// (`/100`) at the view boundary. Override via + /// `type::config,return_cap:num:30` in `projections.srf`. + /// + /// Note this caps the *displayed* conservative "Projected return", + /// not the Monte Carlo bands - those blend Shiller S&P/bond history + /// by the portfolio's aggregate stock_pct and never see individual + /// positions. + return_cap: ?f64 = null, /// Retirement horizons to simulate (years). Defaults to 20,30,45. horizons: [max_horizons]u16 = .{ 20, 30, 45 } ++ @as([max_horizons - 3]u16, @splat(0)), horizon_count: u8 = 3, @@ -437,6 +453,7 @@ const SrfConfig = struct { type: []const u8 = "", target_stock_pct: ?f64 = null, expense_ratio: ?f64 = null, + return_cap: ?f64 = null, horizon: ?u16 = null, horizon_age: ?u16 = null, /// Earliest-retirement promotion override: when paired with @@ -526,6 +543,16 @@ pub fn parseProjectionsConfig(data: ?[]const u8) UserConfig { .config => |c| { config.target_stock_pct = c.target_stock_pct orelse config.target_stock_pct; config.expense_ratio = c.expense_ratio orelse config.expense_ratio; + if (c.return_cap) |cap| { + // A return cap is a ceiling on a position's expected + // forward return; a negative ceiling is nonsensical. + // Stored as a percent (e.g. 30 = 30%). + if (cap >= 0) { + config.return_cap = cap; + } else { + warnUser("projections: return_cap must be >= 0 (got {d}); ignoring record", .{cap}); + } + } if (c.horizon) |h| { if (!saw_horizon) { config.horizon_count = 0; @@ -672,13 +699,6 @@ fn validRetirementTarget(raw: ?u8) ?u8 { return null; } -// ── 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 - // ── Results ──────────────────────────────────────────────────── pub const WithdrawalResult = struct { @@ -2146,6 +2166,32 @@ test "parseProjectionsConfig rejects negative target_spending" { try std.testing.expectEqual(@as(?f64, null), config.target_spending); } +test "parseProjectionsConfig return_cap defaults to null" { + const config = parseProjectionsConfig("#!srfv1\n"); + try std.testing.expectEqual(@as(?f64, null), config.return_cap); +} + +test "parseProjectionsConfig parses return_cap as a percent" { + const data = + \\#!srfv1 + \\type::config,return_cap:num:30 + ; + const config = parseProjectionsConfig(data); + // Stored as a percentage (like target_stock_pct / expense_ratio); + // the view layer divides by 100 before handing it to the analytics. + try std.testing.expectApproxEqAbs(@as(f64, 30), config.return_cap.?, 0.0001); +} + +test "parseProjectionsConfig rejects negative return_cap" { + const data = + \\#!srfv1 + \\type::config,return_cap:num:-5 + ; + const config = parseProjectionsConfig(data); + // Negative ceiling is nonsensical; dropped, default null retained. + try std.testing.expectEqual(@as(?f64, null), config.return_cap); +} + test "parseProjectionsConfig benchmark defaults are SPY and AGG" { const config = parseProjectionsConfig(null); try std.testing.expectEqualStrings("SPY", config.benchmark_stock); diff --git a/src/views/projections.zig b/src/views/projections.zig index a2f2816..72b0e10 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -752,12 +752,19 @@ fn buildContextFromParts( } } + // `return_cap` is stored as a percentage in UserConfig (like + // target_stock_pct / expense_ratio); `conservativeWeightedReturn` + // compares it against annualized fractional returns, so convert to + // a decimal here. `null` (the default) means no cap. + const return_cap_frac: ?f64 = if (config.return_cap) |c| c / 100.0 else null; + const comparison = benchmark.buildComparison( spy_trailing, agg_trailing, split.stock_pct, split.bond_pct, pos_returns.items, + return_cap_frac, ); // Resolve events against ages-as-of the reference date. The