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.
- factorflow/__init__.py +59 -0
- factorflow/bars.py +246 -0
- factorflow/batch.py +221 -0
- factorflow/events.py +610 -0
- factorflow/graph.py +177 -0
- factorflow/grid.py +298 -0
- factorflow/kernels.py +434 -0
- factorflow/live.py +554 -0
- factorflow/panel.py +181 -0
- factorflow/py.typed +0 -0
- factorflow/sim/__init__.py +22 -0
- factorflow/sim/__main__.py +210 -0
- factorflow/sim/bench.py +130 -0
- factorflow/sim/market.py +669 -0
- factorflow/sim/pipelines.py +147 -0
- factorflow/sim/replay.py +385 -0
- factorflow/sim/validate.py +464 -0
- factorflow/terms.py +1921 -0
- factorflow-0.1.0.dist-info/METADATA +368 -0
- factorflow-0.1.0.dist-info/RECORD +23 -0
- factorflow-0.1.0.dist-info/WHEEL +4 -0
- factorflow-0.1.0.dist-info/licenses/LICENSE +202 -0
- factorflow-0.1.0.dist-info/licenses/NOTICE +14 -0
|
@@ -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)
|