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,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)