Files
sure/test/models/retirement/fire/forecast_test.rb
Guillem Arias 36a43f3a35 feat(retirement): PR3a FIRE forecast engine (deterministic, real-terms)
Pure-Ruby projection engine for a single plan. Models in real
(today's-money) terms: portfolio grows at the real return, spending and
pension incomes are held in today's money, so no inflation parameter is
needed and output is fully deterministic. v2 swaps in a Sidekiq Monte
Carlo behind the same call interface.

POROs (app/models/retirement/):
- Fire::Forecast — annual stepper. Accumulate (×(1+r)+savings) to
  retire_age, then draw down max(target − net pension income, 0) with
  lump payouts as portfolio deltas. Computes the glide series,
  money-lasts-to age, terminal value, Coast FIRE age (bisection on the
  minimum survivable portfolio at retirement), feasibility, warnings.
- Fire::Payout — normalises a PensionSource to gross annual income +
  one-time lump per age, across the four payout shapes.
- Fire::Adjustment — age-bounded signed change to the spending target.
- Fire::CohortAccess — min access age (UK NMPA 55→57 from 2028, US
  59.5/62, DE 63/55).
- Tax::StaticRate (+ initializer) — v1 fraction-kept by treatment;
  de_renten falls with the cohort year. Boot-validated against the
  PensionSource enum.

Wiring: Goal::Retirement gains retirement_params store_accessor
(birth_year, retire_age, real_return_pct, monthly_savings, target_spend,
terminal_age), bucket_value, payouts, forecast_inputs, memoised
#forecast (nil until birth_year set), freedom_date, coast_fire_date.
Family#retirement_spending_baseline anchors the default target on the
median monthly expense (the precise trailing-12m 10%-trimmed mean +
its label ship with PR4's "Why this target?" card).

Tests: 28 — exact zero-return stepper checks (accumulation + depletion
with shortfall), pension-covered no-drawdown, tax widening the
drawdown, adjustment lowering the target, Coast extremes, infeasible
warnings, plus tax/payout/cohort units and the model wiring. No new
gems.
2026-05-29 11:31:06 +02:00

109 lines
3.8 KiB
Ruby

require "test_helper"
class Retirement::Fire::ForecastTest < ActiveSupport::TestCase
Forecast = Retirement::Fire::Forecast
Inputs = Retirement::Fire::Inputs
Payout = Retirement::Fire::Payout
Adjustment = Retirement::Fire::Adjustment
def inputs(**over)
defaults = {
current_age: 40, retire_age: 65, terminal_age: 95, real_return: 0.05,
annual_savings: 0, annual_target_spend: 24_000, starting_portfolio: 0,
retire_year: 2050, payouts: [], target_adjustments: []
}
Inputs.new(**defaults.merge(over))
end
def payout(**over)
Payout.new(**{ kind: "state", shape: "monthly_for_life", tax_treatment: "custom_post_tax", start_age: 65, monthly_amount: 0 }.merge(over))
end
test "accumulation is deterministic (zero return-free check)" do
result = Forecast.new(inputs(
current_age: 64, retire_age: 65, terminal_age: 66,
real_return: 0.10, annual_savings: 1000, starting_portfolio: 1000,
annual_target_spend: 0
)).call
assert_equal [ [ 64, 1000 ], [ 65, 2100 ], [ 66, 2310 ] ], result.glide
assert result.feasible
assert_equal 2310, result.terminal_value
end
test "drawdown depletion is exact and flags shortfall" do
result = Forecast.new(inputs(
current_age: 65, retire_age: 65, terminal_age: 67,
real_return: 0.0, starting_portfolio: 100, annual_target_spend: 60
)).call
assert_equal [ [ 65, 100 ], [ 66, 40 ], [ 67, 0 ] ], result.glide
assert_not result.feasible
assert_equal 66, result.money_lasts_to_age
assert_equal 0, result.terminal_value
last = result.income_by_year.last
assert_equal 40, last[:drawdown]
assert_equal 20, last[:shortfall]
assert_includes result.warnings, "depletes_before_terminal"
end
test "a pension that fully covers spend means no drawdown" do
result = Forecast.new(inputs(
retire_age: 65, starting_portfolio: 0, annual_target_spend: 24_000,
payouts: [ payout(kind: "state", start_age: 65, monthly_amount: 2000) ]
)).call
assert result.feasible
assert result.lasts_past_terminal?
first_draw = result.income_by_year.first
assert_equal 24_000, first_draw[:state]
assert_equal 0, first_draw[:drawdown]
end
test "tax reduces net pension income and widens the drawdown" do
result = Forecast.new(inputs(
retire_age: 65, starting_portfolio: 5_000_000, annual_target_spend: 24_000,
payouts: [ payout(kind: "workplace", tax_treatment: "de_bav", start_age: 65, monthly_amount: 2000) ]
)).call
row = result.income_by_year.first
assert_equal 17_760, row[:workplace] # 24,000 gross * 0.74
assert_equal 6_240, row[:drawdown] # 24,000 - 17,760
end
test "a negative adjustment lowers the target spend from its age" do
result = Forecast.new(inputs(
retire_age: 65, starting_portfolio: 5_000_000, annual_target_spend: 24_000,
target_adjustments: [ Adjustment.new(from_age: 65, to_age: nil, annual_amount: -12_000) ]
)).call
assert_equal 12_000, result.income_by_year.first[:drawdown]
end
test "coast_age is current_age when already over-funded" do
result = Forecast.new(inputs(
starting_portfolio: 10_000_000, annual_savings: 0, annual_target_spend: 24_000
)).call
assert_equal 40, result.coast_age
assert result.feasible
end
test "infeasible plan has no coast age and warns" do
result = Forecast.new(inputs(
starting_portfolio: 0, annual_savings: 0, annual_target_spend: 100_000
)).call
assert_nil result.coast_age
assert_not result.feasible
assert_includes result.warnings, "infeasible_no_coast"
end
test "glide spans current_age to terminal_age inclusive" do
result = Forecast.new(inputs(current_age: 30, terminal_age: 95)).call
assert_equal 30, result.glide.first.first
assert_equal 95, result.glide.last.first
assert_equal 66, result.glide.length
end
end