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/panel.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Panel: dense ``T × N`` arrays on a bar grid.
|
|
2
|
+
|
|
3
|
+
Rows are bars, labeled by end time. Columns are named arrays of shape ``(T, N)``
|
|
4
|
+
with one column per asset. Missing factor values are NaN. Classifier codes use
|
|
5
|
+
the column's own dtype (``-1`` is the engine's missing classifier code when the
|
|
6
|
+
column is read as a :class:`~factorflow.terms.Classifier`).
|
|
7
|
+
|
|
8
|
+
Examples
|
|
9
|
+
--------
|
|
10
|
+
>>> import numpy as np
|
|
11
|
+
>>> from factorflow import Panel
|
|
12
|
+
>>> p = Panel(np.arange(3), ("AAA", "BBB"), {"close": np.ones((3, 2))})
|
|
13
|
+
>>> p.shape
|
|
14
|
+
(3, 2)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import polars as pl
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Panel:
|
|
28
|
+
"""One history: bar-end times, asset names, and dense columns.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
times : array of int64
|
|
33
|
+
Bar end, nanoseconds since the Unix epoch, UTC. Must be non-decreasing.
|
|
34
|
+
Event bars may share a timestamp. Length ``T``.
|
|
35
|
+
assets : sequence of str
|
|
36
|
+
Asset names, length ``N``. Order is the column order of every array.
|
|
37
|
+
columns : dict of str to ndarray, optional
|
|
38
|
+
Each array has shape ``(T, N)``. Omitted columns are simply absent;
|
|
39
|
+
a pipeline that reads one will fail at runtime with ``KeyError``.
|
|
40
|
+
starts : array of int64 or None, optional
|
|
41
|
+
Bar start, same clock and length as ``times``. ``None`` if the caller
|
|
42
|
+
does not have starts.
|
|
43
|
+
|
|
44
|
+
Raises
|
|
45
|
+
------
|
|
46
|
+
ValueError
|
|
47
|
+
If ``times`` decreases, or a column's shape is not ``(T, N)``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
times: np.ndarray # int64 ns since epoch (UTC), bar end; non-decreasing (event bars may share a stamp)
|
|
51
|
+
assets: tuple[str, ...]
|
|
52
|
+
columns: dict[str, np.ndarray] = field(default_factory=dict)
|
|
53
|
+
starts: np.ndarray | None = None # optional bar start times
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
"""Check shapes and that bar times do not go backwards.
|
|
57
|
+
|
|
58
|
+
Raises
|
|
59
|
+
------
|
|
60
|
+
ValueError
|
|
61
|
+
If ``times`` is not non-decreasing, or a column shape is not ``(T, N)``.
|
|
62
|
+
"""
|
|
63
|
+
self.times = np.asarray(self.times, dtype=np.int64)
|
|
64
|
+
self.assets = tuple(self.assets)
|
|
65
|
+
t, n = len(self.times), len(self.assets)
|
|
66
|
+
if t > 1 and not np.all(np.diff(self.times) >= 0):
|
|
67
|
+
raise ValueError("panel times must be non-decreasing")
|
|
68
|
+
for k, v in self.columns.items():
|
|
69
|
+
if v.shape != (t, n):
|
|
70
|
+
raise ValueError(f"column {k!r} has shape {v.shape}, expected {(t, n)}")
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def shape(self) -> tuple[int, int]:
|
|
74
|
+
"""Number of bars and number of assets.
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
tuple of int
|
|
79
|
+
``(T, N)``.
|
|
80
|
+
"""
|
|
81
|
+
return len(self.times), len(self.assets)
|
|
82
|
+
|
|
83
|
+
def head(self, rows: int) -> Panel:
|
|
84
|
+
"""Return the first ``rows`` bars.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
rows : int
|
|
89
|
+
How many leading bars to keep. Not a time span.
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
Panel
|
|
94
|
+
A new panel. Arrays are views of the originals.
|
|
95
|
+
"""
|
|
96
|
+
return Panel(
|
|
97
|
+
self.times[:rows],
|
|
98
|
+
self.assets,
|
|
99
|
+
{k: v[:rows] for k, v in self.columns.items()},
|
|
100
|
+
None if self.starts is None else self.starts[:rows],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def with_row(self, row: Mapping[str, np.ndarray], time: int) -> Panel:
|
|
104
|
+
"""Return a copy with one bar appended.
|
|
105
|
+
|
|
106
|
+
Used to treat an open bar as if it were already sealed, which is how a
|
|
107
|
+
live revision is checked against :class:`~factorflow.batch.BatchEngine`.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
row : mapping of str to ndarray
|
|
112
|
+
One value per asset for every column this panel already has.
|
|
113
|
+
Dtypes follow the existing columns.
|
|
114
|
+
time : int
|
|
115
|
+
End time of the new bar, nanoseconds UTC.
|
|
116
|
+
|
|
117
|
+
Returns
|
|
118
|
+
-------
|
|
119
|
+
Panel
|
|
120
|
+
``T + 1`` bars. ``starts`` is dropped.
|
|
121
|
+
"""
|
|
122
|
+
cols = {
|
|
123
|
+
k: np.vstack([v, np.asarray(row[k], dtype=v.dtype)[None, :]]) for k, v in self.columns.items()
|
|
124
|
+
}
|
|
125
|
+
return Panel(np.r_[self.times, np.int64(time)], self.assets, cols)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_frame(
|
|
129
|
+
cls,
|
|
130
|
+
df: pl.DataFrame,
|
|
131
|
+
assets: Sequence[str] | None = None,
|
|
132
|
+
time_col: str = "ts",
|
|
133
|
+
asset_col: str = "asset",
|
|
134
|
+
) -> Panel:
|
|
135
|
+
"""Build a panel from a long polars frame.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
df : polars.DataFrame
|
|
140
|
+
One row per ``(ts, asset)``. Other columns become panel columns.
|
|
141
|
+
``ts`` may be int64 nanoseconds or a UTC datetime. Missing pairs
|
|
142
|
+
become NaN. Non-float columns are cast to float64.
|
|
143
|
+
assets : sequence of str or None, optional
|
|
144
|
+
Column order. ``None`` sorts the names that appear in ``df``.
|
|
145
|
+
time_col : str, optional
|
|
146
|
+
Timestamp column. Default is ``"ts"``.
|
|
147
|
+
asset_col : str, optional
|
|
148
|
+
Asset-name column. Default is ``"asset"``.
|
|
149
|
+
|
|
150
|
+
Returns
|
|
151
|
+
-------
|
|
152
|
+
Panel
|
|
153
|
+
Dense ``(T, N)`` columns. Assets in ``df`` that are not in
|
|
154
|
+
``assets`` are dropped.
|
|
155
|
+
|
|
156
|
+
Examples
|
|
157
|
+
--------
|
|
158
|
+
>>> import polars as pl
|
|
159
|
+
>>> from factorflow import Panel
|
|
160
|
+
>>> df = pl.DataFrame({"ts": [0, 0], "asset": ["A", "B"], "close": [1.0, 2.0]})
|
|
161
|
+
>>> Panel.from_frame(df).assets
|
|
162
|
+
('A', 'B')
|
|
163
|
+
"""
|
|
164
|
+
ts = df[time_col]
|
|
165
|
+
if ts.dtype != pl.Int64:
|
|
166
|
+
ts = ts.cast(pl.Datetime("ns", "UTC")).cast(pl.Int64)
|
|
167
|
+
df = df.with_columns(ts.alias("__t"))
|
|
168
|
+
times = np.unique(df["__t"].to_numpy())
|
|
169
|
+
names = tuple(assets) if assets is not None else tuple(sorted(df[asset_col].unique().to_list()))
|
|
170
|
+
ti = np.searchsorted(times, df["__t"].to_numpy())
|
|
171
|
+
amap = {a: i for i, a in enumerate(names)}
|
|
172
|
+
ai = np.array([amap.get(a, -1) for a in df[asset_col].to_list()])
|
|
173
|
+
keep = ai >= 0
|
|
174
|
+
cols = {}
|
|
175
|
+
for c in df.columns:
|
|
176
|
+
if c in (time_col, asset_col, "__t"):
|
|
177
|
+
continue
|
|
178
|
+
arr = np.full((len(times), len(names)), np.nan)
|
|
179
|
+
arr[ti[keep], ai[keep]] = df[c].to_numpy()[keep].astype(np.float64)
|
|
180
|
+
cols[c] = arr
|
|
181
|
+
return cls(times, names, cols)
|
factorflow/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Synthetic market data with known ground truth, a replay driver and validation tools.
|
|
2
|
+
|
|
3
|
+
from factorflow.sim import SimConfig, simulate
|
|
4
|
+
mkt = simulate(SimConfig(seed=7)) # equities + BTC/ETH/SOL, deterministic
|
|
5
|
+
mkt.equities.daily # daily Panel (signal, industry, styles columns)
|
|
6
|
+
mkt.equities.trades # ticks of the last ``intraday_days`` sessions
|
|
7
|
+
|
|
8
|
+
CLI: ``python -m factorflow.sim demo | validate | bench``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from factorflow.sim.market import (
|
|
12
|
+
STYLES,
|
|
13
|
+
AssetSim,
|
|
14
|
+
CryptoSpec,
|
|
15
|
+
EquitySpec,
|
|
16
|
+
SimConfig,
|
|
17
|
+
SimMarket,
|
|
18
|
+
Truth,
|
|
19
|
+
simulate,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = ["STYLES", "AssetSim", "CryptoSpec", "EquitySpec", "SimConfig", "SimMarket", "Truth", "simulate"]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""``python -m factorflow.sim {demo,validate,bench}``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from factorflow.grid import NS, BarGrid
|
|
12
|
+
from factorflow.live import LiveEngine, Snapshot
|
|
13
|
+
from factorflow.sim.market import EXTRA_COLUMNS, SimConfig, simulate
|
|
14
|
+
from factorflow.sim.pipelines import beta_window_for, crypto_pipeline, daily_pipeline, minute_pipeline
|
|
15
|
+
from factorflow.sim.replay import Extras, replay, verify_replay
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fmt_ts(ns: int) -> str:
|
|
19
|
+
return str(np.datetime64(int(ns), "ns").astype("datetime64[s]")).replace("T", " ") + " UTC"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _verify(mkt, check_every: int = 0) -> bool:
|
|
23
|
+
"""Replay every intraday tick through LiveEngine and compare sealed rows with batch.
|
|
24
|
+
|
|
25
|
+
Revision cadence (market time): daily bars every 60 s, equity minute bars every 1 s,
|
|
26
|
+
crypto minute bars every 5 s.
|
|
27
|
+
"""
|
|
28
|
+
eq, cr = mkt.equities, mkt.crypto
|
|
29
|
+
p, _ = eq.time_bars("1min")
|
|
30
|
+
first = int(np.searchsorted(p.times, eq.closes[eq.intraday[0]], side="right"))
|
|
31
|
+
runs = [
|
|
32
|
+
(
|
|
33
|
+
"equities, daily bars (ticks revise the open daily bar)",
|
|
34
|
+
eq,
|
|
35
|
+
daily_pipeline(len(eq.assets), beta_window_for(eq.n_hist)),
|
|
36
|
+
eq.daily,
|
|
37
|
+
eq.daily_grid(),
|
|
38
|
+
eq.n_hist,
|
|
39
|
+
EXTRA_COLUMNS,
|
|
40
|
+
60,
|
|
41
|
+
),
|
|
42
|
+
(
|
|
43
|
+
"equities, 1-minute bars",
|
|
44
|
+
eq,
|
|
45
|
+
minute_pipeline(),
|
|
46
|
+
p,
|
|
47
|
+
BarGrid("XNYS", "1min"),
|
|
48
|
+
first,
|
|
49
|
+
("signal", "industry"),
|
|
50
|
+
1,
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
if cr is not None:
|
|
54
|
+
cp, _ = cr.time_bars("1min")
|
|
55
|
+
first_c = int(np.searchsorted(cp.times, cr.closes[cr.intraday[0]], side="right"))
|
|
56
|
+
runs.append(
|
|
57
|
+
("crypto 24/7, 1-minute bars", cr, crypto_pipeline(), cp, BarGrid("24/7", "1min"), first_c, (), 5)
|
|
58
|
+
)
|
|
59
|
+
ok = True
|
|
60
|
+
for label, sim, pipe, panel, grid, h, extra, every_s in runs:
|
|
61
|
+
t0 = time.perf_counter()
|
|
62
|
+
v = verify_replay(
|
|
63
|
+
pipe,
|
|
64
|
+
panel,
|
|
65
|
+
sim.trades,
|
|
66
|
+
grid,
|
|
67
|
+
h,
|
|
68
|
+
extra_columns=extra,
|
|
69
|
+
revise_every_ns=every_s * NS,
|
|
70
|
+
check_open_every=check_every,
|
|
71
|
+
)
|
|
72
|
+
s = v.stats.summary()
|
|
73
|
+
status = "OK " if v.ok else "FAIL"
|
|
74
|
+
print(
|
|
75
|
+
f" [{status}] {label}: {v.sealed_rows} sealed rows == batch (bit for bit), "
|
|
76
|
+
f"{v.open_checked} open revisions checked; {int(s['ticks']):,} ticks, {s['revises']} revisions "
|
|
77
|
+
f"every {every_s} s, revise p50 {s['revise_ms_p50']:.2f} ms ({time.perf_counter() - t0:.1f} s)"
|
|
78
|
+
)
|
|
79
|
+
for m in v.mismatches[:5]:
|
|
80
|
+
print(" ", m)
|
|
81
|
+
ok &= v.ok
|
|
82
|
+
return ok
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _live_view(mkt, speed: float, seconds: float, top: int, out=sys.stdout) -> None:
|
|
86
|
+
"""Replay the last session's ticks through a minute-grid engine, printing live values."""
|
|
87
|
+
eq = mkt.equities
|
|
88
|
+
p, _ = eq.time_bars("1min")
|
|
89
|
+
last = eq.intraday[-1]
|
|
90
|
+
h = int(np.searchsorted(p.times, eq.opens[last], side="right"))
|
|
91
|
+
live = LiveEngine(minute_pipeline(), p.head(h), BarGrid("XNYS", "1min"))
|
|
92
|
+
extras = Extras(p.times, {k: p.columns[k] for k in ("signal", "industry")})
|
|
93
|
+
t_open = int(eq.opens[last])
|
|
94
|
+
start = int(np.searchsorted(eq.trades.ts, t_open, side="left"))
|
|
95
|
+
stop = int(np.searchsorted(eq.trades.ts, t_open + int(seconds * speed * NS), side="left"))
|
|
96
|
+
tty = out.isatty()
|
|
97
|
+
last_print = [0.0]
|
|
98
|
+
n_sealed = [0]
|
|
99
|
+
assets = eq.assets
|
|
100
|
+
|
|
101
|
+
def show(snap: Snapshot, force: bool = False) -> None:
|
|
102
|
+
now = time.perf_counter()
|
|
103
|
+
if not force and now - last_print[0] < 1.0:
|
|
104
|
+
return
|
|
105
|
+
last_print[0] = now
|
|
106
|
+
v = snap.values
|
|
107
|
+
order = [int(i) for i in np.argsort(v["rank"]) if np.isfinite(v["rank"][i])][:top]
|
|
108
|
+
flag = "LIVE (open bar, provisional)" if snap.is_open else "SEALED (final)"
|
|
109
|
+
lines = [
|
|
110
|
+
f"bar ending {_fmt_ts(snap.ts)} [{flag}] sealed bars this run: {n_sealed[0]} "
|
|
111
|
+
f"changed: {', '.join(sorted(snap.changed)) or '-'}",
|
|
112
|
+
f" {'asset':<6} {'is_open':<7} {'close':>9} {'mom30':>8} {'vol30':>8} {'vwap_dev':>9} "
|
|
113
|
+
f"{'score':>7} {'rank':>4}",
|
|
114
|
+
]
|
|
115
|
+
for i in order:
|
|
116
|
+
lines.append(
|
|
117
|
+
f" {assets[i]:<6} {snap.is_open!s:<7} {v['close'][i]:9.2f} {v['mom30'][i]:8.4f} "
|
|
118
|
+
f"{v['vol30'][i]:8.5f} {v['vwap_dev'][i]:9.5f} {v['score'][i]:7.3f} {v['rank'][i]:4.0f}"
|
|
119
|
+
)
|
|
120
|
+
if tty:
|
|
121
|
+
out.write("\x1b[2J\x1b[H")
|
|
122
|
+
out.write("\n".join(lines) + "\n\n")
|
|
123
|
+
out.flush()
|
|
124
|
+
|
|
125
|
+
def on_seal(snap: Snapshot) -> None:
|
|
126
|
+
n_sealed[0] += 1
|
|
127
|
+
show(snap)
|
|
128
|
+
|
|
129
|
+
st = replay(
|
|
130
|
+
live, eq.trades, start=start, stop=stop, extras=extras, speed=speed, on_revise=show, on_seal=on_seal
|
|
131
|
+
)
|
|
132
|
+
snap = live.revise()
|
|
133
|
+
show(snap, force=True)
|
|
134
|
+
s = st.summary()
|
|
135
|
+
print(
|
|
136
|
+
f"replayed {int(s['ticks']):,} ticks ({st.market_s / 60:.1f} market minutes at {speed:g}x), "
|
|
137
|
+
f"{len(st.seal_ms)} bars sealed; revise p50 {s['revise_ms_p50']:.2f} ms, "
|
|
138
|
+
f"seal p50 {s['seal_ms_p50']:.2f} ms"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main(argv: list[str] | None = None) -> int:
|
|
143
|
+
"""Run ``demo``, ``validate``, or ``bench`` from the command line.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
argv : list of str or None, optional
|
|
148
|
+
Arguments after the program name. ``None`` reads ``sys.argv``.
|
|
149
|
+
|
|
150
|
+
Returns
|
|
151
|
+
-------
|
|
152
|
+
int
|
|
153
|
+
0 when the live replay matched batch, 1 otherwise. ``bench`` returns 0.
|
|
154
|
+
"""
|
|
155
|
+
ap = argparse.ArgumentParser(prog="python -m factorflow.sim", description=__doc__)
|
|
156
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
157
|
+
for name in ("demo", "validate", "bench"):
|
|
158
|
+
sp = sub.add_parser(name)
|
|
159
|
+
sp.add_argument("--seed", type=int, default=7)
|
|
160
|
+
if name != "bench":
|
|
161
|
+
sp.add_argument("--names", type=int, default=100)
|
|
162
|
+
sp.add_argument("--history-days", type=int, default=252)
|
|
163
|
+
sp.add_argument("--intraday-days", type=int, default=3)
|
|
164
|
+
if name == "demo":
|
|
165
|
+
sp.add_argument("--speed", type=float, default=60.0, help="market seconds per wall second")
|
|
166
|
+
sp.add_argument("--seconds", type=float, default=15.0, help="wall seconds of live replay")
|
|
167
|
+
sp.add_argument("--top", type=int, default=10)
|
|
168
|
+
sp.add_argument("--skip-validation", action="store_true")
|
|
169
|
+
if name == "validate":
|
|
170
|
+
sp.add_argument(
|
|
171
|
+
"--check-open",
|
|
172
|
+
type=int,
|
|
173
|
+
default=0,
|
|
174
|
+
help="also check every Nth open revision against batch (slow)",
|
|
175
|
+
)
|
|
176
|
+
if name == "bench":
|
|
177
|
+
sp.add_argument("--names", type=int, nargs="+", default=[100, 500])
|
|
178
|
+
sp.add_argument("--minutes", type=int, default=30)
|
|
179
|
+
a = ap.parse_args(argv)
|
|
180
|
+
|
|
181
|
+
if a.cmd == "bench":
|
|
182
|
+
from factorflow.sim.bench import main as bench_main
|
|
183
|
+
|
|
184
|
+
bench_main(tuple(a.names), a.minutes, a.seed)
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
from factorflow.sim.validate import validate
|
|
188
|
+
|
|
189
|
+
cfg = SimConfig(seed=a.seed, history_days=a.history_days, intraday_days=a.intraday_days).scaled(n=a.names)
|
|
190
|
+
t0 = time.perf_counter()
|
|
191
|
+
mkt = simulate(cfg)
|
|
192
|
+
print(f"== simulated market (seed {a.seed}, {time.perf_counter() - t0:.1f} s)")
|
|
193
|
+
print(mkt.summary())
|
|
194
|
+
ok = True
|
|
195
|
+
if a.cmd == "validate" or not a.skip_validation:
|
|
196
|
+
print("\n== live replay vs batch")
|
|
197
|
+
ok = _verify(mkt, getattr(a, "check_open", 0))
|
|
198
|
+
print("\n== validation against the planted truth")
|
|
199
|
+
print(validate(mkt).report())
|
|
200
|
+
if a.cmd == "demo":
|
|
201
|
+
print(f"\n== live: last session replayed at {a.speed:g}x for {a.seconds:g} s (Ctrl-C to stop)")
|
|
202
|
+
try:
|
|
203
|
+
_live_view(mkt, a.speed, a.seconds, a.top)
|
|
204
|
+
except KeyboardInterrupt:
|
|
205
|
+
print("stopped")
|
|
206
|
+
return 0 if ok else 1
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
raise SystemExit(main())
|
factorflow/sim/bench.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Replay throughput and latency on simulated minute data (``python -m factorflow.sim bench``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from factorflow.graph import compile_pipeline
|
|
13
|
+
from factorflow.grid import NS, BarGrid
|
|
14
|
+
from factorflow.live import LiveEngine
|
|
15
|
+
from factorflow.sim.market import SimConfig, simulate
|
|
16
|
+
from factorflow.sim.pipelines import minute_pipeline, wide_pipeline
|
|
17
|
+
from factorflow.sim.replay import Extras, ReplayStats, replay
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class BenchRow:
|
|
22
|
+
"""One benchmark measurement.
|
|
23
|
+
|
|
24
|
+
Notes
|
|
25
|
+
-----
|
|
26
|
+
``names`` is the equity count. ``nodes`` is the compiled graph size.
|
|
27
|
+
``stats`` comes from :meth:`ReplayStats.summary`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
names: int
|
|
31
|
+
pipeline: str
|
|
32
|
+
nodes: int
|
|
33
|
+
mode: str
|
|
34
|
+
stats: dict[str, float]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_bench(
|
|
38
|
+
names: tuple[int, ...] = (100, 500),
|
|
39
|
+
minutes: int = 30,
|
|
40
|
+
seed: int = 7,
|
|
41
|
+
pipelines: tuple[str, ...] = ("demo", "wide100"),
|
|
42
|
+
) -> list[BenchRow]:
|
|
43
|
+
"""Replay the first ``minutes`` of a session for each universe size.
|
|
44
|
+
|
|
45
|
+
The window is the busiest part of the intraday U-shape. The engine is a
|
|
46
|
+
minute-grid :class:`~factorflow.live.LiveEngine` warmed on the previous
|
|
47
|
+
session.
|
|
48
|
+
|
|
49
|
+
Modes are ``ingest`` (ticks and seals, no revisions), ``revise@1s`` (a
|
|
50
|
+
revision every second of market time), and ``revise@tick`` (a revision
|
|
51
|
+
after every trade, first 5 minutes only).
|
|
52
|
+
"""
|
|
53
|
+
rows: list[BenchRow] = []
|
|
54
|
+
grid = BarGrid("XNYS", "1min")
|
|
55
|
+
for n in names:
|
|
56
|
+
cfg = SimConfig(seed=seed, history_days=30, intraday_days=2, crypto=None).scaled(n=n)
|
|
57
|
+
eq = simulate(cfg).equities
|
|
58
|
+
panel, _ = eq.time_bars("1min")
|
|
59
|
+
first_day_bars = int(np.searchsorted(panel.times, eq.closes[eq.intraday[0]], side="right"))
|
|
60
|
+
hist = panel.head(first_day_bars)
|
|
61
|
+
extras = Extras(panel.times, {k: panel.columns[k] for k in ("signal", "industry")})
|
|
62
|
+
t_open = int(eq.opens[eq.intraday[1]])
|
|
63
|
+
start = int(np.searchsorted(eq.trades.ts, t_open, side="left"))
|
|
64
|
+
stop = int(np.searchsorted(eq.trades.ts, t_open + minutes * 60 * NS, side="left"))
|
|
65
|
+
stop5 = int(np.searchsorted(eq.trades.ts, t_open + 5 * 60 * NS, side="left"))
|
|
66
|
+
for pname in pipelines:
|
|
67
|
+
pipe = minute_pipeline() if pname == "demo" else wide_pipeline(100)
|
|
68
|
+
g = compile_pipeline(pipe)
|
|
69
|
+
for mode, kw, hi in (
|
|
70
|
+
("ingest", {"revise_every_ns": None}, stop),
|
|
71
|
+
("revise@1s", {"revise_every_ns": NS}, stop),
|
|
72
|
+
("revise@tick", {"revise_every_ns": None, "revise_every_ticks": 1}, stop5),
|
|
73
|
+
):
|
|
74
|
+
live = LiveEngine(g, hist, grid)
|
|
75
|
+
st = ReplayStats()
|
|
76
|
+
replay(live, eq.trades, start=start, stop=hi, extras=extras, stats=st, **kw) # type: ignore[arg-type]
|
|
77
|
+
rows.append(BenchRow(n, pname, len(g.order), mode, st.summary()))
|
|
78
|
+
return rows
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def format_bench(rows: list[BenchRow], minutes: int) -> str:
|
|
82
|
+
"""Format benchmark rows as a text table.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
rows : list of BenchRow
|
|
87
|
+
Measurements from :func:`run_bench`.
|
|
88
|
+
minutes : int
|
|
89
|
+
How many minutes of the session were replayed, except the per-tick mode, which uses 5.
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
str
|
|
94
|
+
A header plus one line per row. Latencies are milliseconds.
|
|
95
|
+
"""
|
|
96
|
+
L = [
|
|
97
|
+
f"factorflow replay benchmark: XNYS 1-minute grid, first {minutes} min of a session "
|
|
98
|
+
f"(revise@tick: first 5 min)",
|
|
99
|
+
f"python {platform.python_version()} numpy {np.__version__} "
|
|
100
|
+
f"{platform.processor() or platform.machine()} cpus={os.cpu_count()}",
|
|
101
|
+
f" {'names':>5} {'pipeline':<8} {'nodes':>5} {'mode':<11} {'ticks':>8} {'ticks/s':>9} "
|
|
102
|
+
f"{'x realtime':>10} {'tick us':>7} {'revise p50/p95 ms':>18} {'seal p50/p95 ms':>16}",
|
|
103
|
+
]
|
|
104
|
+
for r in rows:
|
|
105
|
+
s = r.stats
|
|
106
|
+
rev = f"{s['revise_ms_p50']:.2f}/{s['revise_ms_p95']:.2f}" if s["revises"] > 1 else "-"
|
|
107
|
+
L.append(
|
|
108
|
+
f" {r.names:>5} {r.pipeline:<8} {r.nodes:>5} {r.mode:<11} {int(s['ticks']):>8} "
|
|
109
|
+
f"{s['ticks_per_s']:>9,.0f} {s['speedup_vs_realtime']:>10,.0f} {s['tick_us']:>7.2f} "
|
|
110
|
+
f"{rev:>18} {s['seal_ms_p50']:.2f}/{s['seal_ms_p95']:.2f}"
|
|
111
|
+
)
|
|
112
|
+
return "\n".join(L)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main(names: tuple[int, ...], minutes: int, seed: int) -> None:
|
|
116
|
+
"""Run the benchmark and print the table.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
names : tuple of int
|
|
121
|
+
Equity universe sizes.
|
|
122
|
+
minutes : int
|
|
123
|
+
Minutes of the session to replay.
|
|
124
|
+
seed : int
|
|
125
|
+
SimConfig seed.
|
|
126
|
+
"""
|
|
127
|
+
t0 = time.perf_counter()
|
|
128
|
+
rows = run_bench(names, minutes, seed)
|
|
129
|
+
print(format_bench(rows, minutes))
|
|
130
|
+
print(f"(total {time.perf_counter() - t0:.0f} s)")
|