A standalone, offline-capable portfolio projection tool. Models a user-definable set of account buckets (personal and superannuation) with contribution flow, optional rebalancing haircuts, leverage, downturn stress, wage and Super Guarantee modelling, and an inflation-indexed retirement drawdown with Age Pension support. Australian tax context throughout (AUD, marginal rates, the post-2026-Budget CGT regime, super preservation, Division 293/296).
Not financial advice — a personal modelling tool only. See the disclaimer at the end.
Open index.html in any modern browser. No build step, no server, no package
manager. file:// works fine.
Two libraries load from CDN, version-pinned with SRI integrity hashes:
Chart.js 4.4.0 and marked 12.0.0. For fully offline use, download those two
files locally and update the <script> tags in index.html.
Eight source files, by deliberate convention — see CLAUDE.md before adding more.
index.html Markup + DOM
styles.css Styling (light + dark via prefers-color-scheme)
config.js localStorage persistence, export/import, defaults, migration
calculator-core.js Pure shared math (no DOM) — loaded in the browser, require()'d by tests
calculator.js Simulation engine, chart rendering, allocation table, action panel, event wiring
ai-advisor.js Optional AI Advisor — Google Gemini chat + system prompt
ai-advisor.css AI Advisor chat styling
README.md This file
CLAUDE.md Conventions for AI assistants working on this codebase
tests/ Test suite — NN-name.test.js files + run-all.js, helpers.js, sim-harness.js
Tests live in tests/ as NN-name.test.js files, run by tests/run-all.js.
calculator-core.js is the source of truth for any math shared between the
browser and the tests.
The portfolio is split into buckets — a user-definable array, not a fixed
set. Each bucket is { id, name, type, owner, monthly, objective, structure, risks }:
typeispersonalorsuperand is immutable once created (rename freely, never retype — delete and recreate instead).typedrives every tax, preservation and pension decision.- There is always at least one
personaland onesuperbucket. - Buckets are independent: contributions, rebalancing, flow redirect and
haircuts never cross a bucket boundary. (One sanctioned exception: with
reinvestPensionSurpluson, a forced ATO-minimum drawdown surplus may flow from a super bucket to a personal bucket.) owneris a free-text person label, used for couple-mode Super Guarantee routing and per-person Division 296 grouping.
Each asset has a target percentage. Targets are bucket-internal — within a
bucket they are intended to sum to 100. Cross-bucket allocation is whatever
contributions and growth produce; the model never balances across buckets.
The whole config is a list of scenarios, each with its own global settings, buckets, assets and loans. Scenario tabs switch between them; one is active at a time.
The config schema is version 8. The authoritative shape lives in config.js
— see SCENARIO_DEFAULTS for the defaults and the migration code for how older
versions are upgraded. Top-level shape:
{
schemaVersion: 8,
activeScenarioId: '...',
scenarios: [
{
id, name,
global: { ...simulation settings... },
buckets: [ { id, name, type, owner, monthly, objective, structure, risks, superOverrides? } ],
assets: [ { id, name, account, start, costBasis, ret, income, target, ... } ],
loans: [ { id, name, bucket, balance, rate, ..., collateralAssetIds } ],
actionsJournal: [...], advanceHistory: [...]
}
]
}
Notable global fields (see config.js for the complete list and defaults):
- Time / behaviour:
years,rebalance,flowRedirect,inflationRate,wageInflation,lotMethod(HIFO/FIFO),realDollarView. - Drawdown:
drawdown,drawdownStartYear,livingTarget, optional real spending glide (livingTargetEnd,livingGlideStartYear/EndYear),drawdownFunding(sellFirst/borrowFirst). - Tax:
taxRate(personal marginal),superDefaults(preservation year + super income/CGT rates),concessionalCap,contribTaxEnabled,div293Threshold,div296Enabled/div296Threshold/div296IndexThreshold. - Wage / pension:
wageEnabled,wage,sgRate, the wage-glide fields,partnerWage*,wageOwner/partnerWageOwner,agePensionEnabled,isCouple,homeowner. - Events:
downturn,downturnEvents[],catchupEnabled,dipBuyEnabled,cashInjections[],cashWithdrawals[].
Each asset carries return (ret, with an optional earlyRet/decayYears
glide), income yield, target (with an optional targetEnd lifecycle glide),
costBasis, maxSell, volatility (downturn-event sensitivity — NOT a
statistical std-dev), the isBuffer/illiquid/noLossSell flags, a
region/style/sector taxonomy, and an account (bucket id).
Each loan is top-level, owned by one bucket, with collateralAssetIds, an
interest rate, repayment, deductible-interest percentage, and three LVR
thresholds (safetyLVR, marginCallTrigger, marginCallLVR).
Saved configs from older schema versions (v1–v7) migrate automatically on load — migrations never substitute defaults for stored values.
The engine (calculate() in calculator.js) runs month-by-month over the
horizon. Each year:
Each bucket's monthly contribution is distributed within the bucket by
distributeContribution (in calculator-core.js). With flowRedirect on, money
goes to underweight assets first (equal split), then to at-target/untargeted
assets by target weight; it is never routed into an overweight asset. With it
off, money is distributed by raw target weight. Illiquid assets are excluded.
Each asset's income yield is taxed at its bucket's income-tax rate. Outside drawdown the net income is pooled within the bucket and reinvested via the same flow-redirect logic. In drawdown, spendable income offsets the living target.
When rebalance is on, and it is not a downturn or drawdown year, each bucket
trims assets that are over their raw target and up year-on-year, capped by
each asset's maxSell percentage. Proceeds (after CGT) redistribute to
underweight assets in the same bucket. Buffer and target = 0 assets are skipped.
downturnEvents[] each define a startYear, drawdownYears, flatYears and
severity. During an event, each asset's return is scaled by its volatility.
With catchupEnabled, the years after an event accelerate back toward the
no-downturn trajectory. With dipBuyEnabled, buffer assets redeploy a capped
share into under-target assets during an event.
From drawdownStartYear, the portfolio funds the inflation-indexed
livingTarget. Funding order: after-tax wage and Age Pension first, then the
portfolio. Portfolio sales draw buffer assets first (in downturn years), then
super (past preservation, including the forced ATO age-based minimum), then
personal — each respecting maxSell. drawdownFunding chooses sell-first or
borrow-first.
Loans amortise monthly. LVR is tracked against pledged collateral. Crossing
marginCallTrigger auto-sells collateral down to the cap; marginCallLVR is a
hard borrowing ceiling; safetyLVR is the user's own early-warning line. Three
distinct failure modes are reported separately: margin call, forced-sale failure
(illiquid/maxSell-capped collateral), and credit-limit hit.
Per bucket per year: Close = Open + Contributions + Income + Buys − Sells + Growth. The test suite enforces this stays exact.
The post-2026-Budget CGT regime, effective 1 July 2027, applied uniformly from Y0 (transitional rules are noise over a 10–20 year horizon):
- Income tax at
taxRate(marginal). - CGT =
max(taxRate, 30%) × real_gain, wherereal_gain = proceeds − indexed_costandindexed_cost = lot.cost × (1 + inflationRate)^years_held. Per-lot tracking, HIFO or FIFO. - Loss carryforward accumulates per bucket and offsets future gains in the same bucket; it does not expire and does not cross buckets.
The existing super regime — the 1/3 CGT discount is preserved (≈10% effective in accumulation), no cost-base indexation:
- Income tax:
superTaxAccumin accumulation,superTaxPensionin pension. - CGT:
superCGTAccum/superCGTPension. - Rates resolve from
global.superDefaults, with optional per-bucketsuperOverrides. Loss carryforward is per bucket.
With contribTaxEnabled on, every concessional contribution into a super bucket
(employer Super Guarantee + salary-sacrifice) is reduced by the standard 15%
contributions tax, plus the Division 293 surcharge — an extra 15% on the
slice of a person's concessional contributions that, stacked on their wage,
clears div293Threshold ($250k). Default off.
With div296Enabled on, the model flags the first projected year a person's
super crosses div296Threshold ($3m, optionally CPI-indexed in $150k steps).
The Div 296 earnings tax itself is not modelled — projected super past the
crossing year is therefore mildly optimistic. (Modelling the tax is a recorded
TODO; see CLAUDE.md.)
With wageEnabled on, wage (gross employment income, today's $) drives an
automatic employer Super Guarantee at sgRate. A super bucket's monthly
contribution then becomes extra salary-sacrifice on top of the SG. The wage
can wind down (transition to retirement): full until wageGlideStartYear,
tapering to wageEnd by wageGlideEndYear. In a drawdown year the portfolio
funds only the part of the living target the after-tax wage and Age Pension do
not cover.
Couple mode (isCouple) adds a second earner (partnerWage) taxed
separately at individual marginal brackets, each driving its own SG.
Age Pension (agePensionEnabled) computes a means-tested pension from
preservation age + 7, applying both the assets and income tests and taking the
lower result; it rises as the portfolio depletes.
- Header — export/import config, encrypted backup/restore, reset.
- Scenario bar — scenario tabs, bucket filter, "view by" axis selector.
- Sidebar — collapsible control sections: personal tax, time/horizon, wage & Super Guarantee, drawdown, Age Pension, super (tax, caps, Division 296/293), cash injections, cash withdrawals, downturn events, and AI Advisor config.
- Investment thesis — a collapsible reasoning / invalidation-signal block.
- Bucket tabs — one per bucket, each with its monthly contribution and asset cards; add/remove/rename buckets; an asset card's account dropdown moves it between buckets.
- Loans — loan list with a collateral stress test.
- Action panel — Y1–Y3 next-action cards: contribute, rebalance, drawdown, buffer refills, and tax, with a Division 296 banner when relevant.
- Charts — a stacked projection chart (downturn/drawdown shading, preservation and Div 296 markers) plus allocation pies.
- Allocation table — Balances / YoY change / Cash flows, with a density toggle (every year / 5 / 10).
- Advance year — record actual end-of-year balances and roll the model forward one year.
An optional sidebar chat backed by Google Gemini. It is off until you add an
API key. When used, each turn sends your portfolio data and the conversation
to Google. Granular context toggles control how much is disclosed (structure,
dollar values, percentages, strategy notes, loans). The system prompt — including
the calculator-semantics glossary that keeps the advice accurate — lives in
ai-advisor.js.
- Auto-saves to
localStorageon every change (debounced). - Export / import config as plain JSON — portfolio and scenarios only; the AI API key is deliberately excluded so an export cannot leak it.
- Encrypted backup / restore — "Encrypted backup" encrypts the full config (portfolio, scenarios and AI settings, including the Gemini API key) under a passphrase and downloads it as a JSON file you can store anywhere, including Google Drive. "Restore backup" decrypts a backup file given its passphrase and reloads. Uses the Web Crypto API only — PBKDF2-SHA256 (600k iterations) + AES-256-GCM, random salt and IV per backup, no third-party dependency. The passphrase is never stored and cannot be recovered; lose it and the backup is permanently unreadable.
- Reset to defaults wipes the stored config.
- Storage keys:
portfolio_calc_config_v3(portfolio + scenarios — thev3is a historical key name; the schema inside is version 8),portfolio_ai_config_v1(AI Advisor settings + API key, kept separate so a plain export can't leak the key), plusportfolio_ai_chat_v1,portfolio_ai_consent_v1andportfolio_sidebar_collapsed_v1for UI state. - Older schema versions migrate automatically on load.
- Deterministic returns. No Monte Carlo; no sequence-of-returns variance beyond the optional downturn stress test. A run is reproducible.
- CGT regime applied from Y0. No transitional rules for pre-12-May-2026 assets — noise over a 10–20 year horizon (and slightly overstates early-year tax, which biases plans safe).
- No franking credits. Australian equity income is taxed gross — slightly pessimistic on an Australian-income strategy.
- Division 296 is a guardrail, not a tax model — see above.
- No excess-contributions-tax modelling beyond the concessional-cap warning, and no transfer balance cap.
- Single CPI rate. No regime-change modelling.
- Category targeting is descriptive only. Region/Style/Sector are lenses; rebalancing targets remain per-asset.
Run node tests/run-all.js from the project root. It discovers and runs every
tests/*.test.js file and reports pass/fail counts (--only <substr> filters).
Tests are standalone Node scripts that either mock the minimum browser
environment or drive the real calculate() through tests/sim-harness.js (which
loads config.js + calculator.js with stubbed browser globals).
tests/helpers.js holds shared assertion helpers. Each test file documents the
spec for one area:
02-flow-redirect 13-drawdown-tax (ATO minimum drawdown, super cap)
03-haircut 14-margin-call (LVR thresholds)
04-drawdown-order 15-super-caps-reinvest
05-cgt-new-regime 16-dynamic-buckets-migration (v7)
06-income-redirect 17-div296 (threshold indexation)
07-reconciliation 18-full-sim (end-to-end calculate())
08-illiquid 19-wage-sg (wage + Super Guarantee)
09-leverage 20-contrib-tax (15% base + Division 293)
10-loans (v4 migration)
11-downturn (v5 migration)
12-scenarios (v6 migration)
A passing test suite is the contract. Don't ship changes that break tests without explicit acknowledgement; add a test for every new feature and a regression test for every bug fix.
Free to use. If it helped, the footer has a "buy me a coffee" link.
See CLAUDE.md in this directory for project conventions, architectural
principles and deferred design decisions. Read it first.
Not financial advice. A personal modelling tool only — the author is not a financial adviser, accountant or tax agent and holds no AFS licence. Every figure is an estimate built on assumptions that will not match reality. Verify every assumption with a licensed professional before making any decision.