Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 131 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

Compute sustainable retirement withdrawals using historical backtesting against US market data (stocks, bonds, inflation, plus several alternatives).

Four CLI commands answer four different questions:
Six CLI commands answer six different questions:

- **`constant-dollar`**: "If I withdraw X% per year (inflation-adjusted), how often does that survive my horizon?" (The 4% rule.)
- **`constant-percent`**: "If I withdraw X% of my current balance each year, how often does that survive?"
- **`dynamic-dollar`**: "Given my current balance and remaining years, how much can I sustainably spend this year?"
- **`dynamic-success`**: "Given my balance and a planned budget, how likely is that budget to survive my remaining years?"
- **`coast`**: "Given a retirement target and a horizon, what lump sum today would coast there with no further contributions?"
- **`accumulate`**: "Given a balance and monthly contributions, how long until I reach my target?"

## Quick start

Expand Down Expand Up @@ -298,15 +300,140 @@ drawdown dynamic-success --balance 850000 \
| `-f` | `--fees <fraction>` | `0.0005` | TER as a fraction (e.g. `0.0005` = 0.05%, typical broad-market index ETF) |
| `-st` | `--solver-tolerance <dollars>` | `1` | Comparison binary-search stopping tolerance. Smaller = more iterations, more precision. |

---

### `coast`: lump sum needed to coast to a retirement target

Given a retirement target and a horizon, find the lump sum you need today so that the portfolio grows to the target with no further contributions. Uses historical backtesting with real-dollar (inflation-adjusted) accounting throughout. Solves via binary search.

#### Examples

"I want $1M in 20 years — how much do I need now?" (short flags):

```
drawdown coast -tg 1000000 -y 20 -p "us_stocks:80;us_bonds:20;"
```

Derive years from current age and retirement age:

```
drawdown coast --target 2500000 \
--current-age 30 --retirement-age 50 \
--portfolio "us_stocks:80;us_bonds:20;" \
--target-success 90
```

Evaluate the probability at a specific balance rather than solving:

```
drawdown coast --target 1000000 --years 20 \
--portfolio "us_stocks:80;us_bonds:20;" \
--balance 400000
```

#### Flags

**Required:**

| Short | Long | Meaning |
|---|---|---|
| `-tg` | `--target <dollars>` | Retirement target in today's dollars |
| `-p` | `--portfolio <spec>` | Portfolio spec, e.g. `"us_stocks:80;us_bonds:20;"` |

**Common:**

| Short | Long | Default | Meaning |
|---|---|---|---|
| `-y` | `--years <int>` | `0` | Horizon in years (alternative to `--current-age` + `--retirement-age`) |
| `-a` | `--current-age <float>` | `0` | Current age (used with `--retirement-age` to derive years) |
| `-ra` | `--retirement-age <int>` | `0` | Retirement age (used with `--current-age` to derive years) |
| `-b` | `--balance <dollars>` | `0` | If set, skip solving and report probability at this balance |
| `-i` | `--inflation <series>` | `us_inflation` | Name of an inflation series (embedded or user-supplied) |
| `-r` | `--rebalance <method>` | `yearly` | `none` \| `monthly` \| `yearly` |
| `-t` | `--target-success <pct>` | `80` | Target success rate. The solver finds the lowest balance that meets this. |
| `-j` | `--json` | — | Emit JSON instead of text |
| `-c` | `--csv` | — | Emit CSV per-path output instead of text |

**Advanced:**

| Short | Long | Default | Meaning |
|---|---|---|---|
| `-sy` | `--start-year <year>` | `0` | Earliest historical backtest start year (`0` = use full data) |
| `-ey` | `--end-year <year>` | `0` | Latest historical backtest start year (`0` = use full data) |
| `-smb` | `--search-max-balance <dollars>` | `0` | Upper bound for binary search (`0` = `2 × target`) |
| `-bt` | `--balance-tolerance <dollars>` | `100` | Binary-search stopping tolerance |

---

### `accumulate`: years to reach a target with contributions

Given a current balance and a fixed monthly contribution (constant in real, inflation-adjusted terms), find how many years it takes to reach the target with at least the specified success probability. Scans year-by-year.

#### Examples

"I have $100K and add $3K/month — how long to $1M?" (short flags):

```
drawdown accumulate -b 100000 -mc 3000 -tg 1000000 -p "us_stocks:80;us_bonds:20;"
```

With current age to project the year you'll reach the target:

```
drawdown accumulate --balance 500000 --contribution 5000 \
--target 2500000 \
--portfolio "us_stocks:80;us_bonds:20;" \
--current-age 30
```

Using a 90% success target:

```
drawdown accumulate --balance 100000 --contribution 3000 \
--target 1000000 \
--portfolio "us_stocks:80;us_bonds:20;" \
--target-success 90
```

#### Flags

**Required:**

| Short | Long | Meaning |
|---|---|---|
| `-b` | `--balance <dollars>` | Current portfolio balance (today's dollars) |
| `-mc` | `--contribution <dollars>` | Monthly contribution (today's dollars, constant in real terms) |
| `-tg` | `--target <dollars>` | Target balance (today's dollars) |
| `-p` | `--portfolio <spec>` | Portfolio spec, e.g. `"us_stocks:80;us_bonds:20;"` |

**Common:**

| Short | Long | Default | Meaning |
|---|---|---|---|
| `-i` | `--inflation <series>` | `us_inflation` | Name of an inflation series (embedded or user-supplied) |
| `-r` | `--rebalance <method>` | `yearly` | `none` \| `monthly` \| `yearly` |
| `-t` | `--target-success <pct>` | `80` | Target success rate. The solver returns the first year that hits this. |
| `-a` | `--current-age <float>` | `0` | Current age. If set, output includes the projected age you reach the target. |
| `-j` | `--json` | — | Emit JSON instead of text |
| `-c` | `--csv` | — | Emit CSV per-path output instead of text |

**Advanced:**

| Short | Long | Default | Meaning |
|---|---|---|---|
| `-sy` | `--start-year <year>` | `0` | Earliest historical backtest start year (`0` = use full data) |
| `-ey` | `--end-year <year>` | `0` | Latest historical backtest start year (`0` = use full data) |
| `-my` | `--max-years <int>` | `60` | Maximum years to scan before giving up |

## Output formats

All four commands support three output modes. `--json` and `--csv` are mutually exclusive.
All six commands support three output modes. `--json` and `--csv` are mutually exclusive.

| Mode | Flag | Format |
|---|---|---|
| text | *(default)* | Human-readable `Inputs` / `Results` / `Notes` sections |
| JSON | `--json` | Flat JSON object: `{ "command": ..., "inputs": ..., "results": ..., "notes": [...] }`. Snake-case keys, dollars as floats (no `$` prefix), percentages as floats (e.g. `80.4`, not `"80.4%"`) |
| CSV | `--csv` | Per-historical-start-year tabular detail with columns `start_year,start_month,success,terminal_value,total_withdrawn,worst_duration_months`. Preceded by `#`-prefixed comment lines describing the scenario |
| CSV | `--csv` | Per-historical-start-year tabular detail, preceded by `#`-prefixed comment lines describing the scenario. Withdrawal commands emit columns `start_year,start_month,success,terminal_value,total_withdrawn,worst_duration_months`; `coast` and `accumulate` emit `start_year,reached_target,terminal_value` |

## Data sources

Expand Down Expand Up @@ -358,7 +485,7 @@ A tagged release (`v*` tag pushed to GitHub) builds portable binaries for all th

## Background

This tool was forked from [Baptiste Wicht's swr-calculator](https://github.com/wichtounet/swr-calculator) and reshaped around three point-query commands suitable for ad-hoc retirement planning rather than analytical sweep tables.
This tool was forked from [Baptiste Wicht's swr-calculator](https://github.com/wichtounet/swr-calculator) and reshaped around point-query commands suitable for ad-hoc retirement planning rather than analytical sweep tables.

The methodology and historical-backtesting approach trace back to the *Trinity Study* (Cooley, Hubbard, Walz 1998) and its many updates. See [thepoorswiss.com/updated-trinity-study](https://thepoorswiss.com/updated-trinity-study/) for an accessible overview.

Expand Down
2 changes: 2 additions & 0 deletions include/commands.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,7 @@ int dynamic_dollar(const std::vector<std::string>& args);
int dynamic_success(const std::vector<std::string>& args);
int constant_dollar(const std::vector<std::string>& args);
int constant_percent(const std::vector<std::string>& args);
int coast(const std::vector<std::string>& args);
int accumulate(const std::vector<std::string>& args);

} // namespace swr::cmd
86 changes: 86 additions & 0 deletions include/growth.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//=======================================================================
// Copyright Rick Gray 2026.
// Distributed under the MIT License.
// (See accompanying file LICENSE or copy at
// http://opensource.org/licenses/MIT)
//
// Historical-backtest accumulation engine (real-dollar accounting).
//=======================================================================
#pragma once

#include <cstddef>
#include <string>
#include <vector>

#include "portfolio.hpp"
#include "simulation.hpp" // for Rebalancing enum

namespace swr {

struct growth_input {
float initial_balance = 0.0f;
float monthly_contribution = 0.0f;
float target = 0.0f;
size_t years = 0;
std::vector<allocation> portfolio;
std::string inflation_series = "us_inflation";
Rebalancing rebalance = Rebalancing::YEARLY;
size_t historical_start_year = 0;
size_t historical_end_year = 0;
};

struct growth_per_path {
size_t start_year;
bool reached_target;
float terminal_value;
};

struct growth_result {
float probability_of_reaching_target = 0.0f;
size_t paths_evaluated = 0;
size_t paths_successful = 0;
float median_terminal_value = 0.0f;
float min_terminal_value = 0.0f;
float max_terminal_value = 0.0f;
std::vector<growth_per_path> per_path_details;
bool error = false;
std::string message;
};

growth_result evaluate_growth(const growth_input& input, bool collect_per_path = false);

struct coast_input {
growth_input base; // monthly_contribution forced to 0 internally
float target_success = 80.0f;
float search_min_balance = 0.0f;
float search_max_balance = 0.0f;
float balance_tolerance = 100.0f;
};

struct coast_result {
float required_balance = 0.0f;
float probability_at_result = 0.0f;
growth_result detail;
bool error = false;
std::string message;
};

coast_result solve_coast(const coast_input& input);

struct accumulate_input {
growth_input base; // base.years is ignored; we scan
float target_success = 80.0f;
size_t max_years_to_scan = 60;
};

struct accumulate_result {
size_t required_years = 0;
float probability_at_result = 0.0f;
growth_result detail;
bool error = false;
std::string message;
};

accumulate_result solve_accumulate(const accumulate_input& input);

} // namespace swr
8 changes: 8 additions & 0 deletions scripts/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,12 @@ run_one constant-percent \
constant-percent --percent 4.0 --portfolio "us_stocks:60;us_bonds:40;" \
--years 30 --rebalance yearly

run_one coast \
coast --target 1000000 --years 20 \
--portfolio "us_stocks:80;us_bonds:20;"

run_one accumulate \
accumulate --balance 100000 --contribution 3000 \
--target 1000000 --portfolio "us_stocks:80;us_bonds:20;"

echo "Smoke: all checks passed"
17 changes: 17 additions & 0 deletions scripts/smoke_expected/accumulate.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
accumulate

Inputs
Current balance: $100,000
Monthly contribution: $3,000
Target: $1,000,000
Portfolio: us_stocks:80;us_bonds:20;
Inflation series: us_inflation
Rebalance: yearly
Target success: 80.0%

Results
Years to target: 17 years
Probability at result: 82.7%
Median terminal: $1,333,480
Min terminal: $536,090
Max terminal: $3,058,370
16 changes: 16 additions & 0 deletions scripts/smoke_expected/coast.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
coast

Inputs
Target: $1,000,000
Years: 20 years
Portfolio: us_stocks:80;us_bonds:20;
Inflation series: us_inflation
Rebalance: yearly
Target success: 80.0%

Results
Required balance: $510,071
Probability at result: 80.1%
Median terminal: $1,671,050
Min terminal: $497,206
Max terminal: $4,902,744
Loading
Loading