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/graph.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Compile a pipeline's terms into a deduplicated, height-ordered graph.
|
|
2
|
+
|
|
3
|
+
Compilation does three things the executors rely on:
|
|
4
|
+
|
|
5
|
+
* common-subexpression elimination, so ``close.rolling_mean(5)`` written twice
|
|
6
|
+
is one node;
|
|
7
|
+
* a height (topological) order, so a node runs after its inputs;
|
|
8
|
+
* a lookback per node, the number of sealed rows a consumer needs before the
|
|
9
|
+
open row. The live engine keeps that many rows in a ring buffer.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from factorflow.terms import Constant, ConstFilter, Filter, Term, _Input
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(eq=False)
|
|
20
|
+
class Pipeline:
|
|
21
|
+
"""Named outputs, and an optional screen, over a term graph.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
columns : dict of str to Term
|
|
26
|
+
Output name to term. Names become columns of
|
|
27
|
+
:meth:`~factorflow.batch.BatchResult.frame` and keys of
|
|
28
|
+
:attr:`~factorflow.live.Snapshot.values`. A column must be a real
|
|
29
|
+
term, not a Python number.
|
|
30
|
+
screen : Filter or None, optional
|
|
31
|
+
Rows that are ``False`` are dropped from frames. The arrays in
|
|
32
|
+
``values`` are still the full cross-section. Default is no screen.
|
|
33
|
+
|
|
34
|
+
Raises
|
|
35
|
+
------
|
|
36
|
+
TypeError
|
|
37
|
+
If a column is not a :class:`~factorflow.terms.Term`, or is a constant.
|
|
38
|
+
|
|
39
|
+
Examples
|
|
40
|
+
--------
|
|
41
|
+
>>> import factorflow as ff
|
|
42
|
+
>>> close = ff.Column("close")
|
|
43
|
+
>>> pipe = ff.Pipeline({"mom": close.rolling_mean(5)}, screen=close > 0)
|
|
44
|
+
>>> pipe.columns["mom"].window
|
|
45
|
+
5
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
columns: dict[str, Term]
|
|
49
|
+
screen: Filter | None = None
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
"""Reject constants and non-terms used as pipeline columns.
|
|
53
|
+
|
|
54
|
+
Raises
|
|
55
|
+
------
|
|
56
|
+
TypeError
|
|
57
|
+
If any column is not a non-constant term.
|
|
58
|
+
"""
|
|
59
|
+
for name, t in self.columns.items():
|
|
60
|
+
if not isinstance(t, Term) or isinstance(t, (Constant, ConstFilter)):
|
|
61
|
+
raise TypeError(f"pipeline column {name!r} must be a non-constant term")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(eq=False)
|
|
65
|
+
class Graph:
|
|
66
|
+
"""A compiled pipeline.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
order : list of Term
|
|
71
|
+
Nodes in increasing height, inputs first.
|
|
72
|
+
inputs : list of Term
|
|
73
|
+
Leaf terms. Each has a ``name`` attribute, the panel column it reads.
|
|
74
|
+
outputs : dict of str to Term
|
|
75
|
+
The pipeline's named columns, after common-subexpression elimination.
|
|
76
|
+
screen : Term or None
|
|
77
|
+
The screen, or ``None``.
|
|
78
|
+
lookback : dict of Term to int
|
|
79
|
+
How many rows of this node's output its consumers need. At least 1.
|
|
80
|
+
The live ring buffer keeps ``lookback - 1`` sealed rows plus the open slot.
|
|
81
|
+
heights : dict of Term to int
|
|
82
|
+
``1 + max(input heights)``, and 0 for inputs.
|
|
83
|
+
consumers : dict of Term to list of Term
|
|
84
|
+
Nodes that read this node.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
order: list[Term] # topological, by height
|
|
88
|
+
inputs: list[Term] # input (leaf) terms
|
|
89
|
+
outputs: dict[str, Term]
|
|
90
|
+
screen: Term | None
|
|
91
|
+
lookback: dict[Term, int] = field(default_factory=dict) # rows of each node's output its consumers need
|
|
92
|
+
heights: dict[Term, int] = field(default_factory=dict)
|
|
93
|
+
consumers: dict[Term, list[Term]] = field(default_factory=dict)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def input_names(self) -> list[str]:
|
|
97
|
+
"""Panel columns this graph reads, in execution order.
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
list of str
|
|
102
|
+
Names of the input terms.
|
|
103
|
+
"""
|
|
104
|
+
return [t.name for t in self.inputs] # type: ignore[attr-defined]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _canonical(t: Term, memo: dict[int, Term], table: dict[tuple, Term]) -> Term:
|
|
108
|
+
"""Common-subexpression elimination: structurally equal terms map to one node."""
|
|
109
|
+
if id(t) in memo:
|
|
110
|
+
return memo[id(t)]
|
|
111
|
+
if isinstance(t, _Input):
|
|
112
|
+
key: tuple = ("input", type(t).__name__, t.name)
|
|
113
|
+
else:
|
|
114
|
+
new_inputs = tuple(_canonical(i, memo, table) for i in t.inputs)
|
|
115
|
+
if new_inputs != t.inputs and any(a is not b for a, b in zip(new_inputs, t.inputs, strict=True)):
|
|
116
|
+
# rebuild with canonical inputs (terms are immutable, so copy and swap)
|
|
117
|
+
clone = object.__new__(type(t))
|
|
118
|
+
clone.__dict__.update(t.__dict__)
|
|
119
|
+
clone.inputs = new_inputs
|
|
120
|
+
if hasattr(t, "operands"):
|
|
121
|
+
rep = dict(zip([id(x) for x in t.inputs], new_inputs, strict=True))
|
|
122
|
+
clone.operands = tuple(rep.get(id(o), o) for o in t.operands) # type: ignore[attr-defined]
|
|
123
|
+
t = clone
|
|
124
|
+
key = t._key()
|
|
125
|
+
out = table.setdefault(key, t)
|
|
126
|
+
memo[id(t)] = out
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def compile_pipeline(p: Pipeline) -> Graph:
|
|
131
|
+
"""Turn a pipeline into a :class:`Graph`.
|
|
132
|
+
|
|
133
|
+
Parameters
|
|
134
|
+
----------
|
|
135
|
+
p : Pipeline
|
|
136
|
+
The expression to compile. Compiling twice is safe; nodes are immutable.
|
|
137
|
+
|
|
138
|
+
Returns
|
|
139
|
+
-------
|
|
140
|
+
Graph
|
|
141
|
+
Deduplicated nodes, topological order, and per-node lookback.
|
|
142
|
+
|
|
143
|
+
Notes
|
|
144
|
+
-----
|
|
145
|
+
Two terms are the same node when their class, parameters, window, and
|
|
146
|
+
input identities match. Inputs are keyed by type and column name, so
|
|
147
|
+
``Column("close")`` is one node no matter how many times it is written.
|
|
148
|
+
"""
|
|
149
|
+
memo: dict[int, Term] = {}
|
|
150
|
+
table: dict[tuple, Term] = {}
|
|
151
|
+
outputs = {k: _canonical(v, memo, table) for k, v in p.columns.items()}
|
|
152
|
+
screen = _canonical(p.screen, memo, table) if p.screen is not None else None
|
|
153
|
+
|
|
154
|
+
heights: dict[Term, int] = {}
|
|
155
|
+
consumers: dict[Term, list[Term]] = {}
|
|
156
|
+
order: list[Term] = []
|
|
157
|
+
|
|
158
|
+
def visit(t: Term) -> int:
|
|
159
|
+
if t in heights:
|
|
160
|
+
return heights[t]
|
|
161
|
+
h = 0
|
|
162
|
+
for i in t.inputs:
|
|
163
|
+
h = max(h, visit(i) + 1)
|
|
164
|
+
consumers.setdefault(i, [])
|
|
165
|
+
if t not in consumers[i]:
|
|
166
|
+
consumers[i].append(t)
|
|
167
|
+
heights[t] = h
|
|
168
|
+
order.append(t)
|
|
169
|
+
return h
|
|
170
|
+
|
|
171
|
+
roots = list(outputs.values()) + ([screen] if screen is not None else [])
|
|
172
|
+
for r in roots:
|
|
173
|
+
visit(r)
|
|
174
|
+
order.sort(key=lambda t: heights[t])
|
|
175
|
+
lookback = {t: max([c.window for c in consumers.get(t, [])] + [1]) for t in order}
|
|
176
|
+
inputs: list[Term] = [t for t in order if isinstance(t, _Input)]
|
|
177
|
+
return Graph(order, inputs, outputs, screen, lookback, heights, consumers)
|
factorflow/grid.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Clock-time bar grids: a trading calendar × a bar frequency.
|
|
2
|
+
|
|
3
|
+
``BarGrid("XNYS", "1min")`` gives the regular-session minute bars of the NYSE calendar
|
|
4
|
+
(holidays and early closes from ``exchange_calendars``); ``BarGrid("24/7", "1min")`` is a
|
|
5
|
+
continuous UTC clock for crypto. Bars are half-open ``[start, end)`` and labeled by ``end``.
|
|
6
|
+
The last bar of a session is clipped to the session close.
|
|
7
|
+
|
|
8
|
+
``grace`` is how long after a bar's end the live engine waits before sealing it (late prints
|
|
9
|
+
for the bar are still accepted during the grace period; trades stamped ``>= end`` that
|
|
10
|
+
arrive during it are held for the next bar). Default: 2 s intraday, 0 for daily bars.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from datetime import UTC, date, datetime, timedelta
|
|
17
|
+
from functools import cache
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
NS = 1_000_000_000
|
|
22
|
+
_UNITS = {"s": 1, "sec": 1, "min": 60, "m": 60, "h": 3600, "d": 86400}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_freq(freq: str) -> int:
|
|
26
|
+
"""Parse a bar size into nanoseconds.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
freq : str
|
|
31
|
+
A count and a unit, for example ``"1min"``, ``"5min"``, ``"30s"``,
|
|
32
|
+
``"1h"``, ``"1d"``. The count defaults to 1. Units are ``s``/``sec``,
|
|
33
|
+
``min``/``m``, ``h``, and ``d``.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
int
|
|
38
|
+
Length of one bar in nanoseconds.
|
|
39
|
+
|
|
40
|
+
Raises
|
|
41
|
+
------
|
|
42
|
+
ValueError
|
|
43
|
+
If ``freq`` is not of that form. Multi-day bars are rejected later by
|
|
44
|
+
:class:`BarGrid`, not here.
|
|
45
|
+
"""
|
|
46
|
+
m = re.fullmatch(r"(\d*)\s*([a-z]+)", freq.strip().lower())
|
|
47
|
+
if not m or m.group(2) not in _UNITS:
|
|
48
|
+
raise ValueError(f"bad bar frequency {freq!r} (use e.g. '1min', '5min', '1h', '1d')")
|
|
49
|
+
return int(m.group(1) or 1) * _UNITS[m.group(2)] * NS
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def to_ns(x: str | date | datetime | int | np.datetime64) -> int:
|
|
53
|
+
"""Convert a timestamp to nanoseconds since the Unix epoch, UTC.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
x : str, datetime.date, datetime.datetime, int, or numpy.datetime64
|
|
58
|
+
An int is already nanoseconds and is returned unchanged. A naive
|
|
59
|
+
datetime is treated as UTC. A date is midnight UTC. A string is
|
|
60
|
+
parsed with :meth:`datetime.datetime.fromisoformat` (a trailing ``Z``
|
|
61
|
+
is not accepted; use ``+00:00``).
|
|
62
|
+
|
|
63
|
+
Returns
|
|
64
|
+
-------
|
|
65
|
+
int
|
|
66
|
+
Nanoseconds UTC.
|
|
67
|
+
|
|
68
|
+
Examples
|
|
69
|
+
--------
|
|
70
|
+
>>> to_ns("2026-11-30T14:30:00+00:00")
|
|
71
|
+
1796049000000000000
|
|
72
|
+
"""
|
|
73
|
+
if isinstance(x, (int, np.integer)):
|
|
74
|
+
return int(x)
|
|
75
|
+
if isinstance(x, np.datetime64):
|
|
76
|
+
return int(x.astype("datetime64[ns]").astype(np.int64))
|
|
77
|
+
if isinstance(x, str):
|
|
78
|
+
x = datetime.fromisoformat(x)
|
|
79
|
+
if isinstance(x, datetime):
|
|
80
|
+
if x.tzinfo is None:
|
|
81
|
+
x = x.replace(tzinfo=UTC)
|
|
82
|
+
return int(x.timestamp()) * NS + x.microsecond * 1000
|
|
83
|
+
return int(datetime(x.year, x.month, x.day, tzinfo=UTC).timestamp()) * NS
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@cache
|
|
87
|
+
def _calendar(name: str):
|
|
88
|
+
import exchange_calendars as xcals
|
|
89
|
+
|
|
90
|
+
return xcals.get_calendar(name)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class BarGrid:
|
|
94
|
+
"""A calendar crossed with a bar frequency.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
calendar : str, optional
|
|
99
|
+
An :mod:`exchange_calendars` name, such as ``"XNYS"``, or ``"24/7"``
|
|
100
|
+
(also ``"247"``, ``"ALWAYS_OPEN"``, ``"CRYPTO"``) for a clock that
|
|
101
|
+
does not close. Default is ``"XNYS"``.
|
|
102
|
+
freq : str, optional
|
|
103
|
+
Bar size, parsed by :func:`parse_freq`. Default is ``"1d"``. Only
|
|
104
|
+
``"1d"`` is allowed for daily bars; ``"2d"`` and longer are rejected.
|
|
105
|
+
grace : float or None, optional
|
|
106
|
+
Seconds after a bar's end before the live engine seals it. ``None``
|
|
107
|
+
means 0 for daily bars and 2 for anything finer. Late prints inside
|
|
108
|
+
the grace are still applied to the bar. A trade stamped at or after
|
|
109
|
+
``end`` is held for the next bar.
|
|
110
|
+
|
|
111
|
+
Raises
|
|
112
|
+
------
|
|
113
|
+
ValueError
|
|
114
|
+
If ``freq`` is a multi-day size.
|
|
115
|
+
|
|
116
|
+
Examples
|
|
117
|
+
--------
|
|
118
|
+
A full NYSE session is 390 one-minute bars. The day after Thanksgiving
|
|
119
|
+
2026 closes at 13:00 America/New_York and has 210::
|
|
120
|
+
|
|
121
|
+
grid = BarGrid("XNYS", "1min")
|
|
122
|
+
len(grid.bars("2026-11-30", "2026-11-30")[1]) # 390
|
|
123
|
+
len(grid.bars("2026-11-27", "2026-11-27")[1]) # 210
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, calendar: str = "XNYS", freq: str = "1d", grace: float | None = None):
|
|
127
|
+
"""Store the calendar, the bar length, and the seal grace.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
calendar : str, optional
|
|
132
|
+
Exchange calendar name, or a 24/7 alias. Default ``"XNYS"``.
|
|
133
|
+
freq : str, optional
|
|
134
|
+
Bar size. Default ``"1d"``.
|
|
135
|
+
grace : float or None, optional
|
|
136
|
+
Seal delay in seconds. ``None`` selects the default for ``freq``.
|
|
137
|
+
|
|
138
|
+
Raises
|
|
139
|
+
------
|
|
140
|
+
ValueError
|
|
141
|
+
If ``freq`` spans more than one day.
|
|
142
|
+
"""
|
|
143
|
+
self.calendar = calendar
|
|
144
|
+
self.freq = freq
|
|
145
|
+
self.step = parse_freq(freq)
|
|
146
|
+
self.daily = self.step >= 86400 * NS
|
|
147
|
+
self.continuous = calendar.upper() in ("24/7", "247", "ALWAYS_OPEN", "CRYPTO")
|
|
148
|
+
g = grace if grace is not None else (0.0 if self.daily else 2.0)
|
|
149
|
+
self.grace_ns = round(g * NS)
|
|
150
|
+
if self.daily and self.step != 86400 * NS:
|
|
151
|
+
raise ValueError("multi-day bars are not supported; use '1d'")
|
|
152
|
+
self._cache_s: np.ndarray | None = None
|
|
153
|
+
self._cache_e: np.ndarray | None = None
|
|
154
|
+
|
|
155
|
+
def __repr__(self) -> str:
|
|
156
|
+
"""Return ``BarGrid(calendar, freq, grace=...s)``."""
|
|
157
|
+
return f"BarGrid({self.calendar!r}, {self.freq!r}, grace={self.grace_ns / NS}s)"
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------ sessions
|
|
160
|
+
def _sessions(self, d0: date, d1: date) -> tuple[np.ndarray, np.ndarray]:
|
|
161
|
+
"""Session (open, close) ns for session dates in [d0, d1]."""
|
|
162
|
+
if self.continuous:
|
|
163
|
+
days = np.arange(np.datetime64(d0, "D"), np.datetime64(d1, "D") + np.timedelta64(1, "D"))
|
|
164
|
+
opens = days.astype("datetime64[ns]").astype(np.int64)
|
|
165
|
+
return opens, opens + 86400 * NS
|
|
166
|
+
cal = _calendar(self.calendar)
|
|
167
|
+
lo = max(d0, cal.first_session.date())
|
|
168
|
+
hi = min(d1, cal.last_session.date())
|
|
169
|
+
if lo > hi:
|
|
170
|
+
return np.empty(0, np.int64), np.empty(0, np.int64)
|
|
171
|
+
sch = cal.schedule.loc[str(lo) : str(hi)]
|
|
172
|
+
opens = _ns(sch["open"])
|
|
173
|
+
closes = _ns(sch["close"])
|
|
174
|
+
return opens, closes
|
|
175
|
+
|
|
176
|
+
def bars(self, start, end) -> tuple[np.ndarray, np.ndarray]:
|
|
177
|
+
"""List every bar whose session date is between ``start`` and ``end``.
|
|
178
|
+
|
|
179
|
+
Parameters
|
|
180
|
+
----------
|
|
181
|
+
start, end
|
|
182
|
+
Inclusive session dates. Anything :func:`to_ns` accepts, or a
|
|
183
|
+
``datetime.date``. The calendar's own first and last sessions
|
|
184
|
+
clip the range.
|
|
185
|
+
|
|
186
|
+
Returns
|
|
187
|
+
-------
|
|
188
|
+
starts, ends : ndarray of int64
|
|
189
|
+
Bar start and end, nanoseconds UTC, half-open ``[start, end)``.
|
|
190
|
+
Daily bars use the session open and the official close, so an
|
|
191
|
+
early close is a shorter bar rather than a missing one. Intraday
|
|
192
|
+
bars step by :attr:`step`; the last bar of a session is clipped
|
|
193
|
+
to the close and dropped if that would make it empty.
|
|
194
|
+
|
|
195
|
+
Notes
|
|
196
|
+
-----
|
|
197
|
+
A holiday inside the range contributes no bars. ``"24/7"`` has no
|
|
198
|
+
holidays: every UTC date from ``start`` through ``end`` is included.
|
|
199
|
+
"""
|
|
200
|
+
d0 = _as_date(start)
|
|
201
|
+
d1 = _as_date(end)
|
|
202
|
+
opens, closes = self._sessions(d0, d1)
|
|
203
|
+
if self.daily:
|
|
204
|
+
return opens.copy(), closes.copy()
|
|
205
|
+
ss, es = [], []
|
|
206
|
+
for o, c in zip(opens, closes, strict=True):
|
|
207
|
+
e = np.arange(o + self.step, c + self.step, self.step, dtype=np.int64)
|
|
208
|
+
# Clip the last bar to the official close. An early close therefore
|
|
209
|
+
# shortens the day. Drop the bar if the clip leaves it empty.
|
|
210
|
+
e[-1] = min(e[-1], c)
|
|
211
|
+
if e.size > 1 and e[-1] <= e[-2]:
|
|
212
|
+
e = e[:-1]
|
|
213
|
+
s = np.r_[o, e[:-1]]
|
|
214
|
+
ss.append(s)
|
|
215
|
+
es.append(e)
|
|
216
|
+
if not es:
|
|
217
|
+
return np.empty(0, np.int64), np.empty(0, np.int64)
|
|
218
|
+
return np.concatenate(ss), np.concatenate(es)
|
|
219
|
+
|
|
220
|
+
def bars_after(self, after_end: int, count: int = 1) -> tuple[np.ndarray, np.ndarray]:
|
|
221
|
+
"""Return the next bars after a sealed bar.
|
|
222
|
+
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
after_end : int
|
|
226
|
+
A bar end, nanoseconds UTC. The returned bars have ends strictly
|
|
227
|
+
greater than this.
|
|
228
|
+
count : int, optional
|
|
229
|
+
How many bars. Default is 1. The live engine asks for one bar
|
|
230
|
+
on every seal.
|
|
231
|
+
|
|
232
|
+
Returns
|
|
233
|
+
-------
|
|
234
|
+
starts, ends : ndarray of int64
|
|
235
|
+
The next ``count`` bars.
|
|
236
|
+
|
|
237
|
+
Raises
|
|
238
|
+
------
|
|
239
|
+
ValueError
|
|
240
|
+
If the calendar runs out before ``count`` bars exist (more than
|
|
241
|
+
about 4000 days ahead).
|
|
242
|
+
|
|
243
|
+
Notes
|
|
244
|
+
-----
|
|
245
|
+
A contiguous block of about a month is cached. Repeated seals inside
|
|
246
|
+
that block do not rebuild the exchange schedule.
|
|
247
|
+
"""
|
|
248
|
+
ce = self._cache_e
|
|
249
|
+
if ce is not None and ce.size and ce[0] <= after_end:
|
|
250
|
+
k = int(np.searchsorted(ce, after_end, side="right"))
|
|
251
|
+
if ce.size - k >= count:
|
|
252
|
+
return self._cache_s[k : k + count].copy(), ce[k : k + count].copy() # type: ignore[index]
|
|
253
|
+
d = _as_date(after_end) - timedelta(days=1)
|
|
254
|
+
span = 35
|
|
255
|
+
while True:
|
|
256
|
+
s, e = self.bars(d, d + timedelta(days=span))
|
|
257
|
+
k = int(np.searchsorted(e, after_end, side="right"))
|
|
258
|
+
if e.size - k >= count:
|
|
259
|
+
self._cache_s, self._cache_e = s, e
|
|
260
|
+
return s[k : k + count].copy(), e[k : k + count].copy()
|
|
261
|
+
span *= 2
|
|
262
|
+
if span > 4000:
|
|
263
|
+
raise ValueError("calendar exhausted; extend the exchange_calendars range")
|
|
264
|
+
|
|
265
|
+
def bar_containing(self, ts: int) -> tuple[int, int]:
|
|
266
|
+
"""Return the bar that contains ``ts``.
|
|
267
|
+
|
|
268
|
+
Parameters
|
|
269
|
+
----------
|
|
270
|
+
ts : int
|
|
271
|
+
Nanoseconds UTC.
|
|
272
|
+
|
|
273
|
+
Returns
|
|
274
|
+
-------
|
|
275
|
+
start, end : int
|
|
276
|
+
The bar with ``start <= ts < end`` if ``ts`` falls in a session.
|
|
277
|
+
Implemented as the first bar whose end is strictly after ``ts``,
|
|
278
|
+
which is the ``[start, end)`` rule. A timestamp in a closed
|
|
279
|
+
session still resolves to the next open bar.
|
|
280
|
+
"""
|
|
281
|
+
s, e = self.bars_after(ts, 1)
|
|
282
|
+
return int(s[0]), int(e[0])
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _ns(col) -> np.ndarray:
|
|
286
|
+
import pandas as pd
|
|
287
|
+
|
|
288
|
+
return np.asarray(pd.DatetimeIndex(col.array).tz_convert("UTC").as_unit("ns").asi8, dtype=np.int64)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _as_date(x) -> date:
|
|
292
|
+
if isinstance(x, datetime):
|
|
293
|
+
return x.astimezone(UTC).date() if x.tzinfo else x.date()
|
|
294
|
+
if isinstance(x, date):
|
|
295
|
+
return x
|
|
296
|
+
if isinstance(x, str):
|
|
297
|
+
return datetime.fromisoformat(x).date()
|
|
298
|
+
return datetime.fromtimestamp(int(x) / NS, tz=UTC).date()
|