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:
parent
576d9fba97
commit
19b33d5a18
16
README.md
16
README.md
|
|
@ -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. |
|
| `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. |
|
| `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`). |
|
| `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. |
|
| `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). |
|
| `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. |
|
| `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
|
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 fund company's annual tax statement (1099-DIV detail: boxes 2a/2b),
|
||||||
the shareholder-report body, or a commercial feed (Lipper/Morningstar).
|
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),
|
`~/prog/fin/stocks` is a Yahoo dump and gets re-downloaded (overwritten),
|
||||||
so fixes must live outside it. Pipeline:
|
so fixes must live outside it. Pipeline:
|
||||||
|
|
|
||||||
24
app.py
24
app.py
|
|
@ -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
|
float(_settings.get("st_rate", 15.0))) / 100
|
||||||
div_rate = st.sidebar.number_input("Dividends %", 0.0, 49.0,
|
div_rate = st.sidebar.number_input("Dividends %", 0.0, 49.0,
|
||||||
float(_settings.get("div_rate", 15.0))) / 100
|
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)
|
# statistics available in the Statistics tab (names = metrics.summary keys)
|
||||||
_STATS_VALID = ("total_return", "return", "vol", "sharpe", "sortino",
|
_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 the sidebar inputs now, before any validation st.stop()
|
||||||
_remember(spec=spec, bench_spec=bench_spec, scheme_index=_scheme_labels.index(scheme),
|
_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,
|
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)
|
date_window=win, range_start=range_start, range_end=range_end)
|
||||||
|
|
||||||
if not spec.strip():
|
if not spec.strip():
|
||||||
|
|
@ -286,19 +295,19 @@ if saved:
|
||||||
@st.cache_data(show_spinner=False)
|
@st.cache_data(show_spinner=False)
|
||||||
def _compute_portfolio(w_key: tuple, scheme: str | None, cost_bps: float,
|
def _compute_portfolio(w_key: tuple, scheme: str | None, cost_bps: float,
|
||||||
start: str, lt: float, st_r: float, div: 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)
|
w = dict(w_key)
|
||||||
r = portfolio_returns(bundle.adj, w, rebalance=scheme, cost_bps=cost_bps, start=start)
|
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,
|
t = after_tax_portfolio(bundle.close, bundle.div, bundle.capg, w, rebalance=scheme,
|
||||||
cost_bps=cost_bps, lt_rate=lt, st_rate=st_r,
|
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
|
return r, t
|
||||||
|
|
||||||
|
|
||||||
def build_result(name: str, w: dict, scheme: str | None, cost: float) -> dict:
|
def build_result(name: str, w: dict, scheme: str | None, cost: float) -> dict:
|
||||||
r, t = _compute_portfolio(tuple(sorted(w.items())), scheme, cost, start,
|
r, t = _compute_portfolio(tuple(sorted(w.items())), scheme, cost, start,
|
||||||
lt_rate, st_rate, div_rate, str(root),
|
lt_rate, st_rate, div_rate, niit, sl_rate,
|
||||||
generation(Path(root)))
|
str(root), generation(Path(root)))
|
||||||
return {"name": name, "weights": w, "res": r, "tax": t}
|
return {"name": name, "weights": w, "res": r, "tax": t}
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
@ -338,8 +347,8 @@ for i, item in enumerate(bench_spec.split(), 1):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
r, t = _compute_portfolio(tuple(sorted(w.items())), freq, cost_bps, start,
|
r, t = _compute_portfolio(tuple(sorted(w.items())), freq, cost_bps, start,
|
||||||
lt_rate, st_rate, div_rate, str(root),
|
lt_rate, st_rate, div_rate, niit, sl_rate,
|
||||||
generation(Path(root)))
|
str(root), generation(Path(root)))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
st.sidebar.warning(f"Benchmark {i} ignored: {e}")
|
st.sidebar.warning(f"Benchmark {i} ignored: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
@ -365,6 +374,7 @@ if len(results) > 1:
|
||||||
st.caption(f"Comparing: {', '.join(r['name'] for r in results)}")
|
st.caption(f"Comparing: {', '.join(r['name'] for r in results)}")
|
||||||
st.caption(f"{scheme} · start {start} · cost {cost_bps} bps · "
|
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)}"
|
+ (f" · benchmark: {' ; '.join(b['label'] for b in benchmarks)}"
|
||||||
if benchmarks else ""))
|
if benchmarks else ""))
|
||||||
|
|
||||||
|
|
|
||||||
35
tax.py
35
tax.py
|
|
@ -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
|
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`
|
carried by the (smaller) reinvested units; it is recorded in `taxes`
|
||||||
but not separately deducted from cash (that would double-count it).
|
but not separately deducted from cash (that would double-count it).
|
||||||
dividend income -> taxed at `div_rate`
|
dividend income -> taxed at (div_rate + niit + sl_rate)
|
||||||
capital gain dist-> taxed at `lt_rate`
|
capital gain dist-> taxed at (lt_rate + niit + sl_rate)
|
||||||
The after-tax remainder also increases each lot's cost basis
|
`niit` = federal 3.8% Net Investment Income Tax (0 if MAGI is under
|
||||||
proportionally (standard after-tax-IRR convention, so a liquidation
|
the threshold). `sl_rate` = STATE+LOCAL marginal rate (e.g. NY+NYC)
|
||||||
doesn't re-tax the distribution).
|
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
|
- 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
|
365 days at sale, else short-term; realized gains/losses are taxed at
|
||||||
`lt_rate` / `st_rate`.
|
`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
|
misalignments found by scripts/scan_adj_misalign.py are corrected by
|
||||||
remove/add ops in overrides/corrections/ where a symbol is analyzed.
|
remove/add ops in overrides/corrections/ where a symbol is analyzed.
|
||||||
|
|
||||||
Deliberately NOT modeled: loss carryover, wash sales, state rates,
|
Deliberately NOT modeled: loss carryover, wash sales, progressive
|
||||||
brackets.
|
brackets (flat marginal rates), SALT-deduction interaction.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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],
|
weights: dict[str, float],
|
||||||
rebalance: str | None = None, cost_bps: float = 0.0,
|
rebalance: str | None = None, cost_bps: float = 0.0,
|
||||||
lt_rate: float = 0.20, st_rate: float = 0.15,
|
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
|
start: str | None = None, end: str | None = None
|
||||||
) -> TaxResult:
|
) -> TaxResult:
|
||||||
"""`close` must be RAW close prices (bundle.close), not adj: the
|
"""`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]
|
syms = [s for s in weights if s in close.columns]
|
||||||
p = close[syms]
|
p = close[syms]
|
||||||
d = div[[s for s in syms if s in div.columns]].reindex(index=p.index, columns=syms).fillna(0.0)
|
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.
|
# reinvested units, so a second deduction would double-count it.
|
||||||
dinc = units * dv[t]
|
dinc = units * dv[t]
|
||||||
cinc = units * cv[t]
|
cinc = units * cv[t]
|
||||||
d_tax = float(dinc.sum() * div_rate)
|
d_tax = float(dinc.sum() * (div_rate + niit + sl_rate))
|
||||||
c_tax = float(cinc.sum() * lt_rate)
|
c_tax = float(cinc.sum() * (lt_rate + niit + sl_rate))
|
||||||
tax_rows[t, 0] = d_tax
|
tax_rows[t, 0] = d_tax
|
||||||
tax_rows[t, 1] = c_tax
|
tax_rows[t, 1] = c_tax
|
||||||
for i in range(len(syms)):
|
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)
|
gain = u * prices[i] - (l.cost * u / l.units)
|
||||||
held = (idx[t] - l.date).days
|
held = (idx[t] - l.date).days
|
||||||
li = 1 if held > ST_WINDOW_DAYS else 2
|
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:
|
if gain >= 0:
|
||||||
tax = gain * (lt_rate if li == 1 else st_rate)
|
tax = gain * r_eff
|
||||||
real_rows[t, li - 1] += gain
|
real_rows[t, li - 1] += gain
|
||||||
else:
|
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
|
real_rows[t, 3 if li == 1 else 2] += -gain
|
||||||
cash -= tax
|
cash -= tax
|
||||||
tax_rows[t, 2] += tax
|
tax_rows[t, 2] += tax
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,32 @@ def main() -> int:
|
||||||
abs(r2.taxes["capg_tax"][t5] - 0.20 * 0.6 / 30.0) < 1e-12
|
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)
|
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")
|
print(f"\n{PASS} passed, {FAIL} failed")
|
||||||
return 1 if FAIL else 0
|
return 1 if FAIL else 0
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user