add configurable return cap

This commit is contained in:
Emil Lerch 2026-06-25 10:37:53 -07:00
parent d619091831
commit 1f2b6b32de
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 127 additions and 17 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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 = .{

View file

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

View file

@ -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