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
factorflow/sim/market.py
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"""A synthetic market with known ground truth.
|
|
2
|
+
|
|
3
|
+
Daily returns follow a planted factor model (log returns)::
|
|
4
|
+
|
|
5
|
+
R[t, i] = beta[i] * f_mkt[t] + sum_k X[i, k] * f_k[t] + f_ind[t, g(i)]
|
|
6
|
+
+ kappa * sigma[t, i] * s[t - 1, i] + sigma[t, i] * eps[t, i]
|
|
7
|
+
|
|
8
|
+
* ``f_mkt`` and ``sigma`` (idiosyncratic vol) follow GARCH(1, 1) with Student-t shocks, so
|
|
9
|
+
returns are fat-tailed with volatility clustering;
|
|
10
|
+
* ``X`` are style exposures (``size`` = z-scored log market cap, ``value``, ``quality``),
|
|
11
|
+
``g`` is an industry classifier, ``beta`` the market beta;
|
|
12
|
+
* ``s`` is a planted alpha signal (AR(1), standardised cross-sectionally each day). It is
|
|
13
|
+
published as the ``signal`` column for day ``t`` and predicts day ``t + 1``. Its
|
|
14
|
+
risk-adjusted residual IC, ``corr(s[t], (R[t + 1] - factor part) / sigma[t + 1])``, is
|
|
15
|
+
``ic`` by construction (``kappa = ic / sqrt(1 - ic**2)``).
|
|
16
|
+
|
|
17
|
+
Intraday, each (asset, day) gets a trade stream: Poisson arrivals with a U-shaped (equities)
|
|
18
|
+
or diurnal (crypto) intensity times a persistent random activity multiplier, lognormal trade
|
|
19
|
+
sizes, and a price path that is a Brownian bridge **in volume time** pinned to the day's
|
|
20
|
+
close (so ``Var(dlog p) ∝ traded volume``, Clark's subordination model). Volume time makes
|
|
21
|
+
volume and dollar bars close to Gaussian while clock bars inherit the U-shape and activity
|
|
22
|
+
bursts and come out fat-tailed. Trades print at the mid ± a half spread (bid/ask bounce),
|
|
23
|
+
rounded to the tick. A few names are illiquid (missing minute bars), some (asset, day) pairs
|
|
24
|
+
have a trading halt (no trades, then a jump at the reopen), and the XNYS calendar supplies
|
|
25
|
+
holidays and early closes (2026-11-27 closes at 13:00 ET).
|
|
26
|
+
|
|
27
|
+
Everything is a pure function of :class:`SimConfig` (seeded ``numpy`` generators).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import math
|
|
33
|
+
from dataclasses import dataclass, field, replace
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
from factorflow.bars import Trades, build_time_bars
|
|
38
|
+
from factorflow.grid import NS, BarGrid
|
|
39
|
+
from factorflow.panel import Panel
|
|
40
|
+
|
|
41
|
+
MIN = 60 * NS
|
|
42
|
+
STYLES = ("size", "value", "quality")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ====================================================================== configuration
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class EquitySpec:
|
|
48
|
+
"""Parameters of the synthetic equity book.
|
|
49
|
+
|
|
50
|
+
Notes
|
|
51
|
+
-----
|
|
52
|
+
Volatilities (``market_vol``, ``style_vol``, ``industry_vol``, ``idio_vol``) are
|
|
53
|
+
daily log-return standard deviations, not annualized. ``ic`` is the planted
|
|
54
|
+
risk-adjusted residual information coefficient, dimensionless. Trade sizes are
|
|
55
|
+
shares. ``half_spread_bps`` is the half bid-ask spread in basis points.
|
|
56
|
+
``tick`` is the price increment in price units.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
n: int = 100
|
|
60
|
+
n_industries: int = 8
|
|
61
|
+
style_vol: tuple[float, ...] = (0.004, 0.003, 0.0025) # daily vol of each style factor return
|
|
62
|
+
market_vol: float = 0.010 # long-run daily vol of the market factor
|
|
63
|
+
industry_vol: float = 0.008
|
|
64
|
+
beta_mean: float = 1.0
|
|
65
|
+
beta_sd: float = 0.3
|
|
66
|
+
idio_vol: float = 0.016 # median long-run daily idiosyncratic vol
|
|
67
|
+
garch: tuple[float, float] = (0.08, 0.90) # (alpha, beta); persistence alpha + beta < 1
|
|
68
|
+
t_df: float = 5.0 # Student-t degrees of freedom of every shock
|
|
69
|
+
ic: float = 0.03 # risk-adjusted residual IC of the planted signal
|
|
70
|
+
signal_ar: float = 0.8 # day-to-day persistence of the signal
|
|
71
|
+
trades_per_day: float = 1500.0 # median liquid name
|
|
72
|
+
illiquid_frac: float = 0.05 # smallest names trade rarely -> missing minute bars
|
|
73
|
+
illiquid_trades_per_day: float = 25.0
|
|
74
|
+
halt_prob: float = 0.02 # per (asset, intraday day)
|
|
75
|
+
halt_minutes: tuple[int, int] = (15, 60)
|
|
76
|
+
overnight_frac: float = 0.2 # share of daily variance realised overnight (the open gap)
|
|
77
|
+
half_spread_bps: float = 2.0
|
|
78
|
+
tick: float = 0.01
|
|
79
|
+
mean_trade_size: float = 200.0
|
|
80
|
+
size_sigma: float = 1.0 # lognormal sigma of trade sizes
|
|
81
|
+
activity_sd: float = 0.5 # sd of the log activity multiplier (intraday bursts)
|
|
82
|
+
activity_ar: float = 0.97 # per-minute persistence of the activity multiplier
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class CryptoSpec:
|
|
87
|
+
"""Parameters of the synthetic 24/7 book.
|
|
88
|
+
|
|
89
|
+
Notes
|
|
90
|
+
-----
|
|
91
|
+
``names`` trade every UTC day. ``beta`` is exposure to the first name's factor.
|
|
92
|
+
``idio_vol`` and ``market_vol`` are daily log-return standard deviations.
|
|
93
|
+
``mean_trade_size`` is in coins. There is no overnight gap and no halt.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
names: tuple[str, ...] = ("BTC", "ETH", "SOL")
|
|
97
|
+
start_price: tuple[float, ...] = (95_000.0, 3_500.0, 180.0)
|
|
98
|
+
beta: tuple[float, ...] = (1.0, 1.2, 1.5) # to the BTC factor
|
|
99
|
+
idio_vol: tuple[float, ...] = (0.004, 0.018, 0.03)
|
|
100
|
+
market_vol: float = 0.028 # daily vol of the BTC factor
|
|
101
|
+
garch: tuple[float, float] = (0.10, 0.88)
|
|
102
|
+
t_df: float = 4.0
|
|
103
|
+
trades_per_day: tuple[float, ...] = (20_000.0, 12_000.0, 8_000.0)
|
|
104
|
+
mean_trade_size: tuple[float, ...] = (0.05, 0.8, 15.0) # in coins
|
|
105
|
+
size_sigma: float = 1.2
|
|
106
|
+
half_spread_bps: float = 0.5
|
|
107
|
+
tick: float = 0.01
|
|
108
|
+
activity_sd: float = 0.5
|
|
109
|
+
activity_ar: float = 0.98
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class SimConfig:
|
|
114
|
+
"""Everything :func:`simulate` needs. One config and one seed produce one market.
|
|
115
|
+
|
|
116
|
+
Notes
|
|
117
|
+
-----
|
|
118
|
+
``end`` is the last session date, inclusive, ``YYYY-MM-DD``. ``history_days``
|
|
119
|
+
sessions have daily bars only. The following ``intraday_days`` sessions also
|
|
120
|
+
have trades. Set ``crypto`` to ``None`` to skip the 24/7 book.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
seed: int = 7
|
|
124
|
+
end: str = "2026-11-30" # last session (inclusive); the default window spans the 11-27 early close
|
|
125
|
+
history_days: int = 252 # daily bars only
|
|
126
|
+
intraday_days: int = 3 # the last sessions, which also get trades (ticks)
|
|
127
|
+
equities: EquitySpec = field(default_factory=EquitySpec)
|
|
128
|
+
crypto: CryptoSpec | None = field(default_factory=CryptoSpec)
|
|
129
|
+
|
|
130
|
+
def scaled(self, n: int | None = None, **kw) -> SimConfig:
|
|
131
|
+
"""Return a copy with ``n`` equities and any other fields replaced.
|
|
132
|
+
|
|
133
|
+
Parameters
|
|
134
|
+
----------
|
|
135
|
+
n : int or None, optional
|
|
136
|
+
Equity count. ``None`` leaves it unchanged.
|
|
137
|
+
**kw
|
|
138
|
+
Other :class:`SimConfig` fields passed to ``dataclasses.replace``.
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
SimConfig
|
|
143
|
+
A new config. The original is unchanged.
|
|
144
|
+
"""
|
|
145
|
+
cfg = replace(self, **kw)
|
|
146
|
+
return replace(cfg, equities=replace(cfg.equities, n=n)) if n is not None else cfg
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ====================================================================== outputs
|
|
150
|
+
@dataclass
|
|
151
|
+
class Truth:
|
|
152
|
+
"""The planted ground truth (daily rows aligned with ``AssetSim.daily``)."""
|
|
153
|
+
|
|
154
|
+
beta: np.ndarray # (N,)
|
|
155
|
+
exposures: dict[str, np.ndarray] # style -> (N,)
|
|
156
|
+
industry: np.ndarray # (N,) int codes (empty for crypto)
|
|
157
|
+
factor_returns: dict[str, np.ndarray] # "market" and each style -> (T,)
|
|
158
|
+
industry_returns: np.ndarray # (T, G)
|
|
159
|
+
idio_vol: np.ndarray # (T, N) conditional idiosyncratic vol sigma[t, i]
|
|
160
|
+
total_vol: np.ndarray # (T, N) conditional total daily vol (used to scale the intraday path)
|
|
161
|
+
signal: np.ndarray # (T, N) s[t]; predicts R[t + 1]
|
|
162
|
+
log_returns: np.ndarray # (T, N) planted close-to-close log returns
|
|
163
|
+
ic: float
|
|
164
|
+
kappa: float
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class AssetSim:
|
|
169
|
+
"""One asset class: daily bars for every session, trades for the last ``intraday_days``."""
|
|
170
|
+
|
|
171
|
+
name: str
|
|
172
|
+
calendar: str
|
|
173
|
+
assets: tuple[str, ...]
|
|
174
|
+
sessions: np.ndarray # (T,) datetime64[D] session dates
|
|
175
|
+
opens: np.ndarray # (T,) session open, ns UTC
|
|
176
|
+
closes: np.ndarray # (T,) session close, ns UTC
|
|
177
|
+
daily: Panel # (T, N) daily bars (+ signal / industry / style columns for equities)
|
|
178
|
+
trades: Trades # trades of the intraday sessions, sorted by ts
|
|
179
|
+
intraday: np.ndarray # row indices (into ``daily``) of the sessions that have trades
|
|
180
|
+
halts: list[tuple[int, int, int]] # (asset index, halt start ns, halt end ns)
|
|
181
|
+
truth: Truth
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def n_hist(self) -> int:
|
|
185
|
+
"""Daily rows before the first intraday session (the rows not built from trades)."""
|
|
186
|
+
return int(self.intraday[0]) if self.intraday.size else len(self.sessions)
|
|
187
|
+
|
|
188
|
+
def daily_grid(self) -> BarGrid:
|
|
189
|
+
"""Return the daily bar grid for this book.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
BarGrid
|
|
194
|
+
``BarGrid(self.calendar, '1d')``. Grace is 0.
|
|
195
|
+
"""
|
|
196
|
+
return BarGrid(self.calendar, "1d")
|
|
197
|
+
|
|
198
|
+
def extras(self, rows: np.ndarray) -> dict[str, np.ndarray]:
|
|
199
|
+
"""Non-bar input columns (signal, classifier, styles) for daily rows ``rows``."""
|
|
200
|
+
return {k: v[rows] for k, v in self.daily.columns.items() if k in EXTRA_COLUMNS}
|
|
201
|
+
|
|
202
|
+
def time_bars(self, freq: str) -> tuple[Panel, np.ndarray]:
|
|
203
|
+
"""Return clock bars for the intraday sessions.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
freq : str
|
|
208
|
+
Bar size, for example ``"1min"`` or ``"5min"``.
|
|
209
|
+
|
|
210
|
+
Returns
|
|
211
|
+
-------
|
|
212
|
+
panel : Panel
|
|
213
|
+
Bars built from the simulated trades, with that session's extra
|
|
214
|
+
columns (signal, industry, styles) copied onto every bar.
|
|
215
|
+
session_row : ndarray of int
|
|
216
|
+
Index into the daily panel of the session that contains each bar.
|
|
217
|
+
"""
|
|
218
|
+
grid = BarGrid(self.calendar, freq)
|
|
219
|
+
d0, d1 = str(self.sessions[self.intraday[0]]), str(self.sessions[self.intraday[-1]])
|
|
220
|
+
s, e = grid.bars(d0, d1)
|
|
221
|
+
prev = self.daily.columns["close"][self.n_hist - 1] if self.n_hist else None
|
|
222
|
+
p = build_time_bars(self.trades, self.assets, s, e, prev_close=prev)
|
|
223
|
+
day = np.searchsorted(self.closes, e, side="left") # session whose close >= bar end
|
|
224
|
+
for k, v in self.extras(day).items():
|
|
225
|
+
p.columns[k] = v
|
|
226
|
+
return p, day
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
EXTRA_COLUMNS = ("signal", "industry", *STYLES)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass
|
|
233
|
+
class SimMarket:
|
|
234
|
+
"""An equity book and, unless configured off, a 24/7 book, plus the config that built them."""
|
|
235
|
+
|
|
236
|
+
config: SimConfig
|
|
237
|
+
equities: AssetSim
|
|
238
|
+
crypto: AssetSim | None
|
|
239
|
+
|
|
240
|
+
def summary(self) -> str:
|
|
241
|
+
"""Describe the generated market in a few lines.
|
|
242
|
+
|
|
243
|
+
Returns
|
|
244
|
+
-------
|
|
245
|
+
str
|
|
246
|
+
Asset counts, date range, trade counts, last close, halt count, and the planted IC.
|
|
247
|
+
"""
|
|
248
|
+
lines = []
|
|
249
|
+
for a in (self.equities, self.crypto):
|
|
250
|
+
if a is None:
|
|
251
|
+
continue
|
|
252
|
+
tr = a.trades
|
|
253
|
+
d = a.daily.columns
|
|
254
|
+
idays = [str(a.sessions[i]) for i in a.intraday]
|
|
255
|
+
lines.append(
|
|
256
|
+
f"{a.name:<9} {len(a.assets):>4} assets {len(a.sessions)} daily bars "
|
|
257
|
+
f"({a.sessions[0]} .. {a.sessions[-1]}, calendar {a.calendar}) "
|
|
258
|
+
f"{len(tr):,} trades on {', '.join(idays)}"
|
|
259
|
+
)
|
|
260
|
+
last = d["close"][-1]
|
|
261
|
+
lines.append(
|
|
262
|
+
f" last close: median {np.nanmedian(last):,.2f}, "
|
|
263
|
+
f"range {np.nanmin(last):,.2f} .. {np.nanmax(last):,.2f}; halts: {len(a.halts)}"
|
|
264
|
+
)
|
|
265
|
+
e = self.equities
|
|
266
|
+
lines.append(
|
|
267
|
+
f"planted: {len(STYLES)} styles {STYLES}, {e.truth.industry.max() + 1} industries, "
|
|
268
|
+
f"market beta ~ N({self.config.equities.beta_mean}, {self.config.equities.beta_sd}), "
|
|
269
|
+
f"signal IC {e.truth.ic:.3f} (kappa {e.truth.kappa:.4f}), t({self.config.equities.t_df}) shocks"
|
|
270
|
+
)
|
|
271
|
+
return "\n".join(lines)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ====================================================================== daily model
|
|
275
|
+
def _t_shocks(rng: np.random.Generator, df: float, shape) -> np.ndarray:
|
|
276
|
+
"""Student-t shocks scaled to unit variance."""
|
|
277
|
+
return rng.standard_t(df, size=shape) * math.sqrt((df - 2.0) / df)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _garch(rng: np.random.Generator, uncond: np.ndarray, t: int, ab: tuple[float, float], df: float):
|
|
281
|
+
"""GARCH(1, 1) paths: returns (shocks z (T, N), conditional vols (T, N)), x = vol * z."""
|
|
282
|
+
a, b = ab
|
|
283
|
+
uncond = np.atleast_1d(np.asarray(uncond, dtype=float))
|
|
284
|
+
omega = uncond**2 * (1.0 - a - b)
|
|
285
|
+
z = _t_shocks(rng, df, (t, uncond.size))
|
|
286
|
+
vol = np.empty((t, uncond.size))
|
|
287
|
+
var = uncond**2
|
|
288
|
+
for r in range(t):
|
|
289
|
+
vol[r] = np.sqrt(var)
|
|
290
|
+
x = vol[r] * z[r]
|
|
291
|
+
var = omega + a * x * x + b * var
|
|
292
|
+
return z, vol
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _calendar_sessions(calendar: str, end: str, count: int):
|
|
296
|
+
grid = BarGrid(calendar, "1d")
|
|
297
|
+
end_d = np.datetime64(end, "D")
|
|
298
|
+
span = int(count * 1.6) + 10
|
|
299
|
+
s, e = grid.bars(str(end_d - np.timedelta64(span, "D")), end)
|
|
300
|
+
s, e = s[-count:], e[-count:]
|
|
301
|
+
if len(e) < count:
|
|
302
|
+
raise ValueError("calendar too short for the requested history")
|
|
303
|
+
dates = (e - 1).astype("datetime64[ns]").astype("datetime64[D]")
|
|
304
|
+
return dates, s, e
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _equity_daily(rng: np.random.Generator, spec: EquitySpec, t: int):
|
|
308
|
+
n, g = spec.n, spec.n_industries
|
|
309
|
+
log_cap = rng.normal(23.0, 1.5, n)
|
|
310
|
+
size = (log_cap - log_cap.mean()) / log_cap.std()
|
|
311
|
+
value = rng.standard_normal(n)
|
|
312
|
+
quality = 0.3 * size + math.sqrt(1 - 0.09) * rng.standard_normal(n) # mildly correlated styles
|
|
313
|
+
exposures = {"size": size, "value": value, "quality": (quality - quality.mean()) / quality.std()}
|
|
314
|
+
industry = rng.permutation(np.arange(n) % g).astype(np.int64)
|
|
315
|
+
beta = np.clip(rng.normal(spec.beta_mean, spec.beta_sd, n), 0.2, 2.5)
|
|
316
|
+
idio_long = spec.idio_vol * np.exp(-0.25 * size + rng.normal(0, 0.2, n))
|
|
317
|
+
|
|
318
|
+
z_m, vol_m = _garch(rng, np.array([spec.market_vol]), t, spec.garch, spec.t_df)
|
|
319
|
+
f_mkt = (vol_m * z_m)[:, 0]
|
|
320
|
+
styles = {k: spec.style_vol[i] * _t_shocks(rng, spec.t_df, t) for i, k in enumerate(STYLES)}
|
|
321
|
+
f_ind = spec.industry_vol * _t_shocks(rng, spec.t_df, (t, g))
|
|
322
|
+
eps, sig = _garch(rng, idio_long, t, spec.garch, spec.t_df)
|
|
323
|
+
|
|
324
|
+
# planted signal: AR(1), standardised cross-sectionally every day
|
|
325
|
+
s = np.empty((t, n))
|
|
326
|
+
raw = rng.standard_normal(n)
|
|
327
|
+
for r in range(t):
|
|
328
|
+
raw = spec.signal_ar * raw + math.sqrt(1 - spec.signal_ar**2) * rng.standard_normal(n)
|
|
329
|
+
s[r] = (raw - raw.mean()) / raw.std()
|
|
330
|
+
kappa = spec.ic / math.sqrt(1.0 - spec.ic**2)
|
|
331
|
+
alpha = np.zeros((t, n))
|
|
332
|
+
alpha[1:] = kappa * sig[1:] * s[:-1]
|
|
333
|
+
|
|
334
|
+
common = beta[None, :] * f_mkt[:, None] + f_ind[:, industry]
|
|
335
|
+
for k in STYLES:
|
|
336
|
+
common += exposures[k][None, :] * styles[k][:, None]
|
|
337
|
+
R = common + alpha + sig * eps
|
|
338
|
+
fvar = (
|
|
339
|
+
beta[None, :] ** 2 * vol_m**2
|
|
340
|
+
+ spec.industry_vol**2
|
|
341
|
+
+ np.sum([(exposures[k] * spec.style_vol[i]) ** 2 for i, k in enumerate(STYLES)], axis=0)[None, :]
|
|
342
|
+
)
|
|
343
|
+
total_vol = np.sqrt(fvar + sig**2)
|
|
344
|
+
truth = Truth(
|
|
345
|
+
beta,
|
|
346
|
+
exposures,
|
|
347
|
+
industry,
|
|
348
|
+
{"market": f_mkt, **styles},
|
|
349
|
+
f_ind,
|
|
350
|
+
sig,
|
|
351
|
+
total_vol,
|
|
352
|
+
s,
|
|
353
|
+
R,
|
|
354
|
+
spec.ic,
|
|
355
|
+
kappa,
|
|
356
|
+
)
|
|
357
|
+
return truth, log_cap
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _crypto_daily(rng: np.random.Generator, spec: CryptoSpec, t: int):
|
|
361
|
+
n = len(spec.names)
|
|
362
|
+
beta = np.asarray(spec.beta, dtype=float)
|
|
363
|
+
z_m, vol_m = _garch(rng, np.array([spec.market_vol]), t, spec.garch, spec.t_df)
|
|
364
|
+
f = (vol_m * z_m)[:, 0]
|
|
365
|
+
eps, sig = _garch(rng, np.asarray(spec.idio_vol), t, spec.garch, spec.t_df)
|
|
366
|
+
R = beta[None, :] * f[:, None] + sig * eps
|
|
367
|
+
total = np.sqrt(beta[None, :] ** 2 * vol_m**2 + sig**2)
|
|
368
|
+
truth = Truth(
|
|
369
|
+
beta,
|
|
370
|
+
{},
|
|
371
|
+
np.zeros(0, np.int64),
|
|
372
|
+
{"market": f},
|
|
373
|
+
np.zeros((t, 0)),
|
|
374
|
+
sig,
|
|
375
|
+
total,
|
|
376
|
+
np.zeros((t, n)),
|
|
377
|
+
R,
|
|
378
|
+
0.0,
|
|
379
|
+
0.0,
|
|
380
|
+
)
|
|
381
|
+
return truth
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ====================================================================== intraday model
|
|
385
|
+
def _profile(kind: str, open_ns: int, n_min: int) -> np.ndarray:
|
|
386
|
+
"""Relative trade intensity per minute (mean 1)."""
|
|
387
|
+
u = (np.arange(n_min) + 0.5) / max(n_min, 1)
|
|
388
|
+
if kind == "equity": # U shape: busy open, quiet lunch, busy close
|
|
389
|
+
w = 0.55 + 2.2 * np.exp(-u / 0.05) + 1.3 * np.exp(-(1 - u) / 0.07)
|
|
390
|
+
else: # 24/7: diurnal, peaking during US/EU overlap (~15:00 UTC)
|
|
391
|
+
hours = ((open_ns // MIN + np.arange(n_min)) % 1440) / 60.0
|
|
392
|
+
w = 1.0 + 0.5 * np.cos(2 * np.pi * (hours - 15.0) / 24.0)
|
|
393
|
+
return w / w.mean()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _activity(rng: np.random.Generator, n_min: int, sd: float, ar: float) -> np.ndarray:
|
|
397
|
+
a = np.empty(n_min)
|
|
398
|
+
x = rng.normal(0, sd)
|
|
399
|
+
innov = rng.normal(0, sd * math.sqrt(1 - ar * ar), n_min)
|
|
400
|
+
for m in range(n_min):
|
|
401
|
+
x = ar * x + innov[m]
|
|
402
|
+
a[m] = x
|
|
403
|
+
return np.exp(a - 0.5 * sd * sd)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _day_trades(
|
|
407
|
+
rng: np.random.Generator,
|
|
408
|
+
*,
|
|
409
|
+
kind: str,
|
|
410
|
+
open_ns: int,
|
|
411
|
+
close_ns: int,
|
|
412
|
+
n_expected: float,
|
|
413
|
+
prev_close: float,
|
|
414
|
+
target_log_ret: float,
|
|
415
|
+
gap: float,
|
|
416
|
+
day_vol: float,
|
|
417
|
+
mean_size: float,
|
|
418
|
+
size_sigma: float,
|
|
419
|
+
half_spread: float,
|
|
420
|
+
tick: float,
|
|
421
|
+
activity_sd: float,
|
|
422
|
+
activity_ar: float,
|
|
423
|
+
halt: tuple[int, int] | None,
|
|
424
|
+
integer_sizes: bool,
|
|
425
|
+
overnight_frac: float,
|
|
426
|
+
):
|
|
427
|
+
"""Trades of one asset in one session: (ts, price, size)."""
|
|
428
|
+
n_min = int((close_ns - open_ns) // MIN)
|
|
429
|
+
lam = _profile(kind, open_ns, n_min) * _activity(rng, n_min, activity_sd, activity_ar)
|
|
430
|
+
lam *= n_expected / lam.sum()
|
|
431
|
+
if halt is not None:
|
|
432
|
+
h0, h1 = halt
|
|
433
|
+
lam[h0:h1] = 0.0
|
|
434
|
+
counts = rng.poisson(lam)
|
|
435
|
+
k = int(counts.sum())
|
|
436
|
+
if k == 0:
|
|
437
|
+
return np.empty(0, np.int64), np.empty(0), np.empty(0)
|
|
438
|
+
minute = np.repeat(np.arange(n_min), counts)
|
|
439
|
+
ts = open_ns + minute * MIN + rng.integers(0, MIN, k)
|
|
440
|
+
ts.sort()
|
|
441
|
+
size = mean_size * np.exp(rng.normal(-0.5 * size_sigma**2, size_sigma, k))
|
|
442
|
+
size = np.maximum(np.round(size), 1.0) if integer_sizes else np.maximum(np.round(size, 6), 1e-6)
|
|
443
|
+
|
|
444
|
+
# variance weights: traded volume (volume time); the first trade after a halt carries the
|
|
445
|
+
# volume the halted minutes would have seen (the reopening jump)
|
|
446
|
+
w = size.copy()
|
|
447
|
+
if halt is not None:
|
|
448
|
+
h0, h1 = halt
|
|
449
|
+
after = np.searchsorted(minute, h1, side="left")
|
|
450
|
+
if after < k:
|
|
451
|
+
w[after] += mean_size * float(n_expected * (_profile(kind, open_ns, n_min)[h0:h1].sum() / n_min))
|
|
452
|
+
W = w.sum()
|
|
453
|
+
intraday_var = day_vol**2 * (1.0 - overnight_frac)
|
|
454
|
+
z = rng.standard_normal(k) * np.sqrt(intraday_var * w / W)
|
|
455
|
+
target = target_log_ret - gap
|
|
456
|
+
x = z + (w / W) * (target - z.sum()) # bridge: increments sum exactly to the target
|
|
457
|
+
logp = math.log(prev_close) + gap + np.cumsum(x)
|
|
458
|
+
side = rng.choice((-1.0, 1.0), k)
|
|
459
|
+
price = np.exp(logp) * (1.0 + side * half_spread)
|
|
460
|
+
price = np.maximum(np.round(price / tick) * tick, tick)
|
|
461
|
+
price = np.round(price, 8)
|
|
462
|
+
return ts.astype(np.int64), price, size
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _daily_bars_from_model(
|
|
466
|
+
rng: np.random.Generator,
|
|
467
|
+
closes: np.ndarray,
|
|
468
|
+
gaps: np.ndarray,
|
|
469
|
+
vol: np.ndarray,
|
|
470
|
+
n_trades: np.ndarray,
|
|
471
|
+
mean_size: np.ndarray,
|
|
472
|
+
tick: float,
|
|
473
|
+
first_prev: np.ndarray,
|
|
474
|
+
) -> dict[str, np.ndarray]:
|
|
475
|
+
prev = np.vstack([first_prev[None, :], closes[:-1]])
|
|
476
|
+
o = prev * np.exp(gaps)
|
|
477
|
+
hi = np.maximum(o, closes) * np.exp(np.abs(rng.normal(0, 0.4, closes.shape)) * vol)
|
|
478
|
+
lo = np.minimum(o, closes) * np.exp(-np.abs(rng.normal(0, 0.4, closes.shape)) * vol)
|
|
479
|
+
|
|
480
|
+
def rnd(x):
|
|
481
|
+
return np.round(np.maximum(np.round(x / tick) * tick, tick), 8)
|
|
482
|
+
|
|
483
|
+
o, hi, lo, c = rnd(o), rnd(hi), rnd(lo), rnd(closes)
|
|
484
|
+
hi = np.maximum.reduce([hi, o, c])
|
|
485
|
+
lo = np.minimum.reduce([lo, o, c])
|
|
486
|
+
volume = np.round(n_trades * mean_size[None, :], 6)
|
|
487
|
+
return {
|
|
488
|
+
"open": o,
|
|
489
|
+
"high": hi,
|
|
490
|
+
"low": lo,
|
|
491
|
+
"close": c,
|
|
492
|
+
"volume": volume,
|
|
493
|
+
"dollar": volume * 0.5 * (o + c),
|
|
494
|
+
"n_ticks": n_trades.astype(float),
|
|
495
|
+
"has_trade": n_trades > 0,
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _simulate_class(
|
|
500
|
+
rng: np.random.Generator,
|
|
501
|
+
*,
|
|
502
|
+
name: str,
|
|
503
|
+
calendar: str,
|
|
504
|
+
kind: str,
|
|
505
|
+
assets: tuple[str, ...],
|
|
506
|
+
cfg: SimConfig,
|
|
507
|
+
truth: Truth,
|
|
508
|
+
start_price: np.ndarray,
|
|
509
|
+
trades_per_day: np.ndarray,
|
|
510
|
+
mean_size: np.ndarray,
|
|
511
|
+
size_sigma: float,
|
|
512
|
+
half_spread: float,
|
|
513
|
+
tick: float,
|
|
514
|
+
activity: tuple[float, float],
|
|
515
|
+
overnight_frac: float,
|
|
516
|
+
halt_prob: float,
|
|
517
|
+
halt_minutes: tuple[int, int],
|
|
518
|
+
extra: dict[str, np.ndarray],
|
|
519
|
+
) -> AssetSim:
|
|
520
|
+
t_all = cfg.history_days + cfg.intraday_days
|
|
521
|
+
dates, opens, closes_ns = _calendar_sessions(calendar, cfg.end, t_all)
|
|
522
|
+
R = truth.log_returns
|
|
523
|
+
n = len(assets)
|
|
524
|
+
close_px = start_price[None, :] * np.exp(np.cumsum(R, axis=0))
|
|
525
|
+
gaps = (
|
|
526
|
+
overnight_frac * R
|
|
527
|
+
+ rng.normal(0, 1, R.shape)
|
|
528
|
+
* truth.total_vol
|
|
529
|
+
* math.sqrt(max(overnight_frac * (1 - overnight_frac), 0.0))
|
|
530
|
+
if overnight_frac > 0
|
|
531
|
+
else np.zeros_like(R)
|
|
532
|
+
)
|
|
533
|
+
# activity drives variance: trades per day scale with (vol / long-run vol)^2
|
|
534
|
+
rel = truth.total_vol / truth.total_vol.mean(axis=0, keepdims=True)
|
|
535
|
+
n_trades_exp = trades_per_day[None, :] * rel**2 * np.exp(rng.normal(-0.02, 0.2, R.shape))
|
|
536
|
+
h = cfg.history_days
|
|
537
|
+
n_trades_hist = rng.poisson(n_trades_exp[:h]).astype(float)
|
|
538
|
+
bars = _daily_bars_from_model(
|
|
539
|
+
rng, close_px[:h], gaps[:h], truth.total_vol[:h], n_trades_hist, mean_size, tick, start_price
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
ts_l, px_l, sz_l, as_l = [], [], [], []
|
|
543
|
+
halts: list[tuple[int, int, int]] = []
|
|
544
|
+
hist_close = bars["close"][-1] if h else start_price
|
|
545
|
+
for r in range(h, t_all):
|
|
546
|
+
o_ns, c_ns = int(opens[r]), int(closes_ns[r])
|
|
547
|
+
n_min = (c_ns - o_ns) // MIN
|
|
548
|
+
prev = close_px[r - 1] if r else start_price
|
|
549
|
+
if r == h:
|
|
550
|
+
prev = hist_close # the last sealed daily close
|
|
551
|
+
for j in range(n):
|
|
552
|
+
halt = None
|
|
553
|
+
if halt_prob > 0 and rng.random() < halt_prob and n_min > 120:
|
|
554
|
+
dur = int(rng.integers(halt_minutes[0], halt_minutes[1] + 1))
|
|
555
|
+
h0 = int(rng.integers(30, n_min - dur - 30))
|
|
556
|
+
halt = (h0, h0 + dur)
|
|
557
|
+
halts.append((j, o_ns + h0 * MIN, o_ns + (h0 + dur) * MIN))
|
|
558
|
+
ts, px, sz = _day_trades(
|
|
559
|
+
rng,
|
|
560
|
+
kind=kind,
|
|
561
|
+
open_ns=o_ns,
|
|
562
|
+
close_ns=c_ns,
|
|
563
|
+
n_expected=float(n_trades_exp[r, j]),
|
|
564
|
+
prev_close=float(prev[j]),
|
|
565
|
+
target_log_ret=float(math.log(close_px[r, j] / prev[j])),
|
|
566
|
+
gap=float(gaps[r, j]),
|
|
567
|
+
day_vol=float(truth.total_vol[r, j]),
|
|
568
|
+
mean_size=float(mean_size[j]),
|
|
569
|
+
size_sigma=size_sigma,
|
|
570
|
+
half_spread=half_spread,
|
|
571
|
+
tick=tick,
|
|
572
|
+
activity_sd=activity[0],
|
|
573
|
+
activity_ar=activity[1],
|
|
574
|
+
halt=halt,
|
|
575
|
+
integer_sizes=kind == "equity",
|
|
576
|
+
overnight_frac=overnight_frac,
|
|
577
|
+
)
|
|
578
|
+
ts_l.append(ts)
|
|
579
|
+
px_l.append(px)
|
|
580
|
+
sz_l.append(sz)
|
|
581
|
+
as_l.append(np.full(ts.size, j, np.int64))
|
|
582
|
+
ts = np.concatenate(ts_l) if ts_l else np.empty(0, np.int64)
|
|
583
|
+
order = np.argsort(ts, kind="stable")
|
|
584
|
+
trades = Trades(
|
|
585
|
+
np.concatenate(as_l)[order] if as_l else np.empty(0, np.int64),
|
|
586
|
+
ts[order],
|
|
587
|
+
np.concatenate(px_l)[order] if px_l else np.empty(0),
|
|
588
|
+
np.concatenate(sz_l)[order] if sz_l else np.empty(0),
|
|
589
|
+
)
|
|
590
|
+
# intraday daily rows are built from the trades (as a live feed would)
|
|
591
|
+
intraday = np.arange(h, t_all)
|
|
592
|
+
cols = dict(bars)
|
|
593
|
+
if intraday.size:
|
|
594
|
+
built = build_time_bars(trades, assets, opens[h:], closes_ns[h:], prev_close=hist_close)
|
|
595
|
+
for k in cols:
|
|
596
|
+
cols[k] = np.concatenate([cols[k], built.columns[k]], axis=0)
|
|
597
|
+
for k, v in extra.items():
|
|
598
|
+
cols[k] = v
|
|
599
|
+
daily = Panel(closes_ns, assets, cols, opens)
|
|
600
|
+
return AssetSim(name, calendar, assets, dates, opens, closes_ns, daily, trades, intraday, halts, truth)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def simulate(config: SimConfig | None = None) -> SimMarket:
|
|
604
|
+
"""Generate the market (deterministic for a given config)."""
|
|
605
|
+
cfg = config or SimConfig()
|
|
606
|
+
ss = np.random.SeedSequence(cfg.seed)
|
|
607
|
+
r_eq_daily, r_eq_intra, r_cr_daily, r_cr_intra = (np.random.default_rng(s) for s in ss.spawn(4))
|
|
608
|
+
t_all = cfg.history_days + cfg.intraday_days
|
|
609
|
+
es = cfg.equities
|
|
610
|
+
|
|
611
|
+
truth, log_cap = _equity_daily(r_eq_daily, es, t_all)
|
|
612
|
+
n = es.n
|
|
613
|
+
assets = tuple(f"EQ{i:03d}" for i in range(n))
|
|
614
|
+
start_price = np.round(np.exp(r_eq_daily.normal(math.log(60.0), 0.8, n)), 2)
|
|
615
|
+
tpd = es.trades_per_day * np.exp(0.5 * (log_cap - np.median(log_cap)))
|
|
616
|
+
tpd = np.clip(tpd, 0.2 * es.trades_per_day, 6.0 * es.trades_per_day)
|
|
617
|
+
n_ill = round(es.illiquid_frac * n)
|
|
618
|
+
if n_ill:
|
|
619
|
+
tpd[np.argsort(log_cap)[:n_ill]] = es.illiquid_trades_per_day
|
|
620
|
+
extra = {
|
|
621
|
+
"signal": truth.signal,
|
|
622
|
+
"industry": np.tile(truth.industry, (t_all, 1)),
|
|
623
|
+
**{k: np.tile(v, (t_all, 1)) for k, v in truth.exposures.items()},
|
|
624
|
+
}
|
|
625
|
+
equities = _simulate_class(
|
|
626
|
+
r_eq_intra,
|
|
627
|
+
name="equities",
|
|
628
|
+
calendar="XNYS",
|
|
629
|
+
kind="equity",
|
|
630
|
+
assets=assets,
|
|
631
|
+
cfg=cfg,
|
|
632
|
+
truth=truth,
|
|
633
|
+
start_price=start_price,
|
|
634
|
+
trades_per_day=tpd,
|
|
635
|
+
mean_size=np.full(n, es.mean_trade_size),
|
|
636
|
+
size_sigma=es.size_sigma,
|
|
637
|
+
half_spread=es.half_spread_bps * 1e-4,
|
|
638
|
+
tick=es.tick,
|
|
639
|
+
activity=(es.activity_sd, es.activity_ar),
|
|
640
|
+
overnight_frac=es.overnight_frac,
|
|
641
|
+
halt_prob=es.halt_prob,
|
|
642
|
+
halt_minutes=es.halt_minutes,
|
|
643
|
+
extra=extra,
|
|
644
|
+
)
|
|
645
|
+
crypto = None
|
|
646
|
+
if cfg.crypto is not None:
|
|
647
|
+
cs = cfg.crypto
|
|
648
|
+
ctruth = _crypto_daily(r_cr_daily, cs, t_all)
|
|
649
|
+
crypto = _simulate_class(
|
|
650
|
+
r_cr_intra,
|
|
651
|
+
name="crypto",
|
|
652
|
+
calendar="24/7",
|
|
653
|
+
kind="crypto",
|
|
654
|
+
assets=tuple(cs.names),
|
|
655
|
+
cfg=cfg,
|
|
656
|
+
truth=ctruth,
|
|
657
|
+
start_price=np.asarray(cs.start_price, dtype=float),
|
|
658
|
+
trades_per_day=np.asarray(cs.trades_per_day, dtype=float),
|
|
659
|
+
mean_size=np.asarray(cs.mean_trade_size, dtype=float),
|
|
660
|
+
size_sigma=cs.size_sigma,
|
|
661
|
+
half_spread=cs.half_spread_bps * 1e-4,
|
|
662
|
+
tick=cs.tick,
|
|
663
|
+
activity=(cs.activity_sd, cs.activity_ar),
|
|
664
|
+
overnight_frac=0.0,
|
|
665
|
+
halt_prob=0.0,
|
|
666
|
+
halt_minutes=(0, 0),
|
|
667
|
+
extra={},
|
|
668
|
+
)
|
|
669
|
+
return SimMarket(cfg, equities, crypto)
|