Add NIIT + state/local rates to the after-tax model (NYC support)

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.
This commit is contained in:
Greg Pomerantz 2026-09-01 09:37:58 -04:00
parent 576d9fba97
commit 19b33d5a18
4 changed files with 81 additions and 22 deletions

View File

@ -24,7 +24,7 @@ partial refresh takes seconds instead of a full ~1 min rebuild.
| `data.py` | Ingest `{sym}-history/dividend/capitalGain.csv` -> cached parquet panels (date x symbol), with manifest-based incremental refresh when the data dir changes. `Adj Close` already includes distributions, so it drives pre-tax total returns. |
| `metrics.py` | Total/annualized return, vol, Sharpe, Sortino, max drawdown, Calmar, CAPM beta/alpha. Pure pandas, all transparent. |
| `portfolio.py` | Weighted portfolios with drift and periodic rebalancing to target weights (`1W/1ME/QE/YE`), one-way cost in bps. Spec grammar: commas join the elements of ONE portfolio (`SYM` or `SYM:w`, bare = equal weight), spaces separate DISTINCT symbols/portfolios (`parse_items`). |
| `tax.py` | Simplified DAS after-tax engine: FIFO lots, 365-day long/short split, separate LT/ST/dividend rates. Headline curve = what you keep if you **sell everything today** (unrealized gains taxed daily by lot age). |
| `tax.py` | Simplified DAS after-tax engine: FIFO lots, 365-day long/short split, separate LT/ST/dividend rates plus federal NIIT and a state+local marginal rate (applied at ordinary rates — state/local have no preferential cap-gain rate). Headline curve = what you keep if you **sell everything today** (unrealized gains taxed daily by lot age). |
| `chart_widget.py` | Self-contained plotly.js chart in an iframe: mouse zoom/pan, x clamped to the data, view edges snapped to first/last data points with day-precise labels, y tight-fit, every line re-based to 1.0 at the left edge. |
| `portfolios.py` | Saved portfolio definitions in `portfolios.json` (name, spec, scheme, cost). |
| `settings.json` | Persisted UI inputs (symbol/benchmark specs, scheme, costs, tax rates, period, curve/window mode) — restored on every page load and server restart; delete to reset. |
@ -48,6 +48,20 @@ Notes on the data itself:
predominantly long-term, but the per-fund LT/ST split would come from
the fund company's annual tax statement (1099-DIV detail: boxes 2a/2b),
the shareholder-report body, or a commercial feed (Lipper/Morningstar).
- **NYC residents: add the state+local layer.** The sidebar's two extra
rate fields exist for this. Set `NIIT %` to 3.8 if your MAGI is over
the threshold, and `State + local %` to your NY+NYC MARGINAL rate sum
(2025 single, from the Form IT-201 rate schedules): NYC is 3.876%
above $50k of city taxable income; NY is 6.85% at $215,400-$1.077M of
state taxable income and 9.65% at $1.077M-$5M. So a typical NYC
household pays, on a capital-gain distribution, roughly
20% federal + 3.8% NIIT + 6.85-9.65% NY + 3.876% NYC = ~34-37% —
which is exactly why high-distribution open-end funds lose so much
more to tax than ETFs for NYC residents (see the per-year tax detail
tab). Note: NY/NYC tax capital gains at ORDINARY rates (no
preferential cap-gain rate), which the single `State + local %` field
models correctly; the federal `Long-term gains %` field stays the
preferential 0/15/20%.
`~/prog/fin/stocks` is a Yahoo dump and gets re-downloaded (overwritten),
so fixes must live outside it. Pipeline:

26
app.py
View File

@ -129,6 +129,14 @@ st_rate = st.sidebar.number_input("Short-term gains %", 0.0, 49.0,
float(_settings.get("st_rate", 15.0))) / 100
div_rate = st.sidebar.number_input("Dividends %", 0.0, 49.0,
float(_settings.get("div_rate", 15.0))) / 100
niit = st.sidebar.number_input(
"NIIT % (federal 3.8% on investment income if MAGI over the "
"threshold; 0 otherwise)", 0.0, 5.0,
float(_settings.get("niit", 0.0))) / 100
sl_rate = st.sidebar.number_input(
"State + local % (NY+NYC: capital gains taxed at ORDINARY rates, "
"no preferential cap-gain rate)", 0.0, 49.0,
float(_settings.get("sl_rate", 0.0))) / 100
# statistics available in the Statistics tab (names = metrics.summary keys)
_STATS_VALID = ("total_return", "return", "vol", "sharpe", "sortino",
@ -217,7 +225,8 @@ if _end_ts == "bad":
# remember the sidebar inputs now, before any validation st.stop()
_remember(spec=spec, bench_spec=bench_spec, scheme_index=_scheme_labels.index(scheme),
cost_bps=cost_bps, lt_rate=lt_rate * 100, st_rate=st_rate * 100,
div_rate=div_rate * 100, period=period, stats_order=stats_order,
div_rate=div_rate * 100, niit=niit * 100, sl_rate=sl_rate * 100,
period=period, stats_order=stats_order,
date_window=win, range_start=range_start, range_end=range_end)
if not spec.strip():
@ -286,19 +295,19 @@ if saved:
@st.cache_data(show_spinner=False)
def _compute_portfolio(w_key: tuple, scheme: str | None, cost_bps: float,
start: str, lt: float, st_r: float, div: float,
root: str, gen: int):
n: float, sl: float, root: str, gen: int):
w = dict(w_key)
r = portfolio_returns(bundle.adj, w, rebalance=scheme, cost_bps=cost_bps, start=start)
t = after_tax_portfolio(bundle.close, bundle.div, bundle.capg, w, rebalance=scheme,
cost_bps=cost_bps, lt_rate=lt, st_rate=st_r,
div_rate=div, start=start)
div_rate=div, niit=n, sl_rate=sl, start=start)
return r, t
def build_result(name: str, w: dict, scheme: str | None, cost: float) -> dict:
r, t = _compute_portfolio(tuple(sorted(w.items())), scheme, cost, start,
lt_rate, st_rate, div_rate, str(root),
generation(Path(root)))
lt_rate, st_rate, div_rate, niit, sl_rate,
str(root), generation(Path(root)))
return {"name": name, "weights": w, "res": r, "tax": t}
results = []
@ -338,8 +347,8 @@ for i, item in enumerate(bench_spec.split(), 1):
continue
try:
r, t = _compute_portfolio(tuple(sorted(w.items())), freq, cost_bps, start,
lt_rate, st_rate, div_rate, str(root),
generation(Path(root)))
lt_rate, st_rate, div_rate, niit, sl_rate,
str(root), generation(Path(root)))
except ValueError as e:
st.sidebar.warning(f"Benchmark {i} ignored: {e}")
continue
@ -364,7 +373,8 @@ else:
if len(results) > 1:
st.caption(f"Comparing: {', '.join(r['name'] for r in results)}")
st.caption(f"{scheme} · start {start} · cost {cost_bps} bps · "
f"tax LT/ST/div {lt_rate:.0%}/{st_rate:.0%}/{div_rate:.0%}"
f"tax LT/ST/div {lt_rate:.0%}/{st_rate:.0%}/{div_rate:.0%} "
f"+NIIT {niit:.1%} +state/local {sl_rate:.1%}"
+ (f" · benchmark: {' ; '.join(b['label'] for b in benchmarks)}"
if benchmarks else ""))

35
tax.py
View File

@ -11,11 +11,16 @@ The account starts at 1.0 (growth-ratio units; no fixed capital).
investor keeps d(1-tau) and buys it back cheaper. The tax's effect is
carried by the (smaller) reinvested units; it is recorded in `taxes`
but not separately deducted from cash (that would double-count it).
dividend income -> taxed at `div_rate`
capital gain dist-> taxed at `lt_rate`
The after-tax remainder also increases each lot's cost basis
proportionally (standard after-tax-IRR convention, so a liquidation
doesn't re-tax the distribution).
dividend income -> taxed at (div_rate + niit + sl_rate)
capital gain dist-> taxed at (lt_rate + niit + sl_rate)
`niit` = federal 3.8% Net Investment Income Tax (0 if MAGI is under
the threshold). `sl_rate` = STATE+LOCAL marginal rate (e.g. NY+NYC)
applied at ORDINARY rates to every flow: state and local have no
preferential capital-gains rate, so a NYC resident pays city + state
on top of the federal rate for the same dollar. The after-tax
remainder also increases each lot's cost basis proportionally
(standard after-tax-IRR convention, so a liquidation doesn't re-tax
the distribution).
- On rebalance, sells are FIFO. A lot is long-term if held more than
365 days at sale, else short-term; realized gains/losses are taxed at
`lt_rate` / `st_rate`.
@ -33,8 +38,8 @@ drop). Yahoo usually dates events on the ex-div date; the record-date
misalignments found by scripts/scan_adj_misalign.py are corrected by
remove/add ops in overrides/corrections/ where a symbol is analyzed.
Deliberately NOT modeled: loss carryover, wash sales, state rates,
brackets.
Deliberately NOT modeled: loss carryover, wash sales, progressive
brackets (flat marginal rates), SALT-deduction interaction.
"""
from __future__ import annotations
@ -82,11 +87,14 @@ def after_tax_portfolio(close: pd.DataFrame, div: pd.DataFrame, capg: pd.DataFra
weights: dict[str, float],
rebalance: str | None = None, cost_bps: float = 0.0,
lt_rate: float = 0.20, st_rate: float = 0.15,
div_rate: float = 0.15,
div_rate: float = 0.15, niit: float = 0.0,
sl_rate: float = 0.0,
start: str | None = None, end: str | None = None
) -> TaxResult:
"""`close` must be RAW close prices (bundle.close), not adj: the
per-share distribution dollars are raw, so units must be raw shares."""
per-share distribution dollars are raw, so units must be raw
shares. All rates are decimals; realized gains are taxed at
(st/lt_rate + niit + sl_rate)."""
syms = [s for s in weights if s in close.columns]
p = close[syms]
d = div[[s for s in syms if s in div.columns]].reindex(index=p.index, columns=syms).fillna(0.0)
@ -134,8 +142,8 @@ def after_tax_portfolio(close: pd.DataFrame, div: pd.DataFrame, capg: pd.DataFra
# reinvested units, so a second deduction would double-count it.
dinc = units * dv[t]
cinc = units * cv[t]
d_tax = float(dinc.sum() * div_rate)
c_tax = float(cinc.sum() * lt_rate)
d_tax = float(dinc.sum() * (div_rate + niit + sl_rate))
c_tax = float(cinc.sum() * (lt_rate + niit + sl_rate))
tax_rows[t, 0] = d_tax
tax_rows[t, 1] = c_tax
for i in range(len(syms)):
@ -172,11 +180,12 @@ def after_tax_portfolio(close: pd.DataFrame, div: pd.DataFrame, capg: pd.DataFra
gain = u * prices[i] - (l.cost * u / l.units)
held = (idx[t] - l.date).days
li = 1 if held > ST_WINDOW_DAYS else 2
r_eff = (lt_rate if li == 1 else st_rate) + niit + sl_rate
if gain >= 0:
tax = gain * (lt_rate if li == 1 else st_rate)
tax = gain * r_eff
real_rows[t, li - 1] += gain
else:
tax = gain * (lt_rate if li == 1 else st_rate)
tax = gain * r_eff
real_rows[t, 3 if li == 1 else 2] += -gain
cash -= tax
tax_rows[t, 2] += tax

View File

@ -89,6 +89,32 @@ def main() -> int:
abs(r2.taxes["capg_tax"][t5] - 0.20 * 0.6 / 30.0) < 1e-12
and abs(r2.taxes["div_tax"][t5] - 0.10 * 0.4 / 30.0) < 1e-12)
# --- state/local + NIIT: composite rates add to each component
# (NY/NYC tax cap gains at ordinary rates — the caller passes the
# composite sl_rate; the model must not preferentiate cap gains).
r3 = tax.after_tax_portfolio(close, div2, capg2, {"f": 1.0}, lt_rate=0.20,
st_rate=0.15, div_rate=0.10, niit=0.038,
sl_rate=0.14)
check("capg dist taxed at lt + NIIT + state/local",
abs(r3.taxes["capg_tax"][t5] - (0.20 + 0.038 + 0.14) * 0.6 / 30.0) < 1e-12)
check("div taxed at div + NIIT + state/local",
abs(r3.taxes["div_tax"][t5] - (0.10 + 0.038 + 0.14) * 0.4 / 30.0) < 1e-12)
# realized-gain leg: two symbols, daily rebalance forces ST sells;
# every realized dollar is taxed at (st_rate + niit + sl_rate).
d3 = pd.to_datetime(["2024-01-0%d" % i for i in (1, 2, 3)])
close3 = pd.DataFrame({"a": [30.0, 34.0, 29.0], "b": [10.0, 10.0, 10.0]},
index=d3)
zero3 = pd.DataFrame(0.0, index=d3, columns=["a", "b"])
r4 = tax.after_tax_portfolio(close3, zero3, zero3, {"a": 0.5, "b": 0.5},
rebalance="1D", lt_rate=0.20, st_rate=0.15,
div_rate=0.10, niit=0.038, sl_rate=0.14)
real_tax = float(r4.taxes["realized_tax"].sum())
net_st = float(r4.realized["st_gain"].sum()) - \
float(r4.realized["st_loss"].sum())
check("realized ST gains/losses net-taxed at st + NIIT + state/local",
float(r4.realized["st_gain"].sum()) > 0 and
abs(real_tax - net_st * (0.15 + 0.038 + 0.14)) < 1e-9)
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0