iosislib 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.
- iosislib/__init__.py +13 -0
- iosislib/backtest/__init__.py +70 -0
- iosislib/backtest/backtest.py +467 -0
- iosislib/backtest/feeds.py +101 -0
- iosislib/backtest/policy.py +589 -0
- iosislib/backtest/risk.py +279 -0
- iosislib/backtest/venue.py +50 -0
- iosislib/catalog.py +197 -0
- iosislib/charting/__init__.py +25 -0
- iosislib/charting/plot.py +409 -0
- iosislib/core/__init__.py +78 -0
- iosislib/core/graph.py +1147 -0
- iosislib/core/model.py +928 -0
- iosislib/core/node.py +377 -0
- iosislib/core/tsfn.py +1196 -0
- iosislib/core/utils.py +691 -0
- iosislib/metrics/__init__.py +22 -0
- iosislib/metrics/extract.py +125 -0
- iosislib/metrics/extractor.py +61 -0
- iosislib/metrics/extractors.py +100 -0
- iosislib/metrics/strategy.py +296 -0
- iosislib/models/__init__.py +13 -0
- iosislib/models/_regression.py +95 -0
- iosislib/models/lightgbm.py +381 -0
- iosislib/models/mlp.py +418 -0
- iosislib/py.typed +0 -0
- iosislib/strategy/__init__.py +61 -0
- iosislib/strategy/ir.py +530 -0
- iosislib/strategy/lowering.py +231 -0
- iosislib/strategy/parser.py +126 -0
- iosislib/strategy/strategy.schema.json +108 -0
- iosislib/tsfn/__init__.py +27 -0
- iosislib/tsfn/adapters/__init__.py +59 -0
- iosislib/tsfn/adapters/dataset.py +255 -0
- iosislib/tsfn/adapters/inline_sources.py +98 -0
- iosislib/tsfn/adapters/local_sources.py +431 -0
- iosislib/tsfn/adapters/parquet_stream.py +449 -0
- iosislib/tsfn/adapters/polymarket.py +449 -0
- iosislib/tsfn/adapters/yfinance.py +261 -0
- iosislib/tsfn/transforms/__init__.py +85 -0
- iosislib/tsfn/transforms/_validation.py +14 -0
- iosislib/tsfn/transforms/delta.py +109 -0
- iosislib/tsfn/transforms/ewm.py +133 -0
- iosislib/tsfn/transforms/feature_packer.py +217 -0
- iosislib/tsfn/transforms/feature_unpacker.py +136 -0
- iosislib/tsfn/transforms/lag.py +108 -0
- iosislib/tsfn/transforms/lead.py +110 -0
- iosislib/tsfn/transforms/log.py +100 -0
- iosislib/tsfn/transforms/log_ratio.py +81 -0
- iosislib/tsfn/transforms/log_return.py +61 -0
- iosislib/tsfn/transforms/logit.py +52 -0
- iosislib/tsfn/transforms/negate.py +48 -0
- iosislib/tsfn/transforms/pct_change.py +64 -0
- iosislib/tsfn/transforms/ratio.py +70 -0
- iosislib/tsfn/transforms/rolling.py +283 -0
- iosislib/tsfn/transforms/spread.py +60 -0
- iosislib-0.1.0.dist-info/METADATA +181 -0
- iosislib-0.1.0.dist-info/RECORD +62 -0
- iosislib-0.1.0.dist-info/WHEEL +5 -0
- iosislib-0.1.0.dist-info/licenses/LICENSE +52 -0
- iosislib-0.1.0.dist-info/licenses/NOTICE +13 -0
- iosislib-0.1.0.dist-info/top_level.txt +1 -0
iosislib/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Typed, deterministic computation graphs for Polars time series."""
|
|
2
|
+
|
|
3
|
+
from iosislib.core.graph import Graph
|
|
4
|
+
from iosislib.core.node import Node
|
|
5
|
+
from iosislib.core.tsfn import ColumnSignature, FrameSignature, TimeAxis
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ColumnSignature",
|
|
9
|
+
"FrameSignature",
|
|
10
|
+
"Graph",
|
|
11
|
+
"Node",
|
|
12
|
+
"TimeAxis",
|
|
13
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Graph-native immediate-execution backtesting primitives."""
|
|
2
|
+
|
|
3
|
+
from iosislib.backtest.backtest import (
|
|
4
|
+
BacktestConfig,
|
|
5
|
+
BacktestTSFN,
|
|
6
|
+
list_policies,
|
|
7
|
+
list_risk_policies,
|
|
8
|
+
register_policy,
|
|
9
|
+
register_risk_policy,
|
|
10
|
+
)
|
|
11
|
+
from iosislib.backtest.feeds import Feed, L1Feed
|
|
12
|
+
from iosislib.backtest.policy import (
|
|
13
|
+
Array,
|
|
14
|
+
FeatureBuffer,
|
|
15
|
+
MarketState,
|
|
16
|
+
ModelPolicy,
|
|
17
|
+
ModelPolicyState,
|
|
18
|
+
Order,
|
|
19
|
+
OrderModelPolicy,
|
|
20
|
+
Policy,
|
|
21
|
+
PolicyState,
|
|
22
|
+
SignalPolicy,
|
|
23
|
+
StatefulPolicy,
|
|
24
|
+
ThresholdPolicy,
|
|
25
|
+
)
|
|
26
|
+
from iosislib.backtest.risk import (
|
|
27
|
+
FractionalKellyPolicy,
|
|
28
|
+
FractionalLimitPolicy,
|
|
29
|
+
NO_OP_RISK,
|
|
30
|
+
NoOpRiskPolicy,
|
|
31
|
+
RiskDecision,
|
|
32
|
+
RiskPolicy,
|
|
33
|
+
RiskReason,
|
|
34
|
+
StatefulRiskPolicy,
|
|
35
|
+
classify_reason,
|
|
36
|
+
)
|
|
37
|
+
from iosislib.backtest.venue import Venue
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"Array",
|
|
41
|
+
"BacktestConfig",
|
|
42
|
+
"BacktestTSFN",
|
|
43
|
+
"FeatureBuffer",
|
|
44
|
+
"Feed",
|
|
45
|
+
"FractionalKellyPolicy",
|
|
46
|
+
"FractionalLimitPolicy",
|
|
47
|
+
"L1Feed",
|
|
48
|
+
"MarketState",
|
|
49
|
+
"ModelPolicy",
|
|
50
|
+
"ModelPolicyState",
|
|
51
|
+
"NO_OP_RISK",
|
|
52
|
+
"NoOpRiskPolicy",
|
|
53
|
+
"Order",
|
|
54
|
+
"OrderModelPolicy",
|
|
55
|
+
"Policy",
|
|
56
|
+
"PolicyState",
|
|
57
|
+
"RiskDecision",
|
|
58
|
+
"RiskPolicy",
|
|
59
|
+
"RiskReason",
|
|
60
|
+
"SignalPolicy",
|
|
61
|
+
"StatefulPolicy",
|
|
62
|
+
"StatefulRiskPolicy",
|
|
63
|
+
"ThresholdPolicy",
|
|
64
|
+
"Venue",
|
|
65
|
+
"classify_reason",
|
|
66
|
+
"list_policies",
|
|
67
|
+
"list_risk_policies",
|
|
68
|
+
"register_policy",
|
|
69
|
+
"register_risk_policy",
|
|
70
|
+
]
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""A minimal graph-native, immediate-execution backtest TSFN."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from math import isfinite
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import numpy.typing as npt
|
|
12
|
+
import polars as pl
|
|
13
|
+
|
|
14
|
+
from iosislib.backtest.feeds import Feed, L1Feed
|
|
15
|
+
from iosislib.backtest.policy import (
|
|
16
|
+
Array,
|
|
17
|
+
MarketState,
|
|
18
|
+
ModelPolicy,
|
|
19
|
+
Policy,
|
|
20
|
+
PolicyState,
|
|
21
|
+
SignalPolicy,
|
|
22
|
+
StatefulPolicy,
|
|
23
|
+
ThresholdPolicy,
|
|
24
|
+
)
|
|
25
|
+
from iosislib.backtest.risk import (
|
|
26
|
+
NO_OP_RISK,
|
|
27
|
+
FractionalKellyPolicy,
|
|
28
|
+
FractionalLimitPolicy,
|
|
29
|
+
RiskPolicy,
|
|
30
|
+
StatefulRiskPolicy,
|
|
31
|
+
)
|
|
32
|
+
from iosislib.backtest.venue import Venue
|
|
33
|
+
from iosislib.core.tsfn import BatchTSFN, FrameSignature, TSFNConfig, _column_signatures
|
|
34
|
+
from iosislib.core.utils import (
|
|
35
|
+
_datetime_dtype_without_timezone,
|
|
36
|
+
_dtype_matches,
|
|
37
|
+
numpy_to_series,
|
|
38
|
+
series_to_numpy,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_POLICY_REGISTRY: dict[str, type[Policy]] = {
|
|
43
|
+
"signal": SignalPolicy,
|
|
44
|
+
"threshold": ThresholdPolicy,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_RISK_REGISTRY: dict[str, type[RiskPolicy]] = {
|
|
48
|
+
"fractional_limit": FractionalLimitPolicy,
|
|
49
|
+
"fractional_kelly": FractionalKellyPolicy,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def register_policy(kind: str, cls: type[Policy]) -> None:
|
|
54
|
+
"""Register a custom Policy class under a declarative ``kind`` name.
|
|
55
|
+
|
|
56
|
+
The ``kind`` value is used in YAML strategy declarations as
|
|
57
|
+
``policy: {kind: "<kind>", ...}``. The class must be a concrete
|
|
58
|
+
``Policy`` subclass.
|
|
59
|
+
"""
|
|
60
|
+
if not isinstance(kind, str) or not kind.strip():
|
|
61
|
+
raise ValueError("policy kind must be a non-empty string")
|
|
62
|
+
if not isinstance(cls, type) or not issubclass(cls, Policy):
|
|
63
|
+
raise TypeError("cls must be a concrete Policy subclass")
|
|
64
|
+
if kind in _POLICY_REGISTRY:
|
|
65
|
+
raise ValueError(f"policy kind {kind!r} is already registered")
|
|
66
|
+
_POLICY_REGISTRY[kind] = cls
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def register_risk_policy(kind: str, cls: type[RiskPolicy]) -> None:
|
|
70
|
+
"""Register a custom RiskPolicy class under a declarative ``kind`` name.
|
|
71
|
+
|
|
72
|
+
The ``kind`` value is used in YAML strategy declarations as
|
|
73
|
+
``risk_policy: {kind: "<kind>", ...}``. The class must be a concrete
|
|
74
|
+
``RiskPolicy`` subclass.
|
|
75
|
+
"""
|
|
76
|
+
if not isinstance(kind, str) or not kind.strip():
|
|
77
|
+
raise ValueError("risk_policy kind must be a non-empty string")
|
|
78
|
+
if not isinstance(cls, type) or not issubclass(cls, RiskPolicy):
|
|
79
|
+
raise TypeError("cls must be a concrete RiskPolicy subclass")
|
|
80
|
+
if kind in _RISK_REGISTRY:
|
|
81
|
+
raise ValueError(f"risk_policy kind {kind!r} is already registered")
|
|
82
|
+
_RISK_REGISTRY[kind] = cls
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def list_policies() -> dict[str, type[Policy]]:
|
|
86
|
+
"""Return the current policy registry."""
|
|
87
|
+
return dict(_POLICY_REGISTRY)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def list_risk_policies() -> dict[str, type[RiskPolicy]]:
|
|
91
|
+
"""Return the current risk policy registry."""
|
|
92
|
+
return dict(_RISK_REGISTRY)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _feed_from_declaration(value: Mapping[str, Any]) -> Feed:
|
|
96
|
+
"""Resolve a declarative feed mapping into a concrete ``Feed`` instance."""
|
|
97
|
+
kind = value.get("kind", "l1")
|
|
98
|
+
if kind != "l1":
|
|
99
|
+
raise ValueError(f"Unsupported feed kind: {kind!r}; only 'l1' is supported")
|
|
100
|
+
venue_data = value.get("venue")
|
|
101
|
+
if not isinstance(venue_data, Mapping):
|
|
102
|
+
raise ValueError("feed.venue must be a mapping with 'name' and 'universe'")
|
|
103
|
+
universe = venue_data.get("universe")
|
|
104
|
+
if isinstance(universe, (list, tuple)):
|
|
105
|
+
universe = tuple(universe)
|
|
106
|
+
else:
|
|
107
|
+
raise ValueError("feed.venue.universe must be a list of asset names")
|
|
108
|
+
venue = Venue(name=venue_data["name"], universe=universe)
|
|
109
|
+
return L1Feed(
|
|
110
|
+
venue=venue,
|
|
111
|
+
bid_column=value.get("bid_column", "bid"),
|
|
112
|
+
ask_column=value.get("ask_column", "ask"),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _policy_from_declaration(value: Mapping[str, Any]) -> Policy:
|
|
117
|
+
"""Resolve a declarative policy mapping into a concrete ``Policy`` instance."""
|
|
118
|
+
kind = value.get("kind")
|
|
119
|
+
if kind is None:
|
|
120
|
+
raise ValueError("policy declaration must declare a 'kind'")
|
|
121
|
+
cls = _POLICY_REGISTRY.get(kind)
|
|
122
|
+
if cls is None:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"Unknown policy kind: {kind!r}; "
|
|
125
|
+
f"available: {sorted(_POLICY_REGISTRY)}"
|
|
126
|
+
)
|
|
127
|
+
params = {k: v for k, v in value.items() if k != "kind"}
|
|
128
|
+
return cls(**params)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _risk_policy_from_declaration(value: Mapping[str, Any]) -> RiskPolicy:
|
|
132
|
+
"""Resolve a declarative risk policy mapping into a ``RiskPolicy``."""
|
|
133
|
+
kind = value.get("kind")
|
|
134
|
+
if kind is None:
|
|
135
|
+
raise ValueError("risk_policy declaration must declare a 'kind'")
|
|
136
|
+
cls = _RISK_REGISTRY.get(kind)
|
|
137
|
+
if cls is None:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"Unknown risk_policy kind: {kind!r}; "
|
|
140
|
+
f"available: {sorted(_RISK_REGISTRY)}"
|
|
141
|
+
)
|
|
142
|
+
params = {k: v for k, v in value.items() if k != "kind"}
|
|
143
|
+
return cls(**params)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _normalize_feed(value: Feed | Mapping[str, Any]) -> Feed:
|
|
147
|
+
if isinstance(value, Feed):
|
|
148
|
+
return value
|
|
149
|
+
if isinstance(value, Mapping):
|
|
150
|
+
return _feed_from_declaration(value)
|
|
151
|
+
raise TypeError("feed must be a Feed or a declarative mapping")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _normalize_policy(value: Policy | Mapping[str, Any]) -> Policy:
|
|
155
|
+
if isinstance(value, Policy):
|
|
156
|
+
return value
|
|
157
|
+
if isinstance(value, Mapping):
|
|
158
|
+
return _policy_from_declaration(value)
|
|
159
|
+
raise TypeError("policy must be a Policy or a declarative mapping")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _normalize_risk_policy(value: RiskPolicy | Mapping[str, Any]) -> RiskPolicy:
|
|
163
|
+
if isinstance(value, RiskPolicy):
|
|
164
|
+
return value
|
|
165
|
+
if isinstance(value, Mapping):
|
|
166
|
+
return _risk_policy_from_declaration(value)
|
|
167
|
+
raise TypeError("risk_policy must be a RiskPolicy or a declarative mapping")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True)
|
|
171
|
+
class BacktestConfig(TSFNConfig):
|
|
172
|
+
"""Configuration for one immediate-execution simulation.
|
|
173
|
+
|
|
174
|
+
``feed`` and ``policy`` accept either live Python objects or declarative
|
|
175
|
+
YAML-compatible mappings. ``risk_policy`` follows the same convention.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
feed: Feed
|
|
179
|
+
policy: Policy
|
|
180
|
+
initial_cash: float
|
|
181
|
+
risk_policy: RiskPolicy | None = None
|
|
182
|
+
validate: bool = True
|
|
183
|
+
|
|
184
|
+
def __post_init__(self) -> None:
|
|
185
|
+
object.__setattr__(
|
|
186
|
+
self, "feed", _normalize_feed(self.feed)
|
|
187
|
+
)
|
|
188
|
+
object.__setattr__(
|
|
189
|
+
self, "policy", _normalize_policy(self.policy)
|
|
190
|
+
)
|
|
191
|
+
if self.risk_policy is not None:
|
|
192
|
+
object.__setattr__(
|
|
193
|
+
self, "risk_policy", _normalize_risk_policy(self.risk_policy)
|
|
194
|
+
)
|
|
195
|
+
if not isinstance(self.feed, Feed):
|
|
196
|
+
raise TypeError("feed must be a Feed")
|
|
197
|
+
if not isinstance(self.policy, Policy):
|
|
198
|
+
raise TypeError("policy must be a Policy")
|
|
199
|
+
if not isfinite(self.initial_cash):
|
|
200
|
+
raise ValueError("initial_cash must be finite")
|
|
201
|
+
if self.risk_policy is not None and not isinstance(
|
|
202
|
+
self.risk_policy, RiskPolicy
|
|
203
|
+
):
|
|
204
|
+
raise TypeError("risk_policy must be a RiskPolicy or None")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class BacktestTSFN(BatchTSFN[BacktestConfig]):
|
|
208
|
+
"""Simulate policy orders against a feed's executable quotes, row by row."""
|
|
209
|
+
|
|
210
|
+
VERSION = "1.2.0"
|
|
211
|
+
CONFIG_CLS = BacktestConfig
|
|
212
|
+
|
|
213
|
+
def type_signature(self) -> tuple[FrameSignature, FrameSignature]:
|
|
214
|
+
config = self.parameters
|
|
215
|
+
width = config.feed.width
|
|
216
|
+
feature_shape = (
|
|
217
|
+
config.policy.feature_shape
|
|
218
|
+
if isinstance(config.policy, ModelPolicy)
|
|
219
|
+
else (width,)
|
|
220
|
+
)
|
|
221
|
+
signal_column = (
|
|
222
|
+
("signal", pl.Float64)
|
|
223
|
+
if not feature_shape
|
|
224
|
+
else ("signal", pl.Float64, feature_shape)
|
|
225
|
+
)
|
|
226
|
+
input_columns = (*config.feed.columns, signal_column)
|
|
227
|
+
if isinstance(config.policy, ModelPolicy):
|
|
228
|
+
target_column = (
|
|
229
|
+
("target", pl.Float64)
|
|
230
|
+
if not config.policy.target_shape
|
|
231
|
+
else ("target", pl.Float64, config.policy.target_shape)
|
|
232
|
+
)
|
|
233
|
+
input_columns = (*input_columns, target_column)
|
|
234
|
+
return (
|
|
235
|
+
FrameSignature(time=config.feed.time_axis, columns=input_columns),
|
|
236
|
+
FrameSignature(
|
|
237
|
+
time=config.feed.time_axis,
|
|
238
|
+
columns=(
|
|
239
|
+
("cash", pl.Float64),
|
|
240
|
+
("equity", pl.Float64),
|
|
241
|
+
("balance", pl.Float64, (width,)),
|
|
242
|
+
("order", pl.Float64, (width,)),
|
|
243
|
+
("proposed_order", pl.Float64, (width,)),
|
|
244
|
+
),
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def batch(self, frame: pl.DataFrame) -> pl.DataFrame:
|
|
249
|
+
if self.parameters.validate:
|
|
250
|
+
self._validate_frame(frame)
|
|
251
|
+
config = self.parameters
|
|
252
|
+
width = config.feed.width
|
|
253
|
+
rows = frame.height
|
|
254
|
+
bid, ask = self._quotes(frame)
|
|
255
|
+
feature_shape = (
|
|
256
|
+
config.policy.feature_shape
|
|
257
|
+
if isinstance(config.policy, ModelPolicy)
|
|
258
|
+
else (width,)
|
|
259
|
+
)
|
|
260
|
+
information = self._value_column(
|
|
261
|
+
frame.get_column("signal"), feature_shape, "signal"
|
|
262
|
+
)
|
|
263
|
+
target = (
|
|
264
|
+
self._value_column(
|
|
265
|
+
frame.get_column("target"), config.policy.target_shape, "target"
|
|
266
|
+
)
|
|
267
|
+
if isinstance(config.policy, ModelPolicy)
|
|
268
|
+
else None
|
|
269
|
+
)
|
|
270
|
+
timestamps = frame.get_column(config.feed.time_axis.column)
|
|
271
|
+
|
|
272
|
+
policy = config.policy
|
|
273
|
+
policy_state = self._initial_policy_state(policy)
|
|
274
|
+
|
|
275
|
+
risk_policy = (
|
|
276
|
+
config.risk_policy if config.risk_policy is not None else NO_OP_RISK
|
|
277
|
+
)
|
|
278
|
+
risk_state = self._initial_risk_state(risk_policy)
|
|
279
|
+
|
|
280
|
+
proposed_order = np.empty((rows, width), dtype=np.float64)
|
|
281
|
+
order = np.empty((rows, width), dtype=np.float64)
|
|
282
|
+
balance = np.empty((rows, width), dtype=np.float64)
|
|
283
|
+
cash_col = np.empty(rows, dtype=np.float64)
|
|
284
|
+
|
|
285
|
+
price = np.empty(width, dtype=np.float64)
|
|
286
|
+
price_mask: npt.NDArray[np.bool_] = np.empty(width, dtype=np.bool_)
|
|
287
|
+
|
|
288
|
+
state = MarketState(information, bid, ask, target)
|
|
289
|
+
running_cash = config.initial_cash
|
|
290
|
+
running_balances = np.zeros(width, dtype=np.float64)
|
|
291
|
+
|
|
292
|
+
_move_to = state.move_to
|
|
293
|
+
_policy_decide = policy.decide
|
|
294
|
+
_risk_decide = risk_policy.decide
|
|
295
|
+
_execute = self._execute
|
|
296
|
+
|
|
297
|
+
for row, timestamp in enumerate(timestamps):
|
|
298
|
+
_move_to(timestamp, row)
|
|
299
|
+
policy_state = _policy_decide(
|
|
300
|
+
policy_state, state, running_cash, running_balances,
|
|
301
|
+
proposed_order, row,
|
|
302
|
+
)
|
|
303
|
+
risk_state = _risk_decide(
|
|
304
|
+
risk_state, state, proposed_order, running_cash,
|
|
305
|
+
running_balances, order, row,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
running_cash = _execute(
|
|
309
|
+
running_cash, running_balances, order, bid, ask,
|
|
310
|
+
row, price, price_mask,
|
|
311
|
+
)
|
|
312
|
+
cash_col[row] = running_cash
|
|
313
|
+
balance[row] = running_balances
|
|
314
|
+
|
|
315
|
+
equity = cash_col + (balance * bid).sum(axis=1)
|
|
316
|
+
return pl.DataFrame(
|
|
317
|
+
[
|
|
318
|
+
timestamps,
|
|
319
|
+
numpy_to_series("cash", cash_col),
|
|
320
|
+
numpy_to_series("equity", equity),
|
|
321
|
+
self._array_series("balance", balance, width),
|
|
322
|
+
self._array_series("order", order, width),
|
|
323
|
+
self._array_series("proposed_order", proposed_order, width),
|
|
324
|
+
]
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def _array_series(self, name: str, values: Array, width: int) -> pl.Series:
|
|
328
|
+
if len(values) == 0:
|
|
329
|
+
return pl.Series(name, [], dtype=pl.Array(pl.Float64, width))
|
|
330
|
+
return numpy_to_series(name, values, shape=(width,))
|
|
331
|
+
|
|
332
|
+
def _quotes(self, frame: pl.DataFrame) -> tuple[Array, Array]:
|
|
333
|
+
quote_series = self.parameters.feed.quotes(frame)
|
|
334
|
+
if (
|
|
335
|
+
not isinstance(quote_series, tuple)
|
|
336
|
+
or len(quote_series) != 2
|
|
337
|
+
or not all(isinstance(series, pl.Series) for series in quote_series)
|
|
338
|
+
):
|
|
339
|
+
raise TypeError(
|
|
340
|
+
"Feed.quotes must return a (bid, ask) pair of Polars Series"
|
|
341
|
+
)
|
|
342
|
+
bid_series, ask_series = quote_series
|
|
343
|
+
width = self.parameters.feed.width
|
|
344
|
+
bid = self._array_column(bid_series, width, "bid")
|
|
345
|
+
ask = self._array_column(ask_series, width, "ask")
|
|
346
|
+
if (bid <= 0.0).any() or (ask <= 0.0).any():
|
|
347
|
+
raise ValueError("bid and ask must be positive")
|
|
348
|
+
if (bid > ask).any():
|
|
349
|
+
raise ValueError("bid cannot exceed ask")
|
|
350
|
+
return bid, ask
|
|
351
|
+
|
|
352
|
+
def _array_column(self, series: pl.Series, width: int, name: str) -> Array:
|
|
353
|
+
return self._value_column(series, (width,), name)
|
|
354
|
+
|
|
355
|
+
def _value_column(
|
|
356
|
+
self,
|
|
357
|
+
series: pl.Series,
|
|
358
|
+
shape: tuple[int, ...],
|
|
359
|
+
name: str,
|
|
360
|
+
) -> Array:
|
|
361
|
+
if series.null_count():
|
|
362
|
+
raise ValueError(f"{name} cannot contain nulls")
|
|
363
|
+
if len(series) == 0:
|
|
364
|
+
return np.empty(
|
|
365
|
+
(0, *shape) if shape else (0,),
|
|
366
|
+
dtype=np.float64,
|
|
367
|
+
)
|
|
368
|
+
values = cast(
|
|
369
|
+
Array,
|
|
370
|
+
series_to_numpy(
|
|
371
|
+
series,
|
|
372
|
+
shape=shape or None,
|
|
373
|
+
allow_copy=True,
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
expected_shape = (len(series), *shape) if shape else (len(series),)
|
|
377
|
+
if values.shape != expected_shape:
|
|
378
|
+
raise TypeError(
|
|
379
|
+
f"{name} must have shape {shape or '()'} per row"
|
|
380
|
+
)
|
|
381
|
+
if values.dtype != np.dtype(np.float64):
|
|
382
|
+
raise TypeError(f"{name} must expose Float64 values")
|
|
383
|
+
if not np.isfinite(values).all():
|
|
384
|
+
raise ValueError(f"{name} must be finite")
|
|
385
|
+
return values
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _initial_policy_state(policy: Policy) -> PolicyState | None:
|
|
389
|
+
if not isinstance(policy, StatefulPolicy):
|
|
390
|
+
return None
|
|
391
|
+
policy_state = policy.initial_state()
|
|
392
|
+
if not isinstance(policy_state, PolicyState):
|
|
393
|
+
raise TypeError("StatefulPolicy.initial_state must return a PolicyState")
|
|
394
|
+
return policy_state
|
|
395
|
+
|
|
396
|
+
@staticmethod
|
|
397
|
+
def _initial_risk_state(
|
|
398
|
+
risk_policy: RiskPolicy | None,
|
|
399
|
+
) -> PolicyState | None:
|
|
400
|
+
if risk_policy is None or not isinstance(risk_policy, StatefulRiskPolicy):
|
|
401
|
+
return None
|
|
402
|
+
risk_state = risk_policy.initial_state()
|
|
403
|
+
if not isinstance(risk_state, PolicyState):
|
|
404
|
+
raise TypeError(
|
|
405
|
+
"StatefulRiskPolicy.initial_state must return a PolicyState"
|
|
406
|
+
)
|
|
407
|
+
return risk_state
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def _execute(
|
|
411
|
+
cash: float,
|
|
412
|
+
balances: Array,
|
|
413
|
+
orders: Array,
|
|
414
|
+
bid: Array,
|
|
415
|
+
ask: Array,
|
|
416
|
+
row: int,
|
|
417
|
+
price: Array,
|
|
418
|
+
price_mask: npt.NDArray[np.bool_],
|
|
419
|
+
) -> float:
|
|
420
|
+
order_row = orders[row]
|
|
421
|
+
ask_row = ask[row]
|
|
422
|
+
bid_row = bid[row]
|
|
423
|
+
width = order_row.shape[0]
|
|
424
|
+
if width < 8:
|
|
425
|
+
for asset in range(width):
|
|
426
|
+
quantity = order_row[asset]
|
|
427
|
+
if quantity != 0.0:
|
|
428
|
+
if quantity >= 0.0:
|
|
429
|
+
cash -= quantity * ask_row[asset]
|
|
430
|
+
else:
|
|
431
|
+
cash -= quantity * bid_row[asset]
|
|
432
|
+
balances[asset] += quantity
|
|
433
|
+
return cash
|
|
434
|
+
np.greater_equal(order_row, 0.0, out=price_mask)
|
|
435
|
+
np.copyto(price, bid_row)
|
|
436
|
+
np.copyto(price, ask_row, where=price_mask)
|
|
437
|
+
cash -= float(np.dot(order_row, price))
|
|
438
|
+
balances += order_row
|
|
439
|
+
return cash
|
|
440
|
+
|
|
441
|
+
def _validate_frame(self, frame: pl.DataFrame) -> None:
|
|
442
|
+
config = self.parameters
|
|
443
|
+
time = config.feed.time_axis
|
|
444
|
+
if time.column not in frame.schema:
|
|
445
|
+
raise ValueError(f"Missing required time column: '{time.column}'")
|
|
446
|
+
actual_time = frame.schema[time.column]
|
|
447
|
+
if not _dtype_matches(
|
|
448
|
+
_datetime_dtype_without_timezone(actual_time),
|
|
449
|
+
_datetime_dtype_without_timezone(cast(pl.DataType, time.dtype)),
|
|
450
|
+
):
|
|
451
|
+
raise TypeError(f"Time column '{time.column}' type mismatch")
|
|
452
|
+
if getattr(actual_time, "time_zone", None) != time.timezone:
|
|
453
|
+
raise TypeError(f"Time column '{time.column}' timezone mismatch")
|
|
454
|
+
timestamps = frame.get_column(time.column)
|
|
455
|
+
if timestamps.null_count() or not timestamps.is_sorted():
|
|
456
|
+
raise ValueError(f"{time.column} must be non-null and sorted")
|
|
457
|
+
if timestamps.n_unique() != frame.height:
|
|
458
|
+
raise ValueError(f"{time.column} must be strictly increasing")
|
|
459
|
+
for column in _column_signatures(self.type_signature()[0]):
|
|
460
|
+
actual = frame.schema.get(column.name)
|
|
461
|
+
if actual is None:
|
|
462
|
+
raise ValueError(f"Missing required input column: '{column.name}'")
|
|
463
|
+
if actual != column.physical_dtype:
|
|
464
|
+
raise TypeError(f"Column '{column.name}' type mismatch")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
__all__ = ["BacktestConfig", "BacktestTSFN"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Market-feed declarations for graph-native backtests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, ClassVar
|
|
8
|
+
|
|
9
|
+
import polars as pl
|
|
10
|
+
|
|
11
|
+
from iosislib.core.tsfn import ColumnEntry, FrameSignature, TimeAxis
|
|
12
|
+
from iosislib.core.utils import _canonical_json
|
|
13
|
+
|
|
14
|
+
from iosislib.backtest.venue import Venue
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Feed(ABC):
|
|
19
|
+
"""Declare a market schema and extract executable quotes from a frame."""
|
|
20
|
+
|
|
21
|
+
_SERIALIZE_WITH_TO_DICT: ClassVar[bool] = True
|
|
22
|
+
VERSION: ClassVar[str]
|
|
23
|
+
|
|
24
|
+
venue: Venue
|
|
25
|
+
time_axis: TimeAxis = TimeAxis()
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if not isinstance(self.venue, Venue):
|
|
29
|
+
raise TypeError("venue must be a Venue")
|
|
30
|
+
if not isinstance(self.time_axis, TimeAxis):
|
|
31
|
+
raise TypeError("time_axis must be a TimeAxis")
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def width(self) -> int:
|
|
35
|
+
"""Return the number of quote values expected on each row."""
|
|
36
|
+
return self.venue.width
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def columns(self) -> tuple[ColumnEntry, ...]:
|
|
41
|
+
"""Return physical graph columns required by this feed."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def quotes(self, frame: pl.DataFrame) -> tuple[pl.Series, pl.Series]:
|
|
45
|
+
"""Return one array-valued bid and ask series for every frame row."""
|
|
46
|
+
|
|
47
|
+
def frame_signature(self) -> FrameSignature:
|
|
48
|
+
"""Return the full physical input contract for this feed."""
|
|
49
|
+
return FrameSignature(time=self.time_axis, columns=self.columns)
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"type": f"{type(self).__module__}.{type(self).__qualname__}",
|
|
54
|
+
"version": self.VERSION,
|
|
55
|
+
"venue": self.venue.to_dict(),
|
|
56
|
+
"time_axis": {
|
|
57
|
+
"column": self.time_axis.column,
|
|
58
|
+
"dtype": str(self.time_axis.dtype),
|
|
59
|
+
"timezone": self.time_axis.timezone,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
def __str__(self) -> str:
|
|
64
|
+
return _canonical_json(self.to_dict())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class L1Feed(Feed):
|
|
69
|
+
"""Best bid and ask quote vectors for every asset and timestamp."""
|
|
70
|
+
|
|
71
|
+
VERSION = "1.0.0"
|
|
72
|
+
bid_column: str = "bid"
|
|
73
|
+
ask_column: str = "ask"
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
super().__post_init__()
|
|
77
|
+
if not self.bid_column or not self.ask_column:
|
|
78
|
+
raise ValueError("quote column names cannot be empty")
|
|
79
|
+
if self.bid_column == self.ask_column:
|
|
80
|
+
raise ValueError("bid and ask columns must differ")
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def columns(self) -> tuple[ColumnEntry, ...]:
|
|
84
|
+
shape = (self.width,)
|
|
85
|
+
return (
|
|
86
|
+
(self.bid_column, pl.Float64, shape),
|
|
87
|
+
(self.ask_column, pl.Float64, shape),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def quotes(self, frame: pl.DataFrame) -> tuple[pl.Series, pl.Series]:
|
|
91
|
+
return frame.get_column(self.bid_column), frame.get_column(self.ask_column)
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> dict[str, Any]:
|
|
94
|
+
return {
|
|
95
|
+
**super().to_dict(),
|
|
96
|
+
"bid_column": self.bid_column,
|
|
97
|
+
"ask_column": self.ask_column,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ["Feed", "L1Feed"]
|