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.
@@ -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