- single spec grammar for symbol and benchmark fields: commas join one portfolio (MSFT:0.6,V:0.4), spaces separate distinct symbols/portfolios; both fields accept one or many entries - benchmarks simulated with the same scheme/cost/tax rules; per-benchmark beta/alpha columns; after-tax benchmark curves - global Curve mode (pre/after/both) above the tabs; clean names in single-curve mode - live updates: field commits on Enter/blur, page recomputes per rerun; portfolio+tax sims cached (st.cache_data); plotly.js from CDN (4.6MB -> browser-cached) with F_INLINE_PLOTLY=1 offline fallback - chart: legend underneath, solid lines, pan sticks to data edges (width-preserving), zoom edge-clamped - inputs persist in settings.json across reloads/restarts/devices - tests: tests/test_app.py (AppTest) + tests/test_e2e_browser.py (Playwright) via ./run_tests.sh
148 lines
5.1 KiB
Python
148 lines
5.1 KiB
Python
"""Portfolio construction: weighted blend of assets with rebalancing.
|
|
|
|
All inputs are date x symbol DataFrames of *adjusted* prices (which
|
|
already include distributions). Returns are pre-tax.
|
|
|
|
Supported schemes:
|
|
rebalance=None buy & hold (drift for the whole period)
|
|
rebalance='1W'/'1ME'/'QE'/'YE' etc. (pandas offset alias)
|
|
rebalance to target weights on the first trading
|
|
day of each period
|
|
cost_bps one-way trading cost in basis points, applied on
|
|
the traded fraction (turnover) at each rebalance
|
|
|
|
The weight evolution between rebalances is the standard drift:
|
|
w_{t+1} = w_t (1 + r_t) / (1 + R_t)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from dataclasses import dataclass, field
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
|
|
@dataclass
|
|
class PortfolioResult:
|
|
equity: pd.Series # gross value, starts at 1.0
|
|
returns: pd.Series # daily portfolio return
|
|
allocation: pd.DataFrame # drifted weights (date x asset)
|
|
turnover: pd.Series # one-way turnover at each rebalance (else 0)
|
|
costs: pd.Series # cost paid at each rebalance (else 0)
|
|
rebalance_dates: list = field(default_factory=list)
|
|
|
|
|
|
def _rebalance_dates(index: pd.DatetimeIndex, freq: str) -> set:
|
|
"""First trading day of each period, including the first day."""
|
|
periods = index.to_series().groupby(pd.Grouper(freq=freq)).first()
|
|
return set(periods.index.tolist())
|
|
|
|
|
|
def portfolio_returns(adj: pd.DataFrame, weights: dict[str, float],
|
|
rebalance: str | None = None,
|
|
cost_bps: float = 0.0,
|
|
start: str | None = None, end: str | None = None
|
|
) -> PortfolioResult:
|
|
"""Simulate a (drifting) weighted portfolio on an adjusted price panel.
|
|
|
|
weights: {symbol: raw weight}; normalized internally.
|
|
"""
|
|
p = adj[list(weights)].copy()
|
|
if start or end:
|
|
p = p.loc[start:end]
|
|
p = p.dropna()
|
|
if len(p) < 2:
|
|
raise ValueError("no overlapping data for the given symbols/period")
|
|
|
|
w0 = np.array([weights[s] for s in p.columns], dtype=float)
|
|
w0 = w0 / w0.sum()
|
|
|
|
rets = p.pct_change().fillna(0.0).values
|
|
rebal_set = _rebalance_dates(p.index, rebalance) if rebalance else set()
|
|
cost = cost_bps / 1e4
|
|
|
|
n_days = len(p)
|
|
w = w0.copy()
|
|
eq = np.empty(n_days)
|
|
alloc = np.empty((n_days, len(p.columns)))
|
|
turns = np.zeros(n_days)
|
|
costs = np.zeros(n_days)
|
|
eq[0] = 1.0
|
|
alloc[0] = w
|
|
# initial buy: cost on 100% invested
|
|
eq[0] *= (1.0 - cost * 1.0)
|
|
costs[0] = cost
|
|
|
|
for i in range(1, n_days):
|
|
R = float(np.dot(w, rets[i]))
|
|
w_drifted = w * (1.0 + rets[i]) / (1.0 + R)
|
|
|
|
if p.index[i] in rebal_set:
|
|
target = w0
|
|
turnover = float(np.abs(target - w_drifted).sum() / 2.0)
|
|
c = cost * turnover
|
|
w_drifted = target
|
|
eq[i] = eq[i - 1] * (1.0 + R) * (1.0 - c)
|
|
turns[i] = turnover
|
|
costs[i] = c
|
|
else:
|
|
eq[i] = eq[i - 1] * (1.0 + R)
|
|
|
|
w = w_drifted
|
|
alloc[i] = w
|
|
|
|
idx = p.index
|
|
return PortfolioResult(
|
|
equity=pd.Series(eq, index=idx),
|
|
returns=pd.Series(pd.Series(eq, index=idx).pct_change().fillna(0.0), index=idx),
|
|
allocation=pd.DataFrame(alloc, index=idx, columns=p.columns),
|
|
turnover=pd.Series(turns, index=idx),
|
|
costs=pd.Series(costs, index=idx),
|
|
rebalance_dates=sorted(rebal_set & set(idx)),
|
|
)
|
|
|
|
|
|
def parse_weights(spec: str) -> dict[str, float]:
|
|
"""Parse ONE symbol or ONE portfolio spec. Returns {symbol: weight}.
|
|
|
|
Commas separate the elements of the portfolio; each element is
|
|
'SYM' (equal weight) or 'SYM:w' (weight w > 0). A bare 'SYM' on its
|
|
own is a single symbol, not a portfolio. Surrounding whitespace is
|
|
ignored. For specs that may hold several space-separated
|
|
symbols/portfolios, use parse_items().
|
|
"""
|
|
parts = [t.strip() for t in spec.strip().split(",") if t.strip()]
|
|
if not parts:
|
|
raise ValueError("no symbols given")
|
|
out: dict[str, float] = {}
|
|
for token in parts:
|
|
if ":" in token:
|
|
sym, w_s = token.rsplit(":", 1)
|
|
try:
|
|
w = float(w_s)
|
|
except ValueError:
|
|
raise ValueError(f"bad weight in '{token}'") from None
|
|
if not math.isfinite(w) or w <= 0:
|
|
raise ValueError(f"weight in '{token}' must be a positive number")
|
|
else:
|
|
sym, w = token, 1.0
|
|
sym = sym.strip()
|
|
if not sym:
|
|
raise ValueError(f"empty symbol in '{token}'")
|
|
out[sym] = out.get(sym, 0.0) + w
|
|
if not out:
|
|
raise ValueError("no symbols parsed")
|
|
return out
|
|
|
|
|
|
def parse_items(spec: str) -> list[dict[str, float]]:
|
|
"""Parse a spec that may hold several symbols/portfolios.
|
|
|
|
Spaces (or newlines) separate DISTINCT symbols/portfolios; commas
|
|
join the elements of one portfolio. Each item is parsed by
|
|
parse_weights().
|
|
"""
|
|
return [parse_weights(item) for item in spec.split() if item.strip()]
|