portlab 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- portlab/__init__.py +24 -0
- portlab/attribution.py +111 -0
- portlab/backtest/__init__.py +11 -0
- portlab/backtest/portfolio.py +393 -0
- portlab/backtest/rolling.py +97 -0
- portlab/backtest/stress.py +74 -0
- portlab/config.py +24 -0
- portlab/covariance.py +66 -0
- portlab/metrics.py +455 -0
- portlab/optimize/__init__.py +27 -0
- portlab/optimize/auglag.py +170 -0
- portlab/optimize/black_litterman.py +52 -0
- portlab/optimize/constrained.py +45 -0
- portlab/optimize/constraint_attribution.py +93 -0
- portlab/optimize/core.py +88 -0
- portlab/optimize/cvar.py +195 -0
- portlab/optimize/fista.py +223 -0
- portlab/optimize/gurobi_backend.py +186 -0
- portlab/optimize/hrp.py +64 -0
- portlab/optimize/meanvar.py +285 -0
- portlab/optimize/objectives.py +160 -0
- portlab/optimize/osqp_backend.py +114 -0
- portlab/optimize/resampled.py +46 -0
- portlab/optimize/riskparity.py +37 -0
- portlab/optimize/robust.py +110 -0
- portlab/py.typed +0 -0
- portlab/rebalance.py +54 -0
- portlab/returns.py +70 -0
- portlab/risk/__init__.py +36 -0
- portlab/risk/backtest.py +356 -0
- portlab/risk/regime.py +114 -0
- portlab/risk/tail.py +233 -0
- portlab/risk/var.py +394 -0
- portlab-0.1.0.dist-info/METADATA +104 -0
- portlab-0.1.0.dist-info/RECORD +38 -0
- portlab-0.1.0.dist-info/WHEEL +5 -0
- portlab-0.1.0.dist-info/licenses/LICENSE +21 -0
- portlab-0.1.0.dist-info/top_level.txt +1 -0
portlab/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""portlab — portfolio risk and optimization library.
|
|
2
|
+
|
|
3
|
+
Public core: performance/risk metrics, VaR/ES estimation with VaR
|
|
4
|
+
backtesting (Kupiec, Christoffersen), tail diagnostics, regime
|
|
5
|
+
detection, portfolio optimization (mean-variance, CVaR, Black-
|
|
6
|
+
Litterman, HRP, risk parity, robust), portfolio backtesting, and
|
|
7
|
+
Brinson attribution. See VALIDATION.md for the verification record.
|
|
8
|
+
|
|
9
|
+
Conventions: inputs are *simple returns* (positive = gain); VaR/ES
|
|
10
|
+
are reported as *positive numbers = losses*. In ``portlab.risk``,
|
|
11
|
+
``alpha`` is the tail probability (0.05 = 95% VaR); in
|
|
12
|
+
``portlab.metrics`` and the CVaR optimizer, ``alpha`` is the
|
|
13
|
+
confidence level (0.95). Each module's docstring states which.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
from . import (attribution, backtest, covariance, metrics, optimize,
|
|
19
|
+
rebalance, returns, risk)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"attribution", "backtest", "covariance", "metrics", "optimize",
|
|
23
|
+
"rebalance", "returns", "risk", "__version__",
|
|
24
|
+
]
|
portlab/attribution.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Performance attribution: Brinson-Hood-Beebower (1986).
|
|
2
|
+
|
|
3
|
+
Decomposes a portfolio's excess return over a benchmark into *why* it
|
|
4
|
+
happened — allocation bets vs. stock selection — per segment (sector,
|
|
5
|
+
asset class, region, ...). For segment *i* with portfolio weight ``w_p``,
|
|
6
|
+
benchmark weight ``w_b``, portfolio return ``r_p`` and benchmark return
|
|
7
|
+
``r_b``::
|
|
8
|
+
|
|
9
|
+
allocation_i = (w_p,i - w_b,i) * r_b,i
|
|
10
|
+
selection_i = w_b,i * (r_p,i - r_b,i)
|
|
11
|
+
interaction_i = (w_p,i - w_b,i) * (r_p,i - r_b,i)
|
|
12
|
+
|
|
13
|
+
The identity ``sum(allocation + selection + interaction) == R_p - R_b`` holds
|
|
14
|
+
algebraically whenever both weight vectors sum to 1; :func:`brinson` checks
|
|
15
|
+
it and raises if it fails to close (a non-closing identity means an input
|
|
16
|
+
bug, never a rounding choice).
|
|
17
|
+
|
|
18
|
+
``method="fachler"`` switches allocation to the Brinson-Fachler variant,
|
|
19
|
+
``(w_p,i - w_b,i) * (r_b,i - R_b)``, which scores an overweight only when the
|
|
20
|
+
segment beat the overall benchmark. The identity is unaffected.
|
|
21
|
+
|
|
22
|
+
Reference: Brinson, Hood & Beebower (1986), "Determinants of Portfolio
|
|
23
|
+
Performance," *Financial Analysts Journal* 42(4), 39-44.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import pandas as pd
|
|
29
|
+
|
|
30
|
+
__all__ = ["brinson"]
|
|
31
|
+
|
|
32
|
+
_METHODS = ("bhb", "fachler")
|
|
33
|
+
_IDENTITY_TOL = 1e-9
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _as_frame(segments) -> pd.DataFrame:
|
|
37
|
+
if isinstance(segments, pd.DataFrame):
|
|
38
|
+
df = segments.copy()
|
|
39
|
+
else:
|
|
40
|
+
df = pd.DataFrame(list(segments))
|
|
41
|
+
need = {"segment", "w_p", "w_b", "r_p", "r_b"}
|
|
42
|
+
missing = need - set(df.columns)
|
|
43
|
+
if missing:
|
|
44
|
+
raise ValueError(f"segments need columns {sorted(need)}; missing {sorted(missing)}")
|
|
45
|
+
return df
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def brinson(segments, method: str = "bhb") -> dict:
|
|
49
|
+
"""Single-period Brinson attribution.
|
|
50
|
+
|
|
51
|
+
``segments``: DataFrame (or list of dicts) with columns
|
|
52
|
+
segment/w_p/w_b/r_p/r_b. Weights must each sum to 1 (within 1e-8);
|
|
53
|
+
all values must be finite.
|
|
54
|
+
|
|
55
|
+
Returns dict with ``table`` (per-segment allocation/selection/
|
|
56
|
+
interaction/total), ``portfolio_return``, ``benchmark_return``,
|
|
57
|
+
``excess_return``, and the three effect totals.
|
|
58
|
+
"""
|
|
59
|
+
method = str(method).lower()
|
|
60
|
+
if method not in _METHODS:
|
|
61
|
+
raise ValueError(f"method must be one of {_METHODS}, got {method!r}")
|
|
62
|
+
df = _as_frame(segments)
|
|
63
|
+
if df.empty:
|
|
64
|
+
raise ValueError("brinson() needs at least one segment")
|
|
65
|
+
for col in ("w_p", "w_b", "r_p", "r_b"):
|
|
66
|
+
vals = pd.to_numeric(df[col], errors="raise").to_numpy(dtype=float)
|
|
67
|
+
if not all(pd.notna(vals)) or not all(abs(v) != float("inf") for v in vals):
|
|
68
|
+
raise ValueError(f"column {col!r} must be finite")
|
|
69
|
+
df[col] = vals
|
|
70
|
+
for col, name in (("w_p", "portfolio"), ("w_b", "benchmark")):
|
|
71
|
+
s = float(df[col].sum())
|
|
72
|
+
if abs(s - 1.0) > 1e-8:
|
|
73
|
+
raise ValueError(f"{name} weights sum to {s}, not 1")
|
|
74
|
+
|
|
75
|
+
w_p = df["w_p"].to_numpy()
|
|
76
|
+
w_b = df["w_b"].to_numpy()
|
|
77
|
+
r_p = df["r_p"].to_numpy()
|
|
78
|
+
r_b = df["r_b"].to_numpy()
|
|
79
|
+
|
|
80
|
+
r_p_total = float((w_p * r_p).sum())
|
|
81
|
+
r_b_total = float((w_b * r_b).sum())
|
|
82
|
+
excess = r_p_total - r_b_total
|
|
83
|
+
|
|
84
|
+
active_w = w_p - w_b
|
|
85
|
+
active_r = r_p - r_b
|
|
86
|
+
if method == "fachler":
|
|
87
|
+
allocation = active_w * (r_b - r_b_total)
|
|
88
|
+
else:
|
|
89
|
+
allocation = active_w * r_b
|
|
90
|
+
selection = w_b * active_r
|
|
91
|
+
interaction = active_w * active_r
|
|
92
|
+
|
|
93
|
+
a, s, i = float(allocation.sum()), float(selection.sum()), float(interaction.sum())
|
|
94
|
+
if abs((a + s + i) - excess) > _IDENTITY_TOL:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"attribution identity failed to close: {a + s + i} != {excess} "
|
|
97
|
+
"(check the weight/return inputs)")
|
|
98
|
+
|
|
99
|
+
table = pd.DataFrame({
|
|
100
|
+
"segment": df["segment"].astype(str).to_numpy(),
|
|
101
|
+
"allocation": allocation,
|
|
102
|
+
"selection": selection,
|
|
103
|
+
"interaction": interaction,
|
|
104
|
+
})
|
|
105
|
+
table["total"] = table[["allocation", "selection", "interaction"]].sum(axis=1)
|
|
106
|
+
return {"table": table.set_index("segment"),
|
|
107
|
+
"portfolio_return": r_p_total,
|
|
108
|
+
"benchmark_return": r_b_total,
|
|
109
|
+
"excess_return": excess,
|
|
110
|
+
"allocation": a, "selection": s, "interaction": i,
|
|
111
|
+
"method": method}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .portfolio import (BacktestResult, backtest_dynamic,
|
|
2
|
+
backtest_portfolio, compare_portfolios, glide_path)
|
|
3
|
+
from .rolling import RollingBacktest, equal_weight
|
|
4
|
+
from .stress import (HISTORICAL_EPISODES, episode_returns, shock_scenario,
|
|
5
|
+
worst_windows)
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BacktestResult", "backtest_dynamic", "backtest_portfolio", "compare_portfolios", "glide_path",
|
|
9
|
+
"RollingBacktest", "equal_weight", "HISTORICAL_EPISODES",
|
|
10
|
+
"episode_returns", "shock_scenario", "worst_windows",
|
|
11
|
+
]
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Portfolio backtesting engine.
|
|
2
|
+
|
|
3
|
+
Supports fixed target weights, periodic contributions/withdrawals (optionally
|
|
4
|
+
inflation-adjusted), periodic or tolerance-band rebalancing, benchmark
|
|
5
|
+
comparison, CPI-deflated (real) results, short-cash leverage with an optional
|
|
6
|
+
maintenance-margin rule, and after-tax results (dividend and capital-gains
|
|
7
|
+
taxes).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from ..config import DEFAULT_RF, TRADING_DAYS
|
|
18
|
+
from .. import metrics as M
|
|
19
|
+
|
|
20
|
+
REBALANCE_MONTHS = {"never": None, "monthly": 1, "quarterly": 3,
|
|
21
|
+
"semiannually": 6, "annually": 12}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class BacktestResult:
|
|
26
|
+
balance: pd.Series # end-of-period equity value
|
|
27
|
+
returns: pd.Series # per-period time-weighted returns
|
|
28
|
+
weights: pd.DataFrame # start-of-period effective weights
|
|
29
|
+
cashflows: pd.Series # external flows (+contribution/-withdrawal)
|
|
30
|
+
benchmark_returns: pd.Series | None = None
|
|
31
|
+
real_balance: pd.Series | None = None
|
|
32
|
+
periods: int = TRADING_DAYS
|
|
33
|
+
rf: float | pd.Series = DEFAULT_RF
|
|
34
|
+
name: str = "Portfolio"
|
|
35
|
+
rebalance_dates: list = field(default_factory=list)
|
|
36
|
+
taxes_paid: pd.Series | None = None # per-period taxes deducted
|
|
37
|
+
margin_calls: list = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
def summary(self) -> pd.DataFrame:
|
|
40
|
+
s = M.summary(self.returns, rf=self.rf, periods=self.periods,
|
|
41
|
+
bench=self.benchmark_returns, name=self.name)
|
|
42
|
+
s["Ending Balance"] = float(self.balance.iloc[-1])
|
|
43
|
+
if self.real_balance is not None:
|
|
44
|
+
s["Ending Balance (real)"] = float(self.real_balance.iloc[-1])
|
|
45
|
+
if self.taxes_paid is not None and self.taxes_paid.sum() > 0:
|
|
46
|
+
s["Total Taxes Paid"] = float(self.taxes_paid.sum())
|
|
47
|
+
initial = self.balance.iloc[0] / (1 + self.returns.iloc[0])
|
|
48
|
+
flows = pd.Series(
|
|
49
|
+
[-initial, *(-self.cashflows.iloc[1:])],
|
|
50
|
+
index=self.balance.index)
|
|
51
|
+
flows.iloc[-1] += self.balance.iloc[-1]
|
|
52
|
+
s["Money-Weighted Return (IRR)"] = M.money_weighted_return(flows)
|
|
53
|
+
cols = [s.rename(self.name)]
|
|
54
|
+
if self.benchmark_returns is not None:
|
|
55
|
+
cols.append(M.summary(self.benchmark_returns, rf=self.rf,
|
|
56
|
+
periods=self.periods, name="Benchmark"))
|
|
57
|
+
return pd.concat(cols, axis=1)
|
|
58
|
+
|
|
59
|
+
def drawdowns(self, top: int = 10) -> pd.DataFrame:
|
|
60
|
+
return M.drawdown_table(self.returns, top)
|
|
61
|
+
|
|
62
|
+
def tear_sheet(self, output: str | None = "tear_sheet.html",
|
|
63
|
+
title: str | None = None) -> str:
|
|
64
|
+
"""One-line shareable HTML report for this backtest."""
|
|
65
|
+
from ..report import tear_sheet
|
|
66
|
+
return tear_sheet(self.returns, benchmark=self.benchmark_returns,
|
|
67
|
+
rf=self.rf, periods=self.periods,
|
|
68
|
+
title=title or f"{self.name} — Tear Sheet",
|
|
69
|
+
output=output)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_period_boundary(prev: pd.Timestamp, cur: pd.Timestamp, months: int) -> bool:
|
|
73
|
+
"""True when `cur` starts a new k-month block (calendar-aligned)."""
|
|
74
|
+
if prev.year != cur.year or prev.month != cur.month:
|
|
75
|
+
return ((cur.month - 1) % months) == 0 or months == 1
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def backtest_portfolio(
|
|
80
|
+
returns: pd.DataFrame,
|
|
81
|
+
weights: dict[str, float] | pd.Series,
|
|
82
|
+
initial: float = 10_000.0,
|
|
83
|
+
cashflow: float = 0.0,
|
|
84
|
+
cashflow_freq: str = "monthly",
|
|
85
|
+
cashflow_inflation_adjusted: bool = False,
|
|
86
|
+
rebalance: str = "annually",
|
|
87
|
+
rebalance_band: float | None = None,
|
|
88
|
+
benchmark: pd.Series | None = None,
|
|
89
|
+
cpi: pd.Series | None = None,
|
|
90
|
+
rf: float | pd.Series = DEFAULT_RF,
|
|
91
|
+
periods: int = TRADING_DAYS,
|
|
92
|
+
name: str = "Portfolio",
|
|
93
|
+
leverage: float = 1.0,
|
|
94
|
+
debt_rate: float | pd.Series = 0.0,
|
|
95
|
+
maintenance_margin: float | None = None,
|
|
96
|
+
tax_dividend: float = 0.0,
|
|
97
|
+
tax_capgains: float = 0.0,
|
|
98
|
+
div_yield: dict[str, float] | pd.Series | None = None,
|
|
99
|
+
carry_losses: bool = True,
|
|
100
|
+
) -> BacktestResult:
|
|
101
|
+
"""Simulate a target-weight portfolio through historical returns.
|
|
102
|
+
|
|
103
|
+
returns: per-period simple returns per asset (daily or monthly).
|
|
104
|
+
weights: target allocation, will be normalized to sum to 1.
|
|
105
|
+
cashflow: amount added (+) or withdrawn (-) at each cashflow boundary.
|
|
106
|
+
cashflow_freq: 'monthly' | 'quarterly' | 'annually'.
|
|
107
|
+
cashflow_inflation_adjusted: grow the cashflow with CPI (requires cpi).
|
|
108
|
+
rebalance: 'never' | 'monthly' | 'quarterly' | 'semiannually' | 'annually'
|
|
109
|
+
| 'bands' (use rebalance_band as absolute weight deviation trigger).
|
|
110
|
+
cpi: monthly CPI index series; enables real (inflation-adjusted) balances.
|
|
111
|
+
|
|
112
|
+
Leverage (short cash):
|
|
113
|
+
leverage: gross exposure / equity, reset at every rebalance (1.0 = none).
|
|
114
|
+
debt_rate: annual borrow rate; a pd.Series (e.g. T-bill history) is
|
|
115
|
+
looked up per date (e.g. the historical 3-month T-bill series).
|
|
116
|
+
maintenance_margin: e.g. 0.25 — when equity/gross falls below this,
|
|
117
|
+
positions are sold to restore the target leverage and the date is
|
|
118
|
+
recorded in `margin_calls`.
|
|
119
|
+
|
|
120
|
+
Taxes:
|
|
121
|
+
tax_dividend: rate applied to dividend income each period. Because
|
|
122
|
+
prices are total-return (dividends reinvested), the dividend stream is
|
|
123
|
+
approximated from `div_yield` (annual yield per asset, e.g.
|
|
124
|
+
{"VTI": 0.015}); without div_yield no dividend tax is charged.
|
|
125
|
+
tax_capgains: rate applied to net realized gains on rebalancing sales
|
|
126
|
+
and withdrawals (average-cost basis).
|
|
127
|
+
carry_losses: net realized losses offset later gains.
|
|
128
|
+
"""
|
|
129
|
+
w_target = pd.Series(weights, dtype=float)
|
|
130
|
+
w_target = w_target / w_target.sum()
|
|
131
|
+
rets = returns[list(w_target.index)].dropna(how="any")
|
|
132
|
+
if rets.empty:
|
|
133
|
+
raise ValueError("no overlapping return history for the requested assets")
|
|
134
|
+
if leverage < 1.0:
|
|
135
|
+
raise ValueError("leverage must be >= 1.0 (use cash in the allocation "
|
|
136
|
+
"for sub-1x exposure)")
|
|
137
|
+
|
|
138
|
+
cf_months = {"monthly": 1, "quarterly": 3, "annually": 12}[cashflow_freq]
|
|
139
|
+
if rebalance != "bands":
|
|
140
|
+
if rebalance not in REBALANCE_MONTHS:
|
|
141
|
+
raise ValueError(f"rebalance must be one of {[*REBALANCE_MONTHS, 'bands']}")
|
|
142
|
+
reb_months = REBALANCE_MONTHS[rebalance]
|
|
143
|
+
else:
|
|
144
|
+
reb_months = None
|
|
145
|
+
if rebalance_band is None:
|
|
146
|
+
raise ValueError("rebalance='bands' requires rebalance_band")
|
|
147
|
+
|
|
148
|
+
# Inflation factor for growing cashflows.
|
|
149
|
+
infl_factor = None
|
|
150
|
+
if cashflow_inflation_adjusted and cpi is not None:
|
|
151
|
+
cpi_m = cpi.reindex(rets.index, method="ffill")
|
|
152
|
+
infl_factor = (cpi_m / cpi_m.iloc[0]).fillna(1.0)
|
|
153
|
+
|
|
154
|
+
# Per-period dividend yields per asset (for the dividend-tax haircut).
|
|
155
|
+
dy_p = None
|
|
156
|
+
if tax_dividend > 0 and div_yield is not None:
|
|
157
|
+
dy = pd.Series(div_yield, dtype=float).reindex(w_target.index).fillna(0.0)
|
|
158
|
+
dy_p = dy.values / periods
|
|
159
|
+
|
|
160
|
+
def rate_p(date) -> float:
|
|
161
|
+
if isinstance(debt_rate, pd.Series):
|
|
162
|
+
r_ann = debt_rate.asof(date)
|
|
163
|
+
r_ann = 0.0 if pd.isna(r_ann) else float(r_ann)
|
|
164
|
+
else:
|
|
165
|
+
r_ann = float(debt_rate)
|
|
166
|
+
return (1 + r_ann) ** (1 / periods) - 1
|
|
167
|
+
|
|
168
|
+
def realize(sells: np.ndarray, values: np.ndarray, basis: np.ndarray,
|
|
169
|
+
loss_carry: float) -> tuple[float, float, np.ndarray]:
|
|
170
|
+
"""Tax on pro-rata sales with average-cost basis. Returns
|
|
171
|
+
(tax, new_loss_carry, new_basis)."""
|
|
172
|
+
safe = np.maximum(values, 1e-12)
|
|
173
|
+
gains = np.where(values > 0, sells * (1 - basis / safe), 0.0)
|
|
174
|
+
net = gains.sum() + loss_carry
|
|
175
|
+
tax = tax_capgains * max(net, 0.0)
|
|
176
|
+
new_carry = min(net, 0.0) if carry_losses else 0.0
|
|
177
|
+
new_basis = np.where(values > 0, basis * (1 - sells / safe), basis)
|
|
178
|
+
return tax, new_carry, new_basis
|
|
179
|
+
|
|
180
|
+
equity = initial
|
|
181
|
+
gross0 = equity * leverage
|
|
182
|
+
debt = gross0 - equity
|
|
183
|
+
holdings = gross0 * w_target.values # dollar value per asset
|
|
184
|
+
basis = holdings.copy()
|
|
185
|
+
loss_carry = 0.0
|
|
186
|
+
bal, twr, wts, flows, taxes = [], [], [], [], []
|
|
187
|
+
reb_dates, mcall_dates = [], []
|
|
188
|
+
prev_date = rets.index[0]
|
|
189
|
+
|
|
190
|
+
for i, (date, r) in enumerate(rets.iterrows()):
|
|
191
|
+
period_tax = 0.0
|
|
192
|
+
# --- external cashflow at period start (skip the very first period)
|
|
193
|
+
flow = 0.0
|
|
194
|
+
if i > 0 and cashflow != 0.0 and _is_period_boundary(prev_date, date, cf_months):
|
|
195
|
+
flow = cashflow * (float(infl_factor.loc[date]) if infl_factor is not None else 1.0)
|
|
196
|
+
total = holdings.sum()
|
|
197
|
+
if total - debt + flow <= 0: # ruined by withdrawal
|
|
198
|
+
holdings = np.zeros_like(holdings)
|
|
199
|
+
debt = 0.0
|
|
200
|
+
elif flow > 0 and total > 0:
|
|
201
|
+
added = holdings * (flow / total)
|
|
202
|
+
holdings = holdings + added
|
|
203
|
+
basis = basis + added
|
|
204
|
+
elif flow < 0 and total > 0:
|
|
205
|
+
sells = holdings * (-flow / total)
|
|
206
|
+
t, loss_carry, basis = realize(sells, holdings, basis, loss_carry)
|
|
207
|
+
holdings = holdings - sells
|
|
208
|
+
if t > 0 and holdings.sum() > 0: # pay tax from the portfolio
|
|
209
|
+
frac = t / holdings.sum()
|
|
210
|
+
holdings = holdings * (1 - frac)
|
|
211
|
+
basis = basis * (1 - frac)
|
|
212
|
+
period_tax += t
|
|
213
|
+
|
|
214
|
+
# --- rebalance check at period start
|
|
215
|
+
total = holdings.sum()
|
|
216
|
+
equity = total - debt
|
|
217
|
+
if equity > 0 and i > 0:
|
|
218
|
+
cur_w = holdings / total
|
|
219
|
+
do_reb = False
|
|
220
|
+
if reb_months is not None and _is_period_boundary(prev_date, date, reb_months):
|
|
221
|
+
do_reb = True
|
|
222
|
+
elif rebalance == "bands" and np.abs(cur_w - w_target.values).max() > rebalance_band:
|
|
223
|
+
do_reb = True
|
|
224
|
+
if do_reb:
|
|
225
|
+
target_gross = equity * leverage
|
|
226
|
+
new_holdings = target_gross * w_target.values
|
|
227
|
+
sells = np.maximum(holdings - new_holdings, 0.0)
|
|
228
|
+
t, loss_carry, basis = realize(sells, holdings, basis, loss_carry)
|
|
229
|
+
buys = np.maximum(new_holdings - holdings, 0.0)
|
|
230
|
+
basis = basis + buys
|
|
231
|
+
holdings = new_holdings
|
|
232
|
+
debt = target_gross - equity
|
|
233
|
+
if t > 0 and holdings.sum() > 0:
|
|
234
|
+
frac = t / holdings.sum()
|
|
235
|
+
holdings = holdings * (1 - frac)
|
|
236
|
+
basis = basis * (1 - frac)
|
|
237
|
+
debt = debt # tax paid by shrinking positions
|
|
238
|
+
period_tax += t
|
|
239
|
+
reb_dates.append(date)
|
|
240
|
+
|
|
241
|
+
gross_start = holdings.sum()
|
|
242
|
+
equity_start = gross_start - debt
|
|
243
|
+
wts.append(holdings / gross_start if gross_start > 0 else w_target.values)
|
|
244
|
+
|
|
245
|
+
# --- apply market returns (with optional dividend-tax haircut)
|
|
246
|
+
pre = holdings
|
|
247
|
+
growth = 1 + r.values
|
|
248
|
+
if dy_p is not None:
|
|
249
|
+
growth = growth - dy_p * tax_dividend
|
|
250
|
+
period_tax += float((pre * dy_p * tax_dividend).sum())
|
|
251
|
+
holdings = pre * growth
|
|
252
|
+
|
|
253
|
+
# --- debt accrual and equity
|
|
254
|
+
if debt > 0:
|
|
255
|
+
debt = debt * (1 + rate_p(date))
|
|
256
|
+
equity_end = holdings.sum() - debt
|
|
257
|
+
|
|
258
|
+
if equity_end <= 0 and (leverage > 1.0 or debt > 0):
|
|
259
|
+
holdings = np.zeros_like(holdings) # levered ruin
|
|
260
|
+
debt = 0.0
|
|
261
|
+
equity_end = 0.0
|
|
262
|
+
|
|
263
|
+
# --- maintenance margin
|
|
264
|
+
if (maintenance_margin is not None and equity_end > 0
|
|
265
|
+
and holdings.sum() > 0
|
|
266
|
+
and equity_end / holdings.sum() < maintenance_margin):
|
|
267
|
+
target_gross = equity_end * leverage
|
|
268
|
+
sell_total = holdings.sum() - target_gross
|
|
269
|
+
if sell_total > 0:
|
|
270
|
+
sells = holdings * (sell_total / holdings.sum())
|
|
271
|
+
t, loss_carry, basis = realize(sells, holdings, basis, loss_carry)
|
|
272
|
+
holdings = holdings - sells
|
|
273
|
+
debt = max(debt - (sell_total - t), 0.0)
|
|
274
|
+
period_tax += t
|
|
275
|
+
equity_end = holdings.sum() - debt
|
|
276
|
+
mcall_dates.append(date)
|
|
277
|
+
|
|
278
|
+
twr.append(equity_end / equity_start - 1 if equity_start > 0 else 0.0)
|
|
279
|
+
bal.append(equity_end)
|
|
280
|
+
flows.append(flow)
|
|
281
|
+
taxes.append(period_tax)
|
|
282
|
+
prev_date = date
|
|
283
|
+
|
|
284
|
+
idx = rets.index
|
|
285
|
+
balance = pd.Series(bal, index=idx, name=name)
|
|
286
|
+
port_rets = pd.Series(twr, index=idx, name=name)
|
|
287
|
+
weights_df = pd.DataFrame(wts, index=idx, columns=w_target.index)
|
|
288
|
+
cashflows = pd.Series(flows, index=idx)
|
|
289
|
+
taxes_paid = pd.Series(taxes, index=idx)
|
|
290
|
+
|
|
291
|
+
bench = None
|
|
292
|
+
if benchmark is not None:
|
|
293
|
+
bench = benchmark.reindex(idx).dropna()
|
|
294
|
+
|
|
295
|
+
real_balance = None
|
|
296
|
+
if cpi is not None:
|
|
297
|
+
cpi_m = cpi.reindex(idx, method="ffill")
|
|
298
|
+
real_balance = balance * (cpi_m.iloc[0] / cpi_m)
|
|
299
|
+
|
|
300
|
+
return BacktestResult(balance=balance, returns=port_rets, weights=weights_df,
|
|
301
|
+
cashflows=cashflows, benchmark_returns=bench,
|
|
302
|
+
real_balance=real_balance, periods=periods, rf=rf,
|
|
303
|
+
name=name, rebalance_dates=reb_dates,
|
|
304
|
+
taxes_paid=taxes_paid, margin_calls=mcall_dates)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def compare_portfolios(
|
|
308
|
+
returns: pd.DataFrame,
|
|
309
|
+
allocations: dict[str, dict[str, float]],
|
|
310
|
+
benchmark: pd.Series | None = None,
|
|
311
|
+
**kwargs,
|
|
312
|
+
) -> tuple[pd.DataFrame, dict[str, BacktestResult]]:
|
|
313
|
+
"""Backtest any number of allocations side by side."""
|
|
314
|
+
results = {nm: backtest_portfolio(returns, w, benchmark=benchmark, name=nm, **kwargs)
|
|
315
|
+
for nm, w in allocations.items()}
|
|
316
|
+
table = pd.concat([r.summary().iloc[:, 0] for r in results.values()], axis=1)
|
|
317
|
+
return table, results
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def backtest_dynamic(
|
|
321
|
+
returns: pd.DataFrame,
|
|
322
|
+
schedule: dict,
|
|
323
|
+
initial: float = 10_000.0,
|
|
324
|
+
rf: float | pd.Series = DEFAULT_RF,
|
|
325
|
+
periods: int = TRADING_DAYS,
|
|
326
|
+
name: str = "Dynamic Portfolio",
|
|
327
|
+
) -> BacktestResult:
|
|
328
|
+
"""Dynamic-allocation backtest: weights change on given dates.
|
|
329
|
+
|
|
330
|
+
schedule: {date_like: {ticker: weight}} — on each date the portfolio is
|
|
331
|
+
rebalanced to the new targets; between dates weights drift with returns.
|
|
332
|
+
The union of all tickers across the schedule defines the asset set.
|
|
333
|
+
"""
|
|
334
|
+
sched = {pd.Timestamp(d): pd.Series(w, dtype=float) / sum(w.values())
|
|
335
|
+
for d, w in schedule.items()}
|
|
336
|
+
dates = sorted(sched)
|
|
337
|
+
assets = sorted({a for w in sched.values() for a in w.index})
|
|
338
|
+
rets = returns[assets].dropna(how="any")
|
|
339
|
+
rets = rets.loc[rets.index >= dates[0]]
|
|
340
|
+
if rets.empty:
|
|
341
|
+
raise ValueError("no return history after the first schedule date")
|
|
342
|
+
|
|
343
|
+
holdings = None
|
|
344
|
+
bal, twr, wts, reb_dates = [], [], [], []
|
|
345
|
+
next_i = 0
|
|
346
|
+
for date, r in rets.iterrows():
|
|
347
|
+
while next_i < len(dates) and dates[next_i] <= date:
|
|
348
|
+
target = sched[dates[next_i]].reindex(assets).fillna(0.0)
|
|
349
|
+
total = holdings.sum() if holdings is not None else initial
|
|
350
|
+
holdings = total * target.values
|
|
351
|
+
reb_dates.append(date)
|
|
352
|
+
next_i += 1
|
|
353
|
+
start_total = holdings.sum()
|
|
354
|
+
wts.append(holdings / start_total if start_total > 0 else np.zeros(len(assets)))
|
|
355
|
+
holdings = holdings * (1 + r.values)
|
|
356
|
+
end_total = holdings.sum()
|
|
357
|
+
twr.append(end_total / start_total - 1 if start_total > 0 else 0.0)
|
|
358
|
+
bal.append(end_total)
|
|
359
|
+
|
|
360
|
+
idx = rets.index
|
|
361
|
+
return BacktestResult(
|
|
362
|
+
balance=pd.Series(bal, index=idx, name=name),
|
|
363
|
+
returns=pd.Series(twr, index=idx, name=name),
|
|
364
|
+
weights=pd.DataFrame(wts, index=idx, columns=assets),
|
|
365
|
+
cashflows=pd.Series(0.0, index=idx), periods=periods, rf=rf,
|
|
366
|
+
name=name, rebalance_dates=reb_dates)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def glide_path(
|
|
370
|
+
start_weights: dict[str, float],
|
|
371
|
+
end_weights: dict[str, float],
|
|
372
|
+
start: str,
|
|
373
|
+
end: str,
|
|
374
|
+
steps: int = 12,
|
|
375
|
+
) -> dict[pd.Timestamp, dict[str, float]]:
|
|
376
|
+
"""Linear glide-path schedule for `backtest_dynamic`: transition from a
|
|
377
|
+
career-stage to a retirement-stage portfolio.
|
|
378
|
+
|
|
379
|
+
steps: number of allocation change points between start and end inclusive.
|
|
380
|
+
"""
|
|
381
|
+
if steps < 2:
|
|
382
|
+
raise ValueError("steps must be >= 2")
|
|
383
|
+
assets = sorted(set(start_weights) | set(end_weights))
|
|
384
|
+
w0 = np.array([start_weights.get(a, 0.0) for a in assets], dtype=float)
|
|
385
|
+
w1 = np.array([end_weights.get(a, 0.0) for a in assets], dtype=float)
|
|
386
|
+
w0, w1 = w0 / w0.sum(), w1 / w1.sum()
|
|
387
|
+
dates = pd.date_range(pd.Timestamp(start), pd.Timestamp(end), periods=steps)
|
|
388
|
+
sched = {}
|
|
389
|
+
for k, d in enumerate(dates):
|
|
390
|
+
frac = k / (steps - 1)
|
|
391
|
+
w = (1 - frac) * w0 + frac * w1
|
|
392
|
+
sched[d] = {a: float(x) for a, x in zip(assets, w) if x > 1e-12}
|
|
393
|
+
return sched
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Walk-forward (rolling-window) strategy backtest.
|
|
2
|
+
|
|
3
|
+
Refactor of the RollingBacktest class from Portfolio_Optimization_COLAB:
|
|
4
|
+
the CFG global is gone (explicit parameters), plotting lives in plots.py,
|
|
5
|
+
and the strategy is any callable `weights_fn(train_returns) -> pd.Series`,
|
|
6
|
+
so every optimizer in portlab.optimize plugs in directly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from ..config import DEFAULT_RF, TRADING_DAYS
|
|
16
|
+
from .. import metrics as M
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def equal_weight(train: pd.DataFrame) -> pd.Series:
|
|
20
|
+
return pd.Series(1.0 / train.shape[1], index=train.columns)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RollingBacktest:
|
|
24
|
+
"""Retrain on a lookback window, hold weights for a rebalance block.
|
|
25
|
+
|
|
26
|
+
train_window: lookback length in periods (e.g. 252 daily).
|
|
27
|
+
rebalance_every: holding block length in periods (e.g. 21 daily).
|
|
28
|
+
min_history: drop assets with fewer valid observations in the window.
|
|
29
|
+
tc_bps: one-way transaction cost charged on turnover at each rebalance.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, returns: pd.DataFrame, train_window: int = 252,
|
|
33
|
+
rebalance_every: int = 21, tc_bps: float = 0.0,
|
|
34
|
+
min_history: int | None = None, rf: float | pd.Series = DEFAULT_RF,
|
|
35
|
+
periods: int = TRADING_DAYS):
|
|
36
|
+
self.returns = returns
|
|
37
|
+
self.train_window = train_window
|
|
38
|
+
self.rebalance_every = rebalance_every
|
|
39
|
+
self.tc_bps = tc_bps
|
|
40
|
+
self.min_history = min_history or train_window // 2
|
|
41
|
+
self.rf = rf
|
|
42
|
+
self.periods = periods
|
|
43
|
+
|
|
44
|
+
def run(self, weights_fn: Callable[[pd.DataFrame], pd.Series],
|
|
45
|
+
name: str = "strategy") -> pd.DataFrame:
|
|
46
|
+
"""Returns a DataFrame with per-period out-of-sample strategy returns
|
|
47
|
+
and a `weights` attribute (DataFrame indexed by rebalance date)."""
|
|
48
|
+
R = self.returns
|
|
49
|
+
oos_rets, weight_rows, weight_dates = [], [], []
|
|
50
|
+
w_prev: pd.Series | None = None
|
|
51
|
+
|
|
52
|
+
for start in range(self.train_window, len(R), self.rebalance_every):
|
|
53
|
+
train = R.iloc[start - self.train_window:start]
|
|
54
|
+
# investable filter: enough valid history in the window
|
|
55
|
+
valid = train.columns[train.notna().sum() >= self.min_history]
|
|
56
|
+
train = train[valid].fillna(0.0)
|
|
57
|
+
if train.shape[1] == 0:
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
w = weights_fn(train)
|
|
61
|
+
except Exception:
|
|
62
|
+
w = equal_weight(train)
|
|
63
|
+
w = w.clip(lower=0)
|
|
64
|
+
w = w / w.sum() if w.sum() > 0 else equal_weight(train)
|
|
65
|
+
|
|
66
|
+
test = R.iloc[start:start + self.rebalance_every][w.index].fillna(0.0)
|
|
67
|
+
block = (test * w.values).sum(axis=1)
|
|
68
|
+
|
|
69
|
+
if self.tc_bps > 0:
|
|
70
|
+
prev = w_prev.reindex(w.index).fillna(0.0) if w_prev is not None \
|
|
71
|
+
else pd.Series(0.0, index=w.index)
|
|
72
|
+
turnover = float((w - prev).abs().sum())
|
|
73
|
+
if not block.empty:
|
|
74
|
+
block.iloc[0] -= turnover * self.tc_bps / 1e4
|
|
75
|
+
w_prev = w
|
|
76
|
+
|
|
77
|
+
oos_rets.append(block)
|
|
78
|
+
weight_rows.append(w)
|
|
79
|
+
weight_dates.append(R.index[start])
|
|
80
|
+
|
|
81
|
+
rets = pd.concat(oos_rets) if oos_rets else pd.Series(dtype=float)
|
|
82
|
+
rets.name = name
|
|
83
|
+
out = rets.to_frame()
|
|
84
|
+
out.attrs["weights"] = pd.DataFrame(weight_rows, index=weight_dates).fillna(0.0)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
def run_many(self, strategies: dict[str, Callable[[pd.DataFrame], pd.Series]]
|
|
88
|
+
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
89
|
+
"""Run several strategies; returns (per-period returns, summary table)."""
|
|
90
|
+
frames = [self.run(fn, name) for name, fn in strategies.items()]
|
|
91
|
+
for f in frames:
|
|
92
|
+
f.attrs = {} # DataFrame-valued attrs break pd.concat's attr merge
|
|
93
|
+
rets = pd.concat(frames, axis=1)
|
|
94
|
+
summary = pd.concat(
|
|
95
|
+
[M.summary(rets[c].dropna(), rf=self.rf, periods=self.periods, name=c)
|
|
96
|
+
for c in rets.columns], axis=1)
|
|
97
|
+
return rets, summary
|