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.
Files changed (64) hide show
  1. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/PKG-INFO +1 -1
  2. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/__init__.py +1 -1
  3. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/core/panel.py +41 -0
  4. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/event.py +2 -1
  5. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/portfolio.py +36 -4
  6. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/screen.py +2 -1
  7. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/validation.py +6 -2
  8. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/weights.py +15 -5
  9. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest.egg-info/PKG-INFO +1 -1
  10. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest.egg-info/SOURCES.txt +2 -0
  11. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pyproject.toml +1 -1
  12. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_argument_checks.py +2 -1
  13. pitbacktest-0.2.1/tests/test_hardening_021.py +137 -0
  14. pitbacktest-0.2.1/tests/test_ruin.py +81 -0
  15. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/LICENSE +0 -0
  16. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/README.md +0 -0
  17. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/adapters/__init__.py +0 -0
  18. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/adapters/krx.py +0 -0
  19. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/adapters/long_format.py +0 -0
  20. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/adapters/tiingo.py +0 -0
  21. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/adapters/yfinance.py +0 -0
  22. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/analytics.py +0 -0
  23. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/core/__init__.py +0 -0
  24. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/core/controls.py +0 -0
  25. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/core/costs.py +0 -0
  26. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/core/estimators.py +0 -0
  27. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/core/gates.py +0 -0
  28. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/crypto/__init__.py +0 -0
  29. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/crypto/binance_archive.py +0 -0
  30. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/crypto/costs.py +0 -0
  31. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/crypto/intraday.py +0 -0
  32. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/crypto/panel.py +0 -0
  33. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/equity/__init__.py +0 -0
  34. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/equity/master.py +0 -0
  35. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/equity/scenarios.py +0 -0
  36. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/execution.py +0 -0
  37. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/ledger.py +0 -0
  38. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest/shorting.py +0 -0
  39. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest.egg-info/dependency_links.txt +0 -0
  40. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest.egg-info/requires.txt +0 -0
  41. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/pitbacktest.egg-info/top_level.txt +0 -0
  42. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/setup.cfg +0 -0
  43. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_analytics.py +0 -0
  44. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_annualization.py +0 -0
  45. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_binance_archive.py +0 -0
  46. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_causality.py +0 -0
  47. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_costs_events.py +0 -0
  48. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_crypto.py +0 -0
  49. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_delisting_and_guards.py +0 -0
  50. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_docs_scripts.py +0 -0
  51. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_equity.py +0 -0
  52. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_execution.py +0 -0
  53. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_intraday.py +0 -0
  54. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_krx.py +0 -0
  55. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_ledger.py +0 -0
  56. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_packaging.py +0 -0
  57. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_reconcile.py +0 -0
  58. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_side_costs_shorting.py +0 -0
  59. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_symbol_rules.py +0 -0
  60. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_synthetic.py +0 -0
  61. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_tiingo.py +0 -0
  62. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_validation.py +0 -0
  63. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_weights.py +0 -0
  64. {pitbacktest-0.2.0 → pitbacktest-0.2.1}/tests/test_yfinance_adapter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pitbacktest
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Backtest harness for factor screening, event signals and portfolio alphas, built to make common backtest errors hard
5
5
  Author: Janghyuk Choi
6
6
  License-Expression: MIT
@@ -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.0"
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 = np.nan_to_num(panel.ret1().values.astype(np.float64), nan=0.0)
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
- m["avg_positions"] = float((hl > 0).sum(axis=1)[(hl > 0).sum(axis=1) > 0].mean())
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
- sr_all = R.mean(axis=0) / R.std(axis=0, ddof=1)
40
- j = int(np.nanargmax(sr_all))
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 # dollars of capital
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 number of dollars, not negative, got {self.aum!r}")
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),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pitbacktest
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Backtest harness for factor screening, event signals and portfolio alphas, built to make common backtest errors hard
5
5
  Author: Janghyuk Choi
6
6
  License-Expression: MIT
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pitbacktest"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Backtest harness for factor screening, event signals and portfolio alphas, built to make common backtest errors hard"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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.05, short_q=1.0), dict(hold=1), dict(spread_bp=0.0), dict(spread_bp=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