- liquidation is now a pure function of state: the daily mark-to-market
netting no longer consumes the carried loss pools or the open
wash-sale candidates (unrealized gains/losses were leaking into the
carryover pools, so equity[t] depended on past unrealized values)
- loss offsetting is now global (chronological, by type) instead of
per-symbol: a symbol A gain consuming a symbol B loss marks B's event
consumed, so a later B repurchase cannot subtract the same loss a
second time (pools could go negative -> over-taxed gains)
- progressive mode: the reinvested after-tax remainder now uses the
SAME marginal rates the tax is recorded at (was flat d_keep/c_keep,
so recorded tax and cash flow diverged); the bracket position also
advances by net short-term gains
- renames: WASh->WASH, lt_rf/st_rf->lt_fed/st_fed (rf is the project's
risk-free-rate convention); buys pruned to the 30d wash window;
dropped the per-call sorted() on the constant bracket tables
tests: wash-sale disallowance; liquidation purity (no-trade day with
unrealized gains must not eat the pool); cross-symbol consumption (no
double subtract). All scenarios verified to fail on the pre-fix code.
- loss pools: realized losses carry forward and offset later gains
(net-of-losses taxation); liquidation taxes net of carried pools
- wash sales: repurchase within 30d of a loss sale disallows the loss
- progressive federal brackets (2025 MFJ): marginal LTCG/ordinary rates
at investor's taxable position (fed_base - salt); flat rates otherwise
- app: progressive / fed_base / salt sidebar inputs + settings persistence
tests: new checks for progressive (incl. flat control), loss carryover
The full rate stack (federal + NIIT + state) was used for the recorded
tax columns and for realized gains at rebalances, but the equity path
itself deducted only the FEDERAL rate in two places:
1. distribution reinvestment kept (1 - div_rate)/(1 - lt_rate) instead of
(1 - div_rate - niit - sl_rate);
2. the per-date liquidation tax ('sell everything today') passed bare
(lt_rate, st_rate) to _liquidation_tax.
Symptom: FLCSX 10Y showed a 1.4pt after-tax drag instead of the true
~2.8pt. Fix: d_keep/c_keep factors and stacked liquidation rates.
Regression tests pin the equity path: reinvested net with NIIT+state,
ST and LT liquidation tax at the full stack (all fail on the old code).
Corrected 2015-2026 NYC after-tax: SPY 13.82->11.23, IVV 13.81->11.22,
JLPSX 13.68->9.18 (previously reported 12.51/12.50/11.31, superseded).
NYC residents pay, on a capital-gain distribution, roughly 20% federal
+ 3.8% NIIT + 6.85-9.65% NY + 3.876% NYC = ~34-37% — the old model's
flat 20% understated the real after-tax drag of high-distribution
funds for this user by ~15 points on exactly the flows that matter.
tax.py: new niit + sl_rate params (decimals). sl_rate is the
state+local marginal rate applied at ORDINARY rates to every flow —
state and local have NO preferential cap-gain rate, so the composite
is lt_rate+niit+sl_rate on cap-gain dists, div_rate+niit+sl_rate on
dividends, and (st/lt_rate)+niit+sl_rate on realized gains.
app.py: two new sidebar fields (persisted in settings.json), wired
through _compute_portfolio's cache key.
tests/test_tax.py: 3 new cases (capg and div composite rates,
net-taxed realized ST gains/losses at st+NIIT+SL).
README: NYC rate note with the 2025 IT-201 schedule values (NYC 3.876%
over $50k; NY 6.85% at $215,400-$1.077M, 9.65% at $1.077M-$5M,
single filer) and the composite example.
The old model valued holdings in ADJ (total-return index) units but
computed distribution flows as raw per-share dollars — so every
distribution, and its tax, was overstated by the raw/adj ratio
(JLPSX: 30.10/11.25 = 2.7x; the Dec-2020 cap-gain tax showed as 12.1%
of the account instead of the true 4.5%). The wiggle in the after-tax
curve was this bug, not a convention issue.
tax.py now:
- values holdings in RAW share units on close prices (bundle.close);
- receives the per-share distribution on its event date, pays the tax
(recorded in TaxResult.taxes), and reinvests the after-tax remainder
at the same day's raw close — the tax's effect lives in the (smaller)
reinvested units and is NOT also deducted from cash (double-count
caught and fixed in review);
- recomputes market value after the reinvestment so equity[t] is the
post-event liquidation value.
With the fix, the 'as-if-liquidated' equity on JLPSX's ex-div day drops
by exactly the true tax cost (4.72% vs 12.1% before); the -22.9% price
drop is offset by the distribution kept.
Also:
- app.py passes bundle.close to the after-tax model (pre-tax
portfolio_returns still uses adj);
- JLPSX/JLPYX: the 2020-12-11 6.824 capital-gain distribution is moved
to the true ex-div date 2020-12-14 (remove/add correction ops), so
the reinvestment prices at the post-drop close;
- tests/test_tax.py: 8 synthetic regression tests (tax magnitude,
reinvestment MV, no double-count, ex-div equity step, per-component
rates); run_tests.sh now runs it.