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,147 @@
|
|
|
1
|
+
"""Example pipelines used by the demo, the validation and the benchmarks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from factorflow.graph import Pipeline
|
|
6
|
+
from factorflow.sim.market import STYLES
|
|
7
|
+
from factorflow.terms import BarData, Classifier, Column, Factor, Filter, where
|
|
8
|
+
|
|
9
|
+
close, volume, dollar = BarData.close, BarData.volume, BarData.dollar
|
|
10
|
+
high, low = BarData.high, BarData.low
|
|
11
|
+
industry: Classifier = Column("industry", Classifier)
|
|
12
|
+
signal: Factor = Column("signal")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def beta_window_for(history_days: int) -> int:
|
|
16
|
+
"""200 days, or the history less a month when that is shorter (at least 20)."""
|
|
17
|
+
return max(20, min(200, history_days - 30))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def daily_pipeline(n_assets: int, beta_window: int = 200) -> Pipeline:
|
|
21
|
+
"""Return the daily research pipeline.
|
|
22
|
+
|
|
23
|
+
Outputs are returns, beta to the equal-weight market, styles, the planted
|
|
24
|
+
signal, and decile screens.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
n_assets : int
|
|
29
|
+
Names in the book. Sets the decile width to ``max(1, n_assets // 10)``.
|
|
30
|
+
beta_window : int, optional
|
|
31
|
+
Rolling beta length in daily bars. Default is 200.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
Pipeline
|
|
36
|
+
"""
|
|
37
|
+
r = close.pct_change(1)
|
|
38
|
+
logret = (close / close.shift(1)).log()
|
|
39
|
+
mkt = r - r.demean() # equal-weight market return, broadcast to every asset
|
|
40
|
+
universe: Filter = (dollar.rolling_mean(20) > 0) & close.notnull()
|
|
41
|
+
decile = max(1, n_assets // 10)
|
|
42
|
+
cols = {
|
|
43
|
+
"ret": r,
|
|
44
|
+
"logret": logret,
|
|
45
|
+
"mkt": mkt,
|
|
46
|
+
"beta": r.rolling_beta(mkt, beta_window),
|
|
47
|
+
"vol20": r.rolling_std(20),
|
|
48
|
+
"mom20": close.pct_change(20),
|
|
49
|
+
"ind_rel": r.demean(groupby=industry),
|
|
50
|
+
"signal_z": signal.zscore(mask=universe),
|
|
51
|
+
"long": signal.top(decile, mask=universe),
|
|
52
|
+
"short": signal.bottom(decile, mask=universe),
|
|
53
|
+
**{f"{k}_z": Column(k).zscore(mask=universe) for k in STYLES},
|
|
54
|
+
}
|
|
55
|
+
return Pipeline(cols, screen=universe)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def minute_pipeline() -> Pipeline:
|
|
59
|
+
"""Intraday pipeline shown live by the demo (about 40 graph nodes)."""
|
|
60
|
+
r = close.pct_change(1)
|
|
61
|
+
liquid: Filter = volume.rolling_mean(30) > 0
|
|
62
|
+
vwap30 = dollar.rolling_sum(30) / volume.rolling_sum(30)
|
|
63
|
+
mom30 = close.pct_change(30)
|
|
64
|
+
vol30 = r.rolling_std(30)
|
|
65
|
+
score = (
|
|
66
|
+
mom30.zscore(groupby=industry, mask=liquid) + signal.zscore(mask=liquid) - vol30.zscore(mask=liquid)
|
|
67
|
+
)
|
|
68
|
+
cols = {
|
|
69
|
+
"close": close,
|
|
70
|
+
"ret1": r,
|
|
71
|
+
"mom30": mom30,
|
|
72
|
+
"vol30": vol30,
|
|
73
|
+
"vwap_dev": close / vwap30 - 1.0,
|
|
74
|
+
"rel_vol": volume / volume.rolling_mean(60),
|
|
75
|
+
"range30": (high.rolling_max(30) - low.rolling_min(30)) / close,
|
|
76
|
+
"ewm_ret": r.ewm_mean(10.0),
|
|
77
|
+
"score": score,
|
|
78
|
+
"rank": score.rank(mask=liquid, ascending=False),
|
|
79
|
+
"long": where(score.top(10, mask=liquid), 1.0, 0.0),
|
|
80
|
+
}
|
|
81
|
+
return Pipeline(cols, screen=liquid)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def crypto_pipeline() -> Pipeline:
|
|
85
|
+
"""24/7 minute pipeline for the crypto majors (beta to BTC via the cross-section)."""
|
|
86
|
+
r = close.pct_change(1)
|
|
87
|
+
btc = r - r.demean() # with 3 names this is the equal-weight crypto market
|
|
88
|
+
return Pipeline(
|
|
89
|
+
{
|
|
90
|
+
"close": close,
|
|
91
|
+
"ret1": r,
|
|
92
|
+
"mom60": close.pct_change(60),
|
|
93
|
+
"vol60": r.rolling_std(60),
|
|
94
|
+
"beta240": r.rolling_beta(btc, 240),
|
|
95
|
+
"dollar60": dollar.rolling_sum(60),
|
|
96
|
+
"mom_rank": close.pct_change(60).rank(ascending=False),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def wide_pipeline(n_factors: int = 100) -> Pipeline:
|
|
102
|
+
"""Build the wide benchmark pipeline.
|
|
103
|
+
|
|
104
|
+
This is the 100-factor pipeline used by ``benchmarks/bench_minute.py``,
|
|
105
|
+
including industry groups. ``n_factors`` sets how many style-like terms
|
|
106
|
+
are added.
|
|
107
|
+
"""
|
|
108
|
+
c, v, h, lo = close, volume, high, low
|
|
109
|
+
r = c.pct_change(1)
|
|
110
|
+
liquid = v.rolling_mean(30) > 0
|
|
111
|
+
windows = [5, 10, 15, 20, 30, 60, 90, 120]
|
|
112
|
+
gens = [
|
|
113
|
+
lambda w: c.pct_change(w),
|
|
114
|
+
lambda w: r.rolling_std(w),
|
|
115
|
+
lambda w: c / c.rolling_mean(w) - 1.0,
|
|
116
|
+
lambda w: v.rolling_mean(w),
|
|
117
|
+
lambda w: (c - lo.rolling_min(w)) / (h.rolling_max(w) - lo.rolling_min(w)),
|
|
118
|
+
lambda w: c.ts_rank(w),
|
|
119
|
+
lambda w: r.rolling_corr(v.pct_change(1), w),
|
|
120
|
+
lambda w: r.ewm_mean(w / 2),
|
|
121
|
+
lambda w: c.pct_change(w).zscore(mask=liquid, groupby=industry),
|
|
122
|
+
lambda w: r.rolling_std(w).rank(mask=liquid),
|
|
123
|
+
lambda w: (r.rolling_mean(w) / r.rolling_std(w)).demean(groupby=industry),
|
|
124
|
+
lambda w: r.rolling_beta(r.demean(), w),
|
|
125
|
+
]
|
|
126
|
+
cols: dict = {}
|
|
127
|
+
i = 0
|
|
128
|
+
while len(cols) < n_factors:
|
|
129
|
+
g = gens[i % len(gens)]
|
|
130
|
+
w = windows[(i // len(gens)) % len(windows)]
|
|
131
|
+
cols[f"f{len(cols):03d}"] = g(w)
|
|
132
|
+
i += 1
|
|
133
|
+
return Pipeline(cols, screen=liquid)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def event_pipeline() -> Pipeline:
|
|
137
|
+
"""Per-asset time-series pipeline for event (tick/volume/dollar) bars."""
|
|
138
|
+
r = close.pct_change(1)
|
|
139
|
+
return Pipeline(
|
|
140
|
+
{
|
|
141
|
+
"ret": r,
|
|
142
|
+
"vol20": r.rolling_std(20),
|
|
143
|
+
"mom10": close.pct_change(10),
|
|
144
|
+
"ewm": r.ewm_mean(5.0),
|
|
145
|
+
"logc": close.log(),
|
|
146
|
+
}
|
|
147
|
+
)
|
factorflow/sim/replay.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Replay a trade stream through :class:`~factorflow.LiveEngine` and check it against batch.
|
|
2
|
+
|
|
3
|
+
``replay`` streams trades in timestamp order. Before each trade it calls ``advance(ts)``
|
|
4
|
+
(sealing every bar whose ``end + grace`` has passed, forward-filling empty bars), then
|
|
5
|
+
``tick``. The open bar is revised on a market-time cadence (``revise_every_ns``, like a
|
|
6
|
+
dashboard refresh) and/or every ``revise_every_ticks`` trades. With ``speed`` set, the
|
|
7
|
+
driver sleeps so that market time runs ``speed`` times faster than the wall clock (1.0 =
|
|
8
|
+
real time); without it, it runs as fast as possible.
|
|
9
|
+
|
|
10
|
+
Non-bar inputs (signal, classifiers, style exposures) come from ``extras``: a
|
|
11
|
+
``(times, {column: (T, N) array})`` pair aligned to bar ends; whenever a new bar opens, its
|
|
12
|
+
row is written with ``set_open``, exactly as a feed handler would.
|
|
13
|
+
|
|
14
|
+
``verify_replay`` runs the batch engine on the same bars and asserts that every sealed live
|
|
15
|
+
row is bit-for-bit equal to the batch row (and every sampled open revision equals a batch run
|
|
16
|
+
on sealed history + the open bar treated as final).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Callable, Mapping
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from factorflow.bars import FIELDS, Trades
|
|
28
|
+
from factorflow.batch import BatchEngine
|
|
29
|
+
from factorflow.events import EventBarBuilder, EventBars, EventPipeline, build_event_bars
|
|
30
|
+
from factorflow.graph import Graph, Pipeline, compile_pipeline
|
|
31
|
+
from factorflow.grid import NS, BarGrid
|
|
32
|
+
from factorflow.live import LiveEngine, Snapshot
|
|
33
|
+
from factorflow.panel import Panel
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ReplayStats:
|
|
38
|
+
"""Counters from one :func:`replay` call.
|
|
39
|
+
|
|
40
|
+
Notes
|
|
41
|
+
-----
|
|
42
|
+
``tick_s`` is seconds spent inside ``LiveEngine.tick``. ``revise_ms`` and
|
|
43
|
+
``seal_ms`` are per-call latencies in milliseconds. ``sealed`` keeps every
|
|
44
|
+
sealed snapshot, in order. ``market_s`` is the span of trade timestamps, in seconds.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
n_ticks: int = 0
|
|
48
|
+
wall_s: float = 0.0
|
|
49
|
+
market_s: float = 0.0
|
|
50
|
+
tick_s: float = 0.0 # time inside tick() (bar accumulation + routing)
|
|
51
|
+
revise_ms: list[float] = field(default_factory=list)
|
|
52
|
+
seal_ms: list[float] = field(default_factory=list)
|
|
53
|
+
sealed: list[Snapshot] = field(default_factory=list)
|
|
54
|
+
open_checked: int = 0
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def ticks_per_s(self) -> float:
|
|
58
|
+
"""Return trades processed per wall-clock second.
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
float
|
|
63
|
+
NaN if no wall time was recorded.
|
|
64
|
+
"""
|
|
65
|
+
return self.n_ticks / self.wall_s if self.wall_s > 0 else float("nan")
|
|
66
|
+
|
|
67
|
+
def _q(self, xs: list[float], q: float) -> float:
|
|
68
|
+
return float(np.quantile(xs, q)) if xs else float("nan")
|
|
69
|
+
|
|
70
|
+
def summary(self) -> dict[str, float]:
|
|
71
|
+
"""Return the counters as a flat dict of floats.
|
|
72
|
+
|
|
73
|
+
Returns
|
|
74
|
+
-------
|
|
75
|
+
dict of str to float
|
|
76
|
+
Includes tick throughput, speedup against real time, tick
|
|
77
|
+
microseconds, and revise and seal percentiles.
|
|
78
|
+
"""
|
|
79
|
+
return {
|
|
80
|
+
"ticks": self.n_ticks,
|
|
81
|
+
"wall_s": self.wall_s,
|
|
82
|
+
"ticks_per_s": self.ticks_per_s,
|
|
83
|
+
"speedup_vs_realtime": self.market_s / self.wall_s if self.wall_s else float("nan"),
|
|
84
|
+
"tick_us": 1e6 * self.tick_s / self.n_ticks if self.n_ticks else float("nan"),
|
|
85
|
+
"revises": len(self.revise_ms),
|
|
86
|
+
"revise_ms_p50": self._q(self.revise_ms, 0.5),
|
|
87
|
+
"revise_ms_p95": self._q(self.revise_ms, 0.95),
|
|
88
|
+
"revise_ms_p99": self._q(self.revise_ms, 0.99),
|
|
89
|
+
"seals": len(self.seal_ms),
|
|
90
|
+
"seal_ms_p50": self._q(self.seal_ms, 0.5),
|
|
91
|
+
"seal_ms_p95": self._q(self.seal_ms, 0.95),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Extras:
|
|
96
|
+
"""Row lookup for non-bar input columns: the row of the first bar end >= ``open_end``."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, times: np.ndarray, columns: Mapping[str, np.ndarray]):
|
|
99
|
+
"""Store non-bar columns aligned to bar ends.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
times : array of int64
|
|
104
|
+
Bar ends, nanoseconds UTC, non-decreasing.
|
|
105
|
+
columns : mapping of str to ndarray
|
|
106
|
+
Each array has shape ``(T, N)``.
|
|
107
|
+
"""
|
|
108
|
+
self.times = np.asarray(times, np.int64)
|
|
109
|
+
self.columns = dict(columns)
|
|
110
|
+
|
|
111
|
+
def row(self, open_end: int) -> dict[str, np.ndarray]:
|
|
112
|
+
"""Return the column row for the bar that contains ``open_end``.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
open_end : int
|
|
117
|
+
Open bar end, nanoseconds UTC.
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
dict of str to ndarray
|
|
122
|
+
One ``(N,)`` row per column. If ``open_end`` is past the last time, the last row is used.
|
|
123
|
+
"""
|
|
124
|
+
r = min(int(np.searchsorted(self.times, open_end, side="left")), len(self.times) - 1)
|
|
125
|
+
return {k: v[r] for k, v in self.columns.items()}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def replay(
|
|
129
|
+
engine: LiveEngine,
|
|
130
|
+
trades: Trades,
|
|
131
|
+
*,
|
|
132
|
+
start: int = 0,
|
|
133
|
+
stop: int | None = None,
|
|
134
|
+
revise_every_ns: int | None = NS,
|
|
135
|
+
revise_every_ticks: int | None = None,
|
|
136
|
+
extras: Extras | None = None,
|
|
137
|
+
speed: float | None = None,
|
|
138
|
+
until: int | None = None,
|
|
139
|
+
on_revise: Callable[[Snapshot], None] | None = None,
|
|
140
|
+
on_seal: Callable[[Snapshot], None] | None = None,
|
|
141
|
+
check_open: Callable[[LiveEngine, Snapshot], None] | None = None,
|
|
142
|
+
check_open_every: int = 0,
|
|
143
|
+
stats: ReplayStats | None = None,
|
|
144
|
+
) -> ReplayStats:
|
|
145
|
+
"""Stream ``trades[start:stop]`` through ``engine`` (which must have a grid).
|
|
146
|
+
|
|
147
|
+
``until`` (ns) finally advances the clock to seal every bar ending by then.
|
|
148
|
+
``check_open(engine, snap)`` is called on every ``check_open_every``-th revision.
|
|
149
|
+
"""
|
|
150
|
+
if engine.grid is None:
|
|
151
|
+
raise ValueError("replay needs a LiveEngine with a BarGrid")
|
|
152
|
+
st = stats or ReplayStats()
|
|
153
|
+
stop = len(trades) if stop is None else stop
|
|
154
|
+
a_, t_, p_, q_ = trades.asset, trades.ts, trades.price, trades.size
|
|
155
|
+
pc = time.perf_counter
|
|
156
|
+
wall0 = pc()
|
|
157
|
+
t_first = int(t_[start]) if stop > start else 0
|
|
158
|
+
last_open = None
|
|
159
|
+
|
|
160
|
+
def sealed_now(snaps: list[Snapshot], dt: float) -> None:
|
|
161
|
+
if not snaps:
|
|
162
|
+
return
|
|
163
|
+
per = 1e3 * dt / len(snaps)
|
|
164
|
+
for s in snaps:
|
|
165
|
+
st.seal_ms.append(per)
|
|
166
|
+
st.sealed.append(s)
|
|
167
|
+
if on_seal is not None:
|
|
168
|
+
on_seal(s)
|
|
169
|
+
|
|
170
|
+
def open_bar_inputs() -> None:
|
|
171
|
+
nonlocal last_open
|
|
172
|
+
if extras is not None and engine.open_end != last_open:
|
|
173
|
+
for k, v in extras.row(int(engine.open_end)).items(): # type: ignore[arg-type]
|
|
174
|
+
if k in engine.extra: # columns the pipeline does not read are skipped
|
|
175
|
+
engine.set_open(k, v)
|
|
176
|
+
last_open = engine.open_end
|
|
177
|
+
|
|
178
|
+
open_bar_inputs()
|
|
179
|
+
next_revise = (t_first // revise_every_ns + 1) * revise_every_ns if revise_every_ns else None
|
|
180
|
+
since = 0
|
|
181
|
+
n_rev = 0
|
|
182
|
+
for i in range(start, stop):
|
|
183
|
+
ts = int(t_[i])
|
|
184
|
+
if speed:
|
|
185
|
+
# pace market time; keep sealing bars on the clock while no trades arrive
|
|
186
|
+
while (lag := (ts - t_first) / NS / speed - (pc() - wall0)) > 0:
|
|
187
|
+
time.sleep(min(lag, 0.05))
|
|
188
|
+
now = t_first + int((pc() - wall0) * speed * NS)
|
|
189
|
+
t0 = pc()
|
|
190
|
+
snaps = engine.advance(min(now, ts))
|
|
191
|
+
sealed_now(snaps, pc() - t0)
|
|
192
|
+
if snaps:
|
|
193
|
+
open_bar_inputs()
|
|
194
|
+
# market-time revisions due before this trade
|
|
195
|
+
if next_revise is not None and ts >= next_revise:
|
|
196
|
+
if since:
|
|
197
|
+
n_rev += 1
|
|
198
|
+
_timed_revise(engine, st, on_revise, check_open, check_open_every, n_rev)
|
|
199
|
+
since = 0
|
|
200
|
+
next_revise = (ts // revise_every_ns + 1) * revise_every_ns # type: ignore[operator]
|
|
201
|
+
t0 = pc()
|
|
202
|
+
snaps = engine.advance(ts)
|
|
203
|
+
sealed_now(snaps, pc() - t0)
|
|
204
|
+
if snaps:
|
|
205
|
+
open_bar_inputs()
|
|
206
|
+
t0 = pc()
|
|
207
|
+
engine.tick(int(a_[i]), ts, float(p_[i]), float(q_[i]))
|
|
208
|
+
st.tick_s += pc() - t0
|
|
209
|
+
since += 1
|
|
210
|
+
if revise_every_ticks and since >= revise_every_ticks:
|
|
211
|
+
n_rev += 1
|
|
212
|
+
_timed_revise(engine, st, on_revise, check_open, check_open_every, n_rev)
|
|
213
|
+
since = 0
|
|
214
|
+
if since:
|
|
215
|
+
n_rev += 1
|
|
216
|
+
_timed_revise(engine, st, on_revise, check_open, check_open_every, n_rev)
|
|
217
|
+
if until is not None:
|
|
218
|
+
t0 = pc()
|
|
219
|
+
snaps = engine.advance(until)
|
|
220
|
+
sealed_now(snaps, pc() - t0)
|
|
221
|
+
st.n_ticks += stop - start
|
|
222
|
+
st.wall_s += pc() - wall0
|
|
223
|
+
if stop > start:
|
|
224
|
+
st.market_s += (int(t_[stop - 1]) - t_first) / NS
|
|
225
|
+
return st
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _timed_revise(engine, st, on_revise, check_open, every, n) -> None:
|
|
229
|
+
t0 = time.perf_counter()
|
|
230
|
+
snap = engine.revise()
|
|
231
|
+
st.revise_ms.append(1e3 * (time.perf_counter() - t0))
|
|
232
|
+
if on_revise is not None:
|
|
233
|
+
on_revise(snap)
|
|
234
|
+
if check_open is not None and every and n % every == 0:
|
|
235
|
+
check_open(engine, snap)
|
|
236
|
+
st.open_checked += 1
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ====================================================================== verification
|
|
240
|
+
def _equal(a: np.ndarray, b: np.ndarray) -> bool:
|
|
241
|
+
return bool(np.array_equal(a, b, equal_nan=a.dtype.kind == "f"))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass
|
|
245
|
+
class VerifyResult:
|
|
246
|
+
"""The outcome of comparing a live replay with batch.
|
|
247
|
+
|
|
248
|
+
Notes
|
|
249
|
+
-----
|
|
250
|
+
``mismatches`` is empty when every checked row matched.
|
|
251
|
+
``sealed_rows`` is how many bars the live engine sealed.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
grid: str
|
|
255
|
+
sealed_rows: int
|
|
256
|
+
open_checked: int
|
|
257
|
+
mismatches: list[str]
|
|
258
|
+
stats: ReplayStats
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def ok(self) -> bool:
|
|
262
|
+
"""Return whether the replay matched and sealed at least one row.
|
|
263
|
+
|
|
264
|
+
Returns
|
|
265
|
+
-------
|
|
266
|
+
bool
|
|
267
|
+
``True`` when ``mismatches`` is empty and ``sealed_rows > 0``.
|
|
268
|
+
"""
|
|
269
|
+
return not self.mismatches and self.sealed_rows > 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def verify_replay(
|
|
273
|
+
pipeline: Pipeline | Graph,
|
|
274
|
+
full: Panel,
|
|
275
|
+
trades: Trades,
|
|
276
|
+
grid: BarGrid,
|
|
277
|
+
history_rows: int,
|
|
278
|
+
*,
|
|
279
|
+
extra_columns: tuple[str, ...] = (),
|
|
280
|
+
revise_every_ns: int | None = NS,
|
|
281
|
+
revise_every_ticks: int | None = None,
|
|
282
|
+
check_open_every: int = 0,
|
|
283
|
+
partial_threshold: float = 0.0,
|
|
284
|
+
label: str = "",
|
|
285
|
+
) -> VerifyResult:
|
|
286
|
+
"""Live replay of the trades after ``full.head(history_rows)`` vs batch on ``full``.
|
|
287
|
+
|
|
288
|
+
``full`` must be the batch bars of the same trades on ``grid`` (``build_time_bars``),
|
|
289
|
+
optionally preceded by history rows, plus any ``extra_columns``.
|
|
290
|
+
"""
|
|
291
|
+
g = pipeline if isinstance(pipeline, Graph) else compile_pipeline(pipeline)
|
|
292
|
+
batch = BatchEngine().run(g, full)
|
|
293
|
+
hist = full.head(history_rows)
|
|
294
|
+
live = LiveEngine(g, hist, grid, partial_threshold=partial_threshold)
|
|
295
|
+
extras = Extras(full.times, {k: full.columns[k] for k in extra_columns}) if extra_columns else None
|
|
296
|
+
start = int(np.searchsorted(trades.ts, int(full.times[history_rows - 1]), side="left"))
|
|
297
|
+
mism: list[str] = []
|
|
298
|
+
|
|
299
|
+
def check_open(eng: LiveEngine, snap: Snapshot) -> None:
|
|
300
|
+
n_sealed = history_rows + len(stats.sealed)
|
|
301
|
+
bar_cols = (*FIELDS, "has_trade")
|
|
302
|
+
row = {}
|
|
303
|
+
for k in full.columns:
|
|
304
|
+
if k in bar_cols:
|
|
305
|
+
row[k] = eng.bar.row(k)
|
|
306
|
+
elif k in eng.extra:
|
|
307
|
+
row[k] = eng.extra[k]
|
|
308
|
+
else: # not a pipeline input; any value will do
|
|
309
|
+
row[k] = full.columns[k][min(n_sealed, len(full.times) - 1)]
|
|
310
|
+
ref = BatchEngine().run(g, full.head(n_sealed).with_row(row, int(eng.open_end))) # type: ignore[arg-type]
|
|
311
|
+
for k, v in snap.values.items():
|
|
312
|
+
if not _equal(v, ref.values[k][n_sealed]):
|
|
313
|
+
mism.append(f"open row {n_sealed} column {k}")
|
|
314
|
+
|
|
315
|
+
stats = ReplayStats()
|
|
316
|
+
replay(
|
|
317
|
+
live,
|
|
318
|
+
trades,
|
|
319
|
+
start=start,
|
|
320
|
+
revise_every_ns=revise_every_ns,
|
|
321
|
+
revise_every_ticks=revise_every_ticks,
|
|
322
|
+
extras=extras,
|
|
323
|
+
until=int(full.times[-1]) + grid.grace_ns,
|
|
324
|
+
check_open=check_open if check_open_every else None,
|
|
325
|
+
check_open_every=check_open_every,
|
|
326
|
+
stats=stats,
|
|
327
|
+
)
|
|
328
|
+
sealed = stats.sealed
|
|
329
|
+
expect = len(full.times) - history_rows
|
|
330
|
+
if len(sealed) != expect:
|
|
331
|
+
mism.append(f"sealed {len(sealed)} bars, expected {expect}")
|
|
332
|
+
for j, snap in enumerate(sealed[:expect]):
|
|
333
|
+
r = history_rows + j
|
|
334
|
+
if snap.ts != int(full.times[r]):
|
|
335
|
+
mism.append(f"sealed bar {j} stamped {snap.ts}, batch row {r} is {int(full.times[r])}")
|
|
336
|
+
break
|
|
337
|
+
for k, v in snap.values.items():
|
|
338
|
+
if not _equal(v, batch.values[k][r]):
|
|
339
|
+
mism.append(f"sealed row {r} column {k}")
|
|
340
|
+
if batch.screen is not None and snap.screen is not None and not _equal(snap.screen, batch.screen[r]):
|
|
341
|
+
mism.append(f"sealed row {r} screen")
|
|
342
|
+
return VerifyResult(label or repr(grid), len(sealed), stats.open_checked, mism, stats)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def verify_event_pipeline(
|
|
346
|
+
pipeline: Pipeline | Graph,
|
|
347
|
+
trades: Trades,
|
|
348
|
+
asset: int,
|
|
349
|
+
builder_factory: Callable[[], EventBarBuilder],
|
|
350
|
+
history_frac: float = 0.3,
|
|
351
|
+
label: str = "",
|
|
352
|
+
) -> VerifyResult:
|
|
353
|
+
"""One asset's event bars (tick/volume/dollar): live EventPipeline vs batch."""
|
|
354
|
+
g = pipeline if isinstance(pipeline, Graph) else compile_pipeline(pipeline)
|
|
355
|
+
m = trades.asset == asset
|
|
356
|
+
ts, px, sz = trades.ts[m], trades.price[m], trades.size[m]
|
|
357
|
+
b = builder_factory()
|
|
358
|
+
bars, _ = build_event_bars(ts, px, sz, b.kind, b.rule, b.overshoot, b.day_key)
|
|
359
|
+
batch = BatchEngine().run(g, bars.panel())
|
|
360
|
+
cut = int(len(ts) * history_frac)
|
|
361
|
+
hb = builder_factory()
|
|
362
|
+
hist_bars = EventBars.from_bars(hb.update_many(ts[:cut].tolist(), px[:cut].tolist(), sz[:cut].tolist()))
|
|
363
|
+
# resume the same builder state: replay the history trades into it, then go live
|
|
364
|
+
ep = EventPipeline(g, hb, history=hist_bars)
|
|
365
|
+
stats = ReplayStats()
|
|
366
|
+
t0 = time.perf_counter()
|
|
367
|
+
sealed: list[Snapshot] = []
|
|
368
|
+
for t, p, q in zip(ts[cut:].tolist(), px[cut:].tolist(), sz[cut:].tolist(), strict=True):
|
|
369
|
+
for s in ep.update(t, p, q):
|
|
370
|
+
if not s.is_open:
|
|
371
|
+
sealed.append(s)
|
|
372
|
+
stats.wall_s = time.perf_counter() - t0
|
|
373
|
+
stats.n_ticks = len(ts) - cut
|
|
374
|
+
mism: list[str] = []
|
|
375
|
+
h = len(hist_bars)
|
|
376
|
+
if h + len(sealed) != len(bars):
|
|
377
|
+
mism.append(f"live sealed {h + len(sealed)} bars, batch has {len(bars)}")
|
|
378
|
+
for j, s in enumerate(sealed):
|
|
379
|
+
r = h + j
|
|
380
|
+
if r >= len(bars):
|
|
381
|
+
break
|
|
382
|
+
for k, v in s.values.items():
|
|
383
|
+
if not _equal(v, batch.values[k][r]):
|
|
384
|
+
mism.append(f"event row {r} column {k}")
|
|
385
|
+
return VerifyResult(label or f"event[{asset}]", len(sealed), 0, mism, stats)
|