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.
Files changed (62) hide show
  1. iosislib/__init__.py +13 -0
  2. iosislib/backtest/__init__.py +70 -0
  3. iosislib/backtest/backtest.py +467 -0
  4. iosislib/backtest/feeds.py +101 -0
  5. iosislib/backtest/policy.py +589 -0
  6. iosislib/backtest/risk.py +279 -0
  7. iosislib/backtest/venue.py +50 -0
  8. iosislib/catalog.py +197 -0
  9. iosislib/charting/__init__.py +25 -0
  10. iosislib/charting/plot.py +409 -0
  11. iosislib/core/__init__.py +78 -0
  12. iosislib/core/graph.py +1147 -0
  13. iosislib/core/model.py +928 -0
  14. iosislib/core/node.py +377 -0
  15. iosislib/core/tsfn.py +1196 -0
  16. iosislib/core/utils.py +691 -0
  17. iosislib/metrics/__init__.py +22 -0
  18. iosislib/metrics/extract.py +125 -0
  19. iosislib/metrics/extractor.py +61 -0
  20. iosislib/metrics/extractors.py +100 -0
  21. iosislib/metrics/strategy.py +296 -0
  22. iosislib/models/__init__.py +13 -0
  23. iosislib/models/_regression.py +95 -0
  24. iosislib/models/lightgbm.py +381 -0
  25. iosislib/models/mlp.py +418 -0
  26. iosislib/py.typed +0 -0
  27. iosislib/strategy/__init__.py +61 -0
  28. iosislib/strategy/ir.py +530 -0
  29. iosislib/strategy/lowering.py +231 -0
  30. iosislib/strategy/parser.py +126 -0
  31. iosislib/strategy/strategy.schema.json +108 -0
  32. iosislib/tsfn/__init__.py +27 -0
  33. iosislib/tsfn/adapters/__init__.py +59 -0
  34. iosislib/tsfn/adapters/dataset.py +255 -0
  35. iosislib/tsfn/adapters/inline_sources.py +98 -0
  36. iosislib/tsfn/adapters/local_sources.py +431 -0
  37. iosislib/tsfn/adapters/parquet_stream.py +449 -0
  38. iosislib/tsfn/adapters/polymarket.py +449 -0
  39. iosislib/tsfn/adapters/yfinance.py +261 -0
  40. iosislib/tsfn/transforms/__init__.py +85 -0
  41. iosislib/tsfn/transforms/_validation.py +14 -0
  42. iosislib/tsfn/transforms/delta.py +109 -0
  43. iosislib/tsfn/transforms/ewm.py +133 -0
  44. iosislib/tsfn/transforms/feature_packer.py +217 -0
  45. iosislib/tsfn/transforms/feature_unpacker.py +136 -0
  46. iosislib/tsfn/transforms/lag.py +108 -0
  47. iosislib/tsfn/transforms/lead.py +110 -0
  48. iosislib/tsfn/transforms/log.py +100 -0
  49. iosislib/tsfn/transforms/log_ratio.py +81 -0
  50. iosislib/tsfn/transforms/log_return.py +61 -0
  51. iosislib/tsfn/transforms/logit.py +52 -0
  52. iosislib/tsfn/transforms/negate.py +48 -0
  53. iosislib/tsfn/transforms/pct_change.py +64 -0
  54. iosislib/tsfn/transforms/ratio.py +70 -0
  55. iosislib/tsfn/transforms/rolling.py +283 -0
  56. iosislib/tsfn/transforms/spread.py +60 -0
  57. iosislib-0.1.0.dist-info/METADATA +181 -0
  58. iosislib-0.1.0.dist-info/RECORD +62 -0
  59. iosislib-0.1.0.dist-info/WHEEL +5 -0
  60. iosislib-0.1.0.dist-info/licenses/LICENSE +52 -0
  61. iosislib-0.1.0.dist-info/licenses/NOTICE +13 -0
  62. 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"]