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 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