use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Context;
use clap::{Args, ValueEnum};
use retiretui_engine::market::{
self, BAND_PERCENTILES, Band, History, MonteCarlo, Progress, Run, RunName, Runs,
};
use retiretui_engine::params::TaxTables;
use retiretui_engine::plan::{Dollars, Draw, Market, Plan};
use schemars::JsonSchema;
use serde::Serialize;
use crate::commands::project::OutputFormat;
use crate::commands::{run_refusal, user_config_dir};
use retiretui_engine::project::validate_plan;
use retiretui_tui::table::{align, percentile_label, plain_dollars, rate};
const HISTORY_FILE: &str = "history.toml";
#[derive(Args)]
pub struct MonteCarloArgs {
pub plan: PathBuf,
#[arg(long, value_enum)]
pub draw: Option<DrawArg>,
#[arg(long)]
pub trials: Option<u32>,
#[arg(long)]
pub seed: Option<u32>,
#[command(flatten)]
pub common: MarketArgs,
}
#[derive(Args)]
pub struct HistoricalArgs {
pub plan: PathBuf,
#[arg(long)]
pub from: Option<i16>,
#[arg(long)]
pub to: Option<i16>,
#[arg(long)]
pub no_wrap: bool,
#[command(flatten)]
pub common: MarketArgs,
}
#[derive(Args)]
pub struct MarketArgs {
#[arg(long, value_enum, default_value_t = OutputFormat::Table)]
pub format: OutputFormat,
#[arg(long)]
pub history: Option<PathBuf>,
#[arg(long)]
pub tax_dir: Vec<PathBuf>,
}
#[derive(Clone, Copy, ValueEnum)]
pub enum DrawArg {
Assumptions,
History,
}
#[derive(Serialize, JsonSchema)]
pub struct RunEntry {
pub market: String,
pub trial: Option<u32>,
pub start: Option<i16>,
pub ending: Dollars,
pub unfunded: Dollars,
pub first_short: Option<i16>,
pub success: bool,
}
#[derive(Serialize, JsonSchema)]
pub struct MonteCarloReply {
pub draw: String,
pub seed: u32,
pub runs: usize,
pub successes: usize,
pub success_rate: f64,
pub markets: Vec<RunEntry>,
pub bands: Vec<Band>,
}
#[derive(Serialize, JsonSchema)]
pub struct HistoricalReply {
pub from: i16,
pub to: i16,
pub wrap: bool,
pub runs: usize,
pub successes: usize,
pub success_rate: f64,
pub start_years: Vec<RunEntry>,
}
fn entry(market: String, run: &Run) -> RunEntry {
let (trial, start) = match run.name {
RunName::Planned => (None, None),
RunName::Trial(trial) => (Some(trial), None),
RunName::Start(start) => (None, Some(start)),
};
RunEntry {
market,
trial,
start,
ending: run.ending,
unfunded: run.unfunded,
first_short: run.first_short,
success: run.is_success,
}
}
const PLANNED: &str = "as planned";
impl MonteCarloReply {
pub fn new(plan: &Plan, found: &MonteCarlo) -> Self {
let runs = &found.runs;
let labels = BAND_PERCENTILES
.iter()
.rev()
.map(|&percentile| percentile_label(percentile))
.chain(std::iter::once("worst".to_owned()));
let mut markets = vec![entry(PLANNED.to_owned(), &runs.planned)];
markets.extend(
labels
.zip(&found.singled_out)
.map(|(label, &at)| entry(label, &runs.runs[at])),
);
Self {
draw: plan.market().draw().as_str().to_owned(),
seed: plan.market().seed(),
runs: runs.runs.len(),
successes: runs.successes,
success_rate: runs.success_rate(),
markets,
bands: runs.bands.clone(),
}
}
}
impl HistoricalReply {
pub fn new(plan: &Plan, runs: &Runs) -> Self {
let mut start_years = vec![entry(PLANNED.to_owned(), &runs.planned)];
start_years.extend(runs.worst_first().into_iter().map(|at| {
let run = &runs.runs[at];
let label = match run.name {
RunName::Start(start) => start.to_string(),
RunName::Planned | RunName::Trial(_) => String::new(),
};
entry(label, run)
}));
Self {
from: plan.market().from(),
to: plan.market().to(),
wrap: plan.market().wrap(),
runs: runs.runs.len(),
successes: runs.successes,
success_rate: runs.success_rate(),
start_years,
}
}
}
pub(crate) fn load_history(explicit: Option<&Path>) -> anyhow::Result<History> {
let user = user_config_dir("market").map(|dir| dir.join(HISTORY_FILE));
let Some(path) = explicit
.map(Path::to_path_buf)
.or_else(|| user.filter(|path| path.is_file()))
else {
return Ok(History::embedded().clone());
};
let text =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
History::from_toml_str(&text).with_context(|| format!("in {}", path.display()))
}
fn market_of(plan: &mut Plan) -> &mut Market {
plan.market.get_or_insert_with(Market::default)
}
fn prepare(
path: &Path,
common: &MarketArgs,
settle: impl FnOnce(&mut Plan),
) -> anyhow::Result<(Plan, TaxTables, History)> {
let tables = crate::commands::load_tables(&common.tax_dir)?;
let mut plan = crate::commands::load_validated_plan(path, &tables)?;
settle(&mut plan);
let issues = validate_plan(&plan, &tables);
if !issues.is_empty() {
anyhow::bail!(crate::commands::issue_listing(&issues));
}
Ok((plan, tables, load_history(common.history.as_deref())?))
}
pub fn run_monte_carlo(args: &MonteCarloArgs) -> anyhow::Result<()> {
let (plan, tables, history) = prepare(&args.plan, &args.common, |plan| {
let settings = market_of(plan).monte_carlo.get_or_insert_default();
if let Some(draw) = args.draw {
settings.draw = Some(match draw {
DrawArg::Assumptions => Draw::Assumptions,
DrawArg::History => Draw::History,
});
}
settings.trials = args.trials.or(settings.trials);
settings.seed = args.seed.or(settings.seed);
})?;
let found = market::monte_carlo(&plan, &tables, &history, &Progress::default())
.map_err(|error| anyhow::Error::msg(run_refusal(error)))?;
let reply = MonteCarloReply::new(&plan, &found);
match args.common.format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&reply)?),
OutputFormat::Table => print!("{}", monte_carlo_text(&reply)),
}
Ok(())
}
pub fn run_historical(args: &HistoricalArgs) -> anyhow::Result<()> {
let (plan, tables, history) = prepare(&args.plan, &args.common, |plan| {
let settings = market_of(plan).historical.get_or_insert_default();
settings.from = args.from.or(settings.from);
settings.to = args.to.or(settings.to);
if args.no_wrap {
settings.wrap = Some(false);
}
})?;
let found = market::historical(&plan, &tables, &history, &Progress::default())
.map_err(|error| anyhow::Error::msg(run_refusal(error)))?;
let reply = HistoricalReply::new(&plan, &found);
match args.common.format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&reply)?),
OutputFormat::Table => print!("{}", historical_text(&reply)),
}
Ok(())
}
fn short_text(first_short: Option<i16>) -> String {
first_short.map_or_else(|| "never".to_owned(), |year| year.to_string())
}
fn runs_table(first: &str, entries: &[RunEntry]) -> String {
let header = [first, "ends with", "short in"].map(str::to_owned);
let rows: Vec<Vec<String>> = entries
.iter()
.map(|entry| {
vec![
entry.market.clone(),
plain_dollars(entry.ending),
short_text(entry.first_short),
]
})
.collect();
align(&header, &rows)
}
fn monte_carlo_text(reply: &MonteCarloReply) -> String {
let mut header = vec!["year".to_owned()];
header.extend(
BAND_PERCENTILES
.iter()
.map(|percentile| format!("{percentile}th")),
);
header.push("funded".to_owned());
let rows: Vec<Vec<String>> = reply
.bands
.iter()
.map(|band| {
let mut cells = vec![band.year.to_string()];
cells.extend(band.net_worth.iter().map(|&worth| plain_dollars(worth)));
cells.push(rate(band.funded));
cells
})
.collect();
format!(
"Money lasts in {} of {} markets drawn from {} (seed {}), in today's dollars\n\n{}\n{}",
rate(reply.success_rate),
reply.runs,
reply.draw,
reply.seed,
runs_table("market", &reply.markets),
align(&header, &rows),
)
}
fn historical_text(reply: &HistoricalReply) -> String {
let wrapped = if reply.wrap { ", wrapped" } else { "" };
format!(
"Survived {} of {} start years ({}-{}{wrapped}), {}, in today's dollars\n\n{}",
reply.successes,
reply.runs,
reply.from,
reply.to,
rate(reply.success_rate),
runs_table("started", &reply.start_years),
)
}