pitbacktest 0.2.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.
pitbacktest/ledger.py ADDED
@@ -0,0 +1,128 @@
1
+ """Trial ledger: remember every backtest you ran, and feed the count into the deflated Sharpe.
2
+
3
+ The deflated Sharpe needs the number of strategies you tried. People under-report it, usually without meaning to,
4
+ because the variants they threw away are not in front of them. The ledger records each run as it happens, so the
5
+ count comes from the record and not from memory.
6
+
7
+ What it does and does not do
8
+ - It records runs that go through it (`backtest_portfolio(..., ledger=...)` or `Ledger.record`). A run you did in
9
+ a notebook without the ledger is invisible to it. It is an honesty aid, not a lock.
10
+ - A trial is a distinct configuration: the same settings on the same factor values and the same data count once,
11
+ however often you rerun them. Change a parameter, the factor values or the data and it is a new trial.
12
+ - The file is append-only and each line carries the hash of the previous one, so deleting or editing a line in the
13
+ middle is detectable (`verify`). Deleting the last lines is not detectable by the chain alone; keep the file in git.
14
+ - `family` names the research question. Trials in different families do not inflate each other.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import math
21
+ import time
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+ import pandas as pd
26
+
27
+ from . import validation
28
+
29
+ _GENESIS = "0" * 64
30
+
31
+
32
+ def _sha(b: bytes) -> str:
33
+ return hashlib.sha256(b).hexdigest()
34
+
35
+
36
+ def array_fingerprint(a) -> str:
37
+ """Stable hash of a numeric array or frame (values at float32 precision, shape included)."""
38
+ v = np.ascontiguousarray(np.asarray(a, dtype=np.float32))
39
+ return _sha(repr(v.shape).encode() + v.tobytes())[:16]
40
+
41
+
42
+ class Ledger:
43
+ """Append-only, hash-chained record of backtest runs; see the module docstring for what it does and does not guarantee.
44
+
45
+ Pass it as `ledger=` to `backtest_portfolio` or `backtest_weights` (or call `record`), then ask `deflated_sharpe(family)`: the number of
46
+ trials comes from the record, not from memory. `path` is a folder (created if missing)."""
47
+
48
+ def __init__(self, path):
49
+ self.dir = Path(path)
50
+ self.dir.mkdir(parents=True, exist_ok=True)
51
+ (self.dir / "returns").mkdir(exist_ok=True)
52
+ self.file = self.dir / "trials.jsonl"
53
+
54
+ # ------------------------------------------------------------------ read
55
+ def _rows(self) -> list[dict]:
56
+ if not self.file.exists():
57
+ return []
58
+ return [json.loads(x) for x in self.file.read_text(encoding="utf-8").splitlines() if x.strip()]
59
+
60
+ def trials(self, family: str | None = None) -> list[dict]:
61
+ """One row per distinct configuration (the first time it was run)."""
62
+ seen, out = set(), []
63
+ for r in self._rows():
64
+ if family is not None and r["family"] != family:
65
+ continue
66
+ key = (r["family"], r["config_hash"])
67
+ if key not in seen:
68
+ seen.add(key)
69
+ out.append(r)
70
+ return out
71
+
72
+ def n_trials(self, family: str | None = None) -> int:
73
+ """Number of distinct configurations recorded in `family` (all families if None). Running an identical configuration again adds nothing."""
74
+ return len(self.trials(family))
75
+
76
+ # ----------------------------------------------------------------- write
77
+ def record(self, family: str, name: str, returns: pd.Series, config: dict) -> dict:
78
+ """Append one run. `config` must contain everything that defines the trial (parameters, factor and data
79
+ fingerprints); it is hashed to decide whether this is a new trial."""
80
+ cfg = json.dumps(config, sort_keys=True, default=str)
81
+ config_hash = _sha(cfg.encode())[:16]
82
+ rows = self._rows()
83
+ prev = rows[-1]["row_hash"] if rows else _GENESIS
84
+ rpath = self.dir / "returns" / f"{config_hash}.csv"
85
+ if not rpath.exists():
86
+ returns.rename("ret").to_csv(rpath, header=True)
87
+ s = returns.dropna()
88
+ row = {"t": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "family": family, "name": name, "config_hash": config_hash,
89
+ "config": json.loads(cfg), "n_obs": int(len(s)),
90
+ "sharpe_per_period": float(s.mean() / s.std(ddof=1)) if len(s) > 2 and s.std(ddof=1) > 0 else None,
91
+ "prev": prev}
92
+ row["row_hash"] = _sha(json.dumps(row, sort_keys=True).encode())
93
+ with open(self.file, "a", encoding="utf-8") as fh:
94
+ fh.write(json.dumps(row, sort_keys=True) + "\n")
95
+ return row
96
+
97
+ # ---------------------------------------------------------------- checks
98
+ def verify(self) -> dict:
99
+ """Check the hash chain. `ok` is False if a line in the middle was edited, deleted or reordered."""
100
+ prev = _GENESIS
101
+ for i, r in enumerate(self._rows()):
102
+ body = {k: v for k, v in r.items() if k != "row_hash"}
103
+ if r["prev"] != prev or _sha(json.dumps(body, sort_keys=True).encode()) != r["row_hash"]:
104
+ return {"ok": False, "first_bad_line": i + 1}
105
+ prev = r["row_hash"]
106
+ return {"ok": True, "first_bad_line": None}
107
+
108
+ def returns_matrix(self, family: str) -> tuple[np.ndarray, list[str]]:
109
+ """(T, N) returns of every distinct trial in the family, on the dates they all share."""
110
+ tr = self.trials(family)
111
+ if not tr:
112
+ raise ValueError(f"no trials recorded for family {family!r}")
113
+ cols = {}
114
+ for r in tr:
115
+ s = pd.read_csv(self.dir / "returns" / f"{r['config_hash']}.csv", index_col=0, parse_dates=True)["ret"]
116
+ cols[f"{r['name']}#{r['config_hash'][:6]}"] = s
117
+ df = pd.DataFrame(cols).dropna()
118
+ if len(df) < 30:
119
+ raise ValueError("fewer than 30 dates shared by all trials; record trials on the same sample")
120
+ return df.to_numpy(), list(df.columns)
121
+
122
+ def deflated_sharpe(self, family: str, *, periods_per_year: int = 252) -> dict:
123
+ """Deflated Sharpe of the best trial in the family, with the trial count taken from the ledger."""
124
+ R, names = self.returns_matrix(family)
125
+ out = validation.deflated_sharpe(R, trials=self.n_trials(family), periods_per_year=periods_per_year)
126
+ out["best_name"] = names[out["best"]]
127
+ out["verify"] = self.verify()
128
+ return out
@@ -0,0 +1,373 @@
1
+ """B. Portfolio alpha backtest: from a signal to a result.
2
+
3
+ Timing convention (stated because this is where the bug was)
4
+ holdings[d] = the average of the tranches from the signals of days t = d-hold+1 ... d. A signal is known at close(d),
5
+ entered at close(d+entry_lag), and earns close(d+entry_lag) -> close(d+entry_lag+1).
6
+ An entry that is one day late (off by one) wipes out most of the profit of a 1-day mean-reversion strategy.
7
+ assert_timing() catches it.
8
+
9
+ Costs
10
+ Proportional to turnover. **Use a per-security measured spread panel.**
11
+ A flat assumption can inflate the result of a high-turnover strategy a lot.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import warnings
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ from .core.costs import apply_side_cost, apply_turnover_cost, side_cost_input, turnover
23
+ from .core.panel import Panel
24
+ from .execution import check_freeze_return, exec_masks, freeze_hits, realize
25
+
26
+ ANN = 252
27
+
28
+
29
+ @dataclass
30
+ class PortfolioResult:
31
+ spec: dict
32
+ metrics: dict
33
+ benchmark: dict | None
34
+ excess: dict | None
35
+ yearly: dict
36
+ grid: dict | None
37
+ holdings: np.ndarray | None = None # (date x ticker) net target weights, per unit of capital in each leg
38
+ net_returns: pd.Series | None = None # daily net return series (after costs and funding), for DSR / PBO
39
+ benchmark_returns: pd.Series | None = None # return of the benchmark on the same dates (no costs), when one was asked for
40
+
41
+ def alpha_beta(self, factors=None, **kw) -> dict:
42
+ """Regress the net returns on `factors` (default: the benchmark, a market proxy). See `analytics.alpha_beta`."""
43
+ from .analytics import alpha_beta
44
+ if factors is None:
45
+ if self.benchmark_returns is None:
46
+ raise ValueError("no benchmark was computed; pass factors=... or run with benchmark='cap' or 'equal'")
47
+ factors = self.benchmark_returns.rename("benchmark")
48
+ kw.setdefault("periods_per_year", self.spec.get("periods_per_year", ANN))
49
+ return alpha_beta(self.net_returns, factors, **kw)
50
+
51
+ def sharpe_ci(self, **kw) -> dict:
52
+ """Bootstrap interval for the Sharpe ratio of `net_returns`. Keyword arguments go to `analytics.sharpe_ci`."""
53
+ from .analytics import sharpe_ci
54
+ kw.setdefault("periods_per_year", self.spec.get("periods_per_year", ANN))
55
+ return sharpe_ci(self.net_returns, **kw)
56
+
57
+
58
+ def _tranche(weights: np.ndarray, hold: int) -> np.ndarray:
59
+ """Overlapping tranches: the position on day d is the average of the signals of days t = d-hold+1 ... d (each day 1/hold of capital enters)."""
60
+ T, N = weights.shape
61
+ out = np.zeros((T, N))
62
+ run = np.zeros(N)
63
+ cnt = 0
64
+ for d in range(T):
65
+ run += weights[d]
66
+ cnt += 1
67
+ if d - hold >= 0:
68
+ run -= weights[d - hold]
69
+ cnt -= 1
70
+ if cnt > 0:
71
+ out[d] = run / cnt
72
+ # Adding a tranche and subtracting it later leaves rounding residue (about 1e-18) where the exact weight is 0. A residue is not a position,
73
+ # but `held` and `avg_positions` count anything above 0, so snap it to 0. Real weights are many orders of magnitude larger than the tolerance.
74
+ tol = 1e-12 * float(np.abs(weights).max()) if weights.size else 0.0
75
+ out[np.abs(out) < tol] = 0.0
76
+ return out
77
+
78
+
79
+ def _normalize(mask_or_w: np.ndarray) -> np.ndarray:
80
+ w = np.asarray(mask_or_w, dtype=np.float64)
81
+ s = w.sum(axis=1, keepdims=True)
82
+ s[s == 0] = np.nan
83
+ return np.where(np.isfinite(s), w / s, 0.0)
84
+
85
+
86
+ def metrics(net: np.ndarray, dates: pd.DatetimeIndex, ann: int = ANN) -> dict:
87
+ """Annualised figures of a net return series: CAGR, MDD, Sharpe, Sortino (downside deviation, target 0), Calmar, vol, years, the share of
88
+ positive days and the drawdown dates. With fewer than `ann // 2` observations it warns and returns NaN for CAGR, MDD and Sharpe only."""
89
+ s = pd.Series(net, index=dates[: len(net)]).dropna()
90
+ if len(s) < ann // 2:
91
+ # Annualised figures from under half a year are not reported. Say so: a NaN with no explanation looks like a bug, and
92
+ # intraday studies are often a few months long.
93
+ warnings.warn(f"only {len(s):,} observations = {len(s) / ann:.2f} years (< 0.5): CAGR, MDD and Sharpe are NaN", stacklevel=2)
94
+ return {"CAGR": np.nan, "MDD": np.nan, "Sharpe": np.nan}
95
+ eq = (1 + s).cumprod()
96
+ yrs = len(s) / ann
97
+ cagr = eq.iloc[-1] ** (1 / yrs) - 1
98
+ dd = eq / eq.cummax() - 1
99
+ mdd = dd.min()
100
+ trough = dd.idxmin()
101
+ peak = eq.loc[:trough].idxmax()
102
+ rec = eq.loc[trough:]
103
+ recov = rec[rec >= eq.loc[peak]].index
104
+ return {"CAGR": float(cagr), "MDD": float(mdd),
105
+ "Sharpe": float(s.mean() / (s.std() + 1e-12) * np.sqrt(ann)),
106
+ "Sortino": float(s.mean() / (np.sqrt(np.mean(np.minimum(s.to_numpy(), 0.0) ** 2)) + 1e-12) * np.sqrt(ann)), # downside deviation, target 0
107
+ "Calmar": float(cagr / abs(mdd)) if mdd < 0 else np.nan,
108
+ "vol": float(s.std() * np.sqrt(ann)),
109
+ "years": float(yrs), "pos_days": float((s > 0).mean()),
110
+ "mdd_peak": str(peak.date()), "mdd_trough": str(trough.date()),
111
+ "mdd_recovered": str(recov[0].date()) if len(recov) else "not recovered"}
112
+
113
+
114
+ def _forward_arrays(panel: Panel, funding: bool, delist_return: float | None):
115
+ """The three (date x ticker) arrays every simulation needs, indexed by the **signal date** d:
116
+ fwd[d] return from close(d + lag) to close(d + lag + 1); a missing price earns 0 (cash), and the day after a
117
+ delisting flagged in `panel.delist_after` earns `delist_return` when it is given
118
+ fwdf[d] funding rate over the same day (0 unless `funding` and the panel has it); positive means longs pay
119
+ hit_next[d] True where that day is the delisting day
120
+ 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)
122
+ nxt = np.zeros(ret.shape, dtype=bool)
123
+ if panel.delist_after is not None:
124
+ da = panel.delist_after.reindex(index=panel.dates, columns=panel.tickers).fillna(False).values.astype(bool)
125
+ nxt[1:] = da[:-1] # the day after the last real bar
126
+ if delist_return is not None:
127
+ ret = np.where(nxt, float(delist_return), ret)
128
+ k = panel.entry_lag + 1
129
+ fwd = np.roll(ret, -k, axis=0)
130
+ fwd[-k:] = 0.0
131
+ fwdf = np.zeros(ret.shape)
132
+ if funding and panel.funding is not None:
133
+ fd = np.nan_to_num(panel.funding.reindex(index=panel.dates, columns=panel.tickers).values.astype(np.float64), nan=0.0)
134
+ fwdf = np.roll(fd, -k, axis=0)
135
+ fwdf[-k:] = 0.0
136
+ return fwd, fwdf, np.roll(nxt, -k, axis=0)
137
+
138
+
139
+ def _benchmark_returns(panel: Panel, fwd: np.ndarray, kind: str) -> np.ndarray:
140
+ """Daily return (indexed by signal date, like `fwd`) of the benchmark: market-cap weighted when `kind` is "cap" and the
141
+ panel has market caps, otherwise equal weighted, over the securities eligible that day. No costs."""
142
+ ev = panel.eligible.values
143
+ if kind == "cap" and panel.mkt_cap is not None:
144
+ w = np.where(ev, np.nan_to_num(panel.mkt_cap.values, nan=0.0), 0.0)
145
+ else:
146
+ w = ev.astype(float)
147
+ return (_normalize(w) * fwd).sum(axis=1)
148
+
149
+
150
+ def _check_cost(value, name: str) -> None:
151
+ """A cost must be a finite, non-negative number (or an array of them). A negative cost would pay you to trade."""
152
+ a = np.asarray(value, dtype=float)
153
+ if a.ndim == 0 and not np.isfinite(a):
154
+ raise ValueError(f"{name} must be a finite number, got {value!r}")
155
+ if a.size and (np.nanmin(a, initial=np.inf) < 0 or np.isinf(a).any()):
156
+ raise ValueError(f"{name} must be finite and not negative (a negative cost would pay you for trading), got {np.nanmin(a):g}")
157
+
158
+
159
+ def _check_benchmark(benchmark) -> None:
160
+ if benchmark not in ("cap", "equal", None):
161
+ raise ValueError(f"benchmark must be 'cap', 'equal' or None, not {benchmark!r}")
162
+
163
+
164
+ def _check_portfolio_args(factor, long_q, short_q, hold, weighting, spread_bp, benchmark) -> None:
165
+ if weighting not in ("equal", "signal", "rank"):
166
+ raise ValueError(f"weighting must be 'equal', 'signal' or 'rank', not {weighting!r}")
167
+ _check_benchmark(benchmark)
168
+ if not (0 < long_q <= 1):
169
+ raise ValueError(f"long_q must be in (0, 1], got {long_q!r}")
170
+ if short_q is not None and not (0 < short_q <= 1):
171
+ raise ValueError(f"short_q must be in (0, 1], or None for a long-only portfolio, got {short_q!r}")
172
+ if isinstance(hold, bool) or not isinstance(hold, (int, np.integer)) or hold < 1:
173
+ raise ValueError(f"hold must be a whole number of periods, at least 1, got {hold!r}")
174
+ _check_cost(spread_bp, "spread_bp")
175
+ if isinstance(factor, pd.DataFrame) and len(factor.columns) and (factor.dtypes == bool).all():
176
+ raise ValueError("a boolean factor is not a ranking: pass a numeric score, or use backtest_event for a yes/no signal")
177
+
178
+
179
+ def _side_label(v) -> float | str:
180
+ return float(v) if not isinstance(v, np.ndarray) else ("schedule" if v.shape[1] == 1 else "panel")
181
+
182
+
183
+ def _side_config(buy_bp, sell_bp) -> dict:
184
+ """Ledger configuration of the side costs: left out when both are zero so that runs recorded without them keep their fingerprint."""
185
+ from .ledger import array_fingerprint
186
+ out = {}
187
+ for k, v in (("buy_bp", buy_bp), ("sell_bp", sell_bp)):
188
+ if v is None or (np.ndim(v) == 0 and float(v) == 0.0):
189
+ continue
190
+ out[k] = float(v) if np.ndim(v) == 0 else array_fingerprint(np.asarray(v, dtype=float))
191
+ return out
192
+
193
+
194
+ def backtest_portfolio(panel: Panel, factor: pd.DataFrame, *,
195
+ long_q: float = 0.10, short_q: float | None = 0.10,
196
+ hold: int = 5, weighting: str = "equal",
197
+ spread_bp=20.0, buy_bp=0.0, sell_bp=0.0, benchmark: str | None = "cap",
198
+ grid: bool = True, funding: bool = True,
199
+ delist_return: float | None = None, freeze_days: int | None = None, freeze_return: float = 0.0,
200
+ cap_gross: bool = False, ledger=None, family: str = "default", name: str | None = None) -> PortfolioResult:
201
+ """Factor -> portfolio result.
202
+
203
+ factor numeric score (higher = long). A boolean frame is refused: use backtest_event for yes/no signals.
204
+ long_q long quantile (top q), in (0, 1]. short_q is the short quantile; None means long-only (0 is refused).
205
+ weighting equal | signal (proportional to signal strength) | rank
206
+ spread_bp **Two units.** A scalar is a round-trip spread: each unit of weight traded pays spread_bp/2
207
+ (to use a one-way cost of c bp, pass 2*c). A (date x ticker) panel is a one-way cost in bp, multiplied
208
+ by the weight traded as it is. A scalar 20 equals a panel of 10 (pinned by tests/test_reconcile.py).
209
+ buy_bp, sell_bp One-way cost per unit of weight **bought** / **sold**, in bp, charged on top of the spread; a float, a Series
210
+ indexed by date (a rate that changes over time, for example a transaction tax) or a (date x ticker) frame. A weight going
211
+ down is a sell, so opening a short pays `sell_bp` and covering it pays `buy_bp` (a sales tax is charged when a short is
212
+ opened). The rate is read on the signal date. A missing value raises: an unknown rate is not zero.
213
+ benchmark cap (market-cap weighted) | equal (equal weighted) | None
214
+ funding if panel.funding exists, longs pay and shorts receive it (futures). False ignores it
215
+ ledger pitbacktest.ledger.Ledger. If given, this run is recorded in it, so the number of trials reaches the deflated Sharpe.
216
+ freeze_days, freeze_return A long position in a security whose suspension (a price but no volume) reaches `freeze_days` days is marked down once
217
+ by `freeze_return` (between -1 and 0): the loss is taken by the close of the `freeze_days`-th suspended day (booked on the signal row `entry_lag` days earlier). Short positions are not credited: the gain cannot be taken while the
218
+ security is frozen. A scenario, not a measurement: `execution.freeze_episodes` measures how suspensions ended in your data. None: no markdown.
219
+ cap_gross True: a position that cannot be traded (see `Panel.can_buy`) ties up capital, so the free names of that leg are scaled down to keep the leg's gross
220
+ exposure at its target instead of piling new positions on top (see `execution.realize`). Only matters where something is blocked. Off by default.
221
+ Metrics when masks exist: `mean_free_scale` (the average of the two legs' average k; 1 means nothing was scaled) and `cap_infeasible_days` (leg-days summed,
222
+ so a day on which both legs are infeasible counts twice).
223
+ family name that groups runs of one research question in the ledger. name labels this trial (the same label with other settings is another trial).
224
+ delist_return assumed return on the day **after** the last real bar of a security flagged in panel.delist_after.
225
+ None means 0 (closed at the last price, which can be optimistic). For example -0.5 shows the sensitivity.
226
+ """
227
+ _check_portfolio_args(factor, long_q, short_q, hold, weighting, spread_bp, benchmark)
228
+ fz = freeze_hits(panel, freeze_days)
229
+ freeze_return = check_freeze_return(freeze_return)
230
+ bb = side_cost_input(buy_bp, panel.dates, panel.tickers, "buy_bp")
231
+ sb = side_cost_input(sell_bp, panel.dates, panel.tickers, "sell_bp")
232
+ f = factor.reindex(index=panel.dates, columns=panel.tickers)
233
+ el = panel.eligible
234
+ rk = f.where(el).rank(axis=1, pct=True, na_option="keep")
235
+ # The short leg is chosen among securities that can be sold short on the day the short is opened, the execution day (signal date + lag): a ban that
236
+ # starts on that day stops a short that would be opened then, though the signal was given the day before.
237
+ sh_exec = None if panel.shortable is None else panel.shortable.shift(-panel.entry_lag, fill_value=True)
238
+ el_s = el if sh_exec is None else el & sh_exec
239
+ rk_s = rk if sh_exec is None else f.where(el_s).rank(axis=1, pct=True, na_option="keep")
240
+ fwd, fwdf, hit_next = _forward_arrays(panel, funding, delist_return)
241
+ ev = el.values
242
+
243
+ def leg(q: float, top: bool) -> np.ndarray:
244
+ rk_, el_ = (rk, el) if top else (rk_s, el_s)
245
+ m = ((rk_ >= 1 - q) if top else (rk_ <= q)) & el_
246
+ mv = m.values
247
+ if weighting == "equal":
248
+ w = mv.astype(float)
249
+ elif weighting == "signal":
250
+ w = np.where(mv, np.abs(np.nan_to_num(f.values, nan=0.0)), 0.0)
251
+ else: # rank
252
+ r = np.nan_to_num(rk_.values, nan=0.0)
253
+ w = np.where(mv, (r - (1 - q)) if top else (q - r), 0.0)
254
+ return _normalize(w)
255
+
256
+ bo, so = exec_masks(panel) # can the trade be done on the execution day? (None: always)
257
+ ex = {"asked_turnover": 0.0, "blocked_turnover": 0.0, "blocked_trades": 0, "mean_stuck_weight": 0.0, "longest_freeze_days": 0, "mean_free_scale": 0.0, "infeasible_days": 0, "legs": 0}
258
+
259
+ def execute(h: np.ndarray, sign: float, record: bool) -> np.ndarray:
260
+ """The leg's positions after blocked trades. A short leg is a negative position, so covering it is a buy."""
261
+ if bo is None:
262
+ return h
263
+ pos, st = realize(sign * h, bo, so, cap_gross=cap_gross)
264
+ if record:
265
+ for k in st.keys() & ex.keys():
266
+ ex[k] = max(ex[k], st[k]) if k == "longest_freeze_days" else ex[k] + st[k]
267
+ ex["legs"] += 1
268
+ return sign * pos
269
+
270
+ hl = execute(_tranche(leg(long_q, True), hold), 1.0, True)
271
+ gross = (hl * fwd).sum(axis=1)
272
+ mark = (hl * fz).sum(axis=1) * freeze_return if fz is not None else np.zeros(len(gross)) # the one-off markdown of frozen longs
273
+ gross = gross + mark
274
+ cost = apply_turnover_cost(hl, spread_bp)
275
+ side = apply_side_cost(hl, bb, sb)
276
+ turn = turnover(hl)
277
+ fcost = (hl * fwdf).sum(axis=1) # longs pay a positive funding rate
278
+ held = hl > 0
279
+ hs = None
280
+ if short_q:
281
+ hs = execute(_tranche(leg(short_q, False), hold), -1.0, True)
282
+ gross = gross - (hs * fwd).sum(axis=1)
283
+ cost = cost + apply_turnover_cost(hs, spread_bp)
284
+ side = side + apply_side_cost(-hs, bb, sb) # a short is a negative weight: opening it is a sell
285
+ turn = turn + turnover(hs)
286
+ fcost = fcost - (hs * fwdf).sum(axis=1) # shorts receive it
287
+ held = held | (hs > 0)
288
+ cost = cost + side # `cost_annual_bp` is every trading cost: spread and side costs
289
+ net = gross - cost - fcost
290
+
291
+ cut = len(panel.dates) - (panel.entry_lag + 1)
292
+ m = metrics(net[:cut], panel.dates, panel.periods_per_year)
293
+ m["turnover_daily"] = float(turn[:cut].mean())
294
+ 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"]
296
+ if fz is not None:
297
+ m["freeze_markdown_annual_bp"] = float(mark[:cut].mean() * panel.periods_per_year * 1e4) # negative: the return given up
298
+ m["freeze_markdown_events"] = int(((hl > 0) & fz)[:cut].sum())
299
+ m["side_cost_annual_bp"] = float(side[:cut].mean() * panel.periods_per_year * 1e4) # the part of cost_annual_bp from buy_bp and sell_bp
300
+ if bo is not None:
301
+ m["blocked_trades"] = int(ex["blocked_trades"]) # name-days on which a wanted trade could not be done
302
+ m["blocked_turnover_share"] = float(ex["blocked_turnover"] / ex["asked_turnover"]) if ex["asked_turnover"] > 0 else 0.0
303
+ m["mean_stuck_weight"] = float(ex["mean_stuck_weight"]) # both legs, in units of capital
304
+ m["longest_freeze_days"] = int(ex["longest_freeze_days"])
305
+ if cap_gross:
306
+ m["mean_free_scale"] = float(ex["mean_free_scale"] / max(ex["legs"], 1)) # 1 means nothing had to be scaled down
307
+ m["cap_infeasible_days"] = int(ex["infeasible_days"]) # leg-days on which the stuck positions alone exceeded the target
308
+ if panel.shortable is not None and short_q:
309
+ 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
+ m["funding_annual_bp"] = float(fcost[:cut].mean() * panel.periods_per_year * 1e4) # positive = a cost
311
+ 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())
313
+
314
+ bench = bexc = None
315
+ bret = None
316
+ if benchmark:
317
+ br = _benchmark_returns(panel, fwd, benchmark)
318
+ bench = metrics(br[:cut], panel.dates, panel.periods_per_year)
319
+ bexc = metrics((net - br)[:cut], panel.dates, panel.periods_per_year)
320
+ bret = pd.Series(br[:cut], index=panel.dates[:cut], name="benchmark")
321
+
322
+ s = pd.Series(net[:cut], index=panel.dates[:cut])
323
+ yr = s.groupby(s.index.year).apply(lambda g: float((1 + g).prod() - 1))
324
+
325
+ g = None
326
+ if grid:
327
+ g = {}
328
+ for h in (1, 2, 5, 10, 21):
329
+ hl2 = execute(_tranche(leg(long_q, True), h), 1.0, False)
330
+ hs2 = execute(_tranche(leg(short_q, False), h), -1.0, False) if short_q else None
331
+ for c in (0, 2, 5, 10, 20):
332
+ gr = (hl2 * fwd).sum(axis=1) + ((hl2 * fz).sum(axis=1) * freeze_return if fz is not None else 0.0)
333
+ co = apply_turnover_cost(hl2, c) + apply_side_cost(hl2, bb, sb) + (hl2 * fwdf).sum(axis=1)
334
+ if short_q:
335
+ gr = gr - (hs2 * fwd).sum(axis=1)
336
+ co = co + apply_turnover_cost(hs2, c) + apply_side_cost(-hs2, bb, sb) - (hs2 * fwdf).sum(axis=1)
337
+ mm = metrics((gr - co)[:cut], panel.dates, panel.periods_per_year)
338
+ g[f"h{h}_c{c}"] = {"CAGR": mm["CAGR"], "Sharpe": mm["Sharpe"], "MDD": mm["MDD"]}
339
+
340
+ if ledger is not None:
341
+ from .ledger import array_fingerprint
342
+ ledger.record(family, name or "unnamed", s, {
343
+ "long_q": long_q, "short_q": short_q, "hold": hold, "weighting": weighting,
344
+ "spread_bp": spread_bp if np.isscalar(spread_bp) else array_fingerprint(spread_bp),
345
+ "funding": bool(funding and panel.funding is not None), "delist_return": delist_return,
346
+ **_side_config(bb, sb), **({"freeze_days": int(freeze_days), "freeze_return": freeze_return} if freeze_days is not None else {}), **({"cap_gross": True} if cap_gross else {}),
347
+ "factor": array_fingerprint(f.to_numpy()), "data": panel.fingerprint()})
348
+ return PortfolioResult(
349
+ spec={"long_q": long_q, "short_q": short_q, "hold": hold, "weighting": weighting,
350
+ "entry_lag": panel.entry_lag, "market": panel.market, "periods_per_year": panel.periods_per_year,
351
+ "spread": "panel" if not np.isscalar(spread_bp) else f"{spread_bp}bp flat",
352
+ "buy_bp": _side_label(bb), "sell_bp": _side_label(sb),
353
+ "freeze": None if freeze_days is None else {"days": int(freeze_days), "return": freeze_return},
354
+ "funding": bool(funding and panel.funding is not None), "delist_return": delist_return},
355
+ metrics=m, benchmark=bench, excess=bexc,
356
+ yearly={str(k): v for k, v in yr.items()}, grid=g,
357
+ holdings=(hl - hs) if hs is not None else hl, net_returns=s, benchmark_returns=bret)
358
+
359
+
360
+ def assert_timing(panel: Panel) -> dict:
361
+ """Timing self-check: feed a perfect-foresight factor and see whether it earns money.
362
+
363
+ Using the future return as the factor must give a clearly positive CAGR. If not, the timing is misaligned.
364
+ (A regression test for entry-lag bugs.)
365
+ """
366
+ lag = panel.entry_lag
367
+ oracle = panel.close.shift(-(lag + 1)) / panel.close.shift(-lag) - 1.0
368
+ r = backtest_portfolio(panel, oracle, long_q=0.10, short_q=0.10, hold=1,
369
+ spread_bp=0.0, benchmark=None, grid=False)
370
+ ok = np.isfinite(r.metrics["CAGR"]) and r.metrics["CAGR"] > 0.5
371
+ return {"oracle_CAGR": r.metrics["CAGR"], "oracle_Sharpe": r.metrics["Sharpe"],
372
+ "pass": bool(ok),
373
+ "note": "A perfect-foresight factor must give a large positive number. If it fails, entry_lag or the fwd alignment is wrong."}
pitbacktest/screen.py ADDED
@@ -0,0 +1,150 @@
1
+ """A. Factor screening with neutralisation: the first gate of the research stage.
2
+
3
+ This stage decides the conclusion. A candidate that clears the statistical threshold usually disappears after the cost, timing and
4
+ monotonicity gates and the neutralisation against firm characteristics; that is the normal outcome.
5
+
6
+ Principles
7
+ 1. The statistic that decides the first gate (`t`) is a Fama-MacBeth t **with controls**, and the factor is re-tested after neutralisation
8
+ (`neu_t`, `neu_survival_%`). Uncontrolled figures (`excess_bp`, `net_bp`, `rho`, and `coef_bp_raw`) are returned next to them, labelled as such.
9
+ 2. Missing firm characteristics produce a warning (a missing ROA or book-to-market is the main cause of false findings).
10
+ 3. The multiple-testing threshold is **measured with a shuffled null**.
11
+ 4. The whole **distribution** of a parameter grid is returned, not its best cell.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import warnings
17
+ from dataclasses import dataclass, replace
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ from .core.controls import build_controls, neutralize, xs_norm
23
+ from .core.estimators import (decile_profile, fama_macbeth, newey_west_t,
24
+ shuffle_columns, shuffle_null)
25
+ from .core.gates import GateConfig, fire_structure, run_gates
26
+ from .core.panel import Panel
27
+
28
+
29
+ @dataclass
30
+ class ScreenResult:
31
+ null: dict
32
+ factors: dict
33
+ survivors: list[str]
34
+ funnel: dict
35
+ summary: pd.DataFrame
36
+
37
+
38
+ def _deploy(fire: np.ndarray, cum: np.ndarray, ev: np.ndarray,
39
+ cost_bp: float) -> tuple[np.ndarray, dict]:
40
+ # adapt to the universe size: a fixed threshold throws away every date in a small universe
41
+ med = float(np.median(ev.sum(axis=1)))
42
+ min_pool = int(max(5, min(30, med * 0.5)))
43
+ exc = []
44
+ for i in range(cum.shape[0]):
45
+ s = fire[i] & np.isfinite(cum[i])
46
+ pool = ev[i] & np.isfinite(cum[i])
47
+ if s.sum() >= 1 and pool.sum() >= min_pool:
48
+ exc.append(cum[i][s].mean() - cum[i][pool].mean())
49
+ a = np.array(exc)
50
+ if len(a) < 100:
51
+ return a, {"excess_bp": np.nan, "net_bp": np.nan, "t": np.nan}
52
+ mu, _, t, _ = newey_west_t(a, lag=21)
53
+ return a, {"excess_bp": float(mu * 1e4), "net_bp": float(mu * 1e4 - cost_bp), "t": float(t)}
54
+
55
+
56
+ def screen(panel: Panel, factors: dict[str, pd.DataFrame], *,
57
+ horizons: tuple[int, ...] = (1, 5, 20, 60),
58
+ primary_h: int = 20, cost_bp: float = 20.0,
59
+ fire_q: float = 0.10, n_null: int = 20,
60
+ gate_config: GateConfig | None = None,
61
+ neutralize_all: bool = True, delist_return: float | None = None) -> ScreenResult:
62
+ """Screen many factors at once.
63
+
64
+ factors {name: (date x ticker) continuous factor}
65
+ delist_return return assumed on the day after a security's last bar when it is flagged in panel.delist_after (None: carried at its last price).
66
+
67
+ In the summary, `t` is the Fama-MacBeth t **with controls**, and `neu_t` and `neu_survival_%` re-test the factor after residualising it
68
+ against the controls. `excess_bp`, `net_bp`, `rho` and the gates G2 to G6 come from the **uncontrolled** top `fire_q` firing. The uncontrolled
69
+ coefficient and t of a factor are in result.factors[name]['fm'][h]['coef_bp_raw'] and ['t_raw'].
70
+ """
71
+ el = panel.eligible
72
+ ev = el.values
73
+ ctrl = build_controls(panel)
74
+ if not horizons or any(isinstance(h, bool) or not isinstance(h, (int, np.integer)) or h < 1 for h in horizons):
75
+ raise ValueError(f"horizons must be whole numbers of periods, at least 1, got {horizons!r}")
76
+ if n_null < 1:
77
+ raise ValueError(f"n_null must be at least 1, got {n_null!r}")
78
+ if n_null < 10:
79
+ warnings.warn(f"n_null={n_null}: the G1 threshold is the 95th percentile of only {n_null} shuffled |t| values, which is biased low and moves between "
80
+ f"seeds (on a test panel it averaged 0.9 with n_null=2 and 1.8 with n_null=20; the 95th percentile of pure noise is about 1.96). "
81
+ f"Use at least 10.", stacklevel=2)
82
+ if primary_h not in horizons:
83
+ raise ValueError(f"primary_h={primary_h} must be one of horizons {tuple(horizons)}")
84
+ if not (cost_bp >= 0 and np.isfinite(cost_bp)):
85
+ raise ValueError(f"cost_bp must be finite and not negative, got {cost_bp!r}")
86
+ if not (0 < fire_q <= 1):
87
+ raise ValueError(f"fire_q must be in (0, 1], got {fire_q!r}")
88
+ for fname, fv in factors.items():
89
+ if isinstance(fv, pd.DataFrame) and len(fv.columns) and (fv.dtypes == bool).all():
90
+ raise ValueError(f"factor {fname!r} is boolean: screen ranks a numeric score, use backtest_event for a yes/no signal")
91
+ fwd = {h: panel.forward(h, delist_return) for h in horizons}
92
+ cums = {h: fwd[h].values.astype(np.float64) for h in horizons}
93
+
94
+ # 1) measure the threshold with a shuffled null: running it for every factor is expensive, so a representative factor stands in
95
+ rep = next(iter(factors.values()))
96
+ null = shuffle_null(lambda rng: xs_norm(shuffle_columns(rep, el, rng), el),
97
+ el, {primary_h: fwd[primary_h]}, ctrl, n_rep=n_null)
98
+ thr = null.get("p95", np.nan)
99
+ if not np.isfinite(thr):
100
+ thr = 3.0
101
+ cfg = GateConfig(null_threshold=thr) if gate_config is None else (
102
+ gate_config if gate_config.null_threshold is not None else replace(gate_config, null_threshold=thr))
103
+
104
+ rows, detail = [], {}
105
+ for name, raw in factors.items():
106
+ f = xs_norm(raw.reindex(index=panel.dates, columns=panel.tickers), el)
107
+ fm = fama_macbeth(f, fwd, ctrl, el)
108
+ rk = f.rank(axis=1, pct=True, na_option="keep")
109
+ fire = ((rk >= 1 - fire_q) & el).values
110
+ de, dep = _deploy(fire, cums[primary_h], ev, cost_bp)
111
+ dec = decile_profile(f, fwd[primary_h], el, lag=max(primary_h, 21))
112
+ g = run_gates(de, panel.dates, t_stat=fm[primary_h]["t"], net_bp=dep["net_bp"],
113
+ rho=dec["monotonicity_rho"], fire=fire, tickers=panel.tickers,
114
+ cfg=cfg, lag=max(primary_h, 21))
115
+
116
+ neu = None
117
+ if neutralize_all:
118
+ fn = xs_norm(neutralize(f, ctrl, el), el)
119
+ fmn = fama_macbeth(fn, fwd, ctrl, el)
120
+ rkn = fn.rank(axis=1, pct=True, na_option="keep")
121
+ firen = ((rkn >= 1 - fire_q) & el).values
122
+ _, depn = _deploy(firen, cums[primary_h], ev, cost_bp)
123
+ keep = (depn["excess_bp"] / dep["excess_bp"] * 100
124
+ if np.isfinite(dep["excess_bp"]) and abs(dep["excess_bp"]) > 1e-9 else np.nan)
125
+ neu = {"t": fmn[primary_h]["t"], "excess_bp": depn["excess_bp"],
126
+ "net_bp": depn["net_bp"], "survival_pct": float(keep) if np.isfinite(keep) else np.nan}
127
+
128
+ detail[name] = {"fm": {h: fm[h] for h in horizons}, "deploy": dep,
129
+ "decile": dec, "gates": g, "neutralized": neu}
130
+ rows.append({
131
+ "factor": name,
132
+ "t": fm[primary_h]["t"], # after controls
133
+ "excess_bp": dep["excess_bp"], "net_bp": dep["net_bp"],
134
+ "rho": dec["monotonicity_rho"],
135
+ "neu_t": neu["t"] if neu else np.nan,
136
+ "neu_survival_%": neu["survival_pct"] if neu else np.nan,
137
+ "passed": g["passed"], "failed_at": g["failed_at"],
138
+ })
139
+
140
+ df = pd.DataFrame(rows).sort_values("net_bp", ascending=False)
141
+ surv = df.loc[df.passed, "factor"].tolist()
142
+ n = len(df)
143
+ funnel = {
144
+ "all": n,
145
+ f"G1 |t|>{thr:.2f}": int((df.t.abs() > thr).sum()),
146
+ "G2 net of costs > 0": int(((df.t.abs() > thr) & (df.net_bp > 0)).sum()),
147
+ "passed all gates": len(surv),
148
+ "survival after neutralisation > 50%": int((df["neu_survival_%"] > 50).sum()) if neutralize_all else None,
149
+ }
150
+ return ScreenResult(null=null, factors=detail, survivors=surv, funnel=funnel, summary=df)