From e69df315962e22ceb3e7d71474aef2305fb5173d Mon Sep 17 00:00:00 2001 From: Rick Gray Date: Fri, 22 May 2026 12:08:26 -0500 Subject: [PATCH 1/2] create coast and accum --- README.md | 132 ++++++++++- include/commands.hpp | 2 + include/growth.hpp | 86 +++++++ scripts/smoke.sh | 8 + scripts/smoke_expected/accumulate.txt | 17 ++ scripts/smoke_expected/coast.txt | 16 ++ src/cmd_accumulate.cpp | 243 ++++++++++++++++++++ src/cmd_coast.cpp | 319 ++++++++++++++++++++++++++ src/growth.cpp | 310 +++++++++++++++++++++++++ src/main.cpp | 4 + tests/test_dynamic.cpp | 57 +++++ 11 files changed, 1192 insertions(+), 2 deletions(-) create mode 100644 include/growth.hpp create mode 100644 scripts/smoke_expected/accumulate.txt create mode 100644 scripts/smoke_expected/coast.txt create mode 100644 src/cmd_accumulate.cpp create mode 100644 src/cmd_coast.cpp create mode 100644 src/growth.cpp diff --git a/README.md b/README.md index ec042c7..a251745 100644 --- a/README.md +++ b/README.md @@ -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 @@ -298,9 +300,135 @@ drawdown dynamic-success --balance 850000 \ | `-f` | `--fees ` | `0.0005` | TER as a fraction (e.g. `0.0005` = 0.05%, typical broad-market index ETF) | | `-st` | `--solver-tolerance ` | `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 --target 1000000 --years 20 --portfolio "us_stocks:80;us_bonds:20;" +``` + +Derive years from current age and retirement age: + +``` +drawdown coast --target 2500000 \ + --current-age 33 --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 ` | Retirement target in today's dollars | +| `-p` | `--portfolio ` | Portfolio spec, e.g. `"us_stocks:80;us_bonds:20;"` | + +**Common:** + +| Short | Long | Default | Meaning | +|---|---|---|---| +| `-y` | `--years ` | `0` | Horizon in years (alternative to `--current-age` + `--retirement-age`) | +| `-a` | `--current-age ` | `0` | Current age (used with `--retirement-age` to derive years) | +| `-ra` | `--retirement-age ` | `0` | Retirement age (used with `--current-age` to derive years) | +| `-b` | `--balance ` | `0` | If set, skip solving and report probability at this balance | +| `-i` | `--inflation ` | `us_inflation` | Inflation series | +| `-r` | `--rebalance ` | `yearly` | `none` \| `monthly` \| `yearly` | +| `-t` | `--target-success ` | `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 ` | `0` | Earliest historical backtest start year | +| `-ey` | `--end-year ` | `0` | Latest historical backtest start year | +| `-smb` | `--search-max-balance ` | `0` | Upper bound for binary search (`0` = `2 × target`) | +| `-bt` | `--balance-tolerance ` | `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 --balance 100000 --contribution 3000 \ + --target 1000000 --portfolio "us_stocks:80;us_bonds:20;" +``` + +With current age to project the year you'll reach the target: + +``` +drawdown accumulate --balance 519000 --contribution 5083 \ + --target 2500000 \ + --portfolio "us_stocks:80;us_bonds:20;" \ + --current-age 33 +``` + +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 ` | Current portfolio balance (today's dollars) | +| `-mc` | `--contribution ` | Monthly contribution (today's dollars, constant in real terms) | +| `-tg` | `--target ` | Target balance (today's dollars) | +| `-p` | `--portfolio ` | Portfolio spec, e.g. `"us_stocks:80;us_bonds:20;"` | + +**Common:** + +| Short | Long | Default | Meaning | +|---|---|---|---| +| `-i` | `--inflation ` | `us_inflation` | Inflation series | +| `-r` | `--rebalance ` | `yearly` | `none` \| `monthly` \| `yearly` | +| `-t` | `--target-success ` | `80` | Target success rate. The solver returns the first year that hits this. | +| `-a` | `--current-age ` | `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 ` | `0` | Earliest historical backtest start year | +| `-ey` | `--end-year ` | `0` | Latest historical backtest start year | +| `-my` | `--max-years ` | `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 | |---|---|---| diff --git a/include/commands.hpp b/include/commands.hpp index 2524125..28cd888 100644 --- a/include/commands.hpp +++ b/include/commands.hpp @@ -18,5 +18,7 @@ int dynamic_dollar(const std::vector& args); int dynamic_success(const std::vector& args); int constant_dollar(const std::vector& args); int constant_percent(const std::vector& args); +int coast(const std::vector& args); +int accumulate(const std::vector& args); } // namespace swr::cmd diff --git a/include/growth.hpp b/include/growth.hpp new file mode 100644 index 0000000..987b0d9 --- /dev/null +++ b/include/growth.hpp @@ -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 +#include +#include + +#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 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 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 diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 290413d..f447026 100644 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -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" diff --git a/scripts/smoke_expected/accumulate.txt b/scripts/smoke_expected/accumulate.txt new file mode 100644 index 0000000..7952872 --- /dev/null +++ b/scripts/smoke_expected/accumulate.txt @@ -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 diff --git a/scripts/smoke_expected/coast.txt b/scripts/smoke_expected/coast.txt new file mode 100644 index 0000000..d45f571 --- /dev/null +++ b/scripts/smoke_expected/coast.txt @@ -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 diff --git a/src/cmd_accumulate.cpp b/src/cmd_accumulate.cpp new file mode 100644 index 0000000..33845c2 --- /dev/null +++ b/src/cmd_accumulate.cpp @@ -0,0 +1,243 @@ +//======================================================================= +// Copyright Rick Gray 2026. +// Distributed under the MIT License. +// (See accompanying file LICENSE or copy at +// http://opensource.org/licenses/MIT) +// +// `accumulate` command: given a balance and monthly contributions, how +// long until the portfolio reaches the target? +//======================================================================= +#include "commands.hpp" + +#include +#include +#include +#include + +#include "cli.hpp" +#include "growth.hpp" +#include "output_formatter.hpp" +#include "portfolio.hpp" + +namespace swr::cmd { + +namespace { + +swr::cli::command_schema accumulate_schema() { + using namespace swr::cli; + command_schema s; + s.command_name = "accumulate"; + s.one_line_description = + "Given a balance and monthly contributions, how long until I reach\n" + " my target? Uses historical backtesting with real-dollar accounting."; + s.example_invocation = + "drawdown accumulate --balance 100000 --contribution 3000 \\\n" + " --target 1000000 --portfolio \"us_stocks:80;us_bonds:20;\"\n" + " drawdown accumulate -b 519000 -mc 5083 -tg 2500000 " + "-p \"us_stocks:80;us_bonds:20;\" -a 33"; + + s.flags = { + {"balance", "b", FlagGroup::REQUIRED, FlagKind::VALUE, + "Current portfolio balance (today's dollars)", ""}, + {"contribution", "mc", FlagGroup::REQUIRED, FlagKind::VALUE, + "Monthly contribution (today's dollars, constant in real terms)", ""}, + {"target", "tg", FlagGroup::REQUIRED, FlagKind::VALUE, + "Target balance (today's dollars)", ""}, + {"portfolio", "p", FlagGroup::REQUIRED, FlagKind::VALUE, + "Portfolio spec, e.g. \"us_stocks:80;us_bonds:20;\"", ""}, + + {"inflation", "i", FlagGroup::COMMON, FlagKind::VALUE, + "Inflation series, e.g. \"us_inflation\"", "us_inflation"}, + {"rebalance", "r", FlagGroup::COMMON, FlagKind::VALUE, + "none | monthly | yearly", "yearly"}, + {"target-success", "t", FlagGroup::COMMON, FlagKind::VALUE, + "Target success rate (percent)", "80"}, + {"current-age", "a", FlagGroup::COMMON, FlagKind::VALUE, + "Current age (used to project the reached age in output)", "0"}, + {"json", "j", FlagGroup::COMMON, FlagKind::PRESENCE, + "Emit JSON output", ""}, + {"csv", "c", FlagGroup::COMMON, FlagKind::PRESENCE, + "Emit CSV per-path output", ""}, + + {"start-year", "sy", FlagGroup::ADVANCED, FlagKind::VALUE, + "Earliest historical backtest start year (0 = full data)", "0"}, + {"end-year", "ey", FlagGroup::ADVANCED, FlagKind::VALUE, + "Latest historical backtest start year (0 = full data)", "0"}, + {"max-years", "my", FlagGroup::ADVANCED, FlagKind::VALUE, + "Maximum years to scan before giving up", "60"}, + }; + s.mutually_exclusive.push_back({"json", "csv"}); + return s; +} + +} // namespace + +int accumulate(const std::vector& args) { + auto schema = accumulate_schema(); + swr::output::Mode mode = swr::output::Mode::TEXT; + + swr::cli::parsed_args p; + try { + p = swr::cli::parse_flags(args, schema); + } catch (const std::exception& e) { + for (auto& a : args) { + if (a == "--json" || a == "-j") mode = swr::output::Mode::JSON; + else if (a == "--csv" || a == "-c") mode = swr::output::Mode::CSV; + } + swr::output::emit_error(std::cerr, "accumulate", e.what(), mode); + return 1; + } + + if (p.help_requested) { + swr::cli::render_help(std::cout, schema); + return 0; + } + + if (swr::cli::get_presence(p, "json")) mode = swr::output::Mode::JSON; + if (swr::cli::get_presence(p, "csv")) mode = swr::output::Mode::CSV; + + float balance = 0.0f; + float contribution = 0.0f; + float target = 0.0f; + float target_success = 80.0f; + float current_age = 0.0f; + std::string inflation = "us_inflation"; + std::string rebalance_str = "yearly"; + size_t start_year = 0; + size_t end_year = 0; + size_t max_years = 60; + std::vector portfolio; + + try { + balance = std::stof(swr::cli::get_value(p, schema, "balance")); + contribution = std::stof(swr::cli::get_value(p, schema, "contribution")); + target = std::stof(swr::cli::get_value(p, schema, "target")); + target_success = std::stof(swr::cli::get_value(p, schema, "target-success")); + current_age = std::stof(swr::cli::get_value(p, schema, "current-age")); + inflation = swr::cli::get_value(p, schema, "inflation"); + rebalance_str = swr::cli::get_value(p, schema, "rebalance"); + start_year = static_cast( + std::stoul(swr::cli::get_value(p, schema, "start-year"))); + end_year = static_cast( + std::stoul(swr::cli::get_value(p, schema, "end-year"))); + max_years = static_cast( + std::stoul(swr::cli::get_value(p, schema, "max-years"))); + + portfolio = swr::parse_portfolio(swr::cli::get_value(p, schema, "portfolio"), false); + swr::normalize_portfolio(portfolio); + } catch (const std::exception& e) { + swr::output::emit_error(std::cerr, "accumulate", + std::string("flag parse error: ") + e.what(), mode); + return 1; + } + + if (balance < 0.0f) { + swr::output::emit_error(std::cerr, "accumulate", + "--balance must be >= 0", mode); + return 1; + } + if (contribution <= 0.0f) { + swr::output::emit_error(std::cerr, "accumulate", + "--contribution must be positive", mode); + return 1; + } + if (target <= 0.0f) { + swr::output::emit_error(std::cerr, "accumulate", + "--target must be positive", mode); + return 1; + } + + // Build accumulate_input + swr::accumulate_input ain; + ain.base.initial_balance = balance; + ain.base.monthly_contribution = contribution; + ain.base.target = target; + ain.base.portfolio = portfolio; + ain.base.inflation_series = inflation; + ain.base.rebalance = swr::parse_rebalance(rebalance_str); + ain.base.historical_start_year = start_year; + ain.base.historical_end_year = end_year; + ain.target_success = target_success; + ain.max_years_to_scan = max_years; + + auto ar = swr::solve_accumulate(ain); + if (ar.error) { + swr::output::emit_error(std::cerr, "accumulate", ar.message, mode); + return 1; + } + + // Portfolio string for display + std::string portfolio_str; + for (auto& alloc : portfolio) + portfolio_str += alloc.asset + ":" + std::to_string((int)alloc.allocation) + ";"; + + // Rebalance string for display + std::stringstream reb_ss; + reb_ss << ain.base.rebalance; + + swr::output::report rep; + rep.command_name = "accumulate"; + + { + swr::output::section sec; + sec.name = "inputs"; + sec.title = "Inputs"; + sec.fields.push_back({"Current balance", (double)balance, + swr::output::field::Hint::DOLLARS}); + sec.fields.push_back({"Monthly contribution", (double)contribution, + swr::output::field::Hint::DOLLARS}); + sec.fields.push_back({"Target", (double)target, + swr::output::field::Hint::DOLLARS}); + sec.fields.push_back({"Portfolio", portfolio_str, + swr::output::field::Hint::NONE}); + sec.fields.push_back({"Inflation series", inflation, + swr::output::field::Hint::NONE}); + sec.fields.push_back({"Rebalance", reb_ss.str(), + swr::output::field::Hint::NONE}); + sec.fields.push_back({"Target success", (double)target_success, + swr::output::field::Hint::PERCENT}); + rep.sections.push_back(sec); + } + { + swr::output::section sec; + sec.name = "results"; + sec.title = "Results"; + sec.fields.push_back({"Years to target", (int64_t)ar.required_years, + swr::output::field::Hint::YEARS}); + sec.fields.push_back({"Probability at result", (double)ar.probability_at_result, + swr::output::field::Hint::PERCENT}); + if (current_age > 0.0f) { + size_t reached_age = static_cast( + std::ceil(current_age) + ar.required_years); + sec.fields.push_back({"Projected reached age", (int64_t)reached_age, + swr::output::field::Hint::INTEGER}); + } + sec.fields.push_back({"Median terminal", (double)ar.detail.median_terminal_value, + swr::output::field::Hint::DOLLARS}); + sec.fields.push_back({"Min terminal", (double)ar.detail.min_terminal_value, + swr::output::field::Hint::DOLLARS}); + sec.fields.push_back({"Max terminal", (double)ar.detail.max_terminal_value, + swr::output::field::Hint::DOLLARS}); + rep.sections.push_back(sec); + } + + if (mode == swr::output::Mode::CSV) { + rep.csv_data.column_headers = {"start_year", "reached_target", "terminal_value"}; + std::stringstream pre; + pre << "command: accumulate, years: " << ar.required_years + << ", target: " << (int)target; + rep.csv_data.preamble_comments.push_back(pre.str()); + for (auto& d : ar.detail.per_path_details) { + swr::output::csv_row row; + row.cells.push_back((int64_t)d.start_year); + row.cells.push_back(d.reached_target); + row.cells.push_back((double)d.terminal_value); + rep.csv_data.rows.push_back(row); + } + } + + swr::output::emit(std::cout, rep, mode); + return 0; +} + +} // namespace swr::cmd diff --git a/src/cmd_coast.cpp b/src/cmd_coast.cpp new file mode 100644 index 0000000..b2eb35f --- /dev/null +++ b/src/cmd_coast.cpp @@ -0,0 +1,319 @@ +//======================================================================= +// Copyright Rick Gray 2026. +// Distributed under the MIT License. +// (See accompanying file LICENSE or copy at +// http://opensource.org/licenses/MIT) +// +// `coast` command: find the lump-sum balance that coasts to a retirement +// target with no further contributions. +//======================================================================= +#include "commands.hpp" + +#include +#include +#include +#include + +#include "cli.hpp" +#include "growth.hpp" +#include "output_formatter.hpp" +#include "portfolio.hpp" + +namespace swr::cmd { + +namespace { + +swr::cli::command_schema coast_schema() { + using namespace swr::cli; + command_schema s; + s.command_name = "coast"; + s.one_line_description = + "Given a retirement target and a horizon, what lump sum today would\n" + " coast there with no further contributions? Uses historical backtesting\n" + " with real-dollar (inflation-adjusted) accounting."; + s.example_invocation = + "drawdown coast --target 1000000 --years 20 " + "--portfolio \"us_stocks:80;us_bonds:20;\"\n" + " drawdown coast --target 2500000 --current-age 33 --retirement-age 50 \\\n" + " --portfolio \"us_stocks:80;us_bonds:20;\" --target-success 90"; + + s.flags = { + {"target", "tg", FlagGroup::REQUIRED, FlagKind::VALUE, "Retirement target in today's dollars", ""}, + {"portfolio", "p", FlagGroup::REQUIRED, FlagKind::VALUE, "Portfolio spec, e.g. \"us_stocks:80;us_bonds:20;\"", ""}, + + {"years", "y", FlagGroup::COMMON, FlagKind::VALUE, "Horizon in years (alternative to --current-age + --retirement-age)", "0"}, + {"current-age", "a", FlagGroup::COMMON, FlagKind::VALUE, "Current age (used with --retirement-age to derive years)", "0"}, + {"retirement-age", "ra", FlagGroup::COMMON, FlagKind::VALUE, "Retirement age (used with --current-age to derive years)", "0"}, + {"balance", "b", FlagGroup::COMMON, FlagKind::VALUE, "If set, skip solving and report probability at this balance", "0"}, + {"csv", "c", FlagGroup::COMMON, FlagKind::PRESENCE, "Emit CSV per-path output", ""}, + {"inflation", "i", FlagGroup::COMMON, FlagKind::VALUE, "Inflation series, e.g. \"us_inflation\"", "us_inflation"}, + {"rebalance", "r", FlagGroup::COMMON, FlagKind::VALUE, "none | monthly | yearly", "yearly"}, + {"target-success", "t", FlagGroup::COMMON, FlagKind::VALUE, "Target success rate (percent)", "80"}, + {"json", "j", FlagGroup::COMMON, FlagKind::PRESENCE, "Emit JSON output", ""}, + + {"start-year", "sy", FlagGroup::ADVANCED, FlagKind::VALUE, "Earliest historical backtest start year (0 = full data)", "0"}, + {"end-year", "ey", FlagGroup::ADVANCED, FlagKind::VALUE, "Latest historical backtest start year (0 = full data)", "0"}, + {"search-max-balance", "smb", FlagGroup::ADVANCED, FlagKind::VALUE, "Upper bound for binary search (0 = 2*target)", "0"}, + {"balance-tolerance", "bt", FlagGroup::ADVANCED, FlagKind::VALUE, "Binary-search stopping tolerance (dollars)", "100"}, + }; + s.mutually_exclusive.push_back({"json", "csv"}); + return s; +} + +} // namespace + +int coast(const std::vector& args) { + auto schema = coast_schema(); + swr::output::Mode mode = swr::output::Mode::TEXT; + + swr::cli::parsed_args p; + try { + p = swr::cli::parse_flags(args, schema); + } catch (const std::exception& e) { + for (auto& a : args) { + if (a == "--json" || a == "-j") mode = swr::output::Mode::JSON; + else if (a == "--csv" || a == "-c") mode = swr::output::Mode::CSV; + } + swr::output::emit_error(std::cerr, "coast", e.what(), mode); + return 1; + } + + if (p.help_requested) { + swr::cli::render_help(std::cout, schema); + return 0; + } + + if (swr::cli::get_presence(p, "json")) mode = swr::output::Mode::JSON; + if (swr::cli::get_presence(p, "csv")) mode = swr::output::Mode::CSV; + + // Parse flags + float target = 0.0f; + size_t years = 0; + float current_age = 0.0f; + size_t retirement_age = 0; + float balance_probe = 0.0f; + float target_success = 80.0f; + std::string inflation = "us_inflation"; + std::string rebalance_str = "yearly"; + size_t start_year = 0; + size_t end_year = 0; + float search_max = 0.0f; + float bal_tolerance = 100.0f; + std::vector portfolio; + + try { + target = std::stof(swr::cli::get_value(p, schema, "target")); + years = static_cast(std::stoul(swr::cli::get_value(p, schema, "years"))); + current_age = std::stof(swr::cli::get_value(p, schema, "current-age")); + retirement_age = static_cast( + std::stoul(swr::cli::get_value(p, schema, "retirement-age"))); + balance_probe = std::stof(swr::cli::get_value(p, schema, "balance")); + target_success = std::stof(swr::cli::get_value(p, schema, "target-success")); + inflation = swr::cli::get_value(p, schema, "inflation"); + rebalance_str = swr::cli::get_value(p, schema, "rebalance"); + start_year = static_cast( + std::stoul(swr::cli::get_value(p, schema, "start-year"))); + end_year = static_cast( + std::stoul(swr::cli::get_value(p, schema, "end-year"))); + search_max = std::stof(swr::cli::get_value(p, schema, "search-max-balance")); + bal_tolerance = std::stof(swr::cli::get_value(p, schema, "balance-tolerance")); + + portfolio = swr::parse_portfolio(swr::cli::get_value(p, schema, "portfolio"), false); + swr::normalize_portfolio(portfolio); + } catch (const std::exception& e) { + swr::output::emit_error(std::cerr, "coast", + std::string("flag parse error: ") + e.what(), mode); + return 1; + } + + // Derive years from age pair if needed + bool ages_provided = (current_age > 0.0f && retirement_age > 0); + if (ages_provided && years == 0) { + if (current_age >= static_cast(retirement_age)) { + swr::output::emit_error(std::cerr, "coast", + "current-age must be < retirement-age", mode); + return 1; + } + float diff = static_cast(retirement_age) - current_age; + years = static_cast(std::ceil(diff)); + } + + if (years == 0) { + swr::output::emit_error(std::cerr, "coast", + "must specify --years OR (--current-age + --retirement-age)", mode); + return 1; + } + if (target <= 0.0f) { + swr::output::emit_error(std::cerr, "coast", + "--target must be positive", mode); + return 1; + } + + // Build base growth_input + swr::growth_input base; + base.monthly_contribution = 0.0f; // coast = no contributions + base.target = target; + base.years = years; + base.portfolio = portfolio; + base.inflation_series = inflation; + base.rebalance = swr::parse_rebalance(rebalance_str); + base.historical_start_year = start_year; + base.historical_end_year = end_year; + + // Portfolio string for display + std::string portfolio_str; + for (auto& alloc : portfolio) + portfolio_str += alloc.asset + ":" + std::to_string((int)alloc.allocation) + ";"; + + // Rebalance string for display + std::stringstream reb_ss; + reb_ss << base.rebalance; + + swr::output::report rep; + rep.command_name = "coast"; + + if (balance_probe > 0.0f) { + // Evaluate-mode: user provided a specific balance + base.initial_balance = balance_probe; + bool collect_csv = (mode == swr::output::Mode::CSV); + auto gr = swr::evaluate_growth(base, collect_csv); + if (gr.error) { + swr::output::emit_error(std::cerr, "coast", gr.message, mode); + return 1; + } + + { + swr::output::section sec; + sec.name = "inputs"; + sec.title = "Inputs"; + sec.fields.push_back({"Balance", (double)balance_probe, + swr::output::field::Hint::DOLLARS}); + sec.fields.push_back({"Target", (double)target, + swr::output::field::Hint::DOLLARS}); + if (ages_provided) { + std::string age_str = std::to_string((int)current_age) + " -> " + + std::to_string((int)retirement_age) + " (" + + std::to_string(years) + " years)"; + sec.fields.push_back({"Years from age", age_str, + swr::output::field::Hint::NONE}); + } + sec.fields.push_back({"Years", (int64_t)years, + swr::output::field::Hint::YEARS}); + sec.fields.push_back({"Portfolio", portfolio_str, + swr::output::field::Hint::NONE}); + sec.fields.push_back({"Inflation series", inflation, + swr::output::field::Hint::NONE}); + sec.fields.push_back({"Rebalance", reb_ss.str(), + swr::output::field::Hint::NONE}); + sec.fields.push_back({"Target success", (double)target_success, + swr::output::field::Hint::PERCENT}); + rep.sections.push_back(sec); + } + { + swr::output::section sec; + sec.name = "results"; + sec.title = "Results"; + sec.fields.push_back({"Probability", (double)gr.probability_of_reaching_target, + swr::output::field::Hint::PERCENT}); + sec.fields.push_back({"Median terminal", (double)gr.median_terminal_value, + swr::output::field::Hint::DOLLARS}); + sec.fields.push_back({"Min terminal", (double)gr.min_terminal_value, + swr::output::field::Hint::DOLLARS}); + sec.fields.push_back({"Max terminal", (double)gr.max_terminal_value, + swr::output::field::Hint::DOLLARS}); + rep.sections.push_back(sec); + } + + if (mode == swr::output::Mode::CSV) { + rep.csv_data.column_headers = {"start_year", "reached_target", "terminal_value"}; + std::stringstream pre; + pre << "command: coast, balance: " << (int)balance_probe + << ", target: " << (int)target << ", years: " << years; + rep.csv_data.preamble_comments.push_back(pre.str()); + for (auto& d : gr.per_path_details) { + swr::output::csv_row row; + row.cells.push_back((int64_t)d.start_year); + row.cells.push_back(d.reached_target); + row.cells.push_back((double)d.terminal_value); + rep.csv_data.rows.push_back(row); + } + } + } else { + // Solve-mode: binary search for required balance + swr::coast_input cin; + cin.base = base; + cin.target_success = target_success; + cin.search_max_balance = search_max; + cin.balance_tolerance = bal_tolerance; + + bool collect_csv = (mode == swr::output::Mode::CSV); + // solve_coast always collects per-path at the result + auto cr = swr::solve_coast(cin); + if (cr.error) { + swr::output::emit_error(std::cerr, "coast", cr.message, mode); + return 1; + } + + { + swr::output::section sec; + sec.name = "inputs"; + sec.title = "Inputs"; + sec.fields.push_back({"Target", (double)target, + swr::output::field::Hint::DOLLARS}); + if (ages_provided) { + std::string age_str = std::to_string((int)current_age) + " -> " + + std::to_string((int)retirement_age) + " (" + + std::to_string(years) + " years)"; + sec.fields.push_back({"Years from age", age_str, + swr::output::field::Hint::NONE}); + } + sec.fields.push_back({"Years", (int64_t)years, + swr::output::field::Hint::YEARS}); + sec.fields.push_back({"Portfolio", portfolio_str, + swr::output::field::Hint::NONE}); + sec.fields.push_back({"Inflation series", inflation, + swr::output::field::Hint::NONE}); + sec.fields.push_back({"Rebalance", reb_ss.str(), + swr::output::field::Hint::NONE}); + sec.fields.push_back({"Target success", (double)target_success, + swr::output::field::Hint::PERCENT}); + rep.sections.push_back(sec); + } + { + swr::output::section sec; + sec.name = "results"; + sec.title = "Results"; + sec.fields.push_back({"Required balance", (double)cr.required_balance, + swr::output::field::Hint::DOLLARS}); + sec.fields.push_back({"Probability at result",(double)cr.probability_at_result, + swr::output::field::Hint::PERCENT}); + sec.fields.push_back({"Median terminal", (double)cr.detail.median_terminal_value, + swr::output::field::Hint::DOLLARS}); + sec.fields.push_back({"Min terminal", (double)cr.detail.min_terminal_value, + swr::output::field::Hint::DOLLARS}); + sec.fields.push_back({"Max terminal", (double)cr.detail.max_terminal_value, + swr::output::field::Hint::DOLLARS}); + rep.sections.push_back(sec); + } + + if (collect_csv) { + rep.csv_data.column_headers = {"start_year", "reached_target", "terminal_value"}; + std::stringstream pre; + pre << "command: coast, required_balance: " << (int)cr.required_balance + << ", target: " << (int)target << ", years: " << years; + rep.csv_data.preamble_comments.push_back(pre.str()); + for (auto& d : cr.detail.per_path_details) { + swr::output::csv_row row; + row.cells.push_back((int64_t)d.start_year); + row.cells.push_back(d.reached_target); + row.cells.push_back((double)d.terminal_value); + rep.csv_data.rows.push_back(row); + } + } + } + + swr::output::emit(std::cout, rep, mode); + return 0; +} + +} // namespace swr::cmd diff --git a/src/growth.cpp b/src/growth.cpp new file mode 100644 index 0000000..ff46bf3 --- /dev/null +++ b/src/growth.cpp @@ -0,0 +1,310 @@ +//======================================================================= +// 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). +//======================================================================= +#include "growth.hpp" + +#include +#include +#include +#include +#include + +#include "data.hpp" + +namespace swr { + +growth_result evaluate_growth(const growth_input& input, bool collect_per_path) { + growth_result r; + + // --- Validation + if (input.years == 0) { + r.error = true; + r.message = "years must be > 0"; + return r; + } + if (input.target <= 0.0f) { + r.error = true; + r.message = "target must be positive"; + return r; + } + if (input.portfolio.empty()) { + r.error = true; + r.message = "portfolio must not be empty"; + return r; + } + + // --- Load data + auto values = load_values(input.portfolio); + if (values.empty()) { + r.error = true; + r.message = "failed to load portfolio data"; + return r; + } + auto inflation_data = load_inflation(values, input.inflation_series); + if (inflation_data.empty()) { + r.error = true; + r.message = "failed to load inflation data"; + return r; + } + + // --- Historical window + size_t start_year = (input.historical_start_year > 0) + ? input.historical_start_year + : inflation_data.front().year; + size_t end_year = (input.historical_end_year > 0) + ? input.historical_end_year + : inflation_data.back().year; + + const size_t total_months = input.years * 12; + const size_t n_assets = values.size(); + + size_t successes = 0; + size_t attempts = 0; + std::vector terminal_values; + + for (size_t y = start_year; y <= end_year; ++y) { + // Check that this start year has a month=1 entry in all series + bool valid = is_start_valid(inflation_data, y, 1); + for (size_t i = 0; i < n_assets && valid; ++i) { + valid = is_start_valid(values[i], y, 1); + } + if (!valid) continue; + + // Locate start iterators + std::vector asset_its; + asset_its.reserve(n_assets); + for (size_t i = 0; i < n_assets; ++i) { + asset_its.push_back(get_start(values[i], y, 1)); + } + auto infl_it = get_start(inflation_data, y, 1); + + // Verify there are at least total_months entries from the start. + // We need total_months steps (each step: read current, advance). + { + bool enough = true; + for (size_t i = 0; i < n_assets && enough; ++i) { + auto it_check = asset_its[i]; + size_t count = 0; + while (it_check != values[i].end() && count < total_months) { + ++it_check; + ++count; + } + if (count < total_months) enough = false; + } + if (enough) { + auto infl_check = infl_it; + size_t count = 0; + while (infl_check != inflation_data.end() && count < total_months) { + ++infl_check; + ++count; + } + if (count < total_months) enough = false; + } + if (!enough) continue; + } + + // --- Run one path + // Per-asset balance in real (today's) dollars. + // allocation is stored as a percentage (e.g. 80.0 for 80%); convert + // to a fraction by dividing by 100. + std::vector bal(n_assets); + for (size_t i = 0; i < n_assets; ++i) { + bal[i] = input.initial_balance * (input.portfolio[i].allocation / 100.0f); + } + + // Iterators start at month=1 of the chosen year. + // Each step: read value at current position, then advance — same + // pattern as simulation.cpp. + std::vector cur_its = asset_its; + auto cur_infl = infl_it; + + for (size_t m = 0; m < total_months; ++m) { + float infl_ratio = cur_infl->value; // monthly inflation ratio + ++cur_infl; + + float total = 0.0f; + for (size_t i = 0; i < n_assets; ++i) { + float nom_ratio = cur_its[i]->value; // nominal monthly return ratio + ++cur_its[i]; + float real_ratio = nom_ratio / infl_ratio; + bal[i] *= real_ratio; + // Add monthly contribution split by target weights (fraction) + bal[i] += input.monthly_contribution * (input.portfolio[i].allocation / 100.0f); + total += bal[i]; + } + + // Rebalance if needed + bool do_rebalance = false; + if (input.rebalance == Rebalancing::MONTHLY) { + do_rebalance = true; + } else if (input.rebalance == Rebalancing::YEARLY) { + do_rebalance = ((m + 1) % 12 == 0); + } + if (do_rebalance && total > 0.0f) { + for (size_t i = 0; i < n_assets; ++i) { + bal[i] = total * (input.portfolio[i].allocation / 100.0f); + } + } + } + + float terminal = 0.0f; + for (size_t i = 0; i < n_assets; ++i) terminal += bal[i]; + + bool reached = (terminal >= input.target); + if (reached) ++successes; + + terminal_values.push_back(terminal); + + if (collect_per_path) { + growth_per_path gp; + gp.start_year = y; + gp.reached_target = reached; + gp.terminal_value = terminal; + r.per_path_details.push_back(gp); + } + + ++attempts; + } + + r.paths_evaluated = attempts; + r.paths_successful = successes; + + if (attempts == 0) { + r.error = true; + r.message = "no valid historical paths found"; + return r; + } + + r.probability_of_reaching_target = + static_cast(successes) / static_cast(attempts) * 100.0f; + + // Compute terminal value stats + std::sort(terminal_values.begin(), terminal_values.end()); + r.min_terminal_value = terminal_values.front(); + r.max_terminal_value = terminal_values.back(); + if (terminal_values.size() % 2 == 0) { + size_t mid = terminal_values.size() / 2; + r.median_terminal_value = (terminal_values[mid - 1] + terminal_values[mid]) / 2.0f; + } else { + r.median_terminal_value = terminal_values[terminal_values.size() / 2]; + } + + return r; +} + +coast_result solve_coast(const coast_input& input) { + coast_result r; + + // Force no contributions for coast + growth_input probe = input.base; + probe.monthly_contribution = 0.0f; + + // Auto-set search_max_balance if zero + float lo = input.search_min_balance; + float hi = (input.search_max_balance > 0.0f) + ? input.search_max_balance + : 2.0f * input.base.target; + + if (hi <= lo) { + r.error = true; + r.message = "search_max_balance must be > search_min_balance"; + return r; + } + + // Probe at lo — if it already meets target, return immediately + probe.initial_balance = lo; + auto r_lo = evaluate_growth(probe); + if (r_lo.error) { + r.error = true; + r.message = r_lo.message; + return r; + } + if (r_lo.probability_of_reaching_target >= input.target_success) { + r.required_balance = lo; + r.probability_at_result = r_lo.probability_of_reaching_target; + r.detail = r_lo; + return r; + } + + // Probe at hi — must meet target for binary search to be useful + probe.initial_balance = hi; + auto r_hi = evaluate_growth(probe); + if (r_hi.error) { + r.error = true; + r.message = r_hi.message; + return r; + } + if (r_hi.probability_of_reaching_target < input.target_success) { + r.error = true; + r.message = "even search_max_balance (" + std::to_string((int)hi) + + ") fails to meet target_success; raise search_max_balance"; + return r; + } + + // Binary search: lo is always below target, hi is always at/above target. + while ((hi - lo) > input.balance_tolerance) { + float mid = (lo + hi) / 2.0f; + probe.initial_balance = mid; + auto r_mid = evaluate_growth(probe); + if (r_mid.error) { + r.error = true; + r.message = r_mid.message; + return r; + } + if (r_mid.probability_of_reaching_target >= input.target_success) { + hi = mid; + } else { + lo = mid; + } + } + + // hi is the lowest balance that meets target + probe.initial_balance = hi; + auto r_final = evaluate_growth(probe, true); + if (r_final.error) { + r.error = true; + r.message = r_final.message; + return r; + } + + r.required_balance = hi; + r.probability_at_result = r_final.probability_of_reaching_target; + r.detail = r_final; + return r; +} + +accumulate_result solve_accumulate(const accumulate_input& input) { + accumulate_result r; + + growth_input probe = input.base; + + for (size_t y = 1; y <= input.max_years_to_scan; ++y) { + probe.years = y; + auto gr = evaluate_growth(probe); + if (gr.error) { + r.error = true; + r.message = gr.message; + return r; + } + if (gr.probability_of_reaching_target >= input.target_success) { + r.required_years = y; + r.probability_at_result = gr.probability_of_reaching_target; + // Re-evaluate with per-path detail + auto gr_detail = evaluate_growth(probe, true); + r.detail = gr_detail; + return r; + } + } + + r.error = true; + r.message = "target not reachable within " + + std::to_string(input.max_years_to_scan) + " years"; + return r; +} + +} // namespace swr diff --git a/src/main.cpp b/src/main.cpp index 645ecea..202faed 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -35,6 +35,8 @@ int main(int argc, const char* argv[]) { << " constant-percent Fixed % of current balance each year\n" << " dynamic-dollar Per-year sustainable withdrawal\n" << " dynamic-success Success probability for a given budget\n" + << " coast Lump sum needed to coast to a retirement target\n" + << " accumulate Years to reach a target with contributions\n" << "\nRun ' --help' for command-specific options.\n"; return 1; } @@ -46,6 +48,8 @@ int main(int argc, const char* argv[]) { else if (command == "dynamic-success") return swr::cmd::dynamic_success(sub_args); else if (command == "constant-dollar") return swr::cmd::constant_dollar(sub_args); else if (command == "constant-percent") return swr::cmd::constant_percent(sub_args); + else if (command == "coast") return swr::cmd::coast(sub_args); + else if (command == "accumulate") return swr::cmd::accumulate(sub_args); std::cout << "Unhandled command \"" << command << "\"\n"; return 1; diff --git a/tests/test_dynamic.cpp b/tests/test_dynamic.cpp index b5c7316..d245517 100644 --- a/tests/test_dynamic.cpp +++ b/tests/test_dynamic.cpp @@ -8,6 +8,7 @@ //======================================================================= #include "test_assert.hpp" #include "dynamic.hpp" +#include "growth.hpp" #include "cli.hpp" #include "output_formatter.hpp" #include @@ -685,6 +686,62 @@ TEST(evaluate_with_ssa_offset) { CHECK_NEAR(r.portfolio_withdrawal_this_year, 36000.0f, 0.01f); } +TEST(growth_evaluate_zero_balance_zero_contribution_misses) { + swr::growth_input in; + in.initial_balance = 0.0f; + in.monthly_contribution = 0.0f; + in.target = 1.0f; + in.years = 10; + in.portfolio = swr::parse_portfolio("us_stocks:60;us_bonds:40;", false); + swr::normalize_portfolio(in.portfolio); + auto r = swr::evaluate_growth(in); + CHECK(!r.error); + CHECK_NEAR(r.probability_of_reaching_target, 0.0f, 0.01f); +} + +TEST(growth_evaluate_target_already_met) { + swr::growth_input in; + in.initial_balance = 10000000.0f; + in.monthly_contribution = 0.0f; + in.target = 1.0f; + in.years = 5; + in.portfolio = swr::parse_portfolio("us_stocks:60;us_bonds:40;", false); + swr::normalize_portfolio(in.portfolio); + auto r = swr::evaluate_growth(in); + CHECK(!r.error); + CHECK_NEAR(r.probability_of_reaching_target, 100.0f, 0.01f); +} + +TEST(coast_solve_finds_balance_below_target) { + swr::coast_input in; + in.base.target = 1000000.0f; + in.base.years = 20; + in.base.portfolio = swr::parse_portfolio("us_stocks:80;us_bonds:20;", false); + swr::normalize_portfolio(in.base.portfolio); + in.target_success = 80.0f; + auto r = swr::solve_coast(in); + CHECK(!r.error); + // Real-return growth over 20 years should let you start well below target. + CHECK(r.required_balance > 50000.0f); + CHECK(r.required_balance < 1000000.0f); + CHECK(r.probability_at_result >= 80.0f); +} + +TEST(accumulate_solve_finds_reasonable_year) { + swr::accumulate_input in; + in.base.initial_balance = 100000.0f; + in.base.monthly_contribution = 3000.0f; + in.base.target = 1000000.0f; + in.base.portfolio = swr::parse_portfolio("us_stocks:80;us_bonds:20;", false); + swr::normalize_portfolio(in.base.portfolio); + in.target_success = 80.0f; + auto r = swr::solve_accumulate(in); + CHECK(!r.error); + CHECK(r.required_years >= 5); + CHECK(r.required_years <= 40); + CHECK(r.probability_at_result >= 80.0f); +} + TEST(embedded_us_stocks_first_and_last_data_points) { // load_values() normalises and converts to monthly return ratios, so we // check the shape of the data rather than raw CSV values. From 1ee92796612f83beef0afa61ff99446090b8c70c Mon Sep 17 00:00:00 2001 From: Rick Gray Date: Sun, 26 Jul 2026 13:42:24 -0500 Subject: [PATCH 2/2] fix readme --- README.md | 27 +++++++++++++-------------- src/cmd_accumulate.cpp | 4 ++-- src/cmd_coast.cpp | 2 +- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a251745..1b390c8 100644 --- a/README.md +++ b/README.md @@ -311,14 +311,14 @@ Given a retirement target and a horizon, find the lump sum you need today so tha "I want $1M in 20 years — how much do I need now?" (short flags): ``` -drawdown coast --target 1000000 --years 20 --portfolio "us_stocks:80;us_bonds:20;" +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 33 --retirement-age 50 \ + --current-age 30 --retirement-age 50 \ --portfolio "us_stocks:80;us_bonds:20;" \ --target-success 90 ``` @@ -348,7 +348,7 @@ drawdown coast --target 1000000 --years 20 \ | `-a` | `--current-age ` | `0` | Current age (used with `--retirement-age` to derive years) | | `-ra` | `--retirement-age ` | `0` | Retirement age (used with `--current-age` to derive years) | | `-b` | `--balance ` | `0` | If set, skip solving and report probability at this balance | -| `-i` | `--inflation ` | `us_inflation` | Inflation series | +| `-i` | `--inflation ` | `us_inflation` | Name of an inflation series (embedded or user-supplied) | | `-r` | `--rebalance ` | `yearly` | `none` \| `monthly` \| `yearly` | | `-t` | `--target-success ` | `80` | Target success rate. The solver finds the lowest balance that meets this. | | `-j` | `--json` | — | Emit JSON instead of text | @@ -358,8 +358,8 @@ drawdown coast --target 1000000 --years 20 \ | Short | Long | Default | Meaning | |---|---|---|---| -| `-sy` | `--start-year ` | `0` | Earliest historical backtest start year | -| `-ey` | `--end-year ` | `0` | Latest historical backtest start year | +| `-sy` | `--start-year ` | `0` | Earliest historical backtest start year (`0` = use full data) | +| `-ey` | `--end-year ` | `0` | Latest historical backtest start year (`0` = use full data) | | `-smb` | `--search-max-balance ` | `0` | Upper bound for binary search (`0` = `2 × target`) | | `-bt` | `--balance-tolerance ` | `100` | Binary-search stopping tolerance | @@ -374,17 +374,16 @@ Given a current balance and a fixed monthly contribution (constant in real, infl "I have $100K and add $3K/month — how long to $1M?" (short flags): ``` -drawdown accumulate --balance 100000 --contribution 3000 \ - --target 1000000 --portfolio "us_stocks:80;us_bonds:20;" +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 519000 --contribution 5083 \ +drawdown accumulate --balance 500000 --contribution 5000 \ --target 2500000 \ --portfolio "us_stocks:80;us_bonds:20;" \ - --current-age 33 + --current-age 30 ``` Using a 90% success target: @@ -411,7 +410,7 @@ drawdown accumulate --balance 100000 --contribution 3000 \ | Short | Long | Default | Meaning | |---|---|---|---| -| `-i` | `--inflation ` | `us_inflation` | Inflation series | +| `-i` | `--inflation ` | `us_inflation` | Name of an inflation series (embedded or user-supplied) | | `-r` | `--rebalance ` | `yearly` | `none` \| `monthly` \| `yearly` | | `-t` | `--target-success ` | `80` | Target success rate. The solver returns the first year that hits this. | | `-a` | `--current-age ` | `0` | Current age. If set, output includes the projected age you reach the target. | @@ -422,8 +421,8 @@ drawdown accumulate --balance 100000 --contribution 3000 \ | Short | Long | Default | Meaning | |---|---|---|---| -| `-sy` | `--start-year ` | `0` | Earliest historical backtest start year | -| `-ey` | `--end-year ` | `0` | Latest historical backtest start year | +| `-sy` | `--start-year ` | `0` | Earliest historical backtest start year (`0` = use full data) | +| `-ey` | `--end-year ` | `0` | Latest historical backtest start year (`0` = use full data) | | `-my` | `--max-years ` | `60` | Maximum years to scan before giving up | ## Output formats @@ -434,7 +433,7 @@ All six commands support three output modes. `--json` and `--csv` are mutually e |---|---|---| | 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 @@ -486,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. diff --git a/src/cmd_accumulate.cpp b/src/cmd_accumulate.cpp index 33845c2..5fbab76 100644 --- a/src/cmd_accumulate.cpp +++ b/src/cmd_accumulate.cpp @@ -33,8 +33,8 @@ swr::cli::command_schema accumulate_schema() { s.example_invocation = "drawdown accumulate --balance 100000 --contribution 3000 \\\n" " --target 1000000 --portfolio \"us_stocks:80;us_bonds:20;\"\n" - " drawdown accumulate -b 519000 -mc 5083 -tg 2500000 " - "-p \"us_stocks:80;us_bonds:20;\" -a 33"; + " drawdown accumulate -b 500000 -mc 5000 -tg 2500000 " + "-p \"us_stocks:80;us_bonds:20;\" -a 30"; s.flags = { {"balance", "b", FlagGroup::REQUIRED, FlagKind::VALUE, diff --git a/src/cmd_coast.cpp b/src/cmd_coast.cpp index b2eb35f..20300f2 100644 --- a/src/cmd_coast.cpp +++ b/src/cmd_coast.cpp @@ -34,7 +34,7 @@ swr::cli::command_schema coast_schema() { s.example_invocation = "drawdown coast --target 1000000 --years 20 " "--portfolio \"us_stocks:80;us_bonds:20;\"\n" - " drawdown coast --target 2500000 --current-age 33 --retirement-age 50 \\\n" + " drawdown coast --target 2500000 --current-age 30 --retirement-age 50 \\\n" " --portfolio \"us_stocks:80;us_bonds:20;\" --target-success 90"; s.flags = {