use crate::asset_allocation::{classify_asset_class, AssetClass};
use crate::client::Account;
use serde::Serialize;
#[derive(Debug, Serialize, PartialEq)]
pub struct RetirementReadiness {
pub invested_assets: f64,
pub annual_baseline_spend: f64,
pub sustainable_annual_withdrawal: f64,
pub coverage_ratio: Option<f64>,
pub target_portfolio: Option<f64>,
pub surplus_or_gap: Option<f64>,
pub withdrawal_rate_used: f64,
pub spend_window_months: u32,
pub invested_assets_note: String,
}
pub fn invested_financial_accounts(accounts: &[Account]) -> Vec<&Account> {
accounts
.iter()
.filter(|a| {
let (class, _) = classify_asset_class(a);
class == AssetClass::Equities
})
.collect()
}
pub const WITHDRAWAL_RATE_MIN: f64 = 0.02;
pub const WITHDRAWAL_RATE_MAX: f64 = 0.10;
pub const WITHDRAWAL_RATE_DEFAULT: f64 = 0.04;
pub fn validate_withdrawal_rate(rate: f64) -> Result<f64, String> {
if !(WITHDRAWAL_RATE_MIN..=WITHDRAWAL_RATE_MAX).contains(&rate) {
Err(format!(
"withdrawal_rate {rate} is outside the supported range \
[{WITHDRAWAL_RATE_MIN}, {WITHDRAWAL_RATE_MAX}]: \
use a value between {:.0}% and {:.0}%",
WITHDRAWAL_RATE_MIN * 100.0,
WITHDRAWAL_RATE_MAX * 100.0,
))
} else {
Ok(rate)
}
}
pub fn compute_retirement_readiness(
invested_assets: f64,
annual_baseline_spend: f64,
withdrawal_rate: f64,
spend_window_months: u32,
) -> RetirementReadiness {
let sustainable_annual_withdrawal = invested_assets * withdrawal_rate;
let coverage_ratio = if annual_baseline_spend == 0.0 {
None
} else {
Some(sustainable_annual_withdrawal / annual_baseline_spend)
};
let target_portfolio = if annual_baseline_spend == 0.0 {
None
} else {
Some(annual_baseline_spend / withdrawal_rate)
};
let surplus_or_gap = target_portfolio.map(|t| invested_assets - t);
RetirementReadiness {
invested_assets,
annual_baseline_spend,
sustainable_annual_withdrawal,
coverage_ratio,
target_portfolio,
surplus_or_gap,
withdrawal_rate_used: withdrawal_rate,
spend_window_months,
invested_assets_note: "Invested assets = Equities-class accounts only \
(brokerage, 401k, Roth, HSA, stock plan). Real estate, cash, crypto, \
vehicles, and liabilities are excluded from the SWR base (ADR 0016)."
.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{Account, AccountSubtype, AccountType};
fn make_account(type_name: &str, subtype_name: Option<&str>, balance: f64) -> Account {
Account {
id: format!("{type_name}-{balance}"),
display_name: format!("{type_name} account"),
current_balance: balance,
balance_was_null: false,
account_type: AccountType {
name: type_name.to_string(),
},
subtype: subtype_name.map(|n| AccountSubtype {
name: n.to_string(),
display: n.to_string(),
}),
is_hidden: false,
}
}
#[test]
fn default_rate_is_valid() {
assert!(validate_withdrawal_rate(WITHDRAWAL_RATE_DEFAULT).is_ok());
}
#[test]
fn minimum_rate_is_valid() {
assert!(validate_withdrawal_rate(WITHDRAWAL_RATE_MIN).is_ok());
}
#[test]
fn maximum_rate_is_valid() {
assert!(validate_withdrawal_rate(WITHDRAWAL_RATE_MAX).is_ok());
}
#[test]
fn rate_below_minimum_returns_err() {
let result = validate_withdrawal_rate(0.01);
assert!(result.is_err(), "rate 0.01 is below min 0.02 — must error");
let msg = result.unwrap_err();
assert!(
msg.contains("outside the supported range"),
"error message must mention range, got: {msg}"
);
}
#[test]
fn rate_above_maximum_returns_err() {
let result = validate_withdrawal_rate(0.5);
assert!(result.is_err(), "rate 0.5 is above max 0.10 — must error");
let msg = result.unwrap_err();
assert!(
msg.contains("outside the supported range"),
"error message must mention range, got: {msg}"
);
}
#[test]
fn rate_zero_returns_err() {
assert!(validate_withdrawal_rate(0.0).is_err());
}
#[test]
fn rate_negative_returns_err() {
assert!(validate_withdrawal_rate(-0.04).is_err());
}
#[test]
fn invested_financial_accounts_returns_only_equities() {
let accounts = vec![
make_account("brokerage", Some("roth"), 800_000.0),
make_account("real_estate", Some("house"), 400_000.0),
make_account("depository", Some("checking"), 50_000.0),
make_account("credit", Some("credit_card"), -10_000.0),
make_account("vehicle", Some("car"), 15_000.0),
];
let invested = invested_financial_accounts(&accounts);
assert_eq!(
invested.len(),
1,
"only the brokerage (equities) account is invested; \
real_estate, cash, credit, vehicle excluded"
);
assert_eq!(invested[0].account_type.name, "brokerage");
}
#[test]
fn primary_residence_excluded_from_invested() {
let accounts = vec![
make_account("brokerage", Some("brokerage"), 800_000.0),
make_account("real_estate", Some("house"), 400_000.0),
];
let invested = invested_financial_accounts(&accounts);
assert_eq!(invested.len(), 1);
let total: f64 = invested.iter().map(|a| a.current_balance).sum();
assert!(
(total - 800_000.0).abs() < 0.01,
"invested total must be 800,000 (property excluded), got {total}"
);
}
#[test]
fn multiple_brokerage_accounts_all_included() {
let accounts = vec![
make_account("brokerage", Some("st_401k"), 300_000.0),
make_account("brokerage", Some("roth"), 200_000.0),
make_account("brokerage", Some("brokerage"), 300_000.0),
make_account("depository", Some("checking"), 50_000.0),
];
let invested = invested_financial_accounts(&accounts);
assert_eq!(
invested.len(),
3,
"all three brokerage accounts are invested"
);
let total: f64 = invested.iter().map(|a| a.current_balance).sum();
assert!((total - 800_000.0).abs() < 0.01);
}
#[test]
fn invested_financial_accounts_empty_when_no_equities() {
let accounts = vec![
make_account("depository", Some("checking"), 50_000.0),
make_account("real_estate", Some("house"), 400_000.0),
];
let invested = invested_financial_accounts(&accounts);
assert!(
invested.is_empty(),
"no Equities accounts → empty invested list"
);
}
#[test]
fn coverage_ratio_one_at_breakeven() {
let rr = compute_retirement_readiness(1_000_000.0, 40_000.0, 0.04, 6);
assert!(
(rr.sustainable_annual_withdrawal - 40_000.0).abs() < 0.01,
"sustainable withdrawal must be 40,000, got {}",
rr.sustainable_annual_withdrawal
);
let ratio = rr.coverage_ratio.expect("coverage_ratio must be Some");
assert!(
(ratio - 1.0).abs() < 0.001,
"coverage_ratio must be 1.0, got {ratio}"
);
let target = rr.target_portfolio.expect("target_portfolio must be Some");
assert!(
(target - 1_000_000.0).abs() < 0.01,
"target_portfolio must be 1,000,000, got {target}"
);
let gap = rr.surplus_or_gap.expect("surplus_or_gap must be Some");
assert!(
gap.abs() < 0.01,
"surplus_or_gap must be ~0 at breakeven, got {gap}"
);
}
#[test]
fn coverage_ratio_half_reports_gap() {
let rr = compute_retirement_readiness(500_000.0, 40_000.0, 0.04, 6);
let ratio = rr.coverage_ratio.expect("coverage_ratio must be Some");
assert!(
(ratio - 0.5).abs() < 0.001,
"coverage_ratio must be 0.5, got {ratio}"
);
let target = rr.target_portfolio.expect("target_portfolio must be Some");
assert!(
(target - 1_000_000.0).abs() < 0.01,
"target_portfolio must be 1,000,000, got {target}"
);
let gap = rr.surplus_or_gap.expect("surplus_or_gap must be Some");
assert!(
(gap - (-500_000.0)).abs() < 0.01,
"surplus_or_gap must be -500,000, got {gap}"
);
}
#[test]
fn custom_withdrawal_rate_is_honored_and_echoed() {
let rr = compute_retirement_readiness(1_000_000.0, 35_000.0, 0.035, 6);
assert!(
(rr.withdrawal_rate_used - 0.035).abs() < 1e-9,
"withdrawal_rate_used must echo 0.035, got {}",
rr.withdrawal_rate_used
);
let target = rr.target_portfolio.expect("target_portfolio must be Some");
assert!(
(target - 1_000_000.0).abs() < 0.01,
"target_portfolio at 3.5% + 35k spend must be 1,000,000, got {target}"
);
}
#[test]
fn zero_spend_yields_none_coverage_ratio() {
let rr = compute_retirement_readiness(1_000_000.0, 0.0, 0.04, 6);
assert!(
rr.coverage_ratio.is_none(),
"coverage_ratio must be None when annual_baseline_spend is 0"
);
assert!(
rr.target_portfolio.is_none(),
"target_portfolio must be None when annual_baseline_spend is 0"
);
assert!(
rr.surplus_or_gap.is_none(),
"surplus_or_gap must be None when annual_baseline_spend is 0"
);
assert!(
(rr.sustainable_annual_withdrawal - 40_000.0).abs() < 0.01,
"sustainable_annual_withdrawal must still be 40,000, got {}",
rr.sustainable_annual_withdrawal
);
}
#[test]
fn zero_invested_assets_with_nonzero_spend_reports_zero_withdrawal_and_full_gap() {
let rr = compute_retirement_readiness(0.0, 40_000.0, 0.04, 6);
assert!(
(rr.sustainable_annual_withdrawal - 0.0).abs() < 0.01,
"sustainable_annual_withdrawal must be 0 with no assets"
);
let ratio = rr.coverage_ratio.expect("coverage_ratio must be Some");
assert!(
ratio.abs() < 0.001,
"coverage_ratio must be 0.0 with no invested assets, got {ratio}"
);
let gap = rr.surplus_or_gap.expect("surplus_or_gap must be Some");
assert!(
(gap - (-1_000_000.0)).abs() < 0.01,
"surplus_or_gap must be -1,000,000 (full gap), got {gap}"
);
}
#[test]
fn surplus_when_portfolio_exceeds_target() {
let rr = compute_retirement_readiness(2_000_000.0, 40_000.0, 0.04, 6);
let gap = rr.surplus_or_gap.expect("surplus_or_gap must be Some");
assert!(
(gap - 1_000_000.0).abs() < 0.01,
"surplus_or_gap must be +1,000,000 when 2x target, got {gap}"
);
let ratio = rr.coverage_ratio.expect("coverage_ratio must be Some");
assert!(
(ratio - 2.0).abs() < 0.001,
"coverage_ratio must be 2.0 when 2x target, got {ratio}"
);
}
#[test]
fn spend_window_months_is_echoed_in_output() {
let rr = compute_retirement_readiness(500_000.0, 36_000.0, 0.04, 12);
assert_eq!(
rr.spend_window_months, 12,
"spend_window_months must be echoed as 12"
);
}
#[test]
fn invested_assets_note_is_non_empty() {
let rr = compute_retirement_readiness(500_000.0, 36_000.0, 0.04, 6);
assert!(
!rr.invested_assets_note.is_empty(),
"invested_assets_note must be non-empty"
);
assert!(
rr.invested_assets_note.to_lowercase().contains("equities"),
"note must mention equities"
);
}
#[test]
fn annualised_spend_from_6_month_window_matches_expected() {
let monthly_spend = 3_000.0_f64;
let annual_spend = monthly_spend * 12.0;
let rr = compute_retirement_readiness(900_000.0, annual_spend, 0.04, 6);
let ratio = rr.coverage_ratio.expect("coverage_ratio must be Some");
assert!(
(ratio - 1.0).abs() < 0.001,
"coverage_ratio must be 1.0 when portfolio exactly covers annualised spend"
);
}
}