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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Trading costs: no flat assumptions, per-security measurement as the rule.
|
|
2
|
+
|
|
3
|
+
Why a flat assumption is dangerous
|
|
4
|
+
Spreads differ by security, so assuming some round-trip bp for everything understates the cost of a high-turnover
|
|
5
|
+
strategy more the higher its turnover, and can even flip the sign of the return. With high daily turnover a small
|
|
6
|
+
difference in the one-way cost accumulates a lot over a year.
|
|
7
|
+
|
|
8
|
+
Estimators
|
|
9
|
+
Roll (1984) autocovariance of trade prices. The most reliable when intraday bars exist.
|
|
10
|
+
Corwin-Schultz high-low based. **Can return 0 for ETFs and low-volatility names, so be careful**.
|
|
11
|
+
Traded-value regression interpolates securities with no measurement as log(bp) = a + b*log(traded value).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def roll_spread(prices: np.ndarray) -> float:
|
|
21
|
+
"""Roll (1984) effective spread (in price units). NaN if the autocovariance is positive."""
|
|
22
|
+
p = np.asarray(prices, dtype=np.float64)
|
|
23
|
+
p = p[np.isfinite(p)]
|
|
24
|
+
if len(p) < 30:
|
|
25
|
+
return np.nan
|
|
26
|
+
d = np.diff(p)
|
|
27
|
+
c = np.cov(d[1:], d[:-1])[0, 1]
|
|
28
|
+
return 2 * np.sqrt(-c) if c < 0 else np.nan
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def corwin_schultz(high: pd.DataFrame, low: pd.DataFrame) -> pd.DataFrame:
|
|
32
|
+
"""Corwin-Schultz (2012) high-low spread estimate (as a ratio).
|
|
33
|
+
|
|
34
|
+
Warning: for assets with a narrow high-low range (ETFs, large low-volatility stocks) the estimate goes negative and is clipped to 0.
|
|
35
|
+
If the result is all 0, do not use this estimator; move to Roll or the traded-value regression.
|
|
36
|
+
"""
|
|
37
|
+
h, l = np.log(high), np.log(low)
|
|
38
|
+
beta = (h - l) ** 2 + (h.shift(1) - l.shift(1)) ** 2
|
|
39
|
+
h2 = np.maximum(high, high.shift(1)) # elementwise: pd.concat(axis=1) would double the columns
|
|
40
|
+
l2 = np.minimum(low, low.shift(1))
|
|
41
|
+
gamma = (np.log(h2) - np.log(l2)) ** 2
|
|
42
|
+
k = 3 - 2 * np.sqrt(2)
|
|
43
|
+
alpha = (np.sqrt(2 * beta) - np.sqrt(beta)) / k - np.sqrt(gamma / k)
|
|
44
|
+
return (2 * (np.exp(alpha) - 1) / (1 + np.exp(alpha))).clip(lower=0, upper=0.2)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fit_spread_model(dvol_m: np.ndarray, spread_bp: np.ndarray) -> tuple[float, float]:
|
|
48
|
+
"""Regression log(one-way bp) = a + b*log(traded value in M). For interpolating securities with no measurement."""
|
|
49
|
+
m = np.isfinite(dvol_m) & np.isfinite(spread_bp) & (dvol_m > 0) & (spread_bp > 0)
|
|
50
|
+
if m.sum() < 20:
|
|
51
|
+
return np.nan, np.nan
|
|
52
|
+
b, a = np.polyfit(np.log(dvol_m[m]), np.log(spread_bp[m]), 1)
|
|
53
|
+
return float(a), float(b)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def spread_panel(adv: pd.DataFrame, *, a: float, b: float,
|
|
57
|
+
measured: dict[str, float] | None = None,
|
|
58
|
+
lo: float = 0.05, hi: float = 50.0) -> pd.DataFrame:
|
|
59
|
+
"""(date x ticker) panel of one-way spreads (bp). Measured values override it when present."""
|
|
60
|
+
dv_m = (adv / 1e6).clip(lower=0.1)
|
|
61
|
+
est = np.exp(a + b * np.log(dv_m))
|
|
62
|
+
if measured:
|
|
63
|
+
for k, v in measured.items():
|
|
64
|
+
if k in est.columns and np.isfinite(v):
|
|
65
|
+
est[k] = v
|
|
66
|
+
return est.clip(lower=lo, upper=hi)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def apply_turnover_cost(holdings: np.ndarray, spread_bp: np.ndarray | float) -> np.ndarray:
|
|
70
|
+
"""Cost proportional to turnover. Returns the daily cost (in return units).
|
|
71
|
+
|
|
72
|
+
holdings (date x ticker) weights. Sum = 1 (long) or one per leg for long-short.
|
|
73
|
+
spread_bp scalar (flat) or a (date x ticker) measured panel. **A panel is recommended**.
|
|
74
|
+
"""
|
|
75
|
+
d = np.abs(np.diff(holdings, axis=0))
|
|
76
|
+
cost = np.zeros(holdings.shape[0])
|
|
77
|
+
if np.isscalar(spread_bp):
|
|
78
|
+
cost[1:] = d.sum(axis=1) * (spread_bp / 1e4) * 0.5
|
|
79
|
+
else:
|
|
80
|
+
s = np.nan_to_num(np.asarray(spread_bp), nan=float(np.nanmedian(spread_bp)))
|
|
81
|
+
cost[1:] = (d * s[1:] / 1e4).sum(axis=1)
|
|
82
|
+
return cost
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def rate_schedule(dates: pd.DatetimeIndex, entries, name: str = "rate") -> pd.Series:
|
|
86
|
+
"""A rate that changes on known effective dates, as a value for every date: each date takes the latest entry whose
|
|
87
|
+
effective date is on or before it. `entries` is a list of (effective_date, value). A date before the first entry raises,
|
|
88
|
+
because the rate is then unknown and a made-up rate (or 0) would hide that."""
|
|
89
|
+
if not len(entries):
|
|
90
|
+
raise ValueError(f"{name}: the schedule is empty")
|
|
91
|
+
s = pd.Series({pd.Timestamp(d): float(v) for d, v in entries}).sort_index()
|
|
92
|
+
out = s.reindex(dates, method="ffill")
|
|
93
|
+
if out.isna().any():
|
|
94
|
+
raise ValueError(f"{name}: no rate is known before {s.index[0].date()}, but the panel starts on {dates[0].date()} "
|
|
95
|
+
f"(add an earlier entry or start the panel later)")
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def side_cost_input(value, dates: pd.DatetimeIndex, tickers: pd.Index, name: str):
|
|
100
|
+
"""Validate a one-way cost for one side of a trade (bp) and bring it to a form `apply_side_cost` can use: a float, a
|
|
101
|
+
(dates x 1) array (a Series indexed by date: a rate that changes over time) or a (dates x tickers) array. Unlike `spread_bp`, a
|
|
102
|
+
missing value raises instead of being replaced by a median: a rate you do not know is not a rate of zero."""
|
|
103
|
+
if value is None:
|
|
104
|
+
return 0.0
|
|
105
|
+
if isinstance(value, pd.DataFrame):
|
|
106
|
+
a = value.reindex(index=dates, columns=tickers).to_numpy(float)
|
|
107
|
+
elif isinstance(value, pd.Series):
|
|
108
|
+
if not isinstance(value.index, pd.DatetimeIndex):
|
|
109
|
+
raise ValueError(f"{name}: a Series must be indexed by date")
|
|
110
|
+
a = value.sort_index().reindex(dates, method="ffill").to_numpy(float).reshape(-1, 1)
|
|
111
|
+
else:
|
|
112
|
+
a = np.asarray(value, dtype=float)
|
|
113
|
+
if a.ndim == 0:
|
|
114
|
+
if not np.isfinite(a) or a < 0:
|
|
115
|
+
raise ValueError(f"{name} must be a finite number, not negative, got {value!r}")
|
|
116
|
+
return float(a)
|
|
117
|
+
if a.shape != (len(dates), len(tickers)):
|
|
118
|
+
raise ValueError(f"{name}: an array must have shape (dates, tickers) = {(len(dates), len(tickers))}, got {a.shape}")
|
|
119
|
+
if np.isnan(a).any():
|
|
120
|
+
raise ValueError(f"{name}: the value is unknown for some dates or securities (NaN, or a date before the first entry); "
|
|
121
|
+
f"a missing cost is an error, not zero")
|
|
122
|
+
if np.isinf(a).any() or (a < 0).any():
|
|
123
|
+
raise ValueError(f"{name} must be finite and not negative (a negative cost would pay you for trading)")
|
|
124
|
+
return a
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def apply_side_cost(holdings: np.ndarray, buy_bp, sell_bp) -> np.ndarray:
|
|
128
|
+
"""Daily cost of trades that depends on the side: `buy_bp` per unit of weight bought and `sell_bp` per unit sold (one-way,
|
|
129
|
+
bp, on top of the spread). A weight going up is a buy and a weight going down is a sell, so opening a short is a sell and
|
|
130
|
+
covering it a buy. The first row, a build from cash, is not charged (as for the spread). `buy_bp` and `sell_bp` are floats or
|
|
131
|
+
arrays from `side_cost_input`."""
|
|
132
|
+
d = np.diff(holdings, axis=0)
|
|
133
|
+
out = np.zeros(holdings.shape[0])
|
|
134
|
+
b = buy_bp[1:] if isinstance(buy_bp, np.ndarray) else buy_bp
|
|
135
|
+
s = sell_bp[1:] if isinstance(sell_bp, np.ndarray) else sell_bp
|
|
136
|
+
out[1:] = (np.maximum(d, 0.0) * b + np.maximum(-d, 0.0) * s).sum(axis=1) / 1e4
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def turnover(holdings: np.ndarray) -> np.ndarray:
|
|
141
|
+
"""Daily one-way turnover."""
|
|
142
|
+
t = np.zeros(holdings.shape[0])
|
|
143
|
+
t[1:] = 0.5 * np.abs(np.diff(holdings, axis=0)).sum(axis=1)
|
|
144
|
+
return t
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Estimators: Fama-MacBeth, Newey-West, deciles and the shuffled null.
|
|
2
|
+
|
|
3
|
+
Overlapping returns
|
|
4
|
+
Computing an h-day holding return every day makes h-1 days overlap. The independent observations are far fewer, so without a
|
|
5
|
+
correction t is inflated several times over. Every t is computed with Newey-West.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
EPS = 1e-12
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def newey_west_t(series: np.ndarray, lag: int) -> tuple[float, float, float, int]:
|
|
19
|
+
"""Newey-West corrected t for overlapping returns. Returns (mean, se, t, T)."""
|
|
20
|
+
x = np.asarray(series, dtype=np.float64)
|
|
21
|
+
x = x[np.isfinite(x)]
|
|
22
|
+
T = len(x)
|
|
23
|
+
if T < 30:
|
|
24
|
+
return float("nan"), float("nan"), float("nan"), T
|
|
25
|
+
mu = x.mean()
|
|
26
|
+
xd = x - mu
|
|
27
|
+
s = (xd @ xd) / T
|
|
28
|
+
for lg in range(1, min(lag, T - 1) + 1):
|
|
29
|
+
w = 1.0 - lg / (lag + 1.0)
|
|
30
|
+
s += 2.0 * w * (xd[lg:] @ xd[:-lg]) / T
|
|
31
|
+
if s <= 0:
|
|
32
|
+
return float(mu), float("nan"), float("nan"), T
|
|
33
|
+
se = np.sqrt(s / T)
|
|
34
|
+
return float(mu), float(se), float(mu / se), T
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fama_macbeth(factor: pd.DataFrame, fwd: dict[int, pd.DataFrame],
|
|
38
|
+
controls: dict[str, pd.DataFrame], eligible: pd.DataFrame,
|
|
39
|
+
*, min_stocks: int | None = None) -> dict:
|
|
40
|
+
"""FM coefficient series of one factor x several horizons -> NW t.
|
|
41
|
+
|
|
42
|
+
The factor is orthogonalised against the space of the controls by QR before the coefficient is taken.
|
|
43
|
+
Both the t with controls and the t without (t_raw) are produced, but the caller is made to **report only the one after controls**.
|
|
44
|
+
"""
|
|
45
|
+
fv = factor.values.astype(np.float64)
|
|
46
|
+
ev = eligible.values
|
|
47
|
+
cvs = [c.values.astype(np.float64) for c in controls.values()]
|
|
48
|
+
hs = sorted(fwd)
|
|
49
|
+
|
|
50
|
+
# A fixed minimum sample throws away every date in a small universe (for example 20 names).
|
|
51
|
+
# The least a regression needs is (controls + intercept + factor), so twice that is the floor,
|
|
52
|
+
# but never more than half the median universe size.
|
|
53
|
+
if min_stocks is None:
|
|
54
|
+
need = 2 * (len(cvs) + 2)
|
|
55
|
+
med = float(np.median(ev.sum(axis=1)))
|
|
56
|
+
min_stocks = int(max(need, min(50, med * 0.5)))
|
|
57
|
+
if med < need:
|
|
58
|
+
warnings.warn(
|
|
59
|
+
f"The median universe has {med:.0f} names but there are {len(cvs)} controls, "
|
|
60
|
+
f"so the regression has too few degrees of freedom (at least {need} names needed). "
|
|
61
|
+
f"Use fewer controls or more names.", stacklevel=2)
|
|
62
|
+
rv = {h: fwd[h].values.astype(np.float64) for h in hs}
|
|
63
|
+
n = fv.shape[0]
|
|
64
|
+
betas = {h: np.full(n, np.nan) for h in hs}
|
|
65
|
+
raws = {h: np.full(n, np.nan) for h in hs}
|
|
66
|
+
n_eligible_days = 0 # days with a large enough sample
|
|
67
|
+
n_collinear = 0 # of those, days thrown away because the residual was 0 (perfectly collinear with the controls)
|
|
68
|
+
|
|
69
|
+
for i in range(n):
|
|
70
|
+
f = fv[i]
|
|
71
|
+
ok = ev[i] & np.isfinite(f)
|
|
72
|
+
for c in cvs:
|
|
73
|
+
ok &= np.isfinite(c[i])
|
|
74
|
+
if ok.sum() < min_stocks:
|
|
75
|
+
continue
|
|
76
|
+
fo = f[ok]
|
|
77
|
+
Z = np.column_stack([np.ones(ok.sum())] + [c[i][ok] for c in cvs])
|
|
78
|
+
try:
|
|
79
|
+
Q, _ = np.linalg.qr(Z)
|
|
80
|
+
except np.linalg.LinAlgError:
|
|
81
|
+
continue
|
|
82
|
+
fp = fo - Q @ (Q.T @ fo)
|
|
83
|
+
den = fp @ fp
|
|
84
|
+
foc = fo - fo.mean()
|
|
85
|
+
den_r = foc @ foc
|
|
86
|
+
n_eligible_days += 1
|
|
87
|
+
if den <= EPS or den_r <= EPS:
|
|
88
|
+
n_collinear += 1
|
|
89
|
+
continue
|
|
90
|
+
for h in hs:
|
|
91
|
+
r = rv[h][i]
|
|
92
|
+
okh = ok & np.isfinite(r)
|
|
93
|
+
if okh.sum() < min_stocks:
|
|
94
|
+
continue
|
|
95
|
+
if okh.sum() != ok.sum():
|
|
96
|
+
fo2 = f[okh]
|
|
97
|
+
Z2 = np.column_stack([np.ones(okh.sum())] + [c[i][okh] for c in cvs])
|
|
98
|
+
try:
|
|
99
|
+
Q2, _ = np.linalg.qr(Z2)
|
|
100
|
+
except np.linalg.LinAlgError:
|
|
101
|
+
continue
|
|
102
|
+
fp2 = fo2 - Q2 @ (Q2.T @ fo2)
|
|
103
|
+
d2 = fp2 @ fp2
|
|
104
|
+
if d2 <= EPS:
|
|
105
|
+
continue
|
|
106
|
+
rr = r[okh]
|
|
107
|
+
betas[h][i] = (fp2 @ (rr - Q2 @ (Q2.T @ rr))) / d2
|
|
108
|
+
f2c = fo2 - fo2.mean()
|
|
109
|
+
raws[h][i] = (f2c @ (rr - rr.mean())) / (f2c @ f2c)
|
|
110
|
+
else:
|
|
111
|
+
rr = r[ok]
|
|
112
|
+
betas[h][i] = (fp @ (rr - Q @ (Q.T @ rr))) / den
|
|
113
|
+
raws[h][i] = (foc @ (rr - rr.mean())) / den_r
|
|
114
|
+
|
|
115
|
+
out = {}
|
|
116
|
+
for h in hs:
|
|
117
|
+
mu, se, t, T = newey_west_t(betas[h], lag=max(h, 21))
|
|
118
|
+
_, _, t63, _ = newey_west_t(betas[h], lag=max(h, 63))
|
|
119
|
+
mu_r, _, t_r, _ = newey_west_t(raws[h], lag=max(h, 21))
|
|
120
|
+
out[h] = {"coef_bp": mu * 1e4 if np.isfinite(mu) else np.nan, "t": t,
|
|
121
|
+
"t_nw63": t63, "n_days": T,
|
|
122
|
+
"coef_bp_raw": mu_r * 1e4 if np.isfinite(mu_r) else np.nan, "t_raw": t_r}
|
|
123
|
+
|
|
124
|
+
# Perfect-collinearity diagnosis: if the factor is effectively the same as the controls the residual is 0 and every date is thrown away.
|
|
125
|
+
# A silent NaN would hide the cause, so say it out loud.
|
|
126
|
+
if n_eligible_days and n_collinear / n_eligible_days > 0.5:
|
|
127
|
+
warnings.warn(
|
|
128
|
+
f"The factor is almost perfectly collinear with the controls "
|
|
129
|
+
f"(residual about 0 on {n_collinear}/{n_eligible_days} days). "
|
|
130
|
+
f"No information is left after the controls, so t is NaN. "
|
|
131
|
+
f"Change the factor definition or drop that control.",
|
|
132
|
+
stacklevel=2)
|
|
133
|
+
for h in hs:
|
|
134
|
+
out[h]["collinear_pct"] = (100.0 * n_collinear / n_eligible_days
|
|
135
|
+
if n_eligible_days else np.nan)
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def decile_profile(factor: pd.DataFrame, fwd_h: pd.DataFrame, eligible: pd.DataFrame,
|
|
140
|
+
*, lag: int = 21, n_bins: int = 10) -> dict:
|
|
141
|
+
"""Decile profile. Without monotonicity only the extremes differ, which is hard to call a signal."""
|
|
142
|
+
rk = factor.where(eligible).rank(axis=1, pct=True, na_option="keep")
|
|
143
|
+
c = fwd_h.values.astype(np.float64)
|
|
144
|
+
ev = eligible.values
|
|
145
|
+
prof = []
|
|
146
|
+
for d in range(n_bins):
|
|
147
|
+
lo, hi = d / n_bins, (d + 1) / n_bins + (0.01 if d == n_bins - 1 else 0)
|
|
148
|
+
sel = ((rk >= lo) & (rk < hi) & eligible).values
|
|
149
|
+
v = []
|
|
150
|
+
for i in range(c.shape[0]):
|
|
151
|
+
s = sel[i] & np.isfinite(c[i])
|
|
152
|
+
pool = ev[i] & np.isfinite(c[i])
|
|
153
|
+
if s.sum() >= 3 and pool.sum() >= 30:
|
|
154
|
+
v.append(c[i][s].mean() - c[i][pool].mean())
|
|
155
|
+
prof.append(float(np.mean(v) * 1e4) if v else np.nan)
|
|
156
|
+
# Spearman correlation = Pearson correlation of the ranks. method="spearman" would need scipy, so it is not used
|
|
157
|
+
# (the core dependencies of this package are pandas and numpy only).
|
|
158
|
+
rho = pd.Series(prof).rank().corr(pd.Series(range(n_bins), dtype=float).rank())
|
|
159
|
+
hi_lo = [x for x in (prof[-1], prof[0]) if np.isfinite(x)]
|
|
160
|
+
return {"decile_bp": prof, "monotonicity_rho": float(rho) if pd.notna(rho) else np.nan,
|
|
161
|
+
"spread_bp": float(prof[-1] - prof[0]) if len(hi_lo) == 2 else np.nan}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def shuffle_null(factor_builder, eligible: pd.DataFrame, fwd: dict[int, pd.DataFrame],
|
|
165
|
+
controls: dict[str, pd.DataFrame], *, n_rep: int = 3,
|
|
166
|
+
seed: int = 0) -> dict:
|
|
167
|
+
"""Shuffled null: **measures** the multiple-testing threshold.
|
|
168
|
+
|
|
169
|
+
Only the security order is randomly permuted within each date. The date structure and the factor distribution are kept and
|
|
170
|
+
only the security-factor link is destroyed, so the |t| distribution that comes out is the 'size of chance'.
|
|
171
|
+
Theoretical corrections such as Bonferroni ignore the correlation between tests and are far too conservative.
|
|
172
|
+
|
|
173
|
+
factor_builder: (rng) -> DataFrame -- a callback that rebuilds the factor from the shuffled source
|
|
174
|
+
"""
|
|
175
|
+
rng = np.random.default_rng(seed)
|
|
176
|
+
ts = []
|
|
177
|
+
for _ in range(n_rep):
|
|
178
|
+
f = factor_builder(rng)
|
|
179
|
+
r = fama_macbeth(f, fwd, controls, eligible)
|
|
180
|
+
ts.extend(abs(v["t"]) for v in r.values() if np.isfinite(v["t"]))
|
|
181
|
+
a = np.array(ts)
|
|
182
|
+
if not len(a):
|
|
183
|
+
return {"n": 0, "p95": np.nan, "max": np.nan}
|
|
184
|
+
return {"n": int(len(a)), "median": float(np.median(a)),
|
|
185
|
+
"p95": float(np.percentile(a, 95)), "p99": float(np.percentile(a, 99)),
|
|
186
|
+
"max": float(a.max())}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def shuffle_columns(df: pd.DataFrame, eligible: pd.DataFrame, rng) -> pd.DataFrame:
|
|
190
|
+
"""Permute values only among the eligible securities of each date. The standard shuffler of shuffle_null."""
|
|
191
|
+
a = df.values.copy()
|
|
192
|
+
ev = eligible.values
|
|
193
|
+
for i in range(a.shape[0]):
|
|
194
|
+
j = np.where(ev[i])[0]
|
|
195
|
+
if len(j) > 1:
|
|
196
|
+
a[i, j] = a[i, rng.permutation(j)]
|
|
197
|
+
return pd.DataFrame(a, index=df.index, columns=df.columns)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def paired_diff(base_mask: np.ndarray, alt_mask: np.ndarray, cum: np.ndarray,
|
|
201
|
+
*, lag: int = 21, min_n: int = 5) -> dict:
|
|
202
|
+
"""Paired comparison: the pure contribution of adding a condition on the same dates.
|
|
203
|
+
|
|
204
|
+
Adding a condition almost always improves the headline number. To see whether it is a real improvement it must be compared
|
|
205
|
+
**on the same dates and the same sample**, in pairs.
|
|
206
|
+
"""
|
|
207
|
+
d = []
|
|
208
|
+
for i in range(cum.shape[0]):
|
|
209
|
+
a = base_mask[i] & np.isfinite(cum[i])
|
|
210
|
+
b = alt_mask[i] & np.isfinite(cum[i])
|
|
211
|
+
if a.sum() >= min_n and b.sum() >= min_n:
|
|
212
|
+
d.append(cum[i][b].mean() - cum[i][a].mean())
|
|
213
|
+
if len(d) < 30:
|
|
214
|
+
return {"diff_bp": np.nan, "t": np.nan, "n_days": len(d)}
|
|
215
|
+
mu, _, t, n = newey_west_t(np.array(d), lag=lag)
|
|
216
|
+
return {"diff_bp": float(mu * 1e4), "t": float(t), "n_days": n}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Gate runner: verdict gates fixed before the result is seen.
|
|
2
|
+
|
|
3
|
+
The order of the gates matters: statistics -> cost -> stability -> structure.
|
|
4
|
+
**Each later gate is harder to pass.** With the statistical gate alone many candidates survive, but
|
|
5
|
+
after the cost, stability and structure gates far fewer remain, and that is the normal outcome.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, asdict
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from .estimators import newey_west_t
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class GateConfig:
|
|
20
|
+
"""Gate thresholds. Fix them before testing starts and do not change them afterwards."""
|
|
21
|
+
null_threshold: float | None = None # None means measure it with a shuffled null
|
|
22
|
+
fallback_t: float = 3.0 # only when the null cannot be measured
|
|
23
|
+
require_net_positive: bool = True # net profit after costs > 0
|
|
24
|
+
require_subperiod_sign: bool = True # the sign agrees in the first and second half
|
|
25
|
+
min_monotonicity: float = 0.5 # |rho|
|
|
26
|
+
min_yearly_positive: float = 0.6 # share of years that are positive
|
|
27
|
+
max_single_name_share: float = 0.5 # firing share of the most frequent name (static basket)
|
|
28
|
+
min_turnover: float = 0.05 # lower bound of daily turnover
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fire_structure(fire: np.ndarray, dates: pd.DatetimeIndex,
|
|
32
|
+
tickers: pd.Index) -> dict:
|
|
33
|
+
"""Firing structure: a signal or a static basket?
|
|
34
|
+
|
|
35
|
+
A factor that keeps picking the same large stocks for years is a size factor (a static basket), not a signal.
|
|
36
|
+
The top-name share and the longest run of consecutive firing days catch it.
|
|
37
|
+
"""
|
|
38
|
+
n = fire.sum(axis=1)
|
|
39
|
+
act = n[n > 0]
|
|
40
|
+
cnt = pd.Series(fire.sum(axis=0), index=tickers)
|
|
41
|
+
cnt = cnt[cnt > 0].sort_values(ascending=False)
|
|
42
|
+
tot = int(fire.sum())
|
|
43
|
+
if tot == 0 or len(cnt) == 0:
|
|
44
|
+
return {"fires_per_day": 0.0, "active_days_pct": 0.0, "n_names": 0,
|
|
45
|
+
"top10_share": np.nan, "max_name_share": np.nan,
|
|
46
|
+
"max_consecutive": 0, "daily_turnover": np.nan, "top_names": {},
|
|
47
|
+
"note": "no firings"}
|
|
48
|
+
turn = []
|
|
49
|
+
for i in range(1, fire.shape[0]):
|
|
50
|
+
a, b = fire[i - 1], fire[i]
|
|
51
|
+
u = (a | b).sum()
|
|
52
|
+
if u and a.sum() and b.sum():
|
|
53
|
+
turn.append(1 - (a & b).sum() / u)
|
|
54
|
+
runs = []
|
|
55
|
+
for c in cnt.head(30).index:
|
|
56
|
+
s = fire[:, tickers.get_loc(c)].astype(int)
|
|
57
|
+
best = cur = 0
|
|
58
|
+
for x in s:
|
|
59
|
+
cur = cur + 1 if x else 0
|
|
60
|
+
best = max(best, cur)
|
|
61
|
+
runs.append(best)
|
|
62
|
+
return {
|
|
63
|
+
"fires_per_day": float(act.mean()) if len(act) else 0.0,
|
|
64
|
+
"active_days_pct": float(len(act) / max(len(dates), 1) * 100),
|
|
65
|
+
"n_names": int(len(cnt)),
|
|
66
|
+
"top10_share": float(cnt.head(10).sum() / tot) if tot else np.nan,
|
|
67
|
+
"max_name_share": float(cnt.iloc[0] / len(dates)) if len(cnt) else np.nan,
|
|
68
|
+
"max_consecutive": int(max(runs)) if runs else 0,
|
|
69
|
+
"daily_turnover": float(np.mean(turn)) if turn else np.nan,
|
|
70
|
+
"top_names": cnt.head(8).to_dict(),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def subperiod(values: np.ndarray, dates: pd.DatetimeIndex, lag: int = 21) -> dict:
|
|
75
|
+
"""First half versus second half. If the signs differ the result depends on the period."""
|
|
76
|
+
values = np.asarray(values)
|
|
77
|
+
if len(values) == 0:
|
|
78
|
+
return {"front": {"mean_bp": np.nan, "t": np.nan, "n": 0},
|
|
79
|
+
"back": {"mean_bp": np.nan, "t": np.nan, "n": 0}, "sign_match": False}
|
|
80
|
+
mid = len(values) // 2
|
|
81
|
+
out = {}
|
|
82
|
+
for lab, sl in (("front", slice(0, mid)), ("back", slice(mid, len(values)))):
|
|
83
|
+
mu, _, t, n = newey_west_t(values[sl], lag=lag)
|
|
84
|
+
out[lab] = {"mean_bp": mu * 1e4 if np.isfinite(mu) else np.nan, "t": t, "n": n}
|
|
85
|
+
f, b = out["front"]["mean_bp"], out["back"]["mean_bp"]
|
|
86
|
+
out["sign_match"] = bool(np.isfinite(f) and np.isfinite(b) and (f > 0) == (b > 0))
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def yearly(values: np.ndarray, dates: pd.DatetimeIndex, lag: int = 21) -> dict:
|
|
91
|
+
"""Consistency across years."""
|
|
92
|
+
values = np.asarray(values)
|
|
93
|
+
if len(values) == 0:
|
|
94
|
+
return {"by_year": {}, "positive": 0, "total": 0, "positive_ratio": np.nan}
|
|
95
|
+
s = pd.Series(values, index=dates[: len(values)])
|
|
96
|
+
out, pos, tot = {}, 0, 0
|
|
97
|
+
for y, g in s.groupby(s.index.year):
|
|
98
|
+
if len(g) < 40:
|
|
99
|
+
continue
|
|
100
|
+
mu, _, t, n = newey_west_t(g.values, lag=lag)
|
|
101
|
+
out[int(y)] = {"mean_bp": mu * 1e4 if np.isfinite(mu) else np.nan, "t": t}
|
|
102
|
+
tot += 1
|
|
103
|
+
pos += int(np.isfinite(mu) and mu > 0)
|
|
104
|
+
return {"by_year": out, "positive": pos, "total": tot,
|
|
105
|
+
"positive_ratio": pos / tot if tot else np.nan}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def oos_holdout(values: np.ndarray, dates: pd.DatetimeIndex,
|
|
109
|
+
months: int = 12, lag: int = 21) -> dict:
|
|
110
|
+
"""OOS holdout: set the last N months aside and test on them.
|
|
111
|
+
|
|
112
|
+
With a small sample a low t is normal. To tell 'dead' from 'cannot be measured'
|
|
113
|
+
look at n_days as well.
|
|
114
|
+
"""
|
|
115
|
+
values = np.asarray(values)
|
|
116
|
+
if len(values) == 0:
|
|
117
|
+
return {"IS": {"mean_bp": np.nan, "t": np.nan, "n": 0},
|
|
118
|
+
"OOS": {"mean_bp": np.nan, "t": np.nan, "n": 0},
|
|
119
|
+
"cutoff": None, "note": "no sample: firings are too rare or the universe is too small"}
|
|
120
|
+
d = dates[: len(values)]
|
|
121
|
+
cut = d[-1] - pd.DateOffset(months=months)
|
|
122
|
+
out = {}
|
|
123
|
+
for lab, m in (("IS", d <= cut), ("OOS", d > cut)):
|
|
124
|
+
v = values[m]
|
|
125
|
+
if len(v) < 30:
|
|
126
|
+
out[lab] = {"mean_bp": np.nan, "t": np.nan, "n": len(v)}
|
|
127
|
+
continue
|
|
128
|
+
mu, _, t, n = newey_west_t(v, lag=lag)
|
|
129
|
+
out[lab] = {"mean_bp": mu * 1e4 if np.isfinite(mu) else np.nan, "t": t, "n": n}
|
|
130
|
+
out["cutoff"] = str(cut.date())
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def run_gates(daily_excess: np.ndarray, dates: pd.DatetimeIndex, *,
|
|
135
|
+
t_stat: float, net_bp: float, rho: float,
|
|
136
|
+
fire: np.ndarray | None = None, tickers: pd.Index | None = None,
|
|
137
|
+
cfg: GateConfig | None = None, lag: int = 21) -> dict:
|
|
138
|
+
"""Pass every gate in order and record where the candidate dropped out."""
|
|
139
|
+
cfg = cfg or GateConfig()
|
|
140
|
+
thr = cfg.null_threshold if cfg.null_threshold is not None else cfg.fallback_t
|
|
141
|
+
res = {"config": asdict(cfg), "threshold": thr}
|
|
142
|
+
steps: list[tuple[str, bool, str]] = []
|
|
143
|
+
|
|
144
|
+
ok1 = abs(t_stat) > thr
|
|
145
|
+
steps.append(("G1 statistical threshold", ok1, f"|t|={abs(t_stat):.2f} vs {thr:.2f}"))
|
|
146
|
+
|
|
147
|
+
ok2 = (not cfg.require_net_positive) or (np.isfinite(net_bp) and net_bp > 0)
|
|
148
|
+
steps.append(("G2 net profit after costs", ok2, f"{net_bp:+.1f}bp"))
|
|
149
|
+
|
|
150
|
+
sp = subperiod(daily_excess, dates, lag=lag)
|
|
151
|
+
ok3 = (not cfg.require_subperiod_sign) or sp["sign_match"]
|
|
152
|
+
steps.append(("G3 sub-period sign", ok3,
|
|
153
|
+
f"first {sp['front']['mean_bp']:+.1f} / second {sp['back']['mean_bp']:+.1f}"))
|
|
154
|
+
|
|
155
|
+
ok4 = np.isfinite(rho) and abs(rho) >= cfg.min_monotonicity
|
|
156
|
+
steps.append(("G4 decile monotonicity", ok4, f"rho={rho:+.2f}"))
|
|
157
|
+
|
|
158
|
+
yr = yearly(daily_excess, dates, lag=lag)
|
|
159
|
+
ok5 = np.isfinite(yr["positive_ratio"]) and yr["positive_ratio"] >= cfg.min_yearly_positive
|
|
160
|
+
steps.append(("G5 yearly consistency", ok5, f"{yr['positive']}/{yr['total']} years"))
|
|
161
|
+
|
|
162
|
+
st = None
|
|
163
|
+
if fire is not None and tickers is not None:
|
|
164
|
+
st = fire_structure(fire, dates, tickers)
|
|
165
|
+
ok6 = (st["max_name_share"] <= cfg.max_single_name_share
|
|
166
|
+
and st["daily_turnover"] >= cfg.min_turnover)
|
|
167
|
+
steps.append(("G6 firing structure", ok6,
|
|
168
|
+
f"top name {st['max_name_share']*100:.1f}% | turnover {st['daily_turnover']*100:.1f}%"))
|
|
169
|
+
|
|
170
|
+
res["steps"] = [{"gate": g, "pass": bool(p), "detail": d} for g, p, d in steps]
|
|
171
|
+
res["passed"] = all(p for _, p, _ in steps)
|
|
172
|
+
res["failed_at"] = next((g for g, p, _ in steps if not p), None)
|
|
173
|
+
res["subperiod"], res["yearly"], res["structure"] = sp, yr, st
|
|
174
|
+
res["oos"] = oos_holdout(daily_excess, dates, lag=lag)
|
|
175
|
+
return res
|