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/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())
@@ -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)")