pitbacktest 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/PKG-INFO +1 -1
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/__init__.py +1 -1
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/core/panel.py +41 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/event.py +2 -1
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/portfolio.py +36 -4
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/screen.py +2 -1
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/validation.py +6 -2
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/weights.py +15 -5
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest.egg-info/PKG-INFO +1 -1
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest.egg-info/SOURCES.txt +2 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pyproject.toml +1 -1
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_argument_checks.py +2 -1
- pitbacktest-0.2.1/tests/test_hardening_021.py +137 -0
- pitbacktest-0.2.1/tests/test_ruin.py +81 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/LICENSE +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/README.md +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/adapters/__init__.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/adapters/krx.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/adapters/long_format.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/adapters/tiingo.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/adapters/yfinance.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/analytics.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/core/__init__.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/core/controls.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/core/costs.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/core/estimators.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/core/gates.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/crypto/__init__.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/crypto/binance_archive.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/crypto/costs.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/crypto/intraday.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/crypto/panel.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/equity/__init__.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/equity/master.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/equity/scenarios.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/execution.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/ledger.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/shorting.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest.egg-info/dependency_links.txt +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest.egg-info/requires.txt +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest.egg-info/top_level.txt +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/setup.cfg +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_analytics.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_annualization.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_binance_archive.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_causality.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_costs_events.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_crypto.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_delisting_and_guards.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_docs_scripts.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_equity.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_execution.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_intraday.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_krx.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_ledger.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_packaging.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_reconcile.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_side_costs_shorting.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_symbol_rules.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_synthetic.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_tiingo.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_validation.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_weights.py +0 -0
- {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_yfinance_adapter.py +0 -0
|
@@ -22,6 +22,6 @@ from .ledger import Ledger
|
|
|
22
22
|
from .weights import backtest_weights, capacity_curve, ImpactModel
|
|
23
23
|
from .shorting import shortable_from_bans
|
|
24
24
|
|
|
25
|
-
__version__ = "0.2.
|
|
25
|
+
__version__ = "0.2.1"
|
|
26
26
|
__all__ = ["shortable_from_bans", "execution", "validation", "analytics", "Ledger", "backtest_weights", "capacity_curve", "ImpactModel", "Panel", "build_pit_eligible", "GateConfig", "screen",
|
|
27
27
|
"backtest_event", "backtest_portfolio", "assert_timing"]
|
|
@@ -14,6 +14,7 @@ Timing convention (the only one used in the whole project)
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import warnings
|
|
17
18
|
from dataclasses import dataclass, field, replace
|
|
18
19
|
from typing import Iterable
|
|
19
20
|
|
|
@@ -63,6 +64,17 @@ class Panel:
|
|
|
63
64
|
self.close = self.close.sort_index()
|
|
64
65
|
if self.close.index.has_duplicates: # before any reindex, which would fail with a less helpful pandas message
|
|
65
66
|
raise ValueError("close.index has duplicate dates")
|
|
67
|
+
if self.close.columns.has_duplicates:
|
|
68
|
+
dup = list(self.close.columns[self.close.columns.duplicated()][:3])
|
|
69
|
+
raise ValueError(f"close has duplicate ticker names (for example {dup}): a reindex by name would pick the wrong column")
|
|
70
|
+
px = self.close.to_numpy(dtype=np.float64, na_value=np.nan)
|
|
71
|
+
badpx = ~np.isnan(px) & ~(np.isfinite(px) & (px > 0)) # zero, negative and infinite prices (a halted name written as 0 by a vendor)
|
|
72
|
+
if badpx.any():
|
|
73
|
+
i, j = np.argwhere(badpx)[0]
|
|
74
|
+
warnings.warn(f"{int(badpx.sum())} prices are zero, negative or infinite (first: {self.close.columns[j]} on {self.close.index[i].date()}, value {px[i, j]!r}); "
|
|
75
|
+
"they are treated as missing. Left in, a zero price makes a return of -100% followed by an infinite one", stacklevel=3)
|
|
76
|
+
self.close = self.close.where(~pd.DataFrame(badpx, index=self.close.index, columns=self.close.columns))
|
|
77
|
+
self._check_periods_per_year()
|
|
66
78
|
self.eligible = self.eligible.reindex(
|
|
67
79
|
index=self.close.index, columns=self.close.columns
|
|
68
80
|
).fillna(False).astype(bool)
|
|
@@ -82,6 +94,19 @@ class Panel:
|
|
|
82
94
|
setattr(self, k, v.reindex(index=self.close.index, columns=self.close.columns).fillna(False).astype(bool))
|
|
83
95
|
self.validate()
|
|
84
96
|
|
|
97
|
+
def _check_periods_per_year(self) -> None:
|
|
98
|
+
"""Warn when `periods_per_year` does not fit the spacing of the dates (weekly dates with the default 252 annualise by the wrong factor)."""
|
|
99
|
+
if len(self.close.index) < 3:
|
|
100
|
+
return
|
|
101
|
+
gap = self.close.index.to_series().diff().dropna().median()
|
|
102
|
+
if not gap or gap <= pd.Timedelta(0):
|
|
103
|
+
return
|
|
104
|
+
implied = pd.Timedelta(days=365.25) / gap
|
|
105
|
+
ratio = self.periods_per_year / implied
|
|
106
|
+
if ratio > 2.0 or ratio < 0.5:
|
|
107
|
+
warnings.warn(f"the dates are about {gap} apart (roughly {implied:.0f} bars a year) but periods_per_year={self.periods_per_year}: CAGR, Sharpe and "
|
|
108
|
+
"volatility are annualised by that number, so set periods_per_year to the real number of bars in a year (52 for weekly, 12 for monthly)", stacklevel=3)
|
|
109
|
+
|
|
85
110
|
def validate(self) -> None:
|
|
86
111
|
"""Raise ValueError if the panel breaks its invariants: the date index ascending and without duplicates, at least one eligible
|
|
87
112
|
security, `entry_lag` not negative, and at least 10 eligible securities on half of the dates or more. Runs on construction."""
|
|
@@ -304,3 +329,19 @@ def build_pit_eligible(close: pd.DataFrame, *, listed: pd.DataFrame | None = Non
|
|
|
304
329
|
if exclude is not None:
|
|
305
330
|
ok &= ~exclude.reindex_like(close).fillna(False).astype(bool)
|
|
306
331
|
return ok.fillna(False)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def check_alignment(panel: Panel, x, what: str) -> None:
|
|
335
|
+
"""Refuse an input table whose dates or tickers do not line up with the panel, and warn when only part of it does.
|
|
336
|
+
|
|
337
|
+
The engines align a signal to the panel by label, and a label that is missing becomes NaN. A factor with other column names, transposed, or with a
|
|
338
|
+
different time zone then aligned to nothing, every score was NaN, no position was ever taken, and the result was an unremarkable Sharpe of 0.0."""
|
|
339
|
+
if not isinstance(x, pd.DataFrame):
|
|
340
|
+
raise ValueError(f"{what} must be a pandas DataFrame of dates x tickers, got {type(x).__name__}")
|
|
341
|
+
d_share = float(panel.dates.isin(x.index).mean())
|
|
342
|
+
t_share = float(panel.tickers.isin(x.columns).mean())
|
|
343
|
+
if d_share == 0.0 or t_share == 0.0:
|
|
344
|
+
raise ValueError(f"{what} has no date or no ticker in common with the panel (dates in common: {d_share:.0%}, tickers in common: {t_share:.0%}). "
|
|
345
|
+
"Check that it is dates x tickers (not transposed), uses the same ticker names, and the same time zone handling as the panel")
|
|
346
|
+
if d_share < 0.5 or t_share < 0.5:
|
|
347
|
+
warnings.warn(f"{what} covers only {d_share:.0%} of the panel's dates and {t_share:.0%} of its tickers; the rest count as missing", stacklevel=3)
|
|
@@ -23,7 +23,7 @@ import pandas as pd
|
|
|
23
23
|
from .core.controls import build_controls, xs_norm
|
|
24
24
|
from .core.estimators import fama_macbeth, newey_west_t, paired_diff
|
|
25
25
|
from .core.gates import GateConfig, fire_structure, run_gates
|
|
26
|
-
from .core.panel import Panel
|
|
26
|
+
from .core.panel import Panel, check_alignment
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
@dataclass
|
|
@@ -113,6 +113,7 @@ def backtest_event(panel: Panel, signal: pd.DataFrame, *,
|
|
|
113
113
|
raise ValueError(f"horizons must be whole numbers of periods, at least 1, got {horizons!r}")
|
|
114
114
|
if not (cost_bp >= 0 and np.isfinite(cost_bp)):
|
|
115
115
|
raise ValueError(f"cost_bp must be finite and not negative, got {cost_bp!r}")
|
|
116
|
+
check_alignment(panel, signal, "signal")
|
|
116
117
|
sig = signal.reindex(index=panel.dates, columns=panel.tickers).fillna(False)
|
|
117
118
|
fire = (sig.astype(bool) & panel.eligible).values
|
|
118
119
|
ev = panel.eligible.values
|
|
@@ -20,7 +20,7 @@ import numpy as np
|
|
|
20
20
|
import pandas as pd
|
|
21
21
|
|
|
22
22
|
from .core.costs import apply_side_cost, apply_turnover_cost, side_cost_input, turnover
|
|
23
|
-
from .core.panel import Panel
|
|
23
|
+
from .core.panel import Panel, check_alignment
|
|
24
24
|
from .execution import check_freeze_return, exec_masks, freeze_hits, realize
|
|
25
25
|
|
|
26
26
|
ANN = 252
|
|
@@ -83,6 +83,20 @@ def _normalize(mask_or_w: np.ndarray) -> np.ndarray:
|
|
|
83
83
|
return np.where(np.isfinite(s), w / s, 0.0)
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
def stop_at_ruin(net: np.ndarray, cut: int) -> tuple[np.ndarray, int | None]:
|
|
87
|
+
"""An account that loses 100 percent or more in a day is gone: from the first such day (within the first `cut` rows) the return is -100 percent and
|
|
88
|
+
every later return is 0. Without this, compounding goes on with negative equity (a 30x long that falls 4 percent), and CAGR, drawdown and Sharpe describe an
|
|
89
|
+
account that could not exist (a real one is liquidated first). Returns the series and the row of the ruin, None when there is none. The input is not changed."""
|
|
90
|
+
bad = np.flatnonzero(np.asarray(net[:cut]) <= -1.0)
|
|
91
|
+
if bad.size == 0:
|
|
92
|
+
return net, None
|
|
93
|
+
i = int(bad[0])
|
|
94
|
+
out = np.array(net, dtype=np.float64, copy=True)
|
|
95
|
+
out[i] = -1.0
|
|
96
|
+
out[i + 1:] = 0.0
|
|
97
|
+
return out, i
|
|
98
|
+
|
|
99
|
+
|
|
86
100
|
def metrics(net: np.ndarray, dates: pd.DatetimeIndex, ann: int = ANN) -> dict:
|
|
87
101
|
"""Annualised figures of a net return series: CAGR, MDD, Sharpe, Sortino (downside deviation, target 0), Calmar, vol, years, the share of
|
|
88
102
|
positive days and the drawdown dates. With fewer than `ann // 2` observations it warns and returns NaN for CAGR, MDD and Sharpe only."""
|
|
@@ -118,7 +132,13 @@ def _forward_arrays(panel: Panel, funding: bool, delist_return: float | None):
|
|
|
118
132
|
fwdf[d] funding rate over the same day (0 unless `funding` and the panel has it); positive means longs pay
|
|
119
133
|
hit_next[d] True where that day is the delisting day
|
|
120
134
|
The last lag + 1 rows of fwd and fwdf are 0: their holding period is not in the sample."""
|
|
121
|
-
ret =
|
|
135
|
+
ret = panel.ret1().values.astype(np.float64)
|
|
136
|
+
big = (ret > 10.0) & panel.eligible.to_numpy()
|
|
137
|
+
if big.any():
|
|
138
|
+
i, j = np.unravel_index(np.argmax(np.where(big, ret, -np.inf)), ret.shape)
|
|
139
|
+
warnings.warn(f"{int(big.sum())} daily moves above +1000% on eligible securities (largest: {panel.tickers[j]} on {panel.dates[i].date()}, {ret[i, j]:+.0%}); "
|
|
140
|
+
"they are taken at face value. Look for an unadjusted split or consolidation or a bad price before believing the result", stacklevel=3)
|
|
141
|
+
ret = np.nan_to_num(ret, nan=0.0)
|
|
122
142
|
nxt = np.zeros(ret.shape, dtype=bool)
|
|
123
143
|
if panel.delist_after is not None:
|
|
124
144
|
da = panel.delist_after.reindex(index=panel.dates, columns=panel.tickers).fillna(False).values.astype(bool)
|
|
@@ -169,6 +189,8 @@ def _check_portfolio_args(factor, long_q, short_q, hold, weighting, spread_bp, b
|
|
|
169
189
|
raise ValueError(f"long_q must be in (0, 1], got {long_q!r}")
|
|
170
190
|
if short_q is not None and not (0 < short_q <= 1):
|
|
171
191
|
raise ValueError(f"short_q must be in (0, 1], or None for a long-only portfolio, got {short_q!r}")
|
|
192
|
+
if short_q is not None and long_q + short_q > 1.0 + 1e-12:
|
|
193
|
+
raise ValueError(f"long_q + short_q = {long_q + short_q:g} is above 1: the same security would sit in both legs (long_q={long_q!r}, short_q={short_q!r})")
|
|
172
194
|
if isinstance(hold, bool) or not isinstance(hold, (int, np.integer)) or hold < 1:
|
|
173
195
|
raise ValueError(f"hold must be a whole number of periods, at least 1, got {hold!r}")
|
|
174
196
|
_check_cost(spread_bp, "spread_bp")
|
|
@@ -229,6 +251,7 @@ def backtest_portfolio(panel: Panel, factor: pd.DataFrame, *,
|
|
|
229
251
|
freeze_return = check_freeze_return(freeze_return)
|
|
230
252
|
bb = side_cost_input(buy_bp, panel.dates, panel.tickers, "buy_bp")
|
|
231
253
|
sb = side_cost_input(sell_bp, panel.dates, panel.tickers, "sell_bp")
|
|
254
|
+
check_alignment(panel, factor, "factor")
|
|
232
255
|
f = factor.reindex(index=panel.dates, columns=panel.tickers)
|
|
233
256
|
el = panel.eligible
|
|
234
257
|
rk = f.where(el).rank(axis=1, pct=True, na_option="keep")
|
|
@@ -289,10 +312,15 @@ def backtest_portfolio(panel: Panel, factor: pd.DataFrame, *,
|
|
|
289
312
|
net = gross - cost - fcost
|
|
290
313
|
|
|
291
314
|
cut = len(panel.dates) - (panel.entry_lag + 1)
|
|
315
|
+
net, ruin = stop_at_ruin(net, cut)
|
|
316
|
+
if ruin is not None:
|
|
317
|
+
warnings.warn(f"the account lost 100% or more on {panel.dates[ruin].date()}: from that day the return is -100% and then 0 (a leveraged account would have been liquidated); read CAGR and drawdown, not Sharpe, which no longer describes an account", stacklevel=2)
|
|
292
318
|
m = metrics(net[:cut], panel.dates, panel.periods_per_year)
|
|
319
|
+
m["ruined"] = ruin is not None
|
|
320
|
+
m["ruin_date"] = None if ruin is None else str(panel.dates[ruin].date())
|
|
293
321
|
m["turnover_daily"] = float(turn[:cut].mean())
|
|
294
322
|
m["cost_annual_bp"] = float(cost[:cut].mean() * panel.periods_per_year * 1e4)
|
|
295
|
-
m["gross_CAGR"] = metrics(gross[:cut], panel.dates, panel.periods_per_year)["CAGR"]
|
|
323
|
+
m["gross_CAGR"] = metrics(stop_at_ruin(gross, cut)[0][:cut], panel.dates, panel.periods_per_year)["CAGR"]
|
|
296
324
|
if fz is not None:
|
|
297
325
|
m["freeze_markdown_annual_bp"] = float(mark[:cut].mean() * panel.periods_per_year * 1e4) # negative: the return given up
|
|
298
326
|
m["freeze_markdown_events"] = int(((hl > 0) & fz)[:cut].sum())
|
|
@@ -309,7 +337,11 @@ def backtest_portfolio(panel: Panel, factor: pd.DataFrame, *,
|
|
|
309
337
|
m["short_leg_empty_days"] = int((el.to_numpy(bool).any(axis=1) & ~el_s.to_numpy(bool).any(axis=1))[:cut].sum()) # eligible names exist, none can be sold short
|
|
310
338
|
m["funding_annual_bp"] = float(fcost[:cut].mean() * panel.periods_per_year * 1e4) # positive = a cost
|
|
311
339
|
m["delist_events_held"] = int((held & hit_next)[:cut].sum())
|
|
312
|
-
|
|
340
|
+
npos = (hl > 0).sum(axis=1)
|
|
341
|
+
m["avg_positions"] = float(npos[npos > 0].mean()) if (npos > 0).any() else float("nan")
|
|
342
|
+
if not (npos > 0).any():
|
|
343
|
+
warnings.warn("no position was held on any day: the factor gave no usable score on eligible securities (all NaN, no ranking, or the legs came out empty), "
|
|
344
|
+
"so the zero return below is not a result", stacklevel=2)
|
|
313
345
|
|
|
314
346
|
bench = bexc = None
|
|
315
347
|
bret = None
|
|
@@ -23,7 +23,7 @@ from .core.controls import build_controls, neutralize, xs_norm
|
|
|
23
23
|
from .core.estimators import (decile_profile, fama_macbeth, newey_west_t,
|
|
24
24
|
shuffle_columns, shuffle_null)
|
|
25
25
|
from .core.gates import GateConfig, fire_structure, run_gates
|
|
26
|
-
from .core.panel import Panel
|
|
26
|
+
from .core.panel import Panel, check_alignment
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
@dataclass
|
|
@@ -103,6 +103,7 @@ def screen(panel: Panel, factors: dict[str, pd.DataFrame], *,
|
|
|
103
103
|
|
|
104
104
|
rows, detail = [], {}
|
|
105
105
|
for name, raw in factors.items():
|
|
106
|
+
check_alignment(panel, raw, f"factor {name!r}")
|
|
106
107
|
f = xs_norm(raw.reindex(index=panel.dates, columns=panel.tickers), el)
|
|
107
108
|
fm = fama_macbeth(f, fwd, ctrl, el)
|
|
108
109
|
rk = f.rank(axis=1, pct=True, na_option="keep")
|
|
@@ -36,8 +36,12 @@ def deflated_sharpe(R: np.ndarray, *, trials: int | None = None, periods_per_yea
|
|
|
36
36
|
variants you did not keep in R (and say so honestly)."""
|
|
37
37
|
R = np.asarray(R, dtype=np.float64)
|
|
38
38
|
T, n = R.shape
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
40
|
+
sr_all = R.mean(axis=0) / R.std(axis=0, ddof=1)
|
|
41
|
+
if not np.isfinite(sr_all).any():
|
|
42
|
+
raise ValueError("no column of R has a usable Sharpe ratio: every column contains NaN or inf, or has zero variance (a strategy that never traded). "
|
|
43
|
+
"Columns with a NaN are ignored, so drop or fill the NaN rows if all of them have one")
|
|
44
|
+
j = int(np.nanargmax(np.where(np.isfinite(sr_all), sr_all, np.nan)))
|
|
41
45
|
x = R[:, j]
|
|
42
46
|
sr = float(sr_all[j])
|
|
43
47
|
z = (x - x.mean()) / x.std(ddof=0)
|
|
@@ -39,21 +39,22 @@ If `panel.shortable` is given, opening or increasing a short in a security that
|
|
|
39
39
|
"""
|
|
40
40
|
from __future__ import annotations
|
|
41
41
|
|
|
42
|
+
import warnings
|
|
42
43
|
from dataclasses import dataclass
|
|
43
44
|
|
|
44
45
|
import numpy as np
|
|
45
46
|
import pandas as pd
|
|
46
47
|
|
|
47
48
|
from .core.costs import apply_side_cost, apply_turnover_cost, side_cost_input
|
|
48
|
-
from .core.panel import Panel
|
|
49
|
+
from .core.panel import Panel, check_alignment
|
|
49
50
|
from .execution import check_freeze_return, exec_masks, freeze_hits, realize
|
|
50
|
-
from .portfolio import (PortfolioResult, _benchmark_returns, _check_benchmark, _check_cost, _forward_arrays, _side_config,
|
|
51
|
+
from .portfolio import (PortfolioResult, _benchmark_returns, _check_benchmark, _check_cost, _forward_arrays, _side_config, stop_at_ruin,
|
|
51
52
|
_side_label, metrics)
|
|
52
53
|
|
|
53
54
|
|
|
54
55
|
@dataclass(frozen=True)
|
|
55
56
|
class ImpactModel:
|
|
56
|
-
aum: float #
|
|
57
|
+
aum: float # money, in the currency of close x volume (dollars for US stocks and USDT contracts, won for KRX); participation = trade x aum / ADV
|
|
57
58
|
y: float = 1.0 # coefficient of the square-root law
|
|
58
59
|
vol_window: int = 20
|
|
59
60
|
adv_window: int = 30
|
|
@@ -61,7 +62,7 @@ class ImpactModel:
|
|
|
61
62
|
|
|
62
63
|
def __post_init__(self) -> None:
|
|
63
64
|
if not (self.aum >= 0 and np.isfinite(self.aum)):
|
|
64
|
-
raise ValueError(f"aum must be a finite
|
|
65
|
+
raise ValueError(f"aum must be a finite amount of money (same currency as close x volume), not negative, got {self.aum!r}")
|
|
65
66
|
if not (self.y >= 0 and np.isfinite(self.y)):
|
|
66
67
|
raise ValueError(f"y must be finite and not negative, got {self.y!r}")
|
|
67
68
|
if self.vol_window < 2 or self.adv_window < 1:
|
|
@@ -117,6 +118,7 @@ def backtest_weights(panel: Panel, weights: pd.DataFrame, *, spread_bp=0.0, buy_
|
|
|
117
118
|
freeze_return = check_freeze_return(freeze_return)
|
|
118
119
|
bb = side_cost_input(buy_bp, panel.dates, panel.tickers, "buy_bp")
|
|
119
120
|
sb = side_cost_input(sell_bp, panel.dates, panel.tickers, "sell_bp")
|
|
121
|
+
check_alignment(panel, weights, "weights")
|
|
120
122
|
W = weights.reindex(index=panel.dates, columns=panel.tickers)
|
|
121
123
|
raw = W.to_numpy(float)
|
|
122
124
|
if np.isinf(raw).any():
|
|
@@ -188,10 +190,18 @@ def backtest_weights(panel: Panel, weights: pd.DataFrame, *, spread_bp=0.0, buy_
|
|
|
188
190
|
net = gross - spread - side - borrow - imp - fcost
|
|
189
191
|
|
|
190
192
|
ppy = panel.periods_per_year
|
|
193
|
+
net, ruin = stop_at_ruin(net, cut)
|
|
194
|
+
if ruin is not None:
|
|
195
|
+
warnings.warn(f"the account lost 100% or more on {panel.dates[ruin].date()}: from that day the return is -100% and then 0 (a leveraged account would have been liquidated)", stacklevel=2)
|
|
191
196
|
m = metrics(net[:cut], panel.dates, ppy)
|
|
197
|
+
m["ruined"] = ruin is not None
|
|
198
|
+
if float(np.abs(H[:cut]).sum()) == 0.0:
|
|
199
|
+
warnings.warn("no position was held on any day: the weights are all zero or NaN, or `capital` is too small to buy one lot of anything; "
|
|
200
|
+
"the zero return is not a result", stacklevel=2)
|
|
201
|
+
m["ruin_date"] = None if ruin is None else str(panel.dates[ruin].date())
|
|
192
202
|
tr = np.zeros_like(H)
|
|
193
203
|
tr[1:] = np.abs(np.diff(H, axis=0))
|
|
194
|
-
m.update({"turnover_daily": float(0.5 * tr[:cut].sum(axis=1).mean()), "gross_CAGR": metrics(gross[:cut], panel.dates, ppy)["CAGR"],
|
|
204
|
+
m.update({"turnover_daily": float(0.5 * tr[:cut].sum(axis=1).mean()), "gross_CAGR": metrics(stop_at_ruin(gross, cut)[0][:cut], panel.dates, ppy)["CAGR"],
|
|
195
205
|
"spread_annual_bp": float(spread[:cut].mean() * ppy * 1e4), "borrow_annual_bp": float(borrow[:cut].mean() * ppy * 1e4),
|
|
196
206
|
"side_cost_annual_bp": float(side[:cut].mean() * ppy * 1e4),
|
|
197
207
|
"impact_annual_bp": float(imp[:cut].mean() * ppy * 1e4), "funding_annual_bp": float(fcost[:cut].mean() * ppy * 1e4),
|
|
@@ -46,11 +46,13 @@ tests/test_delisting_and_guards.py
|
|
|
46
46
|
tests/test_docs_scripts.py
|
|
47
47
|
tests/test_equity.py
|
|
48
48
|
tests/test_execution.py
|
|
49
|
+
tests/test_hardening_021.py
|
|
49
50
|
tests/test_intraday.py
|
|
50
51
|
tests/test_krx.py
|
|
51
52
|
tests/test_ledger.py
|
|
52
53
|
tests/test_packaging.py
|
|
53
54
|
tests/test_reconcile.py
|
|
55
|
+
tests/test_ruin.py
|
|
54
56
|
tests/test_side_costs_shorting.py
|
|
55
57
|
tests/test_symbol_rules.py
|
|
56
58
|
tests/test_synthetic.py
|
|
@@ -43,6 +43,7 @@ def test_portfolio_arguments():
|
|
|
43
43
|
_raises(lambda: run(long_q=float("nan")), "long_q")
|
|
44
44
|
_raises(lambda: run(short_q=0.0), "short_q", "None for a long-only")
|
|
45
45
|
_raises(lambda: run(short_q=-0.1), "short_q")
|
|
46
|
+
_raises(lambda: run(long_q=0.7, short_q=0.7), "long_q + short_q", "both legs") # the same security long and short at once
|
|
46
47
|
_raises(lambda: run(hold=0), "hold")
|
|
47
48
|
_raises(lambda: run(hold=2.5), "hold")
|
|
48
49
|
_raises(lambda: run(hold=True), "hold")
|
|
@@ -51,7 +52,7 @@ def test_portfolio_arguments():
|
|
|
51
52
|
_raises(lambda: run(spread_bp=pd.DataFrame(-1.0, index=p.dates, columns=p.tickers).to_numpy()), "spread_bp")
|
|
52
53
|
_raises(lambda: run(factor=(f.rank(axis=1, pct=True) >= 0.9)), "boolean factor", "backtest_event")
|
|
53
54
|
# the boundary values and every documented option still run
|
|
54
|
-
for kw in (dict(long_q=1.0, short_q=None), dict(long_q=0.
|
|
55
|
+
for kw in (dict(long_q=1.0, short_q=None), dict(long_q=0.5, short_q=0.5), dict(hold=1), dict(spread_bp=0.0), dict(spread_bp=0),
|
|
55
56
|
dict(weighting="equal"), dict(weighting="signal"), dict(weighting="rank"),
|
|
56
57
|
dict(benchmark="cap"), dict(benchmark="equal"), dict(benchmark=None), dict(hold=np.int64(3))):
|
|
57
58
|
with warnings.catch_warnings():
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Guards added after hostile-input review (0.2.1).
|
|
2
|
+
|
|
3
|
+
HD1 prices a zero, negative or infinite price is treated as missing, with a warning; a held name with a zero price gives the same result as one with a missing
|
|
4
|
+
price (before, the return was 3.6e307)
|
|
5
|
+
HD2 alignment a factor, weights or signal with other ticker names, transposed, a different time zone, or not a DataFrame is refused with the reason; half covered warns
|
|
6
|
+
HD3 panel duplicate ticker names raise; weekly dates with periods_per_year=252 warn, and the usual daily, 24/7 and 5-minute settings do not
|
|
7
|
+
HD4 big moves a daily move above +1000% on an eligible security warns and changes nothing in the numbers
|
|
8
|
+
HD5 no trades an all-NaN or constant factor, all-zero weights, and a capital too small for one lot warn that nothing was held
|
|
9
|
+
HD6 dsr a matrix with no usable column says so
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import sys, warnings
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pandas as pd
|
|
20
|
+
import pitbacktest as q
|
|
21
|
+
from test_argument_checks import _raises
|
|
22
|
+
from test_synthetic import make_panel
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _warns(fn, *needles):
|
|
26
|
+
with warnings.catch_warnings(record=True) as ws:
|
|
27
|
+
warnings.simplefilter("always")
|
|
28
|
+
out = fn()
|
|
29
|
+
msgs = [str(w.message) for w in ws]
|
|
30
|
+
assert all(any(n in m for m in msgs) for n in needles), (needles, msgs)
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _quiet(fn):
|
|
35
|
+
with warnings.catch_warnings(record=True) as ws:
|
|
36
|
+
warnings.simplefilter("always")
|
|
37
|
+
out = fn()
|
|
38
|
+
return out, [str(w.message) for w in ws]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_bad_prices():
|
|
42
|
+
p, rng = make_panel(n_days=400, n_stocks=40)
|
|
43
|
+
f = pd.DataFrame(rng.standard_normal(p.close.shape), index=p.dates, columns=p.tickers)
|
|
44
|
+
f.iloc[95:110, 3] = 99.0 # name 3 is in the long leg around day 100
|
|
45
|
+
results = {}
|
|
46
|
+
for label, bad in (("nan", np.nan), ("zero", 0.0), ("negative", -5.0), ("inf", np.inf)):
|
|
47
|
+
c = p.close.copy()
|
|
48
|
+
c.iloc[100, 3] = bad
|
|
49
|
+
if label == "nan":
|
|
50
|
+
pp = replace(p, close=c)
|
|
51
|
+
out, _ = _quiet(lambda: q.backtest_portfolio(pp, f, hold=1, spread_bp=10))
|
|
52
|
+
else:
|
|
53
|
+
out = _warns(lambda: q.backtest_portfolio(replace(p, close=c), f, hold=1, spread_bp=10), "zero, negative or infinite")
|
|
54
|
+
results[label] = out.net_returns.to_numpy()
|
|
55
|
+
assert np.isfinite(results[label]).all() and np.abs(results[label]).max() < 1.0, (label, np.abs(results[label]).max())
|
|
56
|
+
for label in ("zero", "negative", "inf"):
|
|
57
|
+
assert np.array_equal(results[label], results["nan"]), label # the same as a price that is simply missing
|
|
58
|
+
print("HD1 zero, negative and infinite prices are missing prices (warned), and a held name with a zero price no longer returns 3.6e307 PASS")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_alignment():
|
|
62
|
+
p, rng = make_panel(n_days=400, n_stocks=40)
|
|
63
|
+
f = pd.DataFrame(rng.standard_normal(p.close.shape), index=p.dates, columns=p.tickers)
|
|
64
|
+
W = f.rank(axis=1, pct=True).ge(0.9).astype(float)
|
|
65
|
+
W = W.div(W.sum(axis=1).replace(0, np.nan), axis=0).fillna(0.0).where(p.eligible, 0.0)
|
|
66
|
+
E = f > 1.5
|
|
67
|
+
for what, call in (("factor", lambda x: q.backtest_portfolio(p, x, hold=5, spread_bp=10)),
|
|
68
|
+
("weights", lambda x: q.backtest_weights(p, x, spread_bp=5)),
|
|
69
|
+
("signal", lambda x: q.backtest_event(p, x, horizons=(5,), neutralize_check=False))):
|
|
70
|
+
good = {"factor": f, "weights": W, "signal": E}[what]
|
|
71
|
+
_raises(lambda: call(good.rename(columns=lambda c: "z" + c)), what, "no date or no ticker in common", "tickers in common: 0%")
|
|
72
|
+
_raises(lambda: call(good.T), what, "no date or no ticker in common")
|
|
73
|
+
_raises(lambda: call(good.tz_localize("UTC")), what, "dates in common: 0%") # a time zone on one side only
|
|
74
|
+
_raises(lambda: call(good.to_numpy()), what, "DataFrame", "ndarray")
|
|
75
|
+
_raises(lambda: call(good.iloc[:, 0]), what, "DataFrame", "Series")
|
|
76
|
+
sc = {"a": f}
|
|
77
|
+
_raises(lambda: q.screen(p, {"a": f.rename(columns=lambda c: "z" + c)}, horizons=(5,), primary_h=5, n_null=10), "factor 'a'", "no date or no ticker")
|
|
78
|
+
_warns(lambda: q.backtest_portfolio(p, f.iloc[:, :10], hold=5, spread_bp=10), "covers only", "25% of its tickers")
|
|
79
|
+
_, msgs = _quiet(lambda: q.backtest_portfolio(p, f.iloc[:, :30], hold=5, spread_bp=10)) # 75 percent covered: no warning
|
|
80
|
+
assert not any("covers only" in m for m in msgs), msgs
|
|
81
|
+
print("HD2 renamed, transposed, time-zone-shifted, array and Series inputs are refused with the reason in all four engines; a quarter of the tickers warns PASS")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_panel_checks():
|
|
85
|
+
p, _ = make_panel(n_days=300, n_stocks=20)
|
|
86
|
+
c = p.close.copy(); c.columns = ["A"] * 20
|
|
87
|
+
_raises(lambda: q.Panel(close=c, eligible=pd.DataFrame(True, index=c.index, columns=c.columns)), "duplicate ticker names")
|
|
88
|
+
wk = p.close.resample("W-FRI").last(); we = p.eligible.resample("W-FRI").last().astype(bool)
|
|
89
|
+
_warns(lambda: q.Panel(close=wk, eligible=we), "periods_per_year=252", "52 for weekly")
|
|
90
|
+
_, msgs = _quiet(lambda: q.Panel(close=wk, eligible=we, periods_per_year=52))
|
|
91
|
+
assert not any("periods_per_year" in m for m in msgs), msgs
|
|
92
|
+
_, msgs = _quiet(lambda: q.Panel(close=p.close, eligible=p.eligible)) # business days with 252
|
|
93
|
+
assert not any("periods_per_year" in m for m in msgs), msgs
|
|
94
|
+
cal = pd.date_range("2020-01-01", periods=300, freq="D") # a 24/7 market with 365
|
|
95
|
+
_, msgs = _quiet(lambda: q.Panel(close=pd.DataFrame(100.0, index=cal, columns=p.tickers), eligible=pd.DataFrame(True, index=cal, columns=p.tickers), periods_per_year=365))
|
|
96
|
+
assert not any("periods_per_year" in m for m in msgs), msgs
|
|
97
|
+
five = pd.date_range("2020-01-01", periods=2000, freq="5min")
|
|
98
|
+
_, msgs = _quiet(lambda: q.Panel(close=pd.DataFrame(100.0, index=five, columns=p.tickers), eligible=pd.DataFrame(True, index=five, columns=p.tickers), periods_per_year=105120))
|
|
99
|
+
assert not any("periods_per_year" in m for m in msgs), msgs
|
|
100
|
+
print("HD3 duplicate tickers raise; weekly dates with 252 warn; business days with 252, calendar days with 365 and 5-minute bars with 105120 do not PASS")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_big_moves_and_no_trades():
|
|
104
|
+
p, rng = make_panel(n_days=400, n_stocks=40)
|
|
105
|
+
f = pd.DataFrame(rng.standard_normal(p.close.shape), index=p.dates, columns=p.tickers)
|
|
106
|
+
c = p.close.copy(); c.iloc[200:, 5] = c.iloc[200:, 5] * 50.0 # a +4900% day
|
|
107
|
+
out = _warns(lambda: q.backtest_portfolio(replace(p, close=c), f, hold=5, spread_bp=10), "daily moves above +1000%", p.tickers[5])
|
|
108
|
+
ref, msgs = _quiet(lambda: q.backtest_portfolio(p, f, hold=5, spread_bp=10))
|
|
109
|
+
assert not any("+1000%" in m for m in msgs), msgs
|
|
110
|
+
_warns(lambda: q.backtest_portfolio(p, f * np.nan, hold=5, spread_bp=10), "no position was held")
|
|
111
|
+
_warns(lambda: q.backtest_portfolio(p, f * 0 + 1.0, hold=5, spread_bp=10), "no position was held") # a constant factor has no ranking
|
|
112
|
+
W = f.rank(axis=1, pct=True).ge(0.9).astype(float).where(p.eligible, 0.0)
|
|
113
|
+
W = W.div(W.sum(axis=1).replace(0, np.nan), axis=0).fillna(0.0)
|
|
114
|
+
_warns(lambda: q.backtest_weights(p, W * 0, spread_bp=5), "no position was held")
|
|
115
|
+
_warns(lambda: q.backtest_weights(p, W, spread_bp=5, capital=1.0, price=p.close), "no position was held", "capital")
|
|
116
|
+
_, msgs = _quiet(lambda: q.backtest_weights(p, W, spread_bp=5))
|
|
117
|
+
assert not any("no position" in m for m in msgs), msgs
|
|
118
|
+
print("HD4/HD5 a +4900% day warns (numbers unchanged); an all-NaN or constant factor, zero weights and a capital below one lot warn that nothing was held PASS")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_dsr_message():
|
|
122
|
+
R = np.random.default_rng(0).normal(0, 0.01, (400, 5))
|
|
123
|
+
bad = np.where(np.random.default_rng(1).random(R.shape) < 0.1, np.nan, R) # every column has a NaN
|
|
124
|
+
_raises(lambda: q.validation.deflated_sharpe(bad, periods_per_year=252), "no column of R has a usable Sharpe")
|
|
125
|
+
_raises(lambda: q.validation.deflated_sharpe(np.zeros((400, 3)), periods_per_year=252), "no column of R has a usable Sharpe")
|
|
126
|
+
ok = R.copy(); ok[5, 2] = np.nan # one column with a NaN is ignored, as documented
|
|
127
|
+
assert q.validation.deflated_sharpe(ok, periods_per_year=252)["best"] != 2
|
|
128
|
+
print("HD6 a matrix with no usable column raises a clear message; one NaN column is still ignored PASS")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
test_bad_prices()
|
|
133
|
+
test_alignment()
|
|
134
|
+
test_panel_checks()
|
|
135
|
+
test_big_moves_and_no_trades()
|
|
136
|
+
test_dsr_message()
|
|
137
|
+
print("hardening tests: all passed")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""An account that is wiped out stays wiped out.
|
|
2
|
+
|
|
3
|
+
RU1 weights a 30x long that falls 50 percent in one name: the return that day is -100 percent, every later return is 0, the drawdown is -100 percent, the
|
|
4
|
+
date is named, a warning is raised; before the ruin day nothing changes
|
|
5
|
+
RU2 portfolio the same for `backtest_portfolio` (weighting by signal gives a gross above 1 only through hold, so the ruin is built with a huge cost)
|
|
6
|
+
RU3 no ruin a result that never loses 100 percent has `ruined` False and is identical with and without the guard (a hand-computed series)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import sys, warnings
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
import pitbacktest as q
|
|
16
|
+
from pitbacktest.portfolio import stop_at_ruin
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _flat_panel(n_days=300, n=12, drop_day=150, drop=-0.5):
|
|
20
|
+
dates = pd.bdate_range("2020-01-01", periods=n_days)
|
|
21
|
+
close = pd.DataFrame(100.0, index=dates, columns=[f"A{i}" for i in range(n)])
|
|
22
|
+
close.iloc[drop_day:, 0] = 100.0 * (1 + drop) # the first name falls once and stays there
|
|
23
|
+
return q.Panel(close=close, eligible=pd.DataFrame(True, index=dates, columns=close.columns), market="TEST")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_weights_ruin():
|
|
27
|
+
p = _flat_panel()
|
|
28
|
+
w = pd.DataFrame(0.0, index=p.dates, columns=p.tickers)
|
|
29
|
+
w.iloc[100:, 0] = 30.0
|
|
30
|
+
with warnings.catch_warnings(record=True) as ws:
|
|
31
|
+
warnings.simplefilter("always")
|
|
32
|
+
r = q.backtest_weights(p, w, spread_bp=0, benchmark=None)
|
|
33
|
+
net = r.net_returns.to_numpy()
|
|
34
|
+
i = 148 # row d earns close(d+1) -> close(d+2): the drop at row 150 is earned by row 148
|
|
35
|
+
assert abs(net[i] + 1.0) < 1e-12 and (net[i + 1:] == 0.0).all() and (net[:i] == 0.0).all(), (net[i - 1:i + 3])
|
|
36
|
+
assert r.metrics["ruined"] is True and r.metrics["ruin_date"] == str(p.dates[i].date())
|
|
37
|
+
assert abs(r.metrics["MDD"] + 1.0) < 1e-12, r.metrics["MDD"]
|
|
38
|
+
assert any("lost 100%" in str(x.message) for x in ws), [str(x.message) for x in ws]
|
|
39
|
+
assert abs(r.metrics["CAGR"] + 1.0) < 1e-9 or np.isfinite(r.metrics["CAGR"]) # a finite number, not NaN from a negative base
|
|
40
|
+
print(f"RU1 a 30x long that falls 50% is ruined on {r.metrics['ruin_date']}: -100%, then 0, MDD -100%, a warning PASS")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_portfolio_ruin():
|
|
44
|
+
p = _flat_panel(n_days=400, n=40)
|
|
45
|
+
f = pd.DataFrame(np.random.default_rng(0).standard_normal(p.close.shape), index=p.dates, columns=p.tickers)
|
|
46
|
+
with warnings.catch_warnings(record=True) as ws:
|
|
47
|
+
warnings.simplefilter("always")
|
|
48
|
+
r = q.backtest_portfolio(p, f, long_q=0.5, short_q=0.5, hold=1, spread_bp=1e6) # cost 100x the traded weight each day
|
|
49
|
+
net = r.net_returns.to_numpy()
|
|
50
|
+
first = int(np.flatnonzero(net <= -1.0)[0])
|
|
51
|
+
assert r.metrics["ruined"] and (net[first + 1:] == 0.0).all() and abs(net[first] + 1.0) < 1e-12
|
|
52
|
+
assert any("lost 100%" in str(x.message) for x in ws)
|
|
53
|
+
print("RU2 backtest_portfolio stops at the ruin day as well PASS")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_no_ruin_is_unchanged():
|
|
57
|
+
r0 = np.array([0.01, -0.02, 0.0, 0.03, -0.5, 0.2])
|
|
58
|
+
out, i = stop_at_ruin(r0, len(r0))
|
|
59
|
+
assert i is None and out is r0 # nothing to do: the very same array
|
|
60
|
+
r1 = np.array([0.01, -1.0, 0.5, 0.5])
|
|
61
|
+
out, i = stop_at_ruin(r1, 4)
|
|
62
|
+
assert i == 1 and out.tolist() == [0.01, -1.0, 0.0, 0.0] and r1.tolist() == [0.01, -1.0, 0.5, 0.5] # input untouched
|
|
63
|
+
out, i = stop_at_ruin(np.array([0.1, -3.0, 0.2]), 2)
|
|
64
|
+
assert i == 1 and out.tolist() == [0.1, -1.0, 0.0] # beyond the cut the value is not looked at; within it -300% becomes -100%
|
|
65
|
+
out, i = stop_at_ruin(np.array([0.1, 0.1, -5.0]), 2)
|
|
66
|
+
assert i is None # a ruin in the rows cut away (outside the sample) is not counted
|
|
67
|
+
p = _flat_panel()
|
|
68
|
+
w = pd.DataFrame(0.0, index=p.dates, columns=p.tickers); w.iloc[100:, 0] = 1.0
|
|
69
|
+
with warnings.catch_warnings():
|
|
70
|
+
warnings.simplefilter("error") # no warning when nothing is lost beyond 100%
|
|
71
|
+
r = q.backtest_weights(p, w, spread_bp=0, benchmark=None)
|
|
72
|
+
assert r.metrics["ruined"] is False and r.metrics["ruin_date"] is None
|
|
73
|
+
assert abs(r.metrics["MDD"] + 0.5) < 1e-12 # the plain 50% fall of an unleveraged long
|
|
74
|
+
print("RU3 no ruin: the same array comes back, the input is never changed, no warning, MDD -50% for an unleveraged long PASS")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
test_weights_ruin()
|
|
79
|
+
test_portfolio_ruin()
|
|
80
|
+
test_no_ruin_is_unchanged()
|
|
81
|
+
print("ruin tests: all passed")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|