diff --git a/README.md b/README.md index 6bf2313..18d40dc 100644 --- a/README.md +++ b/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. | | `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: diff --git a/app.py b/app.py index b56071f..394f900 100644 --- a/app.py +++ b/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 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 "")) diff --git a/tax.py b/tax.py index 78c29fc..144f76e 100644 --- a/tax.py +++ b/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 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 diff --git a/tests/test_tax.py b/tests/test_tax.py index 564002a..e89d2ed 100644 --- a/tests/test_tax.py +++ b/tests/test_tax.py @@ -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