init commit
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
245
Cargo.lock
generated
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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 ±4–7% 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 3–5%/yr (cyclical, project-based work). Engineering and defence
|
||||
sit at 1.5–2%/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.5–2.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 2014–2019 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 4–6 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 P90–P10 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
872
graphs.py
Normal 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="P10–P90 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 (P90–P10) 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("P90–P10 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","30–70%"),("#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)")
|
||||
BIN
graphs/01_career_wealth_range.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
graphs/02_heatmap_career_location.png
Normal file
|
After Width: | Height: | Size: 289 KiB |
BIN
graphs/03_strategy_comparison.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
graphs/04_savings_rate_sensitivity.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
graphs/05_deposit_comparison.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
graphs/06_wealth_components.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
graphs/07_risk_reward_scatter.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
graphs/08_debt_free_probability.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
graphs/09_sell_age_timing.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
graphs/10_buy_age_comparison.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
graphs/11_super_at_50.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
graphs/12_property_equity.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
graphs/13_downside_floor.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
graphs/14_rent_vs_buy.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
graphs/15_location_comparison.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
graphs/16_geraldton_build_vs_buy.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
graphs/17_violin_strategy_nw.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
graphs/18_savings_rate_vs_nw_scatter.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
graphs/19_actual_buy_age.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
graphs/20_top10_composite.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
graphs/21_variable_importance_tornado.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
graphs/22_wealth_trajectory.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
graphs/23_wealth_milestones.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
graphs/24_opportunity_cost.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
graphs/25_realistic_vs_best_case.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
graphs/26_real_vs_nominal_nw.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
graphs/27_accessible_vs_total_nw.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
graphs/graphs.zip
Normal file
41
leaderboard.csv
Normal 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
|
||||
|
BIN
src.zip
Normal file
325
src/income_schedules.rs
Normal 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
|
|
@ -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 2–5) 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 0–30% 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");
|
||||
}
|
||||