init commit

This commit is contained in:
Breadway 2026-07-22 12:00:12 +08:00
commit 17e9b77fa6
39 changed files with 20569 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

245
Cargo.lock generated Normal file
View file

@ -0,0 +1,245 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "career_planner"
version = "0.1.0"
dependencies = [
"csv",
"rand",
"rayon",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "csv"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "csv-core"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
dependencies = [
"memchr",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "zerocopy"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

9
Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "career_planner"
version = "0.1.0"
edition = "2024"
[dependencies]
rand = { version = "0.8", features = ["small_rng"] }
csv = "1"
rayon = "1"

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Breadway Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

149
README.md Normal file
View file

@ -0,0 +1,149 @@
# Career Permutation Generator — README
Models 2,535 different combinations of career path, property location/strategy,
deposit size, savings rate, and purchase timing for Breadway (solo, age 18→50),
running each combination 200 times with randomised income shocks, interest
rate movements, and property crash risk to produce a realistic RANGE of
outcomes rather than one fragile prediction.
---
## Files
| File | What it is |
|---|---|
| `income_schedules.py` | The 13 career path income curves + HECS debt totals. Edit this to change/add careers. |
| `generator_v2.py` | The simulation engine. Run this file to regenerate everything. |
| `career_permutations_v3.csv` | Full output — one row per scenario combination (2,535 rows). |
| `leaderboard_v3.csv` | Top 10 scenarios in four categories, pulled from the full CSV. |
| `super_cgt_notes.txt` | Plain-English notes on the superannuation and capital gains tax rules actually applied. |
**To regenerate:** put `income_schedules.py` and `generator_v2.py` in the same
folder, run `python3 generator_v2.py`. Takes about 75 seconds. It overwrites
`career_permutations_v2.csv` and `leaderboard.csv` in that folder — copy them
out afterwards.
---
## How it works, in order
**1. Pick a career path.** 13 options, each with a year-by-year income
schedule from age 18 to 50 (gap year → study → graduate → career progression).
All salaries are pulled to the conservative end of researched ranges, not the
midpoint.
**2. Pick a property approach.** Location (Geraldton house, Geraldton land,
Perth, Canberra, or none/rent-forever) × strategy (buy and hold, buy land then
build, or rent forever) × deposit size (20% or 50%) × the age you attempt to
buy (23 or 28) × what age you might sell and rebuild in Geraldton instead
(never, 35, 40, 45, or 50). Invalid combinations are automatically skipped
(e.g. you can't "buy land then build" in Perth, you can't set a sell age if
you're renting forever).
**3. Pick a savings rate.** How much of your leftover income after costs
actually gets saved rather than spent — 65%, 75%, or 85%.
**4. Run that exact combination 200 times.** Each run is identical in its
setup but different in its luck: random job-loss events, random year-to-year
income variation, a randomly generated 33-year interest rate path, and random
property market crashes. This produces 200 different final net-worth numbers
for the same scenario. The CSV reports the 10th percentile (bad luck), 50th
percentile (typical), and 90th percentile (good luck) of those 200 outcomes.
**5. Repeat for all 2,535 valid combinations**, producing one summary row per
combination — so 507,000 individual 33-year simulations in total.
---
## What gets randomised, and why
**Income jitter** — every year's "scripted" salary gets ±47% random noise
(varies by career; software/data jitters more, RAAF jitters least) to reflect
that real raises and bonuses aren't perfectly smooth.
**Job loss / income gap risk** — from age 23 onward, each career has an
annual probability of a bad year (redundancy, project ending, injury-equivalent
income hit), calibrated by how stable that sector actually is. FIFO and
trades sit at 35%/yr (cyclical, project-based work). Engineering and defence
sit at 1.52%/yr. RAAF sits at 0.5%/yr (military job security is real).
**These probabilities are my own calibrated estimates, not sourced unemployment
data** — directionally sensible, not empirically precise.
**Interest rate path** — instead of a flat 5.9% for 33 years, each run
generates its own random 33-year rate path: a mean-reverting walk anchored to
a 5.5% "neutral" mortgage rate, with normal year-to-year drift plus an
occasional (~1-in-12-year chance) sharp move up or down, mimicking real RBA
cycles (confirmed history: cash rate has ranged from 0.10% in 2020 to 17% in
1989; mortgage rates track roughly 1.52.5% above cash rate). Mortgage
repayments are recalculated every year against the rate that actually applied
that year, the way a real variable-rate loan behaves.
**Property crash risk** — each location has its own annual probability of a
market downturn and how severe it is if one hits, based on confirmed history:
Perth fell ~15% peak-to-trough 20142019 after the mining boom ended; Pilbara
mining towns (Karratha, Port Hedland) fell close to 80% in the same period.
Geraldton sits between the two — more diversified than a pure mining town,
less stable than Perth. Canberra (government town) gets the smallest crash
risk of anywhere modelled. A crash applies a temporary value discount that
recovers in a straight line over 46 years.
**Cost inflation** — rent and living costs inflate at 3%/yr from age 26
onward, rather than sitting flat for three decades.
---
## What's deliberately NOT in this model
- Addi's income, a shared household, marriage, or splitting up — this is
Breadway solo only, by request.
- Children — explicitly parked, not modelled at all.
- Health/injury shocks that take someone out of work for an extended period
(the job-loss risk models *economic* job loss, not a serious injury).
- Car ownership and replacement costs.
- Lifestyle inflation — the model assumes the same fixed living costs whether
you're earning $80k or $200k, which isn't how real spending works.
- Correlation between bad property markets and job loss — in reality a
commodity price crash often causes both at once (that's exactly what
happened in the Pilbara). This model treats them as independent risks,
which understates how bad the worst-case scenarios could really get.
- Anything past age 50.
---
## Column reference (career_permutations_v3.csv)
| Column | Meaning |
|---|---|
| `career_path` | Which of the 13 careers |
| `location` | Property location (or NONE if renting forever) |
| `strategy` | BUY_HOLD / BUY_LAND_BUILD / RENT_FOREVER |
| `sell_age` | Age at which the Perth/Canberra house is sold and Geraldton is built instead (N/A if never) |
| `deposit_pct` | Deposit size attempted (0.20 or 0.50) |
| `savings_rate` | Share of leftover income actually saved (0.65 / 0.75 / 0.85) |
| `requested_buy_age` | The age the purchase was *attempted* |
| `pct_scenarios_bought_property` | Of the 200 runs, what % actually managed to buy (some never afford the deposit) |
| `median_actual_buy_age` | Of the runs that did buy, the typical age it actually happened (may be later than requested if savings took time to catch up) |
| `pct_debt_free_by_50` | Of the 200 runs, what % had zero mortgage/loan balance by age 50 |
| `median_debt_free_age` | Typical age debt-free was reached, across runs that got there |
| `net_worth_p10_age50` | 10th percentile outcome — roughly "if things went badly" |
| `net_worth_p50_age50` | 50th percentile — the typical/median outcome |
| `net_worth_p90_age50` | 90th percentile — roughly "if things went well" |
| `net_worth_range_age50` | P90 minus P10 — how much luck matters for this combination |
| `median_liquid_age50` / `median_super_age50` / `median_property_value_age50` / `median_mortgage_remaining_age50` | Median breakdown of the net worth figure across the 200 runs |
`leaderboard_v3.csv` uses the same columns with one extra: `leaderboard_category`,
which tags each row as one of: Max Wealth (highest P50), Fastest Debt Free
(lowest median debt-free age), Most Reliable (smallest P90P10 spread among
above-median-wealth scenarios), or Best Worst-Case (highest P10 — the safest
floor if things go badly).
---
## Honest framing
This is a wealth-accumulation model. It answers "how much money might I have
at 50 under this combination of choices" with a realistic spread rather than
a single confident number. It does not answer whether the work itself is
enjoyable, sustainable, or compatible with the rest of a life — FIFO tops
almost every wealth ranking here because the model only measures money, and
real people don't only optimise for that.

18051
career_permutations_v2.csv Normal file

File diff suppressed because it is too large Load diff

872
graphs.py Normal file
View file

@ -0,0 +1,872 @@
"""
Career Planner Graph Suite
Generates ~20 charts from the Monte Carlo simulation output.
"""
import pandas as pd
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.ticker as mticker
import seaborn as sns
from pathlib import Path
# ── Setup ─────────────────────────────────────────────────────────────────────
import sys
DATA = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent / "career_permutations_v2.csv"
OUTDIR = Path(__file__).parent / "graphs"
OUTDIR.mkdir(exist_ok=True)
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.05)
plt.rcParams.update({
"figure.dpi": 150,
"savefig.bbox": "tight",
"savefig.dpi": 150,
})
M = lambda x: x / 1_000_000 # scale to millions
# Inflation deflators: convert nominal future-dollar figures to 2026 real dollars.
# Sim runs age 18-50 (year 1-33). Age 50 = year 33; start year = 2026, so values are in 2058-ish $$.
# Using CPI = 3%/yr (COST_INFLATION in the simulation).
DEFL = {
35: (1.03 ** 18), # age 35 = year 18
40: (1.03 ** 23), # age 40 = year 23
45: (1.03 ** 28), # age 45 = year 28
50: (1.03 ** 33), # age 50 = year 33
}
df = pd.read_csv(DATA)
# Clean mixed-type columns
df["median_actual_buy_age"] = pd.to_numeric(df["median_actual_buy_age"], errors="coerce")
df["median_debt_free_age"] = pd.to_numeric(df["median_debt_free_age"], errors="coerce")
df["requested_buy_age"] = pd.to_numeric(df["requested_buy_age"], errors="coerce")
# Real (2026) dollar equivalents — deflate each nominal milestone by the CPI compounding factor.
# These remove the distortion from 30 years of 3% inflation: $5M nominal ≈ $2M in 2026 dollars.
for age, defl in DEFL.items():
col = f"net_worth_p50_age{age}"
if col in df.columns:
df[f"real_nw_p50_age{age}"] = df[col] / defl
# Accessible net worth at 50: total minus super (super locked until age 60, preservation age).
# Liquid savings + property equity is what someone can actually use at age 50.
df["accessible_nw_p50_age50"] = (
df["net_worth_p50_age50"] - df["median_super_age50"]
)
CAREER_ORDER = [
"FIFO E&I",
"FIFO — Supervisor track",
"Mining Engineering (FIFO)",
"Petroleum Engineering (FIFO)",
"FIFO Electrical Engineer",
"FIFO I&C Engineer",
"Residential Mining Electrician",
"Local Trade — stay Geraldton",
"Trade → Bridge → Engineering",
"RF/Satellite Engineering",
"Engineering — Canberra defence",
"Cloud/Solutions Architect",
"Security Architect",
"Aerospace Engineering",
"Mechatronics/Robotics Engineering",
"Cybersecurity",
"Software Engineering",
"Data Science/AI Engineering",
"RAAF Technical Officer",
]
PALETTE = dict(zip(CAREER_ORDER, sns.color_palette("tab20", len(CAREER_ORDER))))
def fmt_m(x, _=None):
return f"${x:.1f}M"
def save(name):
p = OUTDIR / f"{name}.png"
plt.savefig(p)
plt.close()
print(f" saved {name}.png")
# ── 1. Career P10/P50/P90 range — best single strategy per career ─────────────
print("[1] Career wealth range (best scenario per path)...")
# best scenario = highest P50 across all parameter combos per career
best = (df.sort_values("net_worth_p50_age50", ascending=False)
.drop_duplicates("career_path")
.set_index("career_path")
.reindex(CAREER_ORDER)
.dropna(how="all"))
fig, ax = plt.subplots(figsize=(13, 7))
y = np.arange(len(best))
ax.barh(y, M(best["net_worth_p90_age50"] - best["net_worth_p10_age50"]),
left=M(best["net_worth_p10_age50"]),
height=0.6, color=[PALETTE[c] for c in best.index], alpha=0.35, label="P10P90 range")
ax.scatter(M(best["net_worth_p50_age50"]), y, color=[PALETTE[c] for c in best.index],
zorder=5, s=60, label="P50 (median)")
ax.scatter(M(best["net_worth_p10_age50"]), y, color=[PALETTE[c] for c in best.index],
marker="|", s=120, zorder=5)
ax.scatter(M(best["net_worth_p90_age50"]), y, color=[PALETTE[c] for c in best.index],
marker="|", s=120, zorder=5)
ax.set_yticks(y)
ax.set_yticklabels(best.index, fontsize=10)
ax.xaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_xlabel("Net Worth at 50")
ax.set_title("Net Worth at 50 — Best Scenario per Career Path\n(P10 / Median / P90 across 200 Monte Carlo runs)", fontweight="bold")
ax.legend(loc="lower right")
save("01_career_wealth_range")
# ── 2. P50 wealth by career, strategy, and location (heatmap) ────────────────
print("[2] P50 heatmap: career × location (best savings/deposit per cell)...")
pivot_data = (
df.groupby(["career_path", "location"])["net_worth_p50_age50"]
.max()
.unstack("location")
.reindex(CAREER_ORDER)
)
fig, ax = plt.subplots(figsize=(11, 8))
sns.heatmap(
M(pivot_data), ax=ax,
fmt=".2f", annot=True, cmap="YlOrRd",
cbar_kws={"label": "Median Net Worth at 50 ($M)"},
linewidths=0.4,
)
ax.set_title("Median Net Worth at 50 by Career × Property Location\n(best savings/deposit combo per cell)", fontweight="bold")
ax.set_xlabel("Property Location / Strategy")
ax.set_ylabel("")
plt.xticks(rotation=25, ha="right")
save("02_heatmap_career_location")
# ── 3. Strategy comparison per career ─────────────────────────────────────────
print("[3] Strategy comparison bar chart...")
strat_best = (
df.groupby(["career_path", "strategy"])["net_worth_p50_age50"]
.max()
.unstack("strategy")
.reindex(CAREER_ORDER)
)
strat_colors = {"BUY_HOLD": "#2196F3", "BUY_LAND_BUILD": "#4CAF50", "RENT_FOREVER": "#FF5722"}
fig, ax = plt.subplots(figsize=(13, 7))
x = np.arange(len(strat_best))
w = 0.26
for i, (strat, col) in enumerate(strat_colors.items()):
vals = strat_best[strat] if strat in strat_best.columns else pd.Series([np.nan]*len(strat_best))
ax.bar(x + (i-1)*w, M(vals), width=w, label=strat.replace("_", " ").title(),
color=col, alpha=0.85)
ax.set_xticks(x)
ax.set_xticklabels(strat_best.index, rotation=35, ha="right", fontsize=9)
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_ylabel("Median Net Worth at 50")
ax.set_title("Property Strategy Impact on Median Net Worth at 50\n(best deposit/savings/buy-age per strategy)", fontweight="bold")
ax.legend()
save("03_strategy_comparison")
# ── 4. Savings rate sensitivity ───────────────────────────────────────────────
print("[4] Savings rate sensitivity...")
sr_data = (
df.groupby(["career_path", "savings_rate"])["net_worth_p50_age50"]
.max()
.unstack("savings_rate")
.reindex(CAREER_ORDER)
)
fig, ax = plt.subplots(figsize=(13, 7))
x = np.arange(len(sr_data))
sr_colors = {0.55: "#90A4AE", 0.65: "#B0BEC5", 0.75: "#42A5F5", 0.85: "#1565C0", 0.90: "#0D47A1"}
sr_present = {sr: col for sr, col in sr_colors.items() if sr in sr_data.columns}
n_sr = len(sr_present)
w = 0.80 / n_sr
for i, (sr, col) in enumerate(sr_present.items()):
offset = (i - (n_sr - 1) / 2) * w
ax.bar(x + offset, M(sr_data[sr]), width=w,
label=f"{int(sr*100)}% savings rate", color=col, alpha=0.9)
ax.set_xticks(x)
ax.set_xticklabels(sr_data.index, rotation=35, ha="right", fontsize=9)
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_ylabel("Best-case Median Net Worth at 50")
ax.set_title("Impact of Savings Rate on Net Worth at 50 by Career\n(best property/deposit combo)", fontweight="bold")
ax.legend()
save("04_savings_rate_sensitivity")
# ── 5. Deposit size impact ────────────────────────────────────────────────────
print("[5] 20% vs 50% deposit comparison...")
dep_data = df[df["strategy"] != "RENT_FOREVER"].groupby(
["career_path", "deposit_pct"])["net_worth_p50_age50"].max().unstack()
fig, ax = plt.subplots(figsize=(13, 6))
careers = [c for c in CAREER_ORDER if c in dep_data.index]
x = np.arange(len(careers))
dep_cols = {0.10: ("10% deposit (+LMI)", "#EF9A9A"), 0.20: ("20% deposit", "#FF7043"), 0.50: ("50% deposit", "#26A69A")}
dep_present = {d: (lbl, col) for d, (lbl, col) in dep_cols.items() if d in dep_data.columns}
n_dep = len(dep_present)
w = 0.80 / n_dep
for i, (d, (lbl, col)) in enumerate(dep_present.items()):
offset = (i - (n_dep - 1) / 2) * w
ax.bar(x + offset, M(dep_data.reindex(careers)[d]), width=w, label=lbl, color=col, alpha=0.85)
ax.set_xticks(x)
ax.set_xticklabels(careers, rotation=35, ha="right", fontsize=9)
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_ylabel("Median Net Worth at 50")
ax.set_title("Deposit Size Comparison — Impact on Median Net Worth at 50\n(10% + LMI / 20% / 50%; best savings rate/buy-age per column)", fontweight="bold")
ax.legend()
save("05_deposit_comparison")
# ── 6. Net worth components breakdown (stacked bar, best scenario per career) ─
print("[6] Wealth component breakdown...")
fig, ax = plt.subplots(figsize=(13, 7))
y = np.arange(len(best))
liq = M(best["median_liquid_age50"].clip(lower=0))
sup_ = M(best["median_super_age50"])
prop_ = M(best["median_property_value_age50"])
mort_ = M(best["median_mortgage_remaining_age50"])
ax.barh(y, liq, height=0.6, label="Liquid savings", color="#4CAF50")
ax.barh(y, sup_, height=0.6, left=liq, label="Super", color="#2196F3")
ax.barh(y, prop_ - mort_,height=0.6, left=liq+sup_, label="Property equity", color="#FF9800")
ax.barh(y, mort_, height=0.6, left=liq+sup_+prop_-mort_,
label="Mortgage remaining", color="#F44336", alpha=0.6)
ax.set_yticks(y)
ax.set_yticklabels(best.index, fontsize=10)
ax.xaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_xlabel("Median value at 50")
ax.set_title("Wealth Component Breakdown at 50 — Best Scenario per Career", fontweight="bold")
ax.legend(loc="lower right")
save("06_wealth_components")
# ── 7. Variance (P90P10) vs Median wealth scatter — risk/reward ──────────────
print("[7] Risk vs reward scatter...")
best_all = (df.sort_values("net_worth_p50_age50", ascending=False)
.drop_duplicates("career_path"))
fig, ax = plt.subplots(figsize=(11, 8))
for _, row in best_all.iterrows():
cp = row["career_path"]
ax.scatter(M(row["net_worth_p50_age50"]), M(row["net_worth_range_age50"]),
color=PALETTE.get(cp, "grey"), s=120, zorder=5)
ax.annotate(cp.replace(" Engineering","Eng").replace("Trade → Bridge → ",""),
(M(row["net_worth_p50_age50"]), M(row["net_worth_range_age50"])),
fontsize=8, textcoords="offset points", xytext=(6, 3))
ax.xaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_xlabel("Median Net Worth at 50 (P50) → Higher is better")
ax.set_ylabel("P90P10 Outcome Range → Lower is more predictable")
ax.set_title("Risk vs Reward — Best Scenario per Career\n(lower-right quadrant = high wealth, low variance)", fontweight="bold")
ax.axvline(M(best_all["net_worth_p50_age50"].median()), color="grey", linestyle="--", alpha=0.4)
ax.axhline(M(best_all["net_worth_range_age50"].median()), color="grey", linestyle="--", alpha=0.4)
ax.text(0.02, 0.98, "← Low wealth\nHigh risk", transform=ax.transAxes, va="top",
fontsize=8, color="grey")
ax.text(0.98, 0.02, "High wealth →\nLow risk", transform=ax.transAxes, ha="right",
fontsize=8, color="grey")
save("07_risk_reward_scatter")
# ── 8. Probability of being debt-free by 50 ───────────────────────────────────
print("[8] % debt-free by 50...")
df_buy = df[df["strategy"] == "BUY_HOLD"].copy()
debt_free = (
df_buy.groupby("career_path")["pct_debt_free_by_50"]
.mean()
.reindex(CAREER_ORDER)
.dropna()
)
fig, ax = plt.subplots(figsize=(11, 6))
colors = ["#E53935" if v < 30 else "#FB8C00" if v < 70 else "#43A047" for v in debt_free]
bars = ax.barh(range(len(debt_free)), debt_free.values, color=colors, alpha=0.85)
ax.set_yticks(range(len(debt_free)))
ax.set_yticklabels(debt_free.index, fontsize=10)
ax.set_xlabel("% of Monte Carlo runs debt-free by age 50")
ax.axvline(50, color="grey", linestyle="--", alpha=0.5, label="50% threshold")
ax.set_title("Probability of Being Mortgage-Free by Age 50\n(average across all BUY_HOLD scenarios per career)", fontweight="bold")
for bar, val in zip(bars, debt_free.values):
ax.text(val + 0.5, bar.get_y() + bar.get_height()/2, f"{val:.0f}%",
va="center", fontsize=9)
patches = [mpatches.Patch(color=c, label=l) for c, l in
[("#43A047","≥70%"),("#FB8C00","3070%"),("#E53935","<30%")]]
ax.legend(handles=patches, loc="lower right")
save("08_debt_free_probability")
# ── 9. Sell-age impact (BUY_HOLD — when is the best time to move?) ───────────
print("[9] Sell-age timing impact...")
hold = df[df["strategy"] == "BUY_HOLD"].copy()
hold["sell_label"] = hold["sell_age"].apply(lambda x: "Never sell" if pd.isna(x) else f"Sell at {int(x)}")
sell_pivot = (
hold.groupby(["career_path", "sell_label"])["net_worth_p50_age50"]
.max()
.unstack("sell_label")
.reindex(CAREER_ORDER)
)
sell_cols_all = ["Never sell", "Sell at 30", "Sell at 32", "Sell at 35", "Sell at 40", "Sell at 45", "Sell at 50"]
sell_colors_all = ["#0D1B2A", "#1A237E", "#283593", "#1976D2", "#42A5F5", "#90CAF9", "#BBDEFB"]
sell_cols = [c for c in sell_cols_all if c in sell_pivot.columns]
sell_colors = [sell_colors_all[sell_cols_all.index(c)] for c in sell_cols]
fig, ax = plt.subplots(figsize=(13, 7))
x = np.arange(len(sell_pivot))
w = 0.85 / len(sell_cols)
for i, (col, color) in enumerate(zip(sell_cols, sell_colors)):
offset = (i - len(sell_cols)/2 + 0.5) * w
ax.bar(x + offset, M(sell_pivot[col]), width=w, label=col, color=color, alpha=0.88)
ax.set_xticks(x)
ax.set_xticklabels(sell_pivot.index, rotation=35, ha="right", fontsize=9)
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_ylabel("Median Net Worth at 50")
ax.set_title("Effect of Sell Timing on Net Worth at 50 (BUY_HOLD strategies)\n(sell PPOR and rebuild in Geraldton at that age)", fontweight="bold")
ax.legend()
save("09_sell_age_timing")
# ── 10. Buy age impact (23 vs 28) ────────────────────────────────────────────
print("[10] Buy age comparison...")
buy_data = df[df["strategy"] == "BUY_HOLD"].groupby(
["career_path", "requested_buy_age"])["net_worth_p50_age50"].max().unstack()
fig, ax = plt.subplots(figsize=(13, 6))
buy_age_cols = {23.0: ("Buy at 23", "#7B1FA2"), 25.0: ("Buy at 25", "#9C27B0"), 28.0: ("Buy at 28", "#CE93D8")}
available = {age: label for age, (label, _) in buy_age_cols.items() if age in buy_data.columns}
careers = [c for c in CAREER_ORDER if c in buy_data.index]
x = np.arange(len(careers))
n = len(available)
w = 0.8 / n
for i, (age, (label, color)) in enumerate(buy_age_cols.items()):
if age not in buy_data.columns:
continue
offset = (i - (n - 1) / 2) * w
ax.bar(x + offset, M(buy_data.reindex(careers)[age]), width=w,
label=label, color=color, alpha=0.85)
ax.set_xticks(x)
ax.set_xticklabels(careers, rotation=35, ha="right", fontsize=9)
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_ylabel("Median Net Worth at 50")
ax.set_title("Buy Age Comparison — Impact on Net Worth at 50\n(BUY_HOLD, best savings rate/deposit per bar)", fontweight="bold")
ax.legend()
save("10_buy_age_comparison")
# ── 11. Super balance by career ───────────────────────────────────────────────
print("[11] Super at 50 by career...")
super_data = df.groupby("career_path")["median_super_age50"].max().reindex(CAREER_ORDER).dropna()
fig, ax = plt.subplots(figsize=(11, 6))
colors_s = [PALETTE[c] for c in super_data.index]
bars = ax.barh(range(len(super_data)), M(super_data), color=colors_s, alpha=0.85)
ax.set_yticks(range(len(super_data)))
ax.set_yticklabels(super_data.index, fontsize=10)
ax.xaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_xlabel("Median Super Balance at 50")
ax.set_title("Superannuation Balance at Age 50 by Career\n(best scenario — note RAAF benefits from 16.4% employer rate)", fontweight="bold")
for bar, val in zip(bars, M(super_data)):
ax.text(val + 0.005, bar.get_y() + bar.get_height()/2, f"${val:.2f}M",
va="center", fontsize=8.5)
save("11_super_at_50")
# ── 12. Property value vs mortgage remaining (equity picture) ─────────────────
print("[12] Property equity picture...")
buy_scenarios = df[(df["strategy"] != "RENT_FOREVER") &
(df["median_property_value_age50"] > 0)].copy()
equity_best = (buy_scenarios.sort_values("net_worth_p50_age50", ascending=False)
.drop_duplicates("career_path")
.set_index("career_path")
.reindex(CAREER_ORDER)
.dropna(how="all"))
fig, ax = plt.subplots(figsize=(13, 7))
y = np.arange(len(equity_best))
prop_m = M(equity_best["median_property_value_age50"])
mort_m = M(equity_best["median_mortgage_remaining_age50"])
equity_m = prop_m - mort_m
ax.barh(y, equity_m, height=0.55, label="Property equity", color="#FF8F00", alpha=0.85)
ax.barh(y, mort_m, height=0.55, left=equity_m, label="Remaining mortgage", color="#EF5350", alpha=0.7)
ax.set_yticks(y)
ax.set_yticklabels(equity_best.index, fontsize=10)
ax.xaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_xlabel("Median value at 50")
ax.set_title("Property Equity vs Remaining Mortgage at 50\n(best scenario per career, property-owning paths only)", fontweight="bold")
ax.legend()
save("12_property_equity")
# ── 13. P50 vs P10 "floor" — downside protection ─────────────────────────────
print("[13] Downside floor (P10)...")
floor = (df.sort_values("net_worth_p10_age50", ascending=False)
.drop_duplicates("career_path")
.set_index("career_path")
.reindex(CAREER_ORDER)
.dropna(how="all"))
fig, ax = plt.subplots(figsize=(11, 6))
y = np.arange(len(floor))
ax.barh(y, M(floor["net_worth_p10_age50"]), height=0.45,
label="P10 (bad-luck floor)", color="#EF5350", alpha=0.85)
ax.scatter(M(floor["net_worth_p50_age50"]), y, color=[PALETTE[c] for c in floor.index],
s=70, zorder=5, label="P50 (median)", marker="D")
ax.set_yticks(y)
ax.set_yticklabels(floor.index, fontsize=10)
ax.xaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_xlabel("Net Worth at 50")
ax.set_title("Worst-Case Floor (P10) vs Median Net Worth at 50\n(best scenario per career)", fontweight="bold")
ax.legend()
save("13_downside_floor")
# ── 14. RENT_FOREVER vs best BUY scenario per career ─────────────────────────
print("[14] Rent vs buy comparison...")
rent_nw = df[df["strategy"] == "RENT_FOREVER"].groupby("career_path")["net_worth_p50_age50"].max()
buy_nw = df[df["strategy"] != "RENT_FOREVER"].groupby("career_path")["net_worth_p50_age50"].max()
compare = pd.DataFrame({"Rent forever": rent_nw, "Best buy scenario": buy_nw}).reindex(CAREER_ORDER).dropna()
fig, ax = plt.subplots(figsize=(13, 6))
x = np.arange(len(compare))
w = 0.35
ax.bar(x - w/2, M(compare["Rent forever"]), width=w, label="Rent forever", color="#78909C", alpha=0.85)
ax.bar(x + w/2, M(compare["Best buy scenario"]), width=w, label="Best buy scenario", color="#F57C00", alpha=0.85)
ax.set_xticks(x)
ax.set_xticklabels(compare.index, rotation=35, ha="right", fontsize=9)
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_ylabel("Median Net Worth at 50")
ax.set_title("Renting Forever vs Best Property Strategy — Net Worth at 50", fontweight="bold")
ax.legend()
save("14_rent_vs_buy")
# ── 15. Perth vs Geraldton vs Canberra median NW by career ───────────────────
print("[15] Location comparison per career...")
loc_best = (
df[df["strategy"] == "BUY_HOLD"]
.groupby(["career_path", "location"])["net_worth_p50_age50"]
.max()
.unstack("location")
.reindex(CAREER_ORDER)
)
loc_cols = ["GERALDTON_HOUSE", "PERTH", "CANBERRA"]
loc_names = {"GERALDTON_HOUSE": "Geraldton", "PERTH": "Perth", "CANBERRA": "Canberra"}
loc_colors = {"GERALDTON_HOUSE": "#8D6E63", "PERTH": "#1E88E5", "CANBERRA": "#43A047"}
fig, ax = plt.subplots(figsize=(13, 7))
x = np.arange(len(loc_best))
w = 0.26
for i, col in enumerate(loc_cols):
if col in loc_best.columns:
ax.bar(x + (i-1)*w, M(loc_best[col]), width=w,
label=loc_names[col], color=loc_colors[col], alpha=0.85)
ax.set_xticks(x)
ax.set_xticklabels(loc_best.index, rotation=35, ha="right", fontsize=9)
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_ylabel("Median Net Worth at 50")
ax.set_title("Where You Buy Matters — Net Worth at 50 by Location\n(BUY_HOLD, best savings/deposit/buy-age per bar)", fontweight="bold")
ax.legend()
save("15_location_comparison")
# ── 16. Geraldton land & build vs Geraldton house buy ─────────────────────────
print("[16] Land+build vs buy existing (Geraldton)...")
gld_hold = df[(df["location"] == "GERALDTON_HOUSE") & (df["strategy"] == "BUY_HOLD")]
gld_build = df[(df["location"] == "GERALDTON_LAND") & (df["strategy"] == "BUY_LAND_BUILD")]
gld_h = gld_hold.groupby("career_path")["net_worth_p50_age50"].max().reindex(CAREER_ORDER).dropna()
gld_b = gld_build.groupby("career_path")["net_worth_p50_age50"].max().reindex(CAREER_ORDER).dropna()
careers_gld = [c for c in CAREER_ORDER if c in gld_h.index or c in gld_b.index]
fig, ax = plt.subplots(figsize=(13, 6))
x = np.arange(len(careers_gld))
w = 0.35
ax.bar(x - w/2, M(gld_h.reindex(careers_gld)), width=w,
label="Buy existing house", color="#8D6E63", alpha=0.85)
ax.bar(x + w/2, M(gld_b.reindex(careers_gld)), width=w,
label="Buy land + build", color="#D7CCC8", alpha=0.85)
ax.set_xticks(x)
ax.set_xticklabels(careers_gld, rotation=35, ha="right", fontsize=9)
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_ylabel("Median Net Worth at 50")
ax.set_title("Geraldton: Buy Existing House vs Buy Land and Build\n(best savings/deposit/buy-age per bar)", fontweight="bold")
ax.legend()
save("16_geraldton_build_vs_buy")
# ── 17. Violin — full distribution of P50 outcomes by strategy ───────────────
print("[17] Distribution of P50 by strategy...")
fig, ax = plt.subplots(figsize=(10, 6))
strat_map = {"BUY_HOLD": "Buy & Hold", "BUY_LAND_BUILD": "Land + Build", "RENT_FOREVER": "Rent Forever"}
plot_df = df.copy()
plot_df["strategy_label"] = plot_df["strategy"].map(strat_map)
plot_df["nw_p50_m"] = M(plot_df["net_worth_p50_age50"])
order = ["Buy & Hold", "Land + Build", "Rent Forever"]
pal = {"Buy & Hold": "#1E88E5", "Land + Build": "#43A047", "Rent Forever": "#FB8C00"}
sns.violinplot(data=plot_df, x="strategy_label", y="nw_p50_m",
order=order, palette=pal, ax=ax, cut=0, inner="quartile")
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_xlabel("Strategy")
ax.set_ylabel("Median Net Worth at 50")
ax.set_title("Distribution of Median Net Worth at 50 by Property Strategy\n(all career paths, all parameter combos)", fontweight="bold")
save("17_violin_strategy_nw")
# ── 18. Scatter: savings rate vs NW, coloured by career ──────────────────────
print("[18] Savings rate vs NW scatter...")
fig, ax = plt.subplots(figsize=(11, 7))
for career in CAREER_ORDER:
sub = df[df["career_path"] == career]
ax.scatter(sub["savings_rate"], M(sub["net_worth_p50_age50"]),
color=PALETTE[career], alpha=0.25, s=12)
# Overlay means
means = df.groupby(["career_path", "savings_rate"])["net_worth_p50_age50"].mean().reset_index()
for career in CAREER_ORDER:
sub = means[means["career_path"] == career].sort_values("savings_rate")
ax.plot(sub["savings_rate"], M(sub["net_worth_p50_age50"]),
color=PALETTE[career], linewidth=2, label=career)
sr_ticks = sorted(df["savings_rate"].unique())
ax.set_xticks(sr_ticks)
ax.set_xticklabels([f"{int(s*100)}%" for s in sr_ticks])
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_xlabel("Savings Rate")
ax.set_ylabel("Median Net Worth at 50")
ax.set_title("Savings Rate vs Net Worth at 50 — All Careers\n(dots = individual scenarios, lines = average per savings rate)", fontweight="bold")
ax.legend(fontsize=7, ncol=2, loc="upper left")
save("18_savings_rate_vs_nw_scatter")
# ── 19. Median actual buy age by career + location ───────────────────────────
print("[19] When do they actually buy?...")
buy_age_data = (
df[df["strategy"] != "RENT_FOREVER"]
.dropna(subset=["median_actual_buy_age"])
.groupby(["career_path", "location"])["median_actual_buy_age"]
.min()
.unstack("location")
.reindex(CAREER_ORDER)
)
loc_cols3 = ["GERALDTON_HOUSE", "PERTH", "CANBERRA"]
fig, ax = plt.subplots(figsize=(13, 7))
x = np.arange(len(buy_age_data))
w = 0.26
for i, col in enumerate(loc_cols3):
if col in buy_age_data.columns:
ax.bar(x + (i-1)*w, buy_age_data[col], width=w,
label=loc_names[col], color=loc_colors[col], alpha=0.85)
ax.set_xticks(x)
ax.set_xticklabels(buy_age_data.index, rotation=35, ha="right", fontsize=9)
ax.axhline(23, color="green", linestyle="--", alpha=0.4, label="Age 23 target")
ax.axhline(25, color="purple", linestyle="--", alpha=0.4, label="Age 25 target")
ax.axhline(28, color="orange", linestyle="--", alpha=0.4, label="Age 28 target")
ax.set_ylabel("Median Actual Buy Age")
ax.set_title("When Can Each Career Actually Afford to Buy?\n(earliest feasible median buy age across deposit/savings combos)", fontweight="bold")
ax.legend()
save("19_actual_buy_age")
# ── 20. "Top 10 all-round" comparison — composite score ──────────────────────
print("[20] Top 10 composite leaderboard...")
# Composite score: normalise P50, P10, -range, debt-free pct, then average
scored = df.copy()
scored["pct_df_fill"] = scored["pct_debt_free_by_50"].fillna(0)
for col, weight in [("net_worth_p50_age50", 0.40),
("net_worth_p10_age50", 0.25),
("pct_df_fill", 0.20)]:
mn, mx = scored[col].min(), scored[col].max()
scored[f"norm_{col}"] = (scored[col] - mn) / (mx - mn) * weight
# Penalise high variance (lower is better)
mn, mx = scored["net_worth_range_age50"].min(), scored["net_worth_range_age50"].max()
scored["norm_range"] = (1 - (scored["net_worth_range_age50"] - mn) / (mx - mn)) * 0.15
scored["composite"] = (scored["norm_net_worth_p50_age50"] +
scored["norm_net_worth_p10_age50"] +
scored["norm_pct_df_fill"] +
scored["norm_range"])
top10 = scored.nlargest(10, "composite")[
["career_path","location","strategy","savings_rate","deposit_pct",
"net_worth_p50_age50","net_worth_p10_age50","pct_debt_free_by_50","composite"]
].reset_index(drop=True)
fig, ax = plt.subplots(figsize=(13, 5))
ax.axis("off")
table_data = []
for _, row in top10.iterrows():
table_data.append([
row["career_path"],
row["location"],
row["strategy"].replace("_", " ").title(),
f"{int(row['savings_rate']*100)}%",
f"{int(row['deposit_pct']*100)}%",
f"${row['net_worth_p50_age50']/1e6:.2f}M",
f"${row['net_worth_p10_age50']/1e6:.2f}M",
f"{row['pct_debt_free_by_50']:.0f}%",
f"{row['composite']:.3f}",
])
table = ax.table(
cellText=table_data,
colLabels=["Career","Location","Strategy","Save%","Dep%","P50 NW","P10 NW","Debt-free%","Score"],
loc="center", cellLoc="center",
)
table.auto_set_font_size(False)
table.set_fontsize(8.5)
table.scale(1, 1.6)
for j in range(9):
table[(0, j)].set_facecolor("#1565C0")
table[(0, j)].set_text_props(color="white", fontweight="bold")
for i in range(1, 11):
col = "#E3F2FD" if i % 2 == 0 else "white"
for j in range(9):
table[(i, j)].set_facecolor(col)
ax.set_title("Top 10 All-Round Scenarios — Composite Score\n(40% P50 wealth + 25% P10 floor + 20% debt-free chance + 15% low-variance)",
fontweight="bold", pad=20, y=0.98)
save("20_top10_composite")
# ── 21. Variable importance — tornado chart ──────────────────────────────────
print("[21] Variable importance (tornado)...")
# For each lever compute: (mean NW at best value) - (mean NW at worst value)
# This shows which knob moves the needle most.
prop_df = df[df["strategy"] != "RENT_FOREVER"].copy()
lever_swings = {}
# Career choice
career_means = df.groupby("career_path")["net_worth_p50_age50"].mean()
lever_swings["Career path"] = (career_means.max() - career_means.min(),
career_means.idxmax(), career_means.idxmin())
# Savings rate
sr_means = df.groupby("savings_rate")["net_worth_p50_age50"].mean()
best_sr = sr_means.idxmax(); worst_sr = sr_means.idxmin()
lever_swings["Savings rate"] = (sr_means.max() - sr_means.min(),
f"{int(best_sr*100)}%", f"{int(worst_sr*100)}%")
# Location (property buyers only)
loc_means = prop_df.groupby("location")["net_worth_p50_age50"].mean()
lever_swings["Location"] = (loc_means.max() - loc_means.min(),
loc_means.idxmax(), loc_means.idxmin())
# Deposit size
dep_means = prop_df.groupby("deposit_pct")["net_worth_p50_age50"].mean()
best_dep = dep_means.idxmax(); worst_dep = dep_means.idxmin()
lever_swings["Deposit size"] = (dep_means.max() - dep_means.min(),
f"{int(best_dep*100)}%", f"{int(worst_dep*100)}%")
# Buy age
buyage_means = prop_df.groupby("requested_buy_age")["net_worth_p50_age50"].mean()
best_ba = buyage_means.idxmax(); worst_ba = buyage_means.idxmin()
lever_swings["Buy age"] = (buyage_means.max() - buyage_means.min(),
f"Age {int(best_ba)}", f"Age {int(worst_ba)}")
levers_sorted = sorted(lever_swings.items(), key=lambda x: x[1][0])
labels = [k for k, _ in levers_sorted]
swings = [M(v[0]) for _, v in levers_sorted]
best_labels = [v[1] for _, v in levers_sorted]
worst_labels = [v[2] for _, v in levers_sorted]
fig, ax = plt.subplots(figsize=(11, 5))
colors_t = ["#1E88E5" if s == max(swings) else "#64B5F6" for s in swings]
bars = ax.barh(range(len(labels)), swings, color=colors_t, alpha=0.85)
ax.set_yticks(range(len(labels)))
ax.set_yticklabels(labels, fontsize=11)
ax.xaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_xlabel("Swing in Mean Net Worth at 50 (best vs worst value of that lever)")
ax.set_title("Which Lever Matters Most?\nVariability in Net Worth Attributable to Each Decision", fontweight="bold")
for i, (bar, swing, best, worst) in enumerate(zip(bars, swings, best_labels, worst_labels)):
ax.text(bar.get_width() + 0.01, bar.get_y() + bar.get_height()/2,
f"Best: {best} / Worst: {worst}", va="center", fontsize=9, color="#333")
ax.margins(x=0.32)
save("21_variable_importance_tornado")
# ── 22. Wealth trajectory (age 35 → 50) — top careers ────────────────────────
print("[22] Wealth trajectory over time...")
milestone_ages = [35, 40, 45, 50]
milestone_cols = ["net_worth_p50_age35", "net_worth_p50_age40",
"net_worth_p50_age45", "net_worth_p50_age50"]
# Best scenario per career (highest P50 at 50)
traj = (df.sort_values("net_worth_p50_age50", ascending=False)
.drop_duplicates("career_path")
.set_index("career_path")
.reindex(CAREER_ORDER)
.dropna(how="all"))
fig, ax = plt.subplots(figsize=(12, 7))
for career in traj.index:
row = traj.loc[career]
vals = [M(row[c]) for c in milestone_cols]
color = PALETTE.get(career, "grey")
ax.plot(milestone_ages, vals, color=color, linewidth=2, marker="o", markersize=5, label=career)
ax.annotate(career.replace(" Engineering", " Eng").replace("(FIFO)", ""),
(50, vals[-1]), fontsize=7.5,
textcoords="offset points", xytext=(5, 0), va="center", color=color)
ax.set_xlim(34, 58)
ax.set_xticks(milestone_ages)
ax.set_xticklabels([f"Age {a}" for a in milestone_ages])
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_ylabel("Median Net Worth (P50)")
ax.set_title("Wealth Trajectory — Age 35 to 50\n(best scenario per career: top savings rate, optimal deposit and buy age)", fontweight="bold")
ax.axhline(0, color="grey", linestyle="--", alpha=0.3)
save("22_wealth_trajectory")
# ── 23. Wealth at milestone ages (grouped bar) ────────────────────────────────
print("[23] Wealth at milestone ages...")
# Show median outcome (75% savings, 20% deposit, buy_age 25 or nearest) per career
# Use best scenario per career to keep it optimistic but show the growth arc
milestone_labels = ["Age 35", "Age 40", "Age 45", "Age 50"]
milestone_colors = ["#B3E5FC", "#4FC3F7", "#0288D1", "#01579B"]
top_n = 10
top_careers = (traj["net_worth_p50_age50"]
.dropna()
.sort_values(ascending=False)
.head(top_n)
.index.tolist())
fig, ax = plt.subplots(figsize=(13, 7))
x = np.arange(len(top_careers))
n_m = len(milestone_labels)
w = 0.78 / n_m
for i, (col, label, color) in enumerate(zip(milestone_cols, milestone_labels, milestone_colors)):
offset = (i - (n_m - 1) / 2) * w
vals = [M(traj.loc[c, col]) if c in traj.index else 0 for c in top_careers]
ax.bar(x + offset, vals, width=w, label=label, color=color, alpha=0.9)
ax.set_xticks(x)
ax.set_xticklabels(top_careers, rotation=35, ha="right", fontsize=9)
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_ylabel("Median Net Worth (P50)")
ax.set_title("Wealth Milestones — Net Worth at Ages 35, 40, 45 and 50\n(top 10 careers by final NW; best scenario per career)", fontweight="bold")
ax.legend()
save("23_wealth_milestones")
# ── 24. Opportunity cost vs best career ───────────────────────────────────────
print("[24] Opportunity cost chart...")
best_nw = traj["net_worth_p50_age50"].dropna()
top_career = best_nw.idxmax()
top_val = best_nw.max()
gaps = (top_val - best_nw).sort_values(ascending=True) # sorted: smallest gap at top
fig, ax = plt.subplots(figsize=(12, 7))
y = np.arange(len(gaps))
colors_oc = [PALETTE.get(c, "grey") for c in gaps.index]
bars = ax.barh(y, M(gaps), color=colors_oc, alpha=0.85)
ax.set_yticks(y)
ax.set_yticklabels(gaps.index, fontsize=10)
ax.xaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_xlabel(f"Median NW at 50 foregone vs {top_career}")
ax.set_title(f"Opportunity Cost — What Each Career Path Costs vs the Best\n(gap in median net worth at 50 relative to {top_career})", fontweight="bold")
for bar, val in zip(bars, M(gaps)):
if val > 0.01:
ax.text(val + 0.01, bar.get_y() + bar.get_height()/2,
f"${val:.2f}M", va="center", fontsize=8.5, color="#555")
else:
ax.text(0.01, bar.get_y() + bar.get_height()/2,
"← Best career", va="center", fontsize=8.5, color="#1565C0", fontweight="bold")
save("24_opportunity_cost")
# ── 25. Realistic median vs best case ────────────────────────────────────────
print("[25] Realistic median vs best case...")
# "Realistic median": 75% savings rate, 20% deposit, buy_age 25 (or 23 if 25 not available)
# vs "Best case": highest P50 per career across all combos
realistic_mask = (
(df["savings_rate"] == 0.75) &
(df["deposit_pct"] == 0.20) &
(df["requested_buy_age"].isin([25.0, 23.0]))
)
realistic = (df[realistic_mask]
.groupby("career_path")["net_worth_p50_age50"]
.max()
.reindex(CAREER_ORDER)
.dropna())
best_case = (df.groupby("career_path")["net_worth_p50_age50"]
.max()
.reindex(CAREER_ORDER)
.dropna())
compare2 = pd.DataFrame({"Realistic (75% save, 20% dep)": realistic,
"Best case": best_case}).dropna()
fig, ax = plt.subplots(figsize=(13, 7))
x = np.arange(len(compare2))
w = 0.35
ax.bar(x - w/2, M(compare2["Realistic (75% save, 20% dep)"]), width=w,
label="Realistic (75% savings, 20% deposit)", color="#66BB6A", alpha=0.85)
ax.bar(x + w/2, M(compare2["Best case"]), width=w,
label="Best case (optimal savings/deposit)", color="#FFA726", alpha=0.85)
ax.set_xticks(x)
ax.set_xticklabels(compare2.index, rotation=35, ha="right", fontsize=9)
ax.yaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_ylabel("Median Net Worth at 50")
ax.set_title("Realistic vs Best-Case Scenario — Median Net Worth at 50\n(realistic = 75% savings rate, 20% deposit, buy at 25)", fontweight="bold")
ax.legend()
save("25_realistic_vs_best_case")
# ── 26. Real vs nominal NW — stripping 30 years of inflation ─────────────────
print("[26] Real vs nominal net worth comparison...")
real_best = (df.sort_values("net_worth_p50_age50", ascending=False)
.drop_duplicates("career_path")
.set_index("career_path")
.reindex(CAREER_ORDER)
.dropna(how="all"))
fig, ax = plt.subplots(figsize=(13, 7))
y = np.arange(len(real_best))
nom = M(real_best["net_worth_p50_age50"])
real = M(real_best["real_nw_p50_age50"])
ax.barh(y - 0.18, nom, height=0.32, color="#90CAF9", alpha=0.9, label=f"Nominal (future $)")
ax.barh(y + 0.18, real, height=0.32, color="#1565C0", alpha=0.9, label=f"Real (2026 $, ÷{DEFL[50]:.2f})")
ax.set_yticks(y)
ax.set_yticklabels(real_best.index, fontsize=10)
ax.xaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_xlabel("Median Net Worth at Age 50")
ax.set_title(
f"Nominal vs Real (2026) Net Worth at 50 — Best Scenario per Career\n"
f"30 years of 3% CPI inflates nominal values by ×{DEFL[50]:.2f} · divide by that to get today's purchasing power",
fontweight="bold"
)
ax.legend()
save("26_real_vs_nominal_nw")
# ── 27. Accessible NW vs total NW (super is locked until 60) ─────────────────
print("[27] Accessible vs total net worth at 50...")
acc_best = real_best.copy()
acc_best["accessible"] = M(acc_best["accessible_nw_p50_age50"].clip(lower=0))
acc_best["locked_super"] = M(acc_best["median_super_age50"])
fig, ax = plt.subplots(figsize=(13, 7))
y = np.arange(len(acc_best))
ax.barh(y, acc_best["accessible"], height=0.55, color="#43A047", alpha=0.9, label="Accessible (liquid + property equity)")
ax.barh(y, acc_best["locked_super"], height=0.55, left=acc_best["accessible"],
color="#B0BEC5", alpha=0.7, label="Super (locked until age 60)")
ax.set_yticks(y)
ax.set_yticklabels(acc_best.index, fontsize=10)
ax.xaxis.set_major_formatter(mticker.FuncFormatter(fmt_m))
ax.set_xlabel("Median Net Worth at Age 50")
ax.set_title(
"Accessible vs Locked Wealth at Age 50\n"
"Super cannot be touched until preservation age (60) — this is the real liquidity picture",
fontweight="bold"
)
ax.legend()
save("27_accessible_vs_total_nw")
# ── Done ──────────────────────────────────────────────────────────────────────
files = sorted(OUTDIR.glob("*.png"))
print(f"\nDone — {len(files)} charts saved to {OUTDIR}/")
for f in files:
size_kb = f.stat().st_size // 1024
print(f" {f.name} ({size_kb} KB)")

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

BIN
graphs/11_super_at_50.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

BIN
graphs/14_rent_vs_buy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

BIN
graphs/graphs.zip Normal file

Binary file not shown.

41
leaderboard.csv Normal file
View file

@ -0,0 +1,41 @@
leaderboard_category,career_path,location,strategy,sell_age,deposit_pct,savings_rate,requested_buy_age,pct_scenarios_bought_property,median_actual_buy_age,pct_debt_free_by_50,median_debt_free_age,net_worth_p10_age50,net_worth_p50_age50,net_worth_p90_age50,net_worth_range_age50,median_liquid_age50,median_super_age50,median_property_value_age50,median_mortgage_remaining_age50,net_worth_p50_age35,net_worth_p50_age40,net_worth_p50_age45
TOP 10 — Max Wealth (P50),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.2,0.9,23,100,25,0,Not by 50,4951593,5791996,6728518,1776925,3007455,1585851.5,1345277,148938.5,1606818,2686547.5,4049027.5
TOP 10 — Max Wealth (P50),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.2,0.9,25,100,25,0,Not by 50,4951593,5791996,6728518,1776925,3009327.5,1585851.5,1345277,148812.5,1609793,2686547.5,4049027.5
TOP 10 — Max Wealth (P50),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.1,0.9,23,100,24,0,Not by 50,4913924,5786568,6799045,1885121,2998290,1567322,1392362,128469.5,1648287.5,2730663,4084652
TOP 10 — Max Wealth (P50),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.1,0.9,25,100,25,0,Not by 50,4918011,5769131,6654764,1736753,2991212,1598683,1345277,170982,1589019,2662579,4026979.5
TOP 10 — Max Wealth (P50),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,50,0.1,0.9,23,100,24,0,Not by 50,4893003,5708847,6716251,1823248,3634355.5,1524948,544590,0,1668193.5,2757109.5,4091286
TOP 10 — Max Wealth (P50),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,50,0.2,0.9,23,100,25,0,Not by 50,4975651,5656476,6549086,1573435,3610909,1472854,544590,0,1625199.5,2707302,4054751
TOP 10 — Max Wealth (P50),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,50,0.2,0.9,25,100,25,0,Not by 50,4975651,5656476,6549086,1573435,3610909,1472854,544590,0,1625899,2708494.5,4055046
TOP 10 — Max Wealth (P50),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.5,0.9,28,100,28,0,Not by 50,4908729,5647254,6550475,1641746,3029227.5,1603428.5,1213363,165193,1562508,2607417,3959311
TOP 10 — Max Wealth (P50),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.5,0.9,25,100,28,0,Not by 50,4908729,5636306,6550475,1641746,3029227.5,1599440,1213363,165193,1561581.5,2601832.5,3957456.5
TOP 10 — Max Wealth (P50),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.5,0.9,23,100,28,0,Not by 50,4904202,5631883,6550475,1646273,3029227.5,1595382.5,1213363,165125,1560401.5,2596371,3957456.5
TOP 10 — Fastest Debt Free,FIFO E&I,GERALDTON_HOUSE,BUY_HOLD,30,0.1,0.55,23,100,25,100,37,661193,1665789,2596876,1935683,-2551262.5,1469883,2708890,0,938246,1292336,1518891.5
TOP 10 — Fastest Debt Free,FIFO E&I,GERALDTON_HOUSE,BUY_HOLD,30,0.1,0.55,25,100,25,100,37,661193,1655025,2596876,1935683,-2551262.5,1456349,2708890,0,938246,1284195.5,1503531.5
TOP 10 — Fastest Debt Free,FIFO E&I,GERALDTON_HOUSE,BUY_HOLD,30,0.1,0.65,23,100,25,100,37,1384476,2388336,3465941,2081465,-1829746.5,1458364.5,2716808.5,0,1090649.5,1539847.5,1938442
TOP 10 — Fastest Debt Free,FIFO E&I,GERALDTON_HOUSE,BUY_HOLD,30,0.1,0.65,25,100,25,100,37,1384476,2388336,3465941,2081465,-1831385.5,1458364.5,2716808.5,0,1090649.5,1539847.5,1938442
TOP 10 — Fastest Debt Free,FIFO E&I,GERALDTON_HOUSE,BUY_HOLD,30,0.1,0.75,23,100,25,100,37,1949803,3020410,4144950,2195147,-1171766,1460360.5,2691783.5,0,1227972.5,1784499,2355633.5
TOP 10 — Fastest Debt Free,FIFO E&I,GERALDTON_HOUSE,BUY_HOLD,30,0.1,0.75,25,100,25,100,37,1921350,2999462,4129640,2208290,-1181086,1446563,2703063.5,0,1230098,1754684.5,2321551
TOP 10 — Fastest Debt Free,FIFO E&I,GERALDTON_HOUSE,BUY_HOLD,30,0.1,0.85,23,100,24,100,37,2727597,3768840,4756295,2028698,-488647.5,1462844.5,2731041,0,1397027,2092798.5,2822208
TOP 10 — Fastest Debt Free,FIFO E&I,GERALDTON_HOUSE,BUY_HOLD,30,0.1,0.85,25,100,25,100,37,2503945,3609232,4793338,2289393,-543312.5,1456349,2703063.5,0,1364748,1979688.5,2714515.5
TOP 10 — Fastest Debt Free,FIFO E&I,GERALDTON_HOUSE,BUY_HOLD,30,0.1,0.9,23,100,24,100,37,3024901,4080885,5031717,2006816,-171138.5,1462844.5,2725685,0,1459643,2200130.5,3006072
TOP 10 — Fastest Debt Free,FIFO E&I,GERALDTON_HOUSE,BUY_HOLD,30,0.1,0.9,25,100,25,100,37,2801541,3926451,5069605,2268064,-223705.5,1456349,2703063.5,0,1432133.5,2101748,2912856
"TOP 10 — Most Reliable (low variance, above-median wealth)",Trade → Bridge → Engineering,PERTH,BUY_HOLD,50,0.5,0.9,28,0,Never,0,Not by 50,1740446,2051157,2537912,797466,1161320.5,911586.5,0,0,597722.5,975464.5,1447991
"TOP 10 — Most Reliable (low variance, above-median wealth)",Trade → Bridge → Engineering,PERTH,BUY_HOLD,50,0.5,0.85,23,0,Never,0,Not by 50,1673809,1986152,2474402,800593,1096755,913591.5,0,0,575268,942240.5,1393832
"TOP 10 — Most Reliable (low variance, above-median wealth)",Cybersecurity,GERALDTON_LAND,BUY_LAND_BUILD,N/A,0.2,0.85,23,100,25,0,Not by 50,3140824,3456252,3949525,808701,2126168,923293.5,428043,0,948468,1575703,2408454.5
"TOP 10 — Most Reliable (low variance, above-median wealth)",Cybersecurity,GERALDTON_LAND,BUY_LAND_BUILD,N/A,0.2,0.85,25,100,25,0,Not by 50,3140824,3452505,3949525,808701,2126168,921137.5,428043,0,948468,1575703,2406755.5
"TOP 10 — Most Reliable (low variance, above-median wealth)",Cybersecurity,GERALDTON_LAND,BUY_LAND_BUILD,N/A,0.2,0.9,23,100,25,0,Not by 50,3273753,3587314,4087832,814079,2258788.5,921137.5,428043,0,981246.5,1635416,2500958
"TOP 10 — Most Reliable (low variance, above-median wealth)",Mechatronics/Robotics Engineering,CANBERRA,BUY_HOLD,30,0.5,0.9,23,0,Never,0,Not by 50,1678621,1999192,2494300,815679,1119451,891555,0,0,656290.5,1017199,1461702.5
"TOP 10 — Most Reliable (low variance, above-median wealth)",Cybersecurity,GERALDTON_LAND,BUY_LAND_BUILD,N/A,0.2,0.55,28,100,28,0,Not by 50,2246886,2565685,3063873,816987,1279833.5,929543,386070,0,701298,1177271,1762925.5
"TOP 10 — Most Reliable (low variance, above-median wealth)",Mechatronics/Robotics Engineering,GERALDTON_LAND,BUY_LAND_BUILD,N/A,0.2,0.55,23,100,27,0,Not by 50,2156406,2433717,2977175,820769,1177770.5,863480,399582,0,694451,1140694,1708653.5
"TOP 10 — Most Reliable (low variance, above-median wealth)",Mechatronics/Robotics Engineering,GERALDTON_LAND,BUY_LAND_BUILD,N/A,0.2,0.55,25,100,27,0,Not by 50,2156406,2433717,2977175,820769,1178084,863480,399582,0,694276,1139309,1708653.5
"TOP 10 — Most Reliable (low variance, above-median wealth)",Cybersecurity,GERALDTON_LAND,BUY_LAND_BUILD,N/A,0.1,0.55,25,100,26,0,Not by 50,2257156,2629973,3078264,821108,1299539,902282.5,413568,0,741431,1210443,1819275
TOP 10 — Best Worst-Case (highest P10),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,50,0.2,0.9,23,100,25,0,Not by 50,4975651,5656476,6549086,1573435,3610909,1472854,544590,0,1625199.5,2707302,4054751
TOP 10 — Best Worst-Case (highest P10),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,50,0.2,0.9,25,100,25,0,Not by 50,4975651,5656476,6549086,1573435,3610909,1472854,544590,0,1625899,2708494.5,4055046
TOP 10 — Best Worst-Case (highest P10),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,50,0.1,0.9,25,100,25,0,Not by 50,4964343,5618279,6648050,1683707,3578462,1478897,544590,0,1604617.5,2668573,4019670
TOP 10 — Best Worst-Case (highest P10),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.2,0.9,23,100,25,0,Not by 50,4951593,5791996,6728518,1776925,3007455,1585851.5,1345277,148938.5,1606818,2686547.5,4049027.5
TOP 10 — Best Worst-Case (highest P10),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.2,0.9,25,100,25,0,Not by 50,4951593,5791996,6728518,1776925,3009327.5,1585851.5,1345277,148812.5,1609793,2686547.5,4049027.5
TOP 10 — Best Worst-Case (highest P10),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.1,0.9,25,100,25,0,Not by 50,4918011,5769131,6654764,1736753,2991212,1598683,1345277,170982,1589019,2662579,4026979.5
TOP 10 — Best Worst-Case (highest P10),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.1,0.9,23,100,24,0,Not by 50,4913924,5786568,6799045,1885121,2998290,1567322,1392362,128469.5,1648287.5,2730663,4084652
TOP 10 — Best Worst-Case (highest P10),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.5,0.9,25,100,28,0,Not by 50,4908729,5636306,6550475,1641746,3029227.5,1599440,1213363,165193,1561581.5,2601832.5,3957456.5
TOP 10 — Best Worst-Case (highest P10),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.5,0.9,28,100,28,0,Not by 50,4908729,5647254,6550475,1641746,3029227.5,1603428.5,1213363,165193,1562508,2607417,3959311
TOP 10 — Best Worst-Case (highest P10),FIFO — Supervisor track,GERALDTON_HOUSE,BUY_HOLD,N/A,0.5,0.9,23,100,28,0,Not by 50,4904202,5631883,6550475,1646273,3029227.5,1595382.5,1213363,165125,1560401.5,2596371,3957456.5
1 leaderboard_category career_path location strategy sell_age deposit_pct savings_rate requested_buy_age pct_scenarios_bought_property median_actual_buy_age pct_debt_free_by_50 median_debt_free_age net_worth_p10_age50 net_worth_p50_age50 net_worth_p90_age50 net_worth_range_age50 median_liquid_age50 median_super_age50 median_property_value_age50 median_mortgage_remaining_age50 net_worth_p50_age35 net_worth_p50_age40 net_worth_p50_age45
2 TOP 10 — Max Wealth (P50) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.2 0.9 23 100 25 0 Not by 50 4951593 5791996 6728518 1776925 3007455 1585851.5 1345277 148938.5 1606818 2686547.5 4049027.5
3 TOP 10 — Max Wealth (P50) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.2 0.9 25 100 25 0 Not by 50 4951593 5791996 6728518 1776925 3009327.5 1585851.5 1345277 148812.5 1609793 2686547.5 4049027.5
4 TOP 10 — Max Wealth (P50) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.1 0.9 23 100 24 0 Not by 50 4913924 5786568 6799045 1885121 2998290 1567322 1392362 128469.5 1648287.5 2730663 4084652
5 TOP 10 — Max Wealth (P50) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.1 0.9 25 100 25 0 Not by 50 4918011 5769131 6654764 1736753 2991212 1598683 1345277 170982 1589019 2662579 4026979.5
6 TOP 10 — Max Wealth (P50) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD 50 0.1 0.9 23 100 24 0 Not by 50 4893003 5708847 6716251 1823248 3634355.5 1524948 544590 0 1668193.5 2757109.5 4091286
7 TOP 10 — Max Wealth (P50) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD 50 0.2 0.9 23 100 25 0 Not by 50 4975651 5656476 6549086 1573435 3610909 1472854 544590 0 1625199.5 2707302 4054751
8 TOP 10 — Max Wealth (P50) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD 50 0.2 0.9 25 100 25 0 Not by 50 4975651 5656476 6549086 1573435 3610909 1472854 544590 0 1625899 2708494.5 4055046
9 TOP 10 — Max Wealth (P50) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.5 0.9 28 100 28 0 Not by 50 4908729 5647254 6550475 1641746 3029227.5 1603428.5 1213363 165193 1562508 2607417 3959311
10 TOP 10 — Max Wealth (P50) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.5 0.9 25 100 28 0 Not by 50 4908729 5636306 6550475 1641746 3029227.5 1599440 1213363 165193 1561581.5 2601832.5 3957456.5
11 TOP 10 — Max Wealth (P50) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.5 0.9 23 100 28 0 Not by 50 4904202 5631883 6550475 1646273 3029227.5 1595382.5 1213363 165125 1560401.5 2596371 3957456.5
12 TOP 10 — Fastest Debt Free FIFO E&I GERALDTON_HOUSE BUY_HOLD 30 0.1 0.55 23 100 25 100 37 661193 1665789 2596876 1935683 -2551262.5 1469883 2708890 0 938246 1292336 1518891.5
13 TOP 10 — Fastest Debt Free FIFO E&I GERALDTON_HOUSE BUY_HOLD 30 0.1 0.55 25 100 25 100 37 661193 1655025 2596876 1935683 -2551262.5 1456349 2708890 0 938246 1284195.5 1503531.5
14 TOP 10 — Fastest Debt Free FIFO E&I GERALDTON_HOUSE BUY_HOLD 30 0.1 0.65 23 100 25 100 37 1384476 2388336 3465941 2081465 -1829746.5 1458364.5 2716808.5 0 1090649.5 1539847.5 1938442
15 TOP 10 — Fastest Debt Free FIFO E&I GERALDTON_HOUSE BUY_HOLD 30 0.1 0.65 25 100 25 100 37 1384476 2388336 3465941 2081465 -1831385.5 1458364.5 2716808.5 0 1090649.5 1539847.5 1938442
16 TOP 10 — Fastest Debt Free FIFO E&I GERALDTON_HOUSE BUY_HOLD 30 0.1 0.75 23 100 25 100 37 1949803 3020410 4144950 2195147 -1171766 1460360.5 2691783.5 0 1227972.5 1784499 2355633.5
17 TOP 10 — Fastest Debt Free FIFO E&I GERALDTON_HOUSE BUY_HOLD 30 0.1 0.75 25 100 25 100 37 1921350 2999462 4129640 2208290 -1181086 1446563 2703063.5 0 1230098 1754684.5 2321551
18 TOP 10 — Fastest Debt Free FIFO E&I GERALDTON_HOUSE BUY_HOLD 30 0.1 0.85 23 100 24 100 37 2727597 3768840 4756295 2028698 -488647.5 1462844.5 2731041 0 1397027 2092798.5 2822208
19 TOP 10 — Fastest Debt Free FIFO E&I GERALDTON_HOUSE BUY_HOLD 30 0.1 0.85 25 100 25 100 37 2503945 3609232 4793338 2289393 -543312.5 1456349 2703063.5 0 1364748 1979688.5 2714515.5
20 TOP 10 — Fastest Debt Free FIFO E&I GERALDTON_HOUSE BUY_HOLD 30 0.1 0.9 23 100 24 100 37 3024901 4080885 5031717 2006816 -171138.5 1462844.5 2725685 0 1459643 2200130.5 3006072
21 TOP 10 — Fastest Debt Free FIFO E&I GERALDTON_HOUSE BUY_HOLD 30 0.1 0.9 25 100 25 100 37 2801541 3926451 5069605 2268064 -223705.5 1456349 2703063.5 0 1432133.5 2101748 2912856
22 TOP 10 — Most Reliable (low variance, above-median wealth) Trade → Bridge → Engineering PERTH BUY_HOLD 50 0.5 0.9 28 0 Never 0 Not by 50 1740446 2051157 2537912 797466 1161320.5 911586.5 0 0 597722.5 975464.5 1447991
23 TOP 10 — Most Reliable (low variance, above-median wealth) Trade → Bridge → Engineering PERTH BUY_HOLD 50 0.5 0.85 23 0 Never 0 Not by 50 1673809 1986152 2474402 800593 1096755 913591.5 0 0 575268 942240.5 1393832
24 TOP 10 — Most Reliable (low variance, above-median wealth) Cybersecurity GERALDTON_LAND BUY_LAND_BUILD N/A 0.2 0.85 23 100 25 0 Not by 50 3140824 3456252 3949525 808701 2126168 923293.5 428043 0 948468 1575703 2408454.5
25 TOP 10 — Most Reliable (low variance, above-median wealth) Cybersecurity GERALDTON_LAND BUY_LAND_BUILD N/A 0.2 0.85 25 100 25 0 Not by 50 3140824 3452505 3949525 808701 2126168 921137.5 428043 0 948468 1575703 2406755.5
26 TOP 10 — Most Reliable (low variance, above-median wealth) Cybersecurity GERALDTON_LAND BUY_LAND_BUILD N/A 0.2 0.9 23 100 25 0 Not by 50 3273753 3587314 4087832 814079 2258788.5 921137.5 428043 0 981246.5 1635416 2500958
27 TOP 10 — Most Reliable (low variance, above-median wealth) Mechatronics/Robotics Engineering CANBERRA BUY_HOLD 30 0.5 0.9 23 0 Never 0 Not by 50 1678621 1999192 2494300 815679 1119451 891555 0 0 656290.5 1017199 1461702.5
28 TOP 10 — Most Reliable (low variance, above-median wealth) Cybersecurity GERALDTON_LAND BUY_LAND_BUILD N/A 0.2 0.55 28 100 28 0 Not by 50 2246886 2565685 3063873 816987 1279833.5 929543 386070 0 701298 1177271 1762925.5
29 TOP 10 — Most Reliable (low variance, above-median wealth) Mechatronics/Robotics Engineering GERALDTON_LAND BUY_LAND_BUILD N/A 0.2 0.55 23 100 27 0 Not by 50 2156406 2433717 2977175 820769 1177770.5 863480 399582 0 694451 1140694 1708653.5
30 TOP 10 — Most Reliable (low variance, above-median wealth) Mechatronics/Robotics Engineering GERALDTON_LAND BUY_LAND_BUILD N/A 0.2 0.55 25 100 27 0 Not by 50 2156406 2433717 2977175 820769 1178084 863480 399582 0 694276 1139309 1708653.5
31 TOP 10 — Most Reliable (low variance, above-median wealth) Cybersecurity GERALDTON_LAND BUY_LAND_BUILD N/A 0.1 0.55 25 100 26 0 Not by 50 2257156 2629973 3078264 821108 1299539 902282.5 413568 0 741431 1210443 1819275
32 TOP 10 — Best Worst-Case (highest P10) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD 50 0.2 0.9 23 100 25 0 Not by 50 4975651 5656476 6549086 1573435 3610909 1472854 544590 0 1625199.5 2707302 4054751
33 TOP 10 — Best Worst-Case (highest P10) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD 50 0.2 0.9 25 100 25 0 Not by 50 4975651 5656476 6549086 1573435 3610909 1472854 544590 0 1625899 2708494.5 4055046
34 TOP 10 — Best Worst-Case (highest P10) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD 50 0.1 0.9 25 100 25 0 Not by 50 4964343 5618279 6648050 1683707 3578462 1478897 544590 0 1604617.5 2668573 4019670
35 TOP 10 — Best Worst-Case (highest P10) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.2 0.9 23 100 25 0 Not by 50 4951593 5791996 6728518 1776925 3007455 1585851.5 1345277 148938.5 1606818 2686547.5 4049027.5
36 TOP 10 — Best Worst-Case (highest P10) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.2 0.9 25 100 25 0 Not by 50 4951593 5791996 6728518 1776925 3009327.5 1585851.5 1345277 148812.5 1609793 2686547.5 4049027.5
37 TOP 10 — Best Worst-Case (highest P10) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.1 0.9 25 100 25 0 Not by 50 4918011 5769131 6654764 1736753 2991212 1598683 1345277 170982 1589019 2662579 4026979.5
38 TOP 10 — Best Worst-Case (highest P10) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.1 0.9 23 100 24 0 Not by 50 4913924 5786568 6799045 1885121 2998290 1567322 1392362 128469.5 1648287.5 2730663 4084652
39 TOP 10 — Best Worst-Case (highest P10) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.5 0.9 25 100 28 0 Not by 50 4908729 5636306 6550475 1641746 3029227.5 1599440 1213363 165193 1561581.5 2601832.5 3957456.5
40 TOP 10 — Best Worst-Case (highest P10) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.5 0.9 28 100 28 0 Not by 50 4908729 5647254 6550475 1641746 3029227.5 1603428.5 1213363 165193 1562508 2607417 3959311
41 TOP 10 — Best Worst-Case (highest P10) FIFO — Supervisor track GERALDTON_HOUSE BUY_HOLD N/A 0.5 0.9 23 100 28 0 Not by 50 4904202 5631883 6550475 1646273 3029227.5 1595382.5 1213363 165125 1560401.5 2596371 3957456.5

BIN
src.zip Normal file

Binary file not shown.

325
src/income_schedules.rs Normal file
View file

@ -0,0 +1,325 @@
use std::collections::HashMap;
const BUNNINGS_FT: f64 = 29.0 * 38.0 * 52.0; // 57,304
const BUNNINGS_PT: f64 = (16.0 * 29.0 + 4.0 * 36.25) * 48.0; // 29,232
const ETU_APP: [f64; 4] = [33948.0, 39915.0, 42879.0, 50052.0];
// 2025-26 Commonwealth Supported Place student contributions (full rate, no discount)
// The ATO's one-off 20% balance reduction (Education Amendment Act 2024) applied only to
// debt already accrued by June 2025 — it is NOT a permanent feature of new debt from 2026+
pub const HECS_ENG: f64 = 9314.0 * 4.0; // 37,256
pub const HECS_CS: f64 = 9314.0 * 3.0; // 27,942
pub const HECS_NONE: f64 = 0.0;
pub type Schedule = HashMap<u32, f64>;
fn sched_from(base: &[(u32, f64)]) -> Schedule {
base.iter().cloned().collect()
}
fn fill_prog(sched: &mut Schedule, start_yr: u32, prog: &[f64]) {
for (i, &val) in prog.iter().enumerate() {
sched.insert(start_yr + i as u32, val);
}
}
pub fn path_fifo() -> (Schedule, f64) {
let mut s = sched_from(&[
(1, ETU_APP[0]), (2, ETU_APP[1]), (3, ETU_APP[2]), (4, ETU_APP[3]), (5, 80000.0),
(6, 145000.0), (7, 147500.0), (8, 150000.0), (9, 165000.0),
]);
for fy in 5..29u32 {
let yr = 5 + fy;
s.insert(yr, f64::min(200000.0, 165000.0 + (fy as f64 - 4.0) * 1500.0));
}
(s, HECS_NONE)
}
pub fn path_rf_engineering() -> (Schedule, f64) {
// SalaryExpert 2025: RF Engineer grad avg ~$97k, senior (8+yr) ~$170k
// Conservative: $87k grad (10% below average), senior cap $165k
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, BUNNINGS_PT), (3, BUNNINGS_PT), (4, BUNNINGS_PT), (5, BUNNINGS_PT),
]);
let prog = [
87000.0,92000.0,97000.0,102000.0,107000.0,112000.0,117000.0,121000.0,124000.0,127000.0,
130000.0,133000.0,136000.0,138000.0,140000.0,142000.0,144000.0,146000.0,148000.0,150000.0,
152000.0,154000.0,157000.0,159000.0,161000.0,163000.0,164000.0,165000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_ENG)
}
pub fn path_raaf() -> (Schedule, f64) {
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, 34220.0), (3, 44000.0), (4, 54000.0), (5, 63815.0),
]);
let prog = [
84000.0,86000.0,88000.0,91000.0,94000.0,97000.0,100000.0,103000.0,106000.0,109000.0,
112000.0,115000.0,118000.0,121000.0,124000.0,127000.0,130000.0,133000.0,136000.0,139000.0,
142000.0,145000.0,148000.0,151000.0,154000.0,157000.0,160000.0,163000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_NONE)
}
pub fn path_local_trade() -> (Schedule, f64) {
// SEEK mid-2025: Geraldton electrician avg ~$140k (includes FIFO-adjacent work)
// Conservative local-only: $90k qualified, ceiling $118k — about 15% below SEEK avg
let mut s = sched_from(&[
(1, ETU_APP[0]), (2, ETU_APP[1]), (3, ETU_APP[2]), (4, ETU_APP[3]),
]);
let prog = [
90000.0,93000.0,96000.0,99000.0,101000.0,103000.0,105000.0,107000.0,108000.0,109000.0,
110000.0,111000.0,111000.0,112000.0,112000.0,113000.0,113000.0,114000.0,114000.0,115000.0,
115000.0,116000.0,116000.0,117000.0,117000.0,118000.0,118000.0,118000.0,118000.0,
];
fill_prog(&mut s, 5, &prog);
(s, HECS_NONE)
}
pub fn path_eng_canberra() -> (Schedule, f64) {
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, BUNNINGS_PT), (3, BUNNINGS_PT), (4, BUNNINGS_PT), (5, BUNNINGS_PT),
]);
let prog = [
80000.0,86000.0,92000.0,98000.0,104000.0,110000.0,115000.0,119000.0,123000.0,127000.0,
130000.0,133000.0,136000.0,139000.0,141000.0,143000.0,145000.0,147000.0,149000.0,151000.0,
153000.0,155000.0,157000.0,159000.0,161000.0,163000.0,165000.0,167000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_ENG)
}
pub fn path_fifo_supervisor() -> (Schedule, f64) {
let (mut s, _) = path_fifo();
for yr in 14..=33u32 {
if let Some(v) = s.get(&yr).cloned() {
s.insert(yr, (v * 1.08).round());
}
}
(s, HECS_NONE)
}
pub fn path_residential_mining() -> (Schedule, f64) {
// Research: Karratha/Port Hedland resident electrician $140k-$187k annually
// Conservative: $115k entry (1st local role), ceiling $155k (15+ yrs experience)
let mut s = sched_from(&[
(1, ETU_APP[0]), (2, ETU_APP[1]), (3, ETU_APP[2]), (4, ETU_APP[3]),
]);
let prog = [
115000.0,120000.0,125000.0,130000.0,135000.0,138000.0,140000.0,142000.0,144000.0,146000.0,
147000.0,148000.0,149000.0,150000.0,151000.0,151000.0,152000.0,152000.0,153000.0,153000.0,
154000.0,154000.0,154000.0,155000.0,155000.0,155000.0,155000.0,155000.0,155000.0,
];
fill_prog(&mut s, 5, &prog);
(s, HECS_NONE)
}
pub fn path_bridge_to_engineering() -> (Schedule, f64) {
let mut s = sched_from(&[
(1, ETU_APP[0]), (2, ETU_APP[1]), (3, ETU_APP[2]), (4, ETU_APP[3]),
(5, 78000.0), (6, 40000.0), (7, 40000.0), (8, 40000.0),
]);
let prog = [
75000.0,80000.0,85000.0,90000.0,95000.0,100000.0,104000.0,108000.0,112000.0,115000.0,
118000.0,121000.0,124000.0,126000.0,128000.0,130000.0,132000.0,134000.0,136000.0,138000.0,
140000.0,142000.0,144000.0,146000.0,
];
fill_prog(&mut s, 9, &prog);
(s, HECS_ENG)
}
pub fn path_software_engineering() -> (Schedule, f64) {
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, BUNNINGS_PT), (3, BUNNINGS_PT), (4, BUNNINGS_PT), (5, BUNNINGS_PT),
]);
let prog = [
75000.0,80000.0,85000.0,90000.0,94000.0,97000.0,100000.0,102000.0,104000.0,106000.0,
108000.0,109000.0,110000.0,111000.0,112000.0,113000.0,113000.0,114000.0,114000.0,115000.0,
115000.0,115000.0,115000.0,115000.0,115000.0,115000.0,115000.0,115000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_CS)
}
pub fn path_cybersecurity() -> (Schedule, f64) {
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, BUNNINGS_PT), (3, BUNNINGS_PT), (4, BUNNINGS_PT), (5, BUNNINGS_PT),
]);
let prog = [
68000.0,73000.0,78000.0,83000.0,88000.0,92000.0,96000.0,100000.0,104000.0,107000.0,
110000.0,113000.0,116000.0,118000.0,120000.0,122000.0,124000.0,126000.0,128000.0,130000.0,
132000.0,134000.0,135000.0,136000.0,137000.0,138000.0,139000.0,140000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_CS)
}
pub fn path_aerospace_engineering() -> (Schedule, f64) {
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, BUNNINGS_PT), (3, BUNNINGS_PT), (4, BUNNINGS_PT), (5, BUNNINGS_PT),
]);
let prog = [
72000.0,77000.0,82000.0,87000.0,91000.0,95000.0,99000.0,102000.0,105000.0,108000.0,
111000.0,113000.0,115000.0,117000.0,119000.0,121000.0,122000.0,123000.0,124000.0,125000.0,
126000.0,127000.0,128000.0,129000.0,130000.0,130000.0,130000.0,130000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_ENG)
}
pub fn path_mechatronics_robotics() -> (Schedule, f64) {
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, BUNNINGS_PT), (3, BUNNINGS_PT), (4, BUNNINGS_PT), (5, BUNNINGS_PT),
]);
let prog = [
68000.0,73000.0,78000.0,83000.0,87000.0,91000.0,95000.0,98000.0,101000.0,104000.0,
107000.0,109000.0,111000.0,113000.0,115000.0,117000.0,119000.0,121000.0,122000.0,123000.0,
124000.0,125000.0,126000.0,127000.0,128000.0,128000.0,128000.0,128000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_ENG)
}
pub fn path_data_ai_engineering() -> (Schedule, f64) {
// Glassdoor Perth AI Engineer avg $150k, entry $119k; SEEK data scientist $78-125k
// Conservative data scientist entry $80k (Perth market), senior data/AI cap $155k
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, BUNNINGS_PT), (3, BUNNINGS_PT), (4, BUNNINGS_PT), (5, BUNNINGS_PT),
]);
let prog = [
80000.0,86000.0,92000.0,98000.0,104000.0,110000.0,115000.0,119000.0,123000.0,127000.0,
130000.0,133000.0,136000.0,139000.0,141000.0,143000.0,145000.0,147000.0,149000.0,151000.0,
152000.0,153000.0,154000.0,155000.0,155000.0,155000.0,155000.0,155000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_ENG)
}
pub fn path_mining_engineer_fifo() -> (Schedule, f64) {
// ResourceJobs/Terratern 2025: grad $95k-$130k, senior 10yr+ $220k-$250k+
// Conservative: $100k grad (below mid-market), ceiling $235k
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, BUNNINGS_PT), (3, BUNNINGS_PT), (4, BUNNINGS_PT), (5, BUNNINGS_PT),
]);
let prog = [
100000.0, 110000.0, 120000.0, 130000.0, 140000.0, 150000.0, 160000.0, 170000.0,
178000.0, 185000.0, 192000.0, 198000.0, 204000.0, 209000.0, 214000.0, 218000.0,
222000.0, 225000.0, 228000.0, 230000.0, 232000.0, 233000.0, 234000.0, 235000.0,
235000.0, 235000.0, 235000.0, 235000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_ENG)
}
pub fn path_petroleum_engineer_fifo() -> (Schedule, f64) {
// SalaryExpert/CDR 2025: grad $80k-$101k, senior $200k-$255k with FIFO allowances
// Conservative: $90k grad, ceiling $230k; oil & gas market narrower than mining
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, BUNNINGS_PT), (3, BUNNINGS_PT), (4, BUNNINGS_PT), (5, BUNNINGS_PT),
]);
let prog = [
90000.0, 102000.0, 114000.0, 125000.0, 136000.0, 146000.0, 156000.0, 165000.0,
173000.0, 181000.0, 188000.0, 194000.0, 200000.0, 205000.0, 210000.0, 214000.0,
218000.0, 221000.0, 224000.0, 226000.0, 228000.0, 229000.0, 230000.0, 230000.0,
230000.0, 230000.0, 230000.0, 230000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_ENG)
}
pub fn path_security_architect() -> (Schedule, f64) {
// CS degree → analyst → senior → architect track
// SalaryExpert/Glassdoor 2025: entry architect avg $137k; senior (8yr+) avg $234k
// Conservative: $73k grad (junior analyst), ceiling $228k
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, BUNNINGS_PT), (3, BUNNINGS_PT), (4, BUNNINGS_PT), (5, BUNNINGS_PT),
]);
let prog = [
73000.0, 84000.0, 95000.0, 107000.0, 120000.0, 135000.0, 148000.0, 160000.0,
170000.0, 179000.0, 187000.0, 194000.0, 200000.0, 205000.0, 210000.0, 214000.0,
218000.0, 221000.0, 223000.0, 225000.0, 226000.0, 227000.0, 228000.0, 228000.0,
228000.0, 228000.0, 228000.0, 228000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_CS)
}
pub fn path_cloud_solutions_architect() -> (Schedule, f64) {
// CS degree → cloud engineer → solutions architect
// Glassdoor/Clicks 2025: senior cloud architect avg $195k; Perth senior $232k-$268k
// Conservative: $82k grad (cloud/infra focus), ceiling $230k
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, BUNNINGS_PT), (3, BUNNINGS_PT), (4, BUNNINGS_PT), (5, BUNNINGS_PT),
]);
let prog = [
82000.0, 94000.0, 107000.0, 119000.0, 131000.0, 143000.0, 155000.0, 166000.0,
175000.0, 183000.0, 191000.0, 197000.0, 203000.0, 208000.0, 213000.0, 217000.0,
220000.0, 223000.0, 225000.0, 227000.0, 228000.0, 229000.0, 230000.0, 230000.0,
230000.0, 230000.0, 230000.0, 230000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_CS)
}
pub fn path_fifo_electrical_engineer() -> (Schedule, f64) {
// SEEK 2025: HV electrical engineer FIFO $120k-$150k+ (salaried, mid-career)
// Globe 24-7: entry $90k-$120k, mid $130k-$160k; site allowances push ceiling above office engineer
// Conservative: $105k grad, ceiling $220k (salaried — not contracting rates)
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, BUNNINGS_PT), (3, BUNNINGS_PT), (4, BUNNINGS_PT), (5, BUNNINGS_PT),
]);
let prog = [
105000.0, 116000.0, 127000.0, 137000.0, 147000.0, 156000.0, 164000.0, 171000.0,
177000.0, 183000.0, 188000.0, 193000.0, 197000.0, 200000.0, 203000.0, 206000.0,
208000.0, 210000.0, 213000.0, 215000.0, 217000.0, 218000.0, 219000.0, 220000.0,
220000.0, 220000.0, 220000.0, 220000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_ENG)
}
pub fn path_fifo_ic_engineer() -> (Schedule, f64) {
// I&C (Instrumentation & Control) engineers: SCADA, DCS, process control on resources sites
// Scarcity premium over general electrical FIFO; WA LNG/resources sector consistently short-staffed
// AustralianIndustrialRelations/Hays 2025: I&C engineer $100k-$120k grad; senior lead $200k-$250k+
// Conservative: $108k grad (above electrical due to dual-domain skill), ceiling $230k
let mut s = sched_from(&[
(1, BUNNINGS_FT), (2, BUNNINGS_PT), (3, BUNNINGS_PT), (4, BUNNINGS_PT), (5, BUNNINGS_PT),
]);
let prog = [
108000.0, 120000.0, 131000.0, 141000.0, 151000.0, 160000.0, 168000.0, 175000.0,
181000.0, 187000.0, 192000.0, 197000.0, 201000.0, 205000.0, 208000.0, 211000.0,
214000.0, 216000.0, 218000.0, 220000.0, 222000.0, 224000.0, 226000.0, 228000.0,
229000.0, 230000.0, 230000.0, 230000.0,
];
fill_prog(&mut s, 6, &prog);
(s, HECS_ENG)
}
pub type PathFn = fn() -> (Schedule, f64);
pub fn all_paths() -> Vec<(&'static str, &'static str, PathFn)> {
vec![
("FIFO_EI", "FIFO E&I", path_fifo),
("RF_SATELLITE_ENG", "RF/Satellite Engineering", path_rf_engineering),
("RAAF_TECH_OFFICER", "RAAF Technical Officer", path_raaf),
("LOCAL_TRADE_GLD", "Local Trade — stay Geraldton", path_local_trade),
("ENG_CANBERRA", "Engineering — Canberra defence", path_eng_canberra),
("FIFO_SUPERVISOR", "FIFO — Supervisor track", path_fifo_supervisor),
("RESIDENTIAL_MINING", "Residential Mining Electrician", path_residential_mining),
("BRIDGE_TO_ENG", "Trade → Bridge → Engineering", path_bridge_to_engineering),
("SOFTWARE_ENG", "Software Engineering", path_software_engineering),
("CYBERSECURITY", "Cybersecurity", path_cybersecurity),
("AEROSPACE_ENG", "Aerospace Engineering", path_aerospace_engineering),
("MECHATRONICS_ROBOTICS", "Mechatronics/Robotics Engineering", path_mechatronics_robotics),
("DATA_AI_ENGINEERING", "Data Science/AI Engineering", path_data_ai_engineering),
("MINING_ENG_FIFO", "Mining Engineering (FIFO)", path_mining_engineer_fifo),
("PETROLEUM_ENG_FIFO", "Petroleum Engineering (FIFO)", path_petroleum_engineer_fifo),
("SECURITY_ARCHITECT", "Security Architect", path_security_architect),
("CLOUD_SOLUTIONS_ARCH", "Cloud/Solutions Architect", path_cloud_solutions_architect),
("FIFO_ELEC_ENG", "FIFO Electrical Engineer", path_fifo_electrical_engineer),
("FIFO_IC_ENG", "FIFO I&C Engineer", path_fifo_ic_engineer),
]
}

855
src/main.rs Normal file
View file

@ -0,0 +1,855 @@
mod income_schedules;
use income_schedules::{all_paths, Schedule};
use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng as _};
use rayon::prelude::*;
use std::collections::HashMap;
const LOAN_RATE_BASE: f64 = 0.059;
const SUPER_RATE: f64 = 0.12;
// Super return: 30-yr balanced fund average ~7.5% (AustralianSuper/industry data);
// std dev ~11.5% from observed annual range (-12% GFC to +20% boom years)
const SUPER_RETURN_MEAN: f64 = 0.075;
const SUPER_RETURN_STD: f64 = 0.115;
const SUPER_TAX: f64 = 0.15;
const COST_INFLATION: f64 = 0.03;
const RATE_NEUTRAL: f64 = 0.055;
const RATE_FLOOR: f64 = 0.018;
const RATE_CEILING: f64 = 0.11;
// HECS indexed annually at lower of CPI or WPI (Education Amendment Act 2024);
// 2025 rate was 3.2%; long-run proxy = COST_INFLATION = 3%
const HECS_INDEXATION: f64 = 0.030;
// JobSeeker single, no children, March 2026: $808.70/fn × 26 = $21,026/yr
const JOBSEEKER_ANNUAL: f64 = 21_026.0;
// Geraldton regional build cost: Perth standard ~$2,500/sqm + ~20% regional premium
const BUILD_RATE_NOW: f64 = 3000.0;
const BUILD_INFLATION: f64 = 0.03;
const RAAF_SUPER_RATE: f64 = 0.164; // ADF employer super (16.4% vs civilian 12%)
const N_RUNS: usize = 200;
const SAVINGS_RETURN: f64 = 0.04; // annual return on liquid savings (HISA / conservative invested cash)
// Ongoing property ownership costs: council rates ~$2k, water ~$1.5k, building insurance ~$3k,
// maintenance reserve ~0.5% of value — blended to 1.2% of current property value per year.
// Source: WA rates notices, RACWA insurance benchmarks, HIA maintenance guidelines.
const PROP_HOLDING_RATE: f64 = 0.012;
// ATO 2024-25 concessional super contributions cap (employer SG + salary sacrifice combined)
const SUPER_CONC_CAP: f64 = 30_000.0;
// Property acquisition costs beyond stamp duty and deposit:
// conveyancing ~$2k, building inspection ~$800, pest inspection ~$400, lender establishment ~$800,
// moving + utility connections ~$2.5k. Average across Geraldton / Perth / Canberra markets.
const ACQUISITION_COSTS: f64 = 6_500.0;
// Debt rate applied when cum_liq < 0. Property owners in deficit typically use a line of credit
// secured against their equity (~8-9%), not a credit card (20%). 9% is conservative for that.
const DEBT_RATE: f64 = 0.09;
// Super fund admin + insurance fees above the net-of-investment-fee return.
// AustralianSuper/industry: admin ~0.10% + inside-fund life+TPD+IP insurance ~$900-1,500/yr.
// Modelled as 0.30% of balance; applied to the whole balance after gross investment return.
const SUPER_FEE_RATE: f64 = 0.003;
fn calc_tax(g: f64) -> f64 {
let t = if g <= 18200.0 {
0.0
} else if g <= 45000.0 {
(g - 18200.0) * 0.16
} else if g <= 135000.0 {
(45000.0 - 18200.0) * 0.16 + (g - 45000.0) * 0.30
} else if g <= 190000.0 {
(45000.0 - 18200.0) * 0.16 + 90000.0 * 0.30 + (g - 135000.0) * 0.37
} else {
(45000.0 - 18200.0) * 0.16 + 90000.0 * 0.30 + 55000.0 * 0.37 + (g - 190000.0) * 0.45
};
// Medicare levy 2025-26: exempt below $27,222, phase-in $27,222-$34,028, full 2% above
let m = if g <= 27222.0 {
0.0
} else if g <= 34028.0 {
(g - 27222.0) * 0.10
} else {
g * 0.02
};
let lito = if g <= 37500.0 {
700.0
} else if g <= 45000.0 {
700.0 - (g - 37500.0) * 0.05
} else if g <= 66667.0 {
325.0 - (g - 45000.0) * 0.015
} else {
0.0
};
f64::max(0.0, t + m - lito)
}
fn calc_hecs(g: f64, debt: f64) -> f64 {
// New marginal HELP repayment system from 2025-26 (replaces flat-rate cliff system)
// Source: ATO + Education Amendment Act 2024
if debt <= 0.0 || g < 67000.0 { return 0.0; }
let repayment = if g <= 125000.0 {
(g - 67000.0) * 0.15 // 15c per $1 over $67k
} else if g <= 179285.0 {
8700.0 + (g - 125000.0) * 0.17 // + 17c per $1 over $125k
} else {
g * 0.10 // 10% of total income above $179,285
};
f64::min(repayment, debt)
}
// LMI is required when deposit < 20%. Capitalised onto the loan — no upfront cash needed.
// Approximate QBE/Genworth schedule: ~2.5% of loan at 90% LVR (10% deposit).
fn calc_lmi(loan_amt: f64, deposit_pct: f64) -> f64 {
if deposit_pct >= 0.20 { return 0.0; }
loan_amt * 0.025
}
// Box-Muller transform: two uniform samples → one standard-normal sample
// Used to draw annual super returns from N(SUPER_RETURN_MEAN, SUPER_RETURN_STD²)
fn sample_super_return(rng: &mut SmallRng) -> f64 {
let u1 = rng.gen_range(1e-10_f64..1.0_f64); // exclude 0 to avoid ln(0)
let u2 = rng.r#gen::<f64>();
let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
// Clamp: floor at -25% (below worst observed year), ceiling at +40% (above best observed)
(SUPER_RETURN_MEAN + SUPER_RETURN_STD * z).clamp(-0.25, 0.40)
}
// Perth/Canberra rents track property appreciation; regional rents track CPI
fn rent_inflation_rate(loc: &str) -> f64 {
match loc {
"PERTH" | "NONE" => 0.045, // Perth rents ~4.5%/yr long-run; unit median $560/wk Jan 2026
"CANBERRA" => 0.035,
_ => COST_INFLATION, // Geraldton/regional: ~3%/yr
}
}
fn loan_pmt_annual(principal: f64, rate: f64, years: u32) -> f64 {
let r = rate / 12.0;
let n = (years * 12) as i32;
let factor = (1.0 + r).powi(n);
principal * r * factor / (factor - 1.0) * 12.0
}
fn generate_rate_path(rng: &mut SmallRng) -> [f64; 33] {
let mut rate = LOAN_RATE_BASE;
let mut path = [0.0f64; 33];
for slot in path.iter_mut() {
let reversion = (RATE_NEUTRAL - rate) * 0.08;
let drift = rng.gen_range(-0.0035_f64..0.0035_f64);
let shock = if rng.r#gen::<f64>() < 1.0 / 12.0 {
let choices = [-0.018_f64, -0.012, 0.012, 0.018];
choices[rng.gen_range(0..4)]
} else { 0.0 };
rate = (rate + reversion + drift + shock).clamp(RATE_FLOOR, RATE_CEILING);
*slot = rate;
}
path
}
struct CrashProfile { prob_per_yr: f64, mag_lo: f64, mag_hi: f64, recovery_yrs: u32 }
fn crash_profile(loc: &str) -> CrashProfile {
match loc {
"PERTH" => CrashProfile { prob_per_yr: 0.04, mag_lo: 0.08, mag_hi: 0.18, recovery_yrs: 5 },
"GERALDTON_HOUSE" => CrashProfile { prob_per_yr: 0.035, mag_lo: 0.10, mag_hi: 0.25, recovery_yrs: 6 },
"GERALDTON_LAND" => CrashProfile { prob_per_yr: 0.035, mag_lo: 0.10, mag_hi: 0.25, recovery_yrs: 6 },
"CANBERRA" => CrashProfile { prob_per_yr: 0.015, mag_lo: 0.05, mag_hi: 0.12, recovery_yrs: 4 },
_ => CrashProfile { prob_per_yr: 0.0, mag_lo: 0.0, mag_hi: 0.0, recovery_yrs: 1 },
}
}
struct CrashState { in_recovery: bool, magnitude: f64, yrs_since: u32 }
fn apply_property_shock(rng: &mut SmallRng, loc: &str, s: &mut CrashState) -> f64 {
let p = crash_profile(loc);
if s.in_recovery {
s.yrs_since += 1;
if s.yrs_since >= p.recovery_yrs {
s.in_recovery = false; s.magnitude = 0.0; s.yrs_since = 0;
return 1.0;
}
return 1.0 - s.magnitude * (1.0 - s.yrs_since as f64 / p.recovery_yrs as f64);
}
if rng.r#gen::<f64>() < p.prob_per_yr {
s.magnitude = rng.gen_range(p.mag_lo..p.mag_hi);
s.in_recovery = true; s.yrs_since = 0;
return 1.0 - s.magnitude;
}
1.0
}
struct Location { price_now: f64, growth: f64 }
fn location(key: &str) -> Location {
match key {
// Perth median Apr 2026: $1,087,507 — use $1,000,000 as conservative base (mid-2025)
// Growth: 5% long-run (up from 4.5%; recent boom was 12-15%/yr but unsustainable)
"GERALDTON_HOUSE" => Location { price_now: 550000.0, growth: 0.035 },
// Geraldton land: REIWA Oct 2025 releases show $65k-$200k range; conservative mid-market
"GERALDTON_LAND" => Location { price_now: 175000.0, growth: 0.035 },
"PERTH" => Location { price_now: 1000000.0, growth: 0.050 },
// Canberra median mid-2025: ~$1,040,000; modest growth given 2022-23 correction
"CANBERRA" => Location { price_now: 1040000.0, growth: 0.030 },
_ => Location { price_now: 0.0, growth: 0.0 },
}
}
// WA Transfer Duty (standard tiered rates, effective 2025)
// Source: RevenueWA + Proplyx stamp duty guide
fn wa_stamp_duty(price: f64) -> f64 {
if price <= 120000.0 {
price * 0.019
} else if price <= 150000.0 {
2280.0 + (price - 120000.0) * 0.0285
} else if price <= 360000.0 {
3135.0 + (price - 150000.0) * 0.0475
} else if price <= 725000.0 {
17942.50 + (price - 360000.0) * 0.065
} else if price <= 1000000.0 {
44077.50 + (price - 725000.0) * 0.075
} else {
75077.50 + (price - 1000000.0) * 0.085
}
}
// WA First Home Buyer concession (from 21 Mar 2025): full exemption ≤ $500k,
// then concessional rate to $700k metro / $750k regional
fn wa_stamp_duty_fhb(price: f64, is_regional: bool) -> f64 {
let ceiling = if is_regional { 750000.0 } else { 700000.0 };
let conc_rate = if is_regional { 0.1189 } else { 0.1363 };
if price <= 500000.0 {
0.0
} else if price <= ceiling {
(price - 500000.0) * conc_rate
} else {
wa_stamp_duty(price) // over ceiling: no concession, pay full duty
}
}
// ACT conveyance duty approximation (tiered, simpler than WA but similar ballpark at $1M)
fn act_stamp_duty(price: f64) -> f64 {
// ACT residential conveyance duty tiers (2025-26 approximation)
if price <= 200000.0 {
price * 0.0120
} else if price <= 300000.0 {
2400.0 + (price - 200000.0) * 0.0229
} else if price <= 500000.0 {
4690.0 + (price - 300000.0) * 0.0314
} else if price <= 750000.0 {
10970.0 + (price - 500000.0) * 0.0383
} else if price <= 1000000.0 {
20545.0 + (price - 750000.0) * 0.0435
} else if price <= 1455000.0 {
31420.0 + (price - 1000000.0) * 0.0547
} else {
56316.0 + (price - 1455000.0) * 0.0490
}
}
fn stamp_duty(price: f64, location_key: &str, is_first_home: bool) -> f64 {
match location_key {
"PERTH" => {
if is_first_home { wa_stamp_duty_fhb(price, false) } else { wa_stamp_duty(price) }
}
"GERALDTON_HOUSE" | "GERALDTON_LAND" => {
if is_first_home { wa_stamp_duty_fhb(price, true) } else { wa_stamp_duty(price) }
}
"CANBERRA" => act_stamp_duty(price),
_ => 0.0,
}
}
struct Volatility { job_loss_prob: f64, job_loss_impact: f64, promo_jitter: f64 }
fn volatility(key: &str) -> Volatility {
match key {
"FIFO_EI" => Volatility { job_loss_prob: 0.04, job_loss_impact: 0.35, promo_jitter: 0.06 },
"RF_SATELLITE_ENG" => Volatility { job_loss_prob: 0.02, job_loss_impact: 0.55, promo_jitter: 0.05 },
"RAAF_TECH_OFFICER" => Volatility { job_loss_prob: 0.005, job_loss_impact: 0.85, promo_jitter: 0.03 },
"LOCAL_TRADE_GLD" => Volatility { job_loss_prob: 0.03, job_loss_impact: 0.50, promo_jitter: 0.04 },
"ENG_CANBERRA" => Volatility { job_loss_prob: 0.015, job_loss_impact: 0.60, promo_jitter: 0.05 },
"FIFO_SUPERVISOR" => Volatility { job_loss_prob: 0.035, job_loss_impact: 0.40, promo_jitter: 0.06 },
"RESIDENTIAL_MINING" => Volatility { job_loss_prob: 0.05, job_loss_impact: 0.40, promo_jitter: 0.05 },
"BRIDGE_TO_ENG" => Volatility { job_loss_prob: 0.025, job_loss_impact: 0.55, promo_jitter: 0.05 },
"SOFTWARE_ENG" => Volatility { job_loss_prob: 0.04, job_loss_impact: 0.50, promo_jitter: 0.07 },
"CYBERSECURITY" => Volatility { job_loss_prob: 0.025, job_loss_impact: 0.55, promo_jitter: 0.06 },
"AEROSPACE_ENG" => Volatility { job_loss_prob: 0.02, job_loss_impact: 0.55, promo_jitter: 0.05 },
"MECHATRONICS_ROBOTICS" => Volatility { job_loss_prob: 0.03, job_loss_impact: 0.50, promo_jitter: 0.06 },
"DATA_AI_ENGINEERING" => Volatility { job_loss_prob: 0.035, job_loss_impact: 0.50, promo_jitter: 0.07 },
// Mining/petroleum FIFO: cyclical commodity exposure, project-based contracts
"MINING_ENG_FIFO" => Volatility { job_loss_prob: 0.04, job_loss_impact: 0.40, promo_jitter: 0.06 },
// Oil & gas most volatile sector in WA; bust cycles (2014-16, 2020) cause sharp contractions
"PETROLEUM_ENG_FIFO" => Volatility { job_loss_prob: 0.05, job_loss_impact: 0.40, promo_jitter: 0.07 },
// Security architects: high demand, below-average layoff risk vs general software
"SECURITY_ARCHITECT" => Volatility { job_loss_prob: 0.02, job_loss_impact: 0.55, promo_jitter: 0.06 },
// Cloud architects: tech-sector layoff exposure but cloud demand is structural
"CLOUD_SOLUTIONS_ARCH" => Volatility { job_loss_prob: 0.03, job_loss_impact: 0.50, promo_jitter: 0.07 },
// FIFO electrical engineer: same commodity-cycle exposure as mining FIFO; salaried more stable than contract
"FIFO_ELEC_ENG" => Volatility { job_loss_prob: 0.04, job_loss_impact: 0.40, promo_jitter: 0.06 },
// FIFO I&C: specialist skill scarcity means lower layoff risk and faster re-hire; sector demand structural
"FIFO_IC_ENG" => Volatility { job_loss_prob: 0.03, job_loss_impact: 0.40, promo_jitter: 0.06 },
_ => Volatility { job_loss_prob: 0.03, job_loss_impact: 0.50, promo_jitter: 0.05 },
}
}
// Rent and living costs by location (2026 research data)
// Rent: single-person rate (share house / 1-bed unit) as of mid-2026
// REIWA Jan 2026: Perth median 1-bed unit $560/wk; share house room $300-420/wk → use $460/wk
// Canberra: comparable unit market, slightly lower than Perth → $470/wk
// Geraldton: regional, ~$330/wk unchanged (limited REIWA data, conservative)
fn location_costs(loc: &str) -> (f64, f64) {
match loc {
"CANBERRA" => (470.0 * 52.0, 15000.0),
"PERTH" => (460.0 * 52.0, 12000.0),
"NONE" => (460.0 * 52.0, 12000.0), // rent-forever assumes Perth work location
_ => (330.0 * 52.0, 10500.0), // Geraldton/regional
}
}
fn get_costs(yr: u32, has_uni_gap_yr: bool, owns_prop: bool, strategy: &str, loc: &str) -> (f64, f64) {
if yr == 1 { return (5200.0, 4200.0); }
// Uni years: student in Perth share house regardless of planned purchase location
if yr <= 5 && has_uni_gap_yr { return (270.0 * 52.0, 10500.0); }
let (rent, living) = location_costs(loc);
if strategy == "RENT_FOREVER" || !owns_prop { return (rent, living); }
(0.0, living)
}
// ATO-eligible work-related deductions by career category.
// FIFO: fly-back travel, work clothing/PPE, tools, union fees, technical subscriptions.
// Trade: tools, licensing, safety equipment, professional development.
// Tech/engineering: home office, equipment depreciation, professional memberships.
// Source: ATO Tax Stats 2022-23 median claims by occupation; FIFO premium from CFMEU/ETU guides.
fn work_deductions(path_key: &str) -> f64 {
match path_key {
"FIFO_EI" | "FIFO_SUPERVISOR" | "FIFO_ELEC_ENG" |
"FIFO_IC_ENG" | "MINING_ENG_FIFO" | "PETROLEUM_ENG_FIFO" => 7_000.0,
"LOCAL_TRADE_GLD" | "RESIDENTIAL_MINING" => 4_500.0,
"BRIDGE_TO_ENG" => 3_500.0,
"SOFTWARE_ENG" | "DATA_AI_ENGINEERING" | "CYBERSECURITY" |
"SECURITY_ARCHITECT" | "CLOUD_SOLUTIONS_ARCH" => 2_500.0,
"RAAF_TECH_OFFICER" => 1_800.0,
_ => 2_200.0,
}
}
fn last_income_val(sched: &Schedule) -> f64 {
sched.values().cloned().fold(f64::NEG_INFINITY, f64::max)
}
struct RunResult {
final_nw: f64,
liquid: f64,
sup: f64,
prop_val: f64,
mort_bal: f64,
debt_free_age: Option<u32>,
actual_buy_age: Option<u32>,
nw35: f64,
nw40: f64,
nw45: f64,
}
fn simulate_once(
base_income: &Schedule,
last_income: f64,
hecs_total: f64,
path_key: &str,
location_key: &str,
strategy: &str,
sell_age: Option<u32>,
deposit_pct: f64,
savings_rate: f64,
buy_age: u32,
rng: &mut SmallRng,
) -> RunResult {
let vol = volatility(path_key);
let loc = location(location_key);
let has_uni_gap_yr = base_income.get(&1).copied().unwrap_or(0.0) == 57304.0;
let rate_path = generate_rate_path(rng);
let mut hecs_rem = hecs_total;
let mut cum_liq = 0.0_f64;
let mut sup = 0.0_f64;
let mut mort_bal = 0.0_f64;
let mut prop_val = 0.0_f64;
let mut prop_val_unshocked = 0.0_f64;
let mut bought = false;
let buy_yr = buy_age - 17;
let mut actual_buy_age: Option<u32> = None;
let mut actual_buy_yr: u32 = 0;
let mut land_val = 0.0_f64;
let mut house_val = 0.0_f64;
let mut build_done = false;
let mut sold = false;
let mut debt_free_age: Option<u32> = None;
let is_land = strategy == "BUY_LAND_BUILD";
let is_mining_loc = matches!(location_key, "PERTH" | "GERALDTON_HOUSE" | "GERALDTON_LAND");
let mut income_gap_active = false;
let mut crash = CrashState { in_recovery: false, magnitude: 0.0, yrs_since: 0 };
let mut crash_boost_remaining: u32 = 0; // elevated job-loss years after a commodity-sector crash
let mut remaining_years: u32 = 1;
let mut nw35 = 0.0_f64;
let mut nw40 = 0.0_f64;
let mut nw45 = 0.0_f64;
let mut sold_yr: u32 = 0; // year PPOR was sold; triggers Geraldton build tracking
let mut gld_build_pending: f64 = 0.0; // build cost deferred until 2 yrs after PPOR sale
for yr in 1u32..=33 {
let age = 17 + yr;
let current_rate = rate_path[(yr - 1) as usize];
// Savings return: portion offset against outstanding mortgage earns mortgage rate
// (offset account saves mortgage interest, net of HISA return already earned).
// Negative balance: debt accrues at personal-loan rate — no free overdraft.
if cum_liq >= 0.0 {
let offset_bonus = if mort_bal > 0.0 {
cum_liq.min(mort_bal) * (current_rate - SAVINGS_RETURN)
} else { 0.0 };
cum_liq = cum_liq * (1.0 + SAVINGS_RETURN) + offset_bonus;
} else {
cum_liq *= 1.0 + DEBT_RATE;
}
let scripted_g = base_income.get(&yr).copied().unwrap_or(last_income);
// Correlated crash→job-loss boost: if a commodity-sector crash is still rippling,
// double the annual job-loss probability for mining-exposed locations.
let effective_job_loss_prob = if crash_boost_remaining > 0 && is_mining_loc {
vol.job_loss_prob * 2.0
} else {
vol.job_loss_prob
};
if crash_boost_remaining > 0 { crash_boost_remaining -= 1; }
let jitter = 1.0 + rng.gen_range(-vol.promo_jitter..vol.promo_jitter);
let mut g = scripted_g * jitter;
if yr >= 6 {
if income_gap_active {
// Recovery year: partial income restoration; floor at JobSeeker
// JobSeeker single no-children Mar-2026: $808.70/fn × 26 = $21,026/yr
g = (g * vol.job_loss_impact * 1.3).max(JOBSEEKER_ANNUAL);
income_gap_active = false;
} else if rng.r#gen::<f64>() < effective_job_loss_prob {
// Loss year: income hit; floor at JobSeeker welfare payment
g = (g * vol.job_loss_impact).max(JOBSEEKER_ANNUAL);
income_gap_active = true;
}
}
g = g.max(0.0);
// RAAF/ADF employer super rate is 16.4%, vs civilian SG of 12%
let effective_super_rate = if path_key == "RAAF_TECH_OFFICER" { RAAF_SUPER_RATE } else { SUPER_RATE };
// Concessional cap: $30k/yr combined employer + salary sacrifice (2024-25 limit)
let super_contrib = (g * effective_super_rate).min(SUPER_CONC_CAP);
// Division 293: additional 15% tax on concessional contributions for income > $250k;
// charged to the super fund (ATO bill paid from balance). Affects peak FIFO/petroleum earners.
let d293 = if g > 250_000.0 { super_contrib * 0.15 } else { 0.0 };
// Super return drawn from N(7.5%, 11.5²%) — matches 30-yr AustralianSuper balanced data
let super_return = sample_super_return(rng);
// SUPER_FEE_RATE: admin + insurance fees charged as % of balance (above net return).
// Applied after return and before new contributions so it compounds correctly.
sup = sup * (1.0 + super_return) * (1.0 - SUPER_FEE_RATE) + super_contrib * (1.0 - SUPER_TAX) - d293;
// Work-related deductions reduce taxable income (tools, PPE, union fees, home office etc.)
// Only in working years (yr >= 6); deductions don't reduce gross income, only tax liability.
let deductions = if yr >= 6 { work_deductions(path_key) } else { 0.0 };
let taxable_g = (g - deductions).max(0.0);
let tax = calc_tax(taxable_g);
// HECS accrual (years 25) then annual CPI indexation before repayment
// Indexation capped at lower of CPI or WPI per Education Amendment Act 2024;
// 2025 rate = 3.2%; long-run proxy = 3%
if yr >= 2 && yr <= 5 && hecs_total > 0.0 {
hecs_rem = f64::min(hecs_total, (yr - 1) as f64 * (hecs_total / 4.0));
}
if hecs_rem > 0.0 {
hecs_rem *= 1.0 + HECS_INDEXATION;
}
let hp = if hecs_rem > 0.0 && taxable_g >= 67000.0 {
let h = calc_hecs(taxable_g, hecs_rem);
hecs_rem = f64::max(0.0, hecs_rem - h);
h
} else { 0.0 };
let net = g - tax - hp;
let (rent, living) = get_costs(yr, has_uni_gap_yr, bought && !sold, strategy, location_key);
// Rent and living costs inflate at different rates:
// Perth/Canberra rents track property appreciation (4.5%/3.5%/yr);
// regional rents and all living costs grow at CPI (3%/yr)
let (rent, living) = if yr >= 9 {
let rent_inf = (1.0 + rent_inflation_rate(location_key)).powi((yr - 8) as i32);
let liv_inf = (1.0 + COST_INFLATION).powi((yr - 8) as i32);
(rent * rent_inf, living * liv_inf)
} else { (rent, living) };
let mut loan_repay = 0.0_f64;
if strategy != "RENT_FOREVER" && location_key != "NONE" {
if yr >= buy_yr && !bought {
let price_at_buy = loc.price_now * (1.0 + loc.growth).powi(yr as i32);
let dep = price_at_buy * deposit_pct;
// First purchase is always FHB; use proper WA tiered duty (or ACT approx)
let sd = stamp_duty(price_at_buy, location_key, true);
if cum_liq >= dep + sd + ACQUISITION_COSTS + 50000.0 {
cum_liq -= dep + sd + ACQUISITION_COSTS;
let base_loan = price_at_buy - dep;
let lmi = calc_lmi(base_loan, deposit_pct); // capitalised onto loan
mort_bal = base_loan + lmi;
remaining_years = if is_land { 5 } else { 30 };
prop_val_unshocked = price_at_buy;
land_val = if is_land { price_at_buy } else { 0.0 };
bought = true;
actual_buy_age = Some(age);
actual_buy_yr = yr;
}
}
if bought && !sold && mort_bal > 0.0 {
let annual_repay = loan_pmt_annual(mort_bal, current_rate, remaining_years.max(1));
let interest = mort_bal * current_rate;
let principal = f64::min(annual_repay - interest, mort_bal);
mort_bal = f64::max(0.0, mort_bal - principal);
loan_repay = annual_repay;
remaining_years = remaining_years.saturating_sub(1).max(1);
if mort_bal <= 1.0 {
mort_bal = 0.0;
if debt_free_age.is_none() && !is_land {
debt_free_age = Some(age);
}
}
}
if bought && !sold {
let was_not_recovering = !crash.in_recovery;
let crash_mult = apply_property_shock(rng, location_key, &mut crash);
// If a new crash just triggered in a mining-exposed location, elevate job-loss
// risk for the next 2 years (commodity busts cause correlated layoffs).
if was_not_recovering && crash.in_recovery && is_mining_loc {
crash_boost_remaining = crash_boost_remaining.max(2);
}
if is_land {
let land_fund = loc.price_now * (1.0 + loc.growth).powi((yr - actual_buy_yr + 1) as i32);
land_val = land_fund * crash_mult;
if mort_bal == 0.0 && !build_done && yr >= actual_buy_yr + 6 {
let build_rate = BUILD_RATE_NOW * (1.0 + BUILD_INFLATION).powi(yr as i32);
// Regional WA construction routinely runs 030% over budget:
// supply-chain delays, subcontractor scarcity, council approval overruns.
let overrun = 1.0 + rng.gen_range(0.0_f64..0.30_f64);
let build_cost = 200.0 * build_rate * 1.38 * overrun;
if cum_liq >= build_cost + 50000.0 {
cum_liq -= build_cost;
house_val = build_cost;
build_done = true;
debt_free_age = Some(age);
}
}
prop_val = land_val + house_val;
} else {
prop_val_unshocked = loc.price_now * (1.0 + loc.growth).powi((yr - actual_buy_yr + 1) as i32);
prop_val = prop_val_unshocked * crash_mult;
}
}
if strategy == "BUY_HOLD" && bought && !sold && sell_age.map_or(false, |sa| age == sa) {
let sale_costs = prop_val * 0.025 + 12500.0;
cum_liq += prop_val - mort_bal - sale_costs; // PPOR: no CGT
let gld_base = 175_000.0_f64;
let gld_price = gld_base * 1.035_f64.powi(yr as i32);
let gld_stamp = wa_stamp_duty(gld_price);
let build_rate = BUILD_RATE_NOW * (1.0 + BUILD_INFLATION).powi(yr as i32);
let overrun = 1.0 + rng.gen_range(0.0_f64..0.30_f64);
gld_build_pending = 200.0 * build_rate * 1.38 * overrun;
cum_liq -= gld_price + gld_stamp; // land purchased now; builder paid on completion
prop_val = gld_price; // land value only during ~2-year construction
mort_bal = 0.0;
sold = true;
sold_yr = yr;
// debt_free_age is set when construction completes (yr + 2)
}
// Post-sell Geraldton tracking: land appreciates during construction; build paid on completion.
// Person pays rent each year (get_costs returns rent when !owns_prop) while this runs.
if bought && sold && sold_yr > 0 {
let yrs = yr - sold_yr;
let land_now = 175_000.0_f64 * 1.035_f64.powi(yr as i32);
if yrs < 2 {
prop_val = land_now;
} else if gld_build_pending > 0.0 {
// Build proceeds when person has enough savings, or after 7 years (forced completion).
// Prevents entering explosive debt spiral from an unfeasible early-sell scenario.
let can_afford = cum_liq >= gld_build_pending - 150_000.0;
let must_build = yrs >= 7;
if can_afford || must_build {
cum_liq -= gld_build_pending;
prop_val = land_now + gld_build_pending;
gld_build_pending = 0.0;
if debt_free_age.is_none() { debt_free_age = Some(age); }
} else {
prop_val = land_now; // still saving; continue renting
}
} else {
prop_val *= 1.0 + 0.035; // Geraldton growth on completed property
}
}
}
// Ongoing property costs: council rates, water, building insurance, maintenance reserve.
// Applied to current property value whether PPOR or post-sell Geraldton property.
let prop_holding = if prop_val > 0.0 { prop_val * PROP_HOLDING_RATE } else { 0.0 };
let delta = net - rent - living - loan_repay - prop_holding;
cum_liq += if delta >= 0.0 { delta * savings_rate } else { delta };
let cur_nw = cum_liq + sup + prop_val - mort_bal;
if age == 35 { nw35 = cur_nw; }
if age == 40 { nw40 = cur_nw; }
if age == 45 { nw45 = cur_nw; }
}
RunResult {
final_nw: (cum_liq + sup + prop_val - mort_bal).round(),
liquid: cum_liq.round(),
sup: sup.round(),
prop_val: prop_val.round(),
mort_bal: mort_bal.round(),
debt_free_age,
actual_buy_age,
nw35: nw35.round(),
nw40: nw40.round(),
nw45: nw45.round(),
}
}
fn percentile(sorted: &[f64], p: f64) -> f64 {
let idx = ((p / 100.0 * (sorted.len() - 1) as f64).round() as usize).min(sorted.len() - 1);
sorted[idx]
}
fn median_sorted(sorted: &[f64]) -> f64 {
let n = sorted.len();
if n == 0 { return 0.0; }
if n % 2 == 0 { (sorted[n/2 - 1] + sorted[n/2]) / 2.0 } else { sorted[n/2] }
}
struct ScenarioRow {
career_path: String,
location: String,
strategy: String,
sell_age: String,
deposit_pct: f64,
savings_rate: f64,
requested_buy_age: String,
pct_scenarios_bought_property: f64,
median_actual_buy_age: String,
pct_debt_free_by_50: f64,
median_debt_free_age: String,
net_worth_p10_age50: f64,
net_worth_p50_age50: f64,
net_worth_p90_age50: f64,
net_worth_range_age50: f64,
median_liquid_age50: f64,
median_super_age50: f64,
median_property_value_age50: f64,
median_mortgage_remaining_age50: f64,
net_worth_p50_age35: f64,
net_worth_p50_age40: f64,
net_worth_p50_age45: f64,
}
fn simulate_scenario(
path_key: &str, path_name: &str, base_income: &Schedule, last_income: f64, hecs_total: f64,
location_key: &str, strategy: &str, sell_age: Option<u32>,
deposit_pct: f64, savings_rate: f64, buy_age: u32, seed_base: u64,
) -> ScenarioRow {
let results: Vec<RunResult> = (0..N_RUNS).map(|i| {
let mut rng = SmallRng::seed_from_u64(seed_base + i as u64);
simulate_once(base_income, last_income, hecs_total, path_key, location_key, strategy,
sell_age, deposit_pct, savings_rate, buy_age, &mut rng)
}).collect();
let mut nws: Vec<f64> = results.iter().map(|r| r.final_nw).collect();
nws.sort_by(|a, b| a.partial_cmp(b).unwrap());
let df: Vec<f64> = results.iter().filter_map(|r| r.debt_free_age.map(|a| a as f64)).collect();
let pct_df = 100.0 * df.len() as f64 / N_RUNS as f64;
let mut df_s = df.clone(); df_s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let med_df = if df_s.is_empty() { "Not by 50".into() } else { median_sorted(&df_s).to_string() };
let ba: Vec<f64> = results.iter().filter_map(|r| r.actual_buy_age.map(|a| a as f64)).collect();
let pct_b = 100.0 * ba.len() as f64 / N_RUNS as f64;
let mut ba_s = ba.clone(); ba_s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let med_ba = if ba_s.is_empty() { "Never".into() } else { median_sorted(&ba_s).round().to_string() };
let sort_f = |v: &mut Vec<f64>| v.sort_by(|a, b| a.partial_cmp(b).unwrap());
let mut liq: Vec<f64> = results.iter().map(|r| r.liquid).collect(); sort_f(&mut liq);
let mut sup: Vec<f64> = results.iter().map(|r| r.sup).collect(); sort_f(&mut sup);
let mut prp: Vec<f64> = results.iter().map(|r| r.prop_val).collect(); sort_f(&mut prp);
let mut mrt: Vec<f64> = results.iter().map(|r| r.mort_bal).collect(); sort_f(&mut mrt);
let mut nw35s: Vec<f64> = results.iter().map(|r| r.nw35).collect(); sort_f(&mut nw35s);
let mut nw40s: Vec<f64> = results.iter().map(|r| r.nw40).collect(); sort_f(&mut nw40s);
let mut nw45s: Vec<f64> = results.iter().map(|r| r.nw45).collect(); sort_f(&mut nw45s);
let no_prop = strategy == "RENT_FOREVER" || location_key == "NONE";
ScenarioRow {
career_path: path_name.into(),
location: location_key.into(),
strategy: strategy.into(),
sell_age: sell_age.map_or("N/A".into(), |a| a.to_string()),
deposit_pct,
savings_rate,
requested_buy_age: if no_prop { "N/A".into() } else { buy_age.to_string() },
pct_scenarios_bought_property: if no_prop { 0.0 } else { pct_b },
median_actual_buy_age: med_ba,
pct_debt_free_by_50: pct_df,
median_debt_free_age: med_df,
net_worth_p10_age50: percentile(&nws, 10.0),
net_worth_p50_age50: percentile(&nws, 50.0),
net_worth_p90_age50: percentile(&nws, 90.0),
net_worth_range_age50: percentile(&nws, 90.0) - percentile(&nws, 10.0),
median_liquid_age50: median_sorted(&liq),
median_super_age50: median_sorted(&sup),
median_property_value_age50: median_sorted(&prp),
median_mortgage_remaining_age50: median_sorted(&mrt),
net_worth_p50_age35: median_sorted(&nw35s),
net_worth_p50_age40: median_sorted(&nw40s),
net_worth_p50_age45: median_sorted(&nw45s),
}
}
fn row_fields(r: &ScenarioRow) -> [String; 22] {
[
r.career_path.clone(), r.location.clone(), r.strategy.clone(), r.sell_age.clone(),
r.deposit_pct.to_string(), r.savings_rate.to_string(),
r.requested_buy_age.clone(), r.pct_scenarios_bought_property.to_string(),
r.median_actual_buy_age.clone(), r.pct_debt_free_by_50.to_string(),
r.median_debt_free_age.clone(),
r.net_worth_p10_age50.to_string(), r.net_worth_p50_age50.to_string(),
r.net_worth_p90_age50.to_string(), r.net_worth_range_age50.to_string(),
r.median_liquid_age50.to_string(), r.median_super_age50.to_string(),
r.median_property_value_age50.to_string(), r.median_mortgage_remaining_age50.to_string(),
r.net_worth_p50_age35.to_string(), r.net_worth_p50_age40.to_string(),
r.net_worth_p50_age45.to_string(),
]
}
fn write_csv(path: &str, rows: &[ScenarioRow]) -> csv::Result<()> {
let mut w = csv::Writer::from_path(path)?;
w.write_record([
"career_path","location","strategy","sell_age","deposit_pct","savings_rate",
"requested_buy_age","pct_scenarios_bought_property","median_actual_buy_age",
"pct_debt_free_by_50","median_debt_free_age",
"net_worth_p10_age50","net_worth_p50_age50","net_worth_p90_age50",
"net_worth_range_age50","median_liquid_age50","median_super_age50",
"median_property_value_age50","median_mortgage_remaining_age50",
"net_worth_p50_age35","net_worth_p50_age40","net_worth_p50_age45",
])?;
for r in rows { w.write_record(row_fields(r))?; }
w.flush()?; Ok(())
}
fn write_leaderboard(path: &str, rows: &[ScenarioRow]) -> csv::Result<()> {
let mut w = csv::Writer::from_path(path)?;
w.write_record([
"leaderboard_category",
"career_path","location","strategy","sell_age","deposit_pct","savings_rate",
"requested_buy_age","pct_scenarios_bought_property","median_actual_buy_age",
"pct_debt_free_by_50","median_debt_free_age",
"net_worth_p10_age50","net_worth_p50_age50","net_worth_p90_age50",
"net_worth_range_age50","median_liquid_age50","median_super_age50",
"median_property_value_age50","median_mortgage_remaining_age50",
"net_worth_p50_age35","net_worth_p50_age40","net_worth_p50_age45",
])?;
let write_cat = |w: &mut csv::Writer<_>, cat: &str, r: &ScenarioRow| -> csv::Result<()> {
let mut rec = vec![cat.to_string()];
rec.extend(row_fields(r));
w.write_record(&rec)
};
let mut by_wealth: Vec<&ScenarioRow> = rows.iter().collect();
by_wealth.sort_by(|a, b| b.net_worth_p50_age50.partial_cmp(&a.net_worth_p50_age50).unwrap());
for r in by_wealth.iter().take(10) { write_cat(&mut w, "TOP 10 — Max Wealth (P50)", r)?; }
let mut df_rows: Vec<&ScenarioRow> = rows.iter()
.filter(|r| r.median_debt_free_age != "Not by 50").collect();
df_rows.sort_by(|a, b| {
a.median_debt_free_age.parse::<f64>().unwrap_or(99.0)
.partial_cmp(&b.median_debt_free_age.parse::<f64>().unwrap_or(99.0)).unwrap()
});
for r in df_rows.iter().take(10) { write_cat(&mut w, "TOP 10 — Fastest Debt Free", r)?; }
let mut nws: Vec<f64> = rows.iter().map(|r| r.net_worth_p50_age50).collect();
nws.sort_by(|a, b| a.partial_cmp(b).unwrap());
let med_nw = median_sorted(&nws);
let mut reliable: Vec<&ScenarioRow> = rows.iter()
.filter(|r| r.net_worth_p50_age50 >= med_nw).collect();
reliable.sort_by(|a, b| a.net_worth_range_age50.partial_cmp(&b.net_worth_range_age50).unwrap());
for r in reliable.iter().take(10) {
write_cat(&mut w, "TOP 10 — Most Reliable (low variance, above-median wealth)", r)?;
}
let mut by_p10: Vec<&ScenarioRow> = rows.iter().collect();
by_p10.sort_by(|a, b| b.net_worth_p10_age50.partial_cmp(&a.net_worth_p10_age50).unwrap());
for r in by_p10.iter().take(10) { write_cat(&mut w, "TOP 10 — Best Worst-Case (highest P10)", r)?; }
w.flush()?; Ok(())
}
const LOCATION_KEYS: &[&str] = &["GERALDTON_HOUSE", "GERALDTON_LAND", "PERTH", "CANBERRA", "NONE"];
const STRATEGIES: &[&str] = &["BUY_HOLD", "BUY_LAND_BUILD", "RENT_FOREVER"];
const SELL_AGE_OPTS: &[Option<u32>] = &[None, Some(30), Some(32), Some(35), Some(40), Some(45), Some(50)];
const DEPOSIT_PCTS: &[f64] = &[0.10, 0.20, 0.50];
const SAVINGS_RATES: &[f64] = &[0.55, 0.65, 0.75, 0.85, 0.90];
const BUY_AGES: &[u32] = &[23, 25, 28];
fn main() {
let out_dir = std::env::args().nth(1).unwrap_or_else(|| ".".into());
std::fs::create_dir_all(&out_dir).expect("cannot create output dir");
let paths = all_paths();
let path_map: HashMap<&str, (Schedule, f64, f64, &str)> = paths.iter().map(|(key, name, fn_)| {
let (sched, hecs) = fn_();
let last = last_income_val(&sched);
(*key, (sched, hecs, last, *name))
}).collect();
// Build scenario parameter list
let mut scenarios: Vec<(&str, &str, &str, &str, Option<u32>, f64, f64, u32, u64)> = Vec::new();
let mut seed: u64 = 1000;
for (path_key, path_name, _) in &paths {
for &loc in LOCATION_KEYS {
for &strat in STRATEGIES {
if strat == "BUY_LAND_BUILD" && loc != "GERALDTON_LAND" { continue; }
if strat == "BUY_HOLD" && (loc == "NONE" || loc == "GERALDTON_LAND") { continue; }
if strat == "RENT_FOREVER" && loc != "NONE" { continue; }
if strat != "RENT_FOREVER" && loc == "NONE" { continue; }
let sell_opts: &[Option<u32>] = if strat == "BUY_HOLD" { SELL_AGE_OPTS } else { &[None] };
for &sell in sell_opts {
let dep_opts: &[f64] = if strat != "RENT_FOREVER" { DEPOSIT_PCTS } else { &[0.0] };
for &dep in dep_opts {
for &sr in SAVINGS_RATES {
let buy_opts: &[u32] = if strat != "RENT_FOREVER" { BUY_AGES } else { &[0] };
for &ba in buy_opts {
if sell.map_or(false, |sa| sa <= ba + 3) { continue; }
seed += 1;
scenarios.push((path_key, path_name, loc, strat, sell, dep, sr, ba, seed));
}
}
}
}
}
}
}
println!("Generated {} scenarios × {} Monte Carlo runs each = {} total simulations",
scenarios.len(), N_RUNS, scenarios.len() * N_RUNS);
let rows: Vec<ScenarioRow> = scenarios.par_iter().map(|&(pk, pn, loc, strat, sell, dep, sr, ba, seed)| {
let (sched, hecs, last, _) = path_map.get(pk).unwrap();
simulate_scenario(pk, pn, sched, *last, *hecs, loc, strat, sell, dep, sr, ba, seed)
}).collect();
write_csv(&format!("{}/career_permutations_v2.csv", out_dir), &rows)
.expect("failed to write main CSV");
println!("Written career_permutations_v2.csv");
write_leaderboard(&format!("{}/leaderboard.csv", out_dir), &rows)
.expect("failed to write leaderboard CSV");
println!("Written leaderboard.csv");
}