factorflow 0.1.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,464 @@
1
+ """Validation against the planted ground truth.
2
+
3
+ * :func:`factor_recovery`: daily cross-sectional OLS of engine-computed log returns on the
4
+ true exposures (beta, styles, industry dummies) recovers the planted factor returns; the
5
+ engine's rolling time-series beta recovers each stock's true beta.
6
+ * :func:`signal_ic`: information coefficient of the planted signal (risk-adjusted residual
7
+ IC, the quantity the generator plants, plus raw and rank IC on total returns).
8
+ * :func:`bar_distributions`: return distributions of clock bars vs tick/volume/dollar bars
9
+ (excess kurtosis, skew, Jarque-Bera).
10
+ * :func:`decile_backtest`: long top decile / short bottom decile of the signal, using the
11
+ engine's ``top``/``bottom`` filters, next-day returns.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+
21
+ from factorflow.batch import BatchEngine, BatchResult
22
+ from factorflow.events import AdaptiveDaily, build_event_bars
23
+ from factorflow.sim.market import STYLES, AssetSim, SimMarket
24
+ from factorflow.sim.pipelines import beta_window_for, daily_pipeline
25
+
26
+
27
+ # ====================================================================== stats helpers
28
+ def moments(x: np.ndarray) -> dict[str, float]:
29
+ """n, skew, excess kurtosis and the Jarque-Bera test (chi2 with 2 dof: p = exp(-JB/2))."""
30
+ x = np.asarray(x, dtype=float)
31
+ x = x[np.isfinite(x)]
32
+ n = x.size
33
+ if n < 8:
34
+ return {"n": n, "skew": math.nan, "exkurt": math.nan, "jb": math.nan, "jb_p": math.nan}
35
+ d = x - x.mean()
36
+ m2 = float(np.mean(d * d))
37
+ s = float(np.mean(d**3)) / m2**1.5
38
+ k = float(np.mean(d**4)) / m2**2 - 3.0
39
+ jb = n / 6.0 * (s * s + k * k / 4.0)
40
+ return {"n": n, "skew": s, "exkurt": k, "jb": jb, "jb_p": math.exp(-jb / 2.0)}
41
+
42
+
43
+ def _corr(a: np.ndarray, b: np.ndarray) -> float:
44
+ m = np.isfinite(a) & np.isfinite(b)
45
+ if m.sum() < 3:
46
+ return math.nan
47
+ return float(np.corrcoef(a[m], b[m])[0, 1])
48
+
49
+
50
+ def _rank(x: np.ndarray) -> np.ndarray:
51
+ out = np.full(x.shape, np.nan)
52
+ m = np.isfinite(x)
53
+ out[m] = np.argsort(np.argsort(x[m])).astype(float)
54
+ return out
55
+
56
+
57
+ def _tstat(x: np.ndarray) -> float:
58
+ x = x[np.isfinite(x)]
59
+ return float(x.mean() / (x.std(ddof=1) / math.sqrt(x.size))) if x.size > 2 else math.nan
60
+
61
+
62
+ def run_daily(sim: AssetSim) -> BatchResult:
63
+ """Run the daily research pipeline on one simulated book.
64
+
65
+ Parameters
66
+ ----------
67
+ sim : AssetSim
68
+ A book from :func:`simulate`.
69
+
70
+ Returns
71
+ -------
72
+ BatchResult
73
+ Outputs of :func:`factorflow.sim.pipelines.daily_pipeline`.
74
+ """
75
+ return BatchEngine().run(daily_pipeline(len(sim.assets), beta_window_for(sim.n_hist)), sim.daily)
76
+
77
+
78
+ # ====================================================================== factor recovery
79
+ @dataclass
80
+ class FactorRecovery:
81
+ """How closely the engine's daily returns and betas match the planted factors.
82
+
83
+ Notes
84
+ -----
85
+ ``factors`` maps a factor name to correlation, slope, bias in basis points,
86
+ and volatility of the error. ``beta`` compares the engine's last rolling beta
87
+ with the true beta. ``residuals`` is ``(T, N)``.
88
+ """
89
+
90
+ factors: dict[str, dict[str, float]] # name -> corr / slope / bias / rmse_rel
91
+ beta: dict[str, float] # engine rolling beta vs true beta
92
+ residuals: np.ndarray # (T, N) regression residuals (NaN where not estimated)
93
+ factor_hat: dict[str, np.ndarray]
94
+
95
+
96
+ def _design(sim: AssetSim) -> tuple[np.ndarray, list[str]]:
97
+ tr = sim.truth
98
+ g = int(tr.industry.max()) + 1
99
+ dummies = (tr.industry[:, None] == np.arange(g)[None, :]).astype(float)
100
+ X = np.column_stack([tr.beta, *[tr.exposures[k] for k in STYLES], dummies])
101
+ names = ["market", *STYLES, *[f"ind{j}" for j in range(g)]]
102
+ return X, names
103
+
104
+
105
+ def factor_recovery(sim: AssetSim, res: BatchResult | None = None, skip: int = 1) -> FactorRecovery:
106
+ """Regress each day's engine log returns on the true exposures.
107
+
108
+ Parameters
109
+ ----------
110
+ sim : AssetSim
111
+ The book. Uses equities in :func:`validate`.
112
+ res : BatchResult or None, optional
113
+ A previous :func:`run_daily`. Computed if omitted.
114
+ skip : int, optional
115
+ Leading days dropped before the fit summary. Default is 1, so the
116
+ first return, which has no lag, is skipped.
117
+
118
+ Returns
119
+ -------
120
+ FactorRecovery
121
+ Per-factor fit statistics and the residual panel.
122
+ """
123
+ res = res or run_daily(sim)
124
+ R = res.values["logret"]
125
+ X, names = _design(sim)
126
+ t = R.shape[0]
127
+ fh = np.full((t, X.shape[1]), np.nan)
128
+ resid = np.full(R.shape, np.nan)
129
+ for r in range(skip, t):
130
+ y = R[r]
131
+ m = np.isfinite(y)
132
+ if m.sum() <= X.shape[1] + 2:
133
+ continue
134
+ coef, *_ = np.linalg.lstsq(X[m], y[m], rcond=None)
135
+ fh[r] = coef
136
+ resid[r, m] = y[m] - X[m] @ coef
137
+ tr = sim.truth
138
+ truth = {"market": tr.factor_returns["market"], **{k: tr.factor_returns[k] for k in STYLES}}
139
+ for j in range(tr.industry_returns.shape[1]):
140
+ truth[f"ind{j}"] = tr.industry_returns[:, j]
141
+ out: dict[str, dict[str, float]] = {}
142
+ for j, k in enumerate(names):
143
+ a, b = fh[skip:, j], truth[k][skip:]
144
+ m = np.isfinite(a)
145
+ slope = float(np.polyfit(b[m], a[m], 1)[0]) if m.sum() > 2 else math.nan
146
+ out[k] = {
147
+ "corr": _corr(a, b),
148
+ "slope": slope,
149
+ "bias_bp": 1e4 * float(np.mean(a[m] - b[m])),
150
+ "true_vol_bp": 1e4 * float(np.std(b[m])),
151
+ "err_vol_bp": 1e4 * float(np.std(a[m] - b[m])),
152
+ }
153
+ bh = res.values["beta"][-1]
154
+ m = np.isfinite(bh)
155
+ beta = {
156
+ "corr": _corr(bh, tr.beta),
157
+ "slope": float(np.polyfit(tr.beta[m], bh[m], 1)[0]) if m.sum() > 2 else math.nan,
158
+ "mae": float(np.mean(np.abs(bh[m] - tr.beta[m] / tr.beta.mean()))),
159
+ "names": int(m.sum()),
160
+ }
161
+ return FactorRecovery(out, beta, resid, {k: fh[:, j] for j, k in enumerate(names)})
162
+
163
+
164
+ # ====================================================================== IC
165
+ @dataclass
166
+ class ICResult:
167
+ """Information coefficients of the planted signal.
168
+
169
+ Notes
170
+ -----
171
+ ``resid_ic`` is the mean correlation of yesterday's signal with today's
172
+ risk-adjusted residual, which is what the generator plants. ``raw_ic`` uses
173
+ total next-day returns. ``rank_ic`` rank-correlates the signal with total returns.
174
+ ``resid_ic_se`` is the standard error of the daily residual IC.
175
+ """
176
+
177
+ planted: float
178
+ resid_ic: float # mean risk-adjusted residual IC (what the generator plants)
179
+ resid_ic_se: float
180
+ raw_ic: float # corr(signal_z, next-day total return)
181
+ rank_ic: float
182
+ days: int
183
+
184
+ @property
185
+ def z(self) -> float:
186
+ """Return how many standard errors the residual IC sits from the planted value.
187
+
188
+ Returns
189
+ -------
190
+ float
191
+ ``(resid_ic - planted) / resid_ic_se``.
192
+ """
193
+ return (self.resid_ic - self.planted) / self.resid_ic_se
194
+
195
+
196
+ def signal_ic(sim: AssetSim, res: BatchResult | None = None, rec: FactorRecovery | None = None) -> ICResult:
197
+ """Measure the planted signal's information coefficient on engine outputs.
198
+
199
+ Parameters
200
+ ----------
201
+ sim : AssetSim
202
+ The book.
203
+ res : BatchResult or None, optional
204
+ Daily pipeline result. Computed if omitted.
205
+ rec : FactorRecovery or None, optional
206
+ Residuals. Computed if omitted.
207
+
208
+ Returns
209
+ -------
210
+ ICResult
211
+ Residual, raw, and rank IC.
212
+ """
213
+ res = res or run_daily(sim)
214
+ rec = rec or factor_recovery(sim, res)
215
+ s = res.values["signal_z"]
216
+ R = res.values["logret"]
217
+ sig = sim.truth.idio_vol
218
+ resid_ic, raw_ic, rank_ic = [], [], []
219
+ for r in range(1, R.shape[0] - 1):
220
+ e = rec.residuals[r + 1] / sig[r + 1]
221
+ resid_ic.append(_corr(s[r], e))
222
+ raw_ic.append(_corr(s[r], R[r + 1]))
223
+ rank_ic.append(_corr(_rank(s[r]), _rank(R[r + 1])))
224
+ a = np.array(resid_ic)
225
+ a = a[np.isfinite(a)]
226
+ return ICResult(
227
+ sim.truth.ic,
228
+ float(a.mean()),
229
+ float(a.std(ddof=1) / math.sqrt(a.size)),
230
+ float(np.nanmean(raw_ic)),
231
+ float(np.nanmean(rank_ic)),
232
+ int(a.size),
233
+ )
234
+
235
+
236
+ # ====================================================================== backtest
237
+ @dataclass
238
+ class Backtest:
239
+ """A decile long-short of the planted signal.
240
+
241
+ Notes
242
+ -----
243
+ Returns are in basis points per day except ``sharpe`` (annualized, 252 days)
244
+ and ``tstat`` (mean over standard error). ``expected_bp`` is the mean the
245
+ planted kappa implies for a normal signal's top and bottom deciles.
246
+ ``daily`` is the long-short return series, not in basis points.
247
+ """
248
+
249
+ days: int
250
+ mean_bp: float # mean daily long-short return
251
+ vol_bp: float
252
+ sharpe: float # annualised
253
+ tstat: float
254
+ hit_rate: float
255
+ expected_bp: float # implied by the planted kappa
256
+ long_n: float
257
+ short_n: float
258
+ daily: np.ndarray
259
+
260
+
261
+ def decile_backtest(sim: AssetSim, res: BatchResult | None = None) -> Backtest:
262
+ """Equal-weight long ``long`` / short ``short`` (engine filters) at close t, return to t + 1."""
263
+ res = res or run_daily(sim)
264
+ close = sim.daily.columns["close"]
265
+ fwd = np.full(close.shape, np.nan)
266
+ fwd[:-1] = close[1:] / close[:-1] - 1.0
267
+ L, S = res.values["long"], res.values["short"]
268
+ ls = []
269
+ for r in range(1, close.shape[0] - 1):
270
+ lo, sh = L[r] & np.isfinite(fwd[r]), S[r] & np.isfinite(fwd[r])
271
+ if lo.any() and sh.any():
272
+ ls.append(fwd[r, lo].mean() - fwd[r, sh].mean())
273
+ x = np.array(ls)
274
+ # E[LS] = kappa * E[sigma] * (E[s | top decile] - E[s | bottom decile]); for a normal
275
+ # signal the decile means are +-phi(z_0.9) / 0.1 = +-1.755
276
+ sig = float(np.mean(sim.truth.idio_vol))
277
+ expected = sim.truth.kappa * sig * 2 * 1.755
278
+ return Backtest(
279
+ int(x.size),
280
+ 1e4 * float(x.mean()),
281
+ 1e4 * float(x.std(ddof=1)),
282
+ float(x.mean() / x.std(ddof=1) * math.sqrt(252)),
283
+ _tstat(x),
284
+ float(np.mean(x > 0)),
285
+ 1e4 * expected,
286
+ float(np.median(L.sum(axis=1)[L.any(axis=1)])),
287
+ float(np.median(S.sum(axis=1)[S.any(axis=1)])),
288
+ x,
289
+ )
290
+
291
+
292
+ # ====================================================================== bar distributions
293
+ def _session_returns(close: np.ndarray, end: np.ndarray, day_of) -> np.ndarray:
294
+ """Return log returns between consecutive bars in the same session.
295
+
296
+ Overnight gaps are excluded: a return is kept only when both bars close
297
+ on the same session day.
298
+ """
299
+ if close.size < 2:
300
+ return np.empty(0)
301
+ r = np.diff(np.log(close))
302
+ same = day_of(end[1:]) == day_of(end[:-1])
303
+ return r[same]
304
+
305
+
306
+ def bar_distributions(
307
+ sim: AssetSim,
308
+ clock_freqs: tuple[str, ...] = ("1min", "5min"),
309
+ bars_per_day: int = 78,
310
+ min_trades_per_day: float = 200.0,
311
+ ) -> dict[str, dict[str, float]]:
312
+ """Pooled per-asset-standardised intraday returns of clock bars vs event bars.
313
+
314
+ Event bars use an adaptive threshold of ``bars_per_day`` per day (initialised from the
315
+ history's mean daily measure, point-in-time), so they are comparable with 5-minute bars
316
+ (78 per regular session). Illiquid names (< ``min_trades_per_day``) are excluded.
317
+ """
318
+ d = sim.daily.columns
319
+ h = sim.n_hist
320
+ n_hist_ticks = d["n_ticks"][:h].mean(axis=0) if h else np.full(len(sim.assets), np.inf)
321
+ names = np.flatnonzero(n_hist_ticks >= min_trades_per_day)
322
+ closes = sim.closes
323
+
324
+ def session(ts: np.ndarray) -> np.ndarray:
325
+ return np.searchsorted(closes, ts, side="left")
326
+
327
+ out: dict[str, dict[str, float]] = {}
328
+ per_asset_k: dict[str, list[float]] = {}
329
+ pooled: dict[str, list[np.ndarray]] = {}
330
+ for f in clock_freqs:
331
+ p, _ = sim.time_bars(f)
332
+ c = p.columns["close"]
333
+ for j in names:
334
+ r = _session_returns(c[:, j], p.times, session)
335
+ r = r[np.isfinite(r)]
336
+ if r.size > 10 and r.std() > 0:
337
+ pooled.setdefault(f, []).append(r / r.std())
338
+ per_asset_k.setdefault(f, []).append(moments(r)["exkurt"])
339
+ tr = sim.trades
340
+ measure_col = {"tick": "n_ticks", "volume": "volume", "dollar": "dollar"}
341
+ for kind, col in measure_col.items():
342
+ for j in names:
343
+ m = tr.asset == j
344
+ init = float(d[col][:h, j].mean()) / bars_per_day if h else 1.0
345
+ bars, _ = build_event_bars(
346
+ tr.ts[m],
347
+ tr.price[m],
348
+ tr.size[m],
349
+ kind,
350
+ AdaptiveDaily(bars_per_day, lookback_days=5, initial=init),
351
+ "close",
352
+ day_key=lambda ts: int(np.searchsorted(closes, ts, side="left")),
353
+ )
354
+ if len(bars) < 12:
355
+ continue
356
+ r = _session_returns(bars["close"], bars["end"], session)
357
+ r = r[np.isfinite(r)]
358
+ if r.size > 10 and r.std() > 0:
359
+ pooled.setdefault(kind, []).append(r / r.std())
360
+ per_asset_k.setdefault(kind, []).append(moments(r)["exkurt"])
361
+ for k, rs in pooled.items():
362
+ x = np.concatenate(rs)
363
+ mo = moments(x)
364
+ mo["median_asset_exkurt"] = float(np.median(per_asset_k[k]))
365
+ mo["assets"] = len(rs)
366
+ mo["bars_per_asset_day"] = x.size / len(rs) / max(len(sim.intraday), 1)
367
+ out[k] = mo
368
+ return out
369
+
370
+
371
+ # ====================================================================== everything
372
+ @dataclass
373
+ class Validation:
374
+ """Factor recovery, signal IC, the decile backtest, and bar-shape statistics."""
375
+
376
+ recovery: FactorRecovery
377
+ ic: ICResult
378
+ backtest: Backtest
379
+ bars: dict[str, dict[str, float]]
380
+ crypto_bars: dict[str, dict[str, float]] | None
381
+
382
+ def report(self) -> str:
383
+ """Format the validation as a text report.
384
+
385
+ Returns
386
+ -------
387
+ str
388
+ One block for factors, one for the signal, and one per asset class for bar distributions.
389
+ """
390
+ L = ["Factor recovery (daily cross-sectional OLS on true exposures):"]
391
+ L.append(
392
+ f" {'factor':<9} {'corr':>6} {'slope':>6} {'bias bp':>8} {'true vol bp':>11} {'err vol bp':>10}"
393
+ )
394
+ for k, v in self.recovery.factors.items():
395
+ if k.startswith("ind") and k != "ind0":
396
+ continue
397
+ L.append(
398
+ f" {k:<9} {v['corr']:6.3f} {v['slope']:6.3f} {v['bias_bp']:8.2f} "
399
+ f"{v['true_vol_bp']:11.1f} {v['err_vol_bp']:10.1f}"
400
+ )
401
+ inds = [v for k, v in self.recovery.factors.items() if k.startswith("ind")]
402
+ L.append(
403
+ f" industries ({len(inds)}): mean corr {np.mean([v['corr'] for v in inds]):.3f}, "
404
+ f"mean slope {np.mean([v['slope'] for v in inds]):.3f}"
405
+ )
406
+ b = self.recovery.beta
407
+ L.append(
408
+ f" rolling beta vs true beta ({b['names']} names): corr {b['corr']:.3f}, "
409
+ f"slope {b['slope']:.3f}, MAE {b['mae']:.3f}"
410
+ )
411
+ ic = self.ic
412
+ L.append("Planted signal:")
413
+ L.append(
414
+ f" risk-adjusted residual IC {ic.resid_ic:.4f} ± {ic.resid_ic_se:.4f} "
415
+ f"(planted {ic.planted:.4f}, "
416
+ f"z = {ic.z:+.2f}) over {ic.days} days; raw IC {ic.raw_ic:.4f}, rank IC {ic.rank_ic:.4f}"
417
+ )
418
+ bt = self.backtest
419
+ L.append(
420
+ f" decile long-short ({bt.long_n:.0f} long / {bt.short_n:.0f} short): "
421
+ f"mean {bt.mean_bp:.1f} bp/day "
422
+ f"(expected ~{bt.expected_bp:.1f}), vol {bt.vol_bp:.0f} bp, Sharpe {bt.sharpe:.2f}, "
423
+ f"t {bt.tstat:.2f}, hit {bt.hit_rate:.1%}"
424
+ )
425
+ for title, bars in (("Equity", self.bars), ("Crypto", self.crypto_bars)):
426
+ if not bars:
427
+ continue
428
+ L.append(f"{title} intraday return distributions (pooled, standardised per asset):")
429
+ L.append(
430
+ f" {'bars':<7} {'n':>8} {'bars/day':>8} {'skew':>6} {'ex.kurt':>8} "
431
+ f"{'med kurt':>8} {'JB':>10}"
432
+ )
433
+ for k, v in bars.items():
434
+ L.append(
435
+ f" {k:<7} {v['n']:>8} {v['bars_per_asset_day']:8.1f} {v['skew']:6.2f} "
436
+ f"{v['exkurt']:8.2f} "
437
+ f"{v['median_asset_exkurt']:8.2f} {v['jb']:10.0f}"
438
+ )
439
+ return "\n".join(L)
440
+
441
+
442
+ def validate(mkt: SimMarket) -> Validation:
443
+ """Run every check on a simulated market.
444
+
445
+ Parameters
446
+ ----------
447
+ mkt : SimMarket
448
+ From :func:`factorflow.sim.market.simulate`.
449
+
450
+ Returns
451
+ -------
452
+ Validation
453
+ Equity results, plus 24/7 bar distributions when the market has a crypto book.
454
+ """
455
+ eq = mkt.equities
456
+ res = run_daily(eq)
457
+ rec = factor_recovery(eq, res)
458
+ ic = signal_ic(eq, res, rec)
459
+ bt = decile_backtest(eq, res)
460
+ bars = bar_distributions(eq)
461
+ cb = None
462
+ if mkt.crypto is not None:
463
+ cb = bar_distributions(mkt.crypto, ("1min", "5min"), bars_per_day=288, min_trades_per_day=0)
464
+ return Validation(rec, ic, bt, bars, cb)