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/__init__.py +27 -0
- pitbacktest/adapters/__init__.py +0 -0
- pitbacktest/adapters/krx.py +249 -0
- pitbacktest/adapters/long_format.py +87 -0
- pitbacktest/adapters/tiingo.py +236 -0
- pitbacktest/adapters/yfinance.py +243 -0
- pitbacktest/analytics.py +234 -0
- pitbacktest/core/__init__.py +0 -0
- pitbacktest/core/controls.py +126 -0
- pitbacktest/core/costs.py +144 -0
- pitbacktest/core/estimators.py +216 -0
- pitbacktest/core/gates.py +175 -0
- pitbacktest/core/panel.py +306 -0
- pitbacktest/crypto/__init__.py +12 -0
- pitbacktest/crypto/binance_archive.py +287 -0
- pitbacktest/crypto/costs.py +80 -0
- pitbacktest/crypto/intraday.py +326 -0
- pitbacktest/crypto/panel.py +120 -0
- pitbacktest/equity/__init__.py +10 -0
- pitbacktest/equity/master.py +60 -0
- pitbacktest/equity/scenarios.py +104 -0
- pitbacktest/event.py +206 -0
- pitbacktest/execution.py +310 -0
- pitbacktest/ledger.py +128 -0
- pitbacktest/portfolio.py +373 -0
- pitbacktest/screen.py +150 -0
- pitbacktest/shorting.py +46 -0
- pitbacktest/validation.py +118 -0
- pitbacktest/weights.py +271 -0
- pitbacktest-0.2.0.dist-info/METADATA +395 -0
- pitbacktest-0.2.0.dist-info/RECORD +34 -0
- pitbacktest-0.2.0.dist-info/WHEEL +5 -0
- pitbacktest-0.2.0.dist-info/licenses/LICENSE +21 -0
- pitbacktest-0.2.0.dist-info/top_level.txt +1 -0
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
|
pitbacktest/portfolio.py
ADDED
|
@@ -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)
|