pineforge-data 0.2.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.
- pineforge_data/__init__.py +124 -0
- pineforge_data/backtest.py +552 -0
- pineforge_data/cli/__init__.py +1 -0
- pineforge_data/cli/backtest.py +354 -0
- pineforge_data/compile_cache.py +171 -0
- pineforge_data/docker_runtime.py +226 -0
- pineforge_data/engine.py +185 -0
- pineforge_data/errors.py +5 -0
- pineforge_data/models.py +199 -0
- pineforge_data/providers/__init__.py +69 -0
- pineforge_data/providers/base.py +51 -0
- pineforge_data/providers/ccxt.py +411 -0
- pineforge_data/providers/local.py +207 -0
- pineforge_data/providers/registry.py +252 -0
- pineforge_data/providers/sqlalchemy.py +138 -0
- pineforge_data/providers/tabular.py +530 -0
- pineforge_data/py.typed +1 -0
- pineforge_data/release_contract.py +153 -0
- pineforge_data/requests.py +87 -0
- pineforge_data/server.py +646 -0
- pineforge_data/server_client.py +142 -0
- pineforge_data-0.2.0.dist-info/METADATA +364 -0
- pineforge_data-0.2.0.dist-info/RECORD +26 -0
- pineforge_data-0.2.0.dist-info/WHEEL +4 -0
- pineforge_data-0.2.0.dist-info/entry_points.txt +3 -0
- pineforge_data-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Run backtests through the published pineforge-release container."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from .backtest import BacktestOptions, JsonValue
|
|
16
|
+
from .models import Bar, Instrument
|
|
17
|
+
from .release_contract import (
|
|
18
|
+
DEFAULT_RELEASE_IMAGE,
|
|
19
|
+
ReleaseContractError,
|
|
20
|
+
parse_release_report,
|
|
21
|
+
release_environment,
|
|
22
|
+
release_response,
|
|
23
|
+
write_release_inputs,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
PullPolicy = Literal["always", "missing", "never"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DockerPrerequisiteError(RuntimeError):
|
|
30
|
+
"""Docker or the configured release image is unavailable."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DockerExecutionError(RuntimeError):
|
|
34
|
+
"""The isolated release-container backtest failed."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _completed_error(completed: subprocess.CompletedProcess[str]) -> str:
|
|
38
|
+
return completed.stderr.strip() or completed.stdout.strip() or "command failed without output"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class DockerBacktestRuntime:
|
|
43
|
+
"""Pull and execute an immutable pineforge-release image."""
|
|
44
|
+
|
|
45
|
+
image: str = DEFAULT_RELEASE_IMAGE
|
|
46
|
+
pull_policy: PullPolicy = "missing"
|
|
47
|
+
timeout_seconds: float = 300.0
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if not self.image.strip():
|
|
51
|
+
raise ValueError("image must not be empty")
|
|
52
|
+
if self.pull_policy not in ("always", "missing", "never"):
|
|
53
|
+
raise ValueError(f"invalid pull policy: {self.pull_policy}")
|
|
54
|
+
if self.timeout_seconds <= 0:
|
|
55
|
+
raise ValueError("timeout_seconds must be positive")
|
|
56
|
+
|
|
57
|
+
def resolved_image(self) -> str:
|
|
58
|
+
return self.image
|
|
59
|
+
|
|
60
|
+
def _check_docker(self) -> None:
|
|
61
|
+
if shutil.which("docker") is None:
|
|
62
|
+
raise DockerPrerequisiteError(
|
|
63
|
+
"Docker is required but the `docker` command was not found"
|
|
64
|
+
)
|
|
65
|
+
completed = subprocess.run(
|
|
66
|
+
["docker", "info", "--format", "{{.ServerVersion}}"],
|
|
67
|
+
text=True,
|
|
68
|
+
capture_output=True,
|
|
69
|
+
check=False,
|
|
70
|
+
)
|
|
71
|
+
if completed.returncode != 0:
|
|
72
|
+
raise DockerPrerequisiteError(
|
|
73
|
+
f"Docker daemon is unavailable: {_completed_error(completed)}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _image_exists(self) -> bool:
|
|
77
|
+
completed = subprocess.run(
|
|
78
|
+
["docker", "image", "inspect", self.image],
|
|
79
|
+
text=True,
|
|
80
|
+
stdout=subprocess.DEVNULL,
|
|
81
|
+
stderr=subprocess.DEVNULL,
|
|
82
|
+
check=False,
|
|
83
|
+
)
|
|
84
|
+
return completed.returncode == 0
|
|
85
|
+
|
|
86
|
+
def _pull_image(self) -> None:
|
|
87
|
+
completed = subprocess.run(
|
|
88
|
+
["docker", "pull", self.image],
|
|
89
|
+
text=True,
|
|
90
|
+
capture_output=True,
|
|
91
|
+
check=False,
|
|
92
|
+
)
|
|
93
|
+
if completed.returncode != 0:
|
|
94
|
+
raise DockerPrerequisiteError(
|
|
95
|
+
f"failed to pull pineforge-release image: {_completed_error(completed)}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _image_identity(self) -> dict[str, object]:
|
|
99
|
+
completed = subprocess.run(
|
|
100
|
+
["docker", "image", "inspect", self.image],
|
|
101
|
+
text=True,
|
|
102
|
+
capture_output=True,
|
|
103
|
+
check=False,
|
|
104
|
+
)
|
|
105
|
+
if completed.returncode != 0:
|
|
106
|
+
return {}
|
|
107
|
+
try:
|
|
108
|
+
values = json.loads(completed.stdout)
|
|
109
|
+
value = values[0]
|
|
110
|
+
labels = value.get("Config", {}).get("Labels", {})
|
|
111
|
+
digests = value.get("RepoDigests", [])
|
|
112
|
+
except (IndexError, AttributeError, json.JSONDecodeError, TypeError):
|
|
113
|
+
return {}
|
|
114
|
+
if not isinstance(labels, dict) or not isinstance(digests, list):
|
|
115
|
+
return {}
|
|
116
|
+
return {
|
|
117
|
+
"resolved_digest": str(digests[0]) if digests else None,
|
|
118
|
+
"release_version": labels.get("org.opencontainers.image.version"),
|
|
119
|
+
"engine_version": labels.get("io.pineforge.engine.version"),
|
|
120
|
+
"codegen_version": labels.get("io.pineforge.codegen.version"),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def ensure_image(self) -> str:
|
|
124
|
+
"""Apply the configured pull policy and return the immutable image ref."""
|
|
125
|
+
|
|
126
|
+
self._check_docker()
|
|
127
|
+
exists = self._image_exists()
|
|
128
|
+
if self.pull_policy == "always" or (self.pull_policy == "missing" and not exists):
|
|
129
|
+
self._pull_image()
|
|
130
|
+
exists = True
|
|
131
|
+
if not exists:
|
|
132
|
+
raise DockerPrerequisiteError(
|
|
133
|
+
f"pineforge-release image is not available locally: {self.image}"
|
|
134
|
+
)
|
|
135
|
+
return self.image
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _remove_timed_out_container(cid_path: Path) -> None:
|
|
139
|
+
if not cid_path.is_file():
|
|
140
|
+
return
|
|
141
|
+
container_id = cid_path.read_text(encoding="utf-8").strip()
|
|
142
|
+
if container_id:
|
|
143
|
+
subprocess.run(
|
|
144
|
+
["docker", "rm", "--force", container_id],
|
|
145
|
+
stdout=subprocess.DEVNULL,
|
|
146
|
+
stderr=subprocess.DEVNULL,
|
|
147
|
+
check=False,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def run(
|
|
151
|
+
self,
|
|
152
|
+
pine_source: str,
|
|
153
|
+
bars: Sequence[Bar],
|
|
154
|
+
*,
|
|
155
|
+
instrument: Instrument,
|
|
156
|
+
source: str,
|
|
157
|
+
options: BacktestOptions,
|
|
158
|
+
strategy_params: Mapping[str, JsonValue] | None = None,
|
|
159
|
+
strategy_overrides: Mapping[str, JsonValue] | None = None,
|
|
160
|
+
) -> dict[str, object]:
|
|
161
|
+
"""Run PineScript and normalized OHLCV in pineforge-release."""
|
|
162
|
+
|
|
163
|
+
image = self.ensure_image()
|
|
164
|
+
with tempfile.TemporaryDirectory(prefix="pineforge-data-") as temporary:
|
|
165
|
+
workspace = Path(temporary)
|
|
166
|
+
write_release_inputs(workspace, pine_source, bars, instrument)
|
|
167
|
+
environment = release_environment(
|
|
168
|
+
"/in",
|
|
169
|
+
instrument,
|
|
170
|
+
options,
|
|
171
|
+
strategy_params,
|
|
172
|
+
strategy_overrides,
|
|
173
|
+
)
|
|
174
|
+
environment["PINEFORGE_DATA_SOURCE"] = source
|
|
175
|
+
cid_path = workspace / "container.cid"
|
|
176
|
+
command = [
|
|
177
|
+
"docker",
|
|
178
|
+
"run",
|
|
179
|
+
"--rm",
|
|
180
|
+
"--cidfile",
|
|
181
|
+
str(cid_path),
|
|
182
|
+
"--network",
|
|
183
|
+
"none",
|
|
184
|
+
"--read-only",
|
|
185
|
+
"--tmpfs",
|
|
186
|
+
"/tmp:rw,exec,nosuid,nodev,size=512m",
|
|
187
|
+
"--cap-drop",
|
|
188
|
+
"ALL",
|
|
189
|
+
"--security-opt",
|
|
190
|
+
"no-new-privileges",
|
|
191
|
+
"--mount",
|
|
192
|
+
f"type=bind,src={workspace},dst=/in,readonly",
|
|
193
|
+
]
|
|
194
|
+
for key, value in sorted(environment.items()):
|
|
195
|
+
command.extend(("--env", f"{key}={value}"))
|
|
196
|
+
command.append(image)
|
|
197
|
+
try:
|
|
198
|
+
completed = subprocess.run(
|
|
199
|
+
command,
|
|
200
|
+
text=True,
|
|
201
|
+
capture_output=True,
|
|
202
|
+
check=False,
|
|
203
|
+
timeout=self.timeout_seconds,
|
|
204
|
+
env=os.environ.copy(),
|
|
205
|
+
)
|
|
206
|
+
except subprocess.TimeoutExpired as exc:
|
|
207
|
+
self._remove_timed_out_container(cid_path)
|
|
208
|
+
raise DockerExecutionError(
|
|
209
|
+
f"pineforge-release exceeded {self.timeout_seconds:g} seconds"
|
|
210
|
+
) from exc
|
|
211
|
+
if completed.returncode != 0:
|
|
212
|
+
phase = {2: "input", 3: "compile", 4: "backtest", 5: "transpile"}.get(
|
|
213
|
+
completed.returncode, "container"
|
|
214
|
+
)
|
|
215
|
+
raise DockerExecutionError(f"{phase} failed: {_completed_error(completed)}")
|
|
216
|
+
try:
|
|
217
|
+
report = parse_release_report(completed.stdout)
|
|
218
|
+
except ReleaseContractError as exc:
|
|
219
|
+
raise DockerExecutionError(str(exc)) from exc
|
|
220
|
+
return release_response(
|
|
221
|
+
report,
|
|
222
|
+
release_image=image,
|
|
223
|
+
mode="local-container",
|
|
224
|
+
request_id=None,
|
|
225
|
+
runtime_metadata=self._image_identity(),
|
|
226
|
+
)
|
pineforge_data/engine.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Dependency-free interoperability with PineForge's streaming C ABI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from .errors import EngineStreamError
|
|
9
|
+
from .models import Bar, Instrument, TradeTick
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PfBar(ctypes.Structure):
|
|
13
|
+
"""``ctypes`` mirror of PineForge's ``pf_bar_t``."""
|
|
14
|
+
|
|
15
|
+
_fields_ = [
|
|
16
|
+
("open", ctypes.c_double),
|
|
17
|
+
("high", ctypes.c_double),
|
|
18
|
+
("low", ctypes.c_double),
|
|
19
|
+
("close", ctypes.c_double),
|
|
20
|
+
("volume", ctypes.c_double),
|
|
21
|
+
("timestamp", ctypes.c_int64),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PfTradeTick(ctypes.Structure):
|
|
26
|
+
"""``ctypes`` mirror of PineForge's ``pf_trade_tick_t``."""
|
|
27
|
+
|
|
28
|
+
_fields_ = [
|
|
29
|
+
("timestamp", ctypes.c_int64),
|
|
30
|
+
("sequence", ctypes.c_uint64),
|
|
31
|
+
("price", ctypes.c_double),
|
|
32
|
+
("quantity", ctypes.c_double),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _require_instrument(
|
|
37
|
+
records: Sequence[Bar] | Sequence[TradeTick], expected: Instrument | None
|
|
38
|
+
) -> None:
|
|
39
|
+
if not records:
|
|
40
|
+
return
|
|
41
|
+
reference = expected or records[0].instrument
|
|
42
|
+
if any(record.instrument != reference for record in records):
|
|
43
|
+
raise ValueError("all records must belong to the expected instrument")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pack_bars(bars: Sequence[Bar], *, instrument: Instrument | None = None) -> ctypes.Array[PfBar]:
|
|
47
|
+
"""Pack normalized bars into one contiguous ``pf_bar_t`` array."""
|
|
48
|
+
|
|
49
|
+
_require_instrument(bars, instrument)
|
|
50
|
+
array_type = PfBar * len(bars)
|
|
51
|
+
return array_type(
|
|
52
|
+
*(
|
|
53
|
+
PfBar(bar.open, bar.high, bar.low, bar.close, bar.volume, bar.timestamp_ms)
|
|
54
|
+
for bar in bars
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pack_trade_ticks(
|
|
60
|
+
ticks: Sequence[TradeTick], *, instrument: Instrument | None = None
|
|
61
|
+
) -> ctypes.Array[PfTradeTick]:
|
|
62
|
+
"""Pack normalized trades into one contiguous ``pf_trade_tick_t`` array."""
|
|
63
|
+
|
|
64
|
+
_require_instrument(ticks, instrument)
|
|
65
|
+
array_type = PfTradeTick * len(ticks)
|
|
66
|
+
return array_type(
|
|
67
|
+
*(
|
|
68
|
+
PfTradeTick(tick.timestamp_ms, tick.sequence, tick.price, tick.quantity)
|
|
69
|
+
for tick in ticks
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class EngineStreamSink:
|
|
75
|
+
"""Send normalized records to an existing PineForge strategy instance.
|
|
76
|
+
|
|
77
|
+
The caller owns both ``library`` and ``state``. This adapter configures and
|
|
78
|
+
invokes only the streaming functions; it never creates or frees a strategy.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
library: ctypes.CDLL,
|
|
84
|
+
state: int | ctypes.c_void_p,
|
|
85
|
+
instrument: Instrument,
|
|
86
|
+
) -> None:
|
|
87
|
+
if not state:
|
|
88
|
+
raise ValueError("state must be a non-null PineForge strategy handle")
|
|
89
|
+
self._library = library
|
|
90
|
+
self._state = state
|
|
91
|
+
self.instrument = instrument
|
|
92
|
+
self._configure_signatures()
|
|
93
|
+
|
|
94
|
+
def _configure_signatures(self) -> None:
|
|
95
|
+
self._library.strategy_get_last_error.argtypes = [ctypes.c_void_p]
|
|
96
|
+
self._library.strategy_get_last_error.restype = ctypes.c_char_p
|
|
97
|
+
self._library.strategy_stream_begin.argtypes = [
|
|
98
|
+
ctypes.c_void_p,
|
|
99
|
+
ctypes.POINTER(PfBar),
|
|
100
|
+
ctypes.c_int,
|
|
101
|
+
ctypes.c_char_p,
|
|
102
|
+
ctypes.c_char_p,
|
|
103
|
+
]
|
|
104
|
+
self._library.strategy_stream_begin.restype = ctypes.c_int
|
|
105
|
+
self._library.strategy_stream_push_tick.argtypes = [
|
|
106
|
+
ctypes.c_void_p,
|
|
107
|
+
ctypes.POINTER(PfTradeTick),
|
|
108
|
+
]
|
|
109
|
+
self._library.strategy_stream_push_tick.restype = ctypes.c_int
|
|
110
|
+
self._library.strategy_stream_push_ticks.argtypes = [
|
|
111
|
+
ctypes.c_void_p,
|
|
112
|
+
ctypes.POINTER(PfTradeTick),
|
|
113
|
+
ctypes.c_int,
|
|
114
|
+
]
|
|
115
|
+
self._library.strategy_stream_push_ticks.restype = ctypes.c_int
|
|
116
|
+
self._library.strategy_stream_advance_time.argtypes = [ctypes.c_void_p, ctypes.c_int64]
|
|
117
|
+
self._library.strategy_stream_advance_time.restype = ctypes.c_int
|
|
118
|
+
self._library.strategy_stream_end.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
|
119
|
+
self._library.strategy_stream_end.restype = ctypes.c_int
|
|
120
|
+
|
|
121
|
+
def _check(self, status: int, operation: str) -> None:
|
|
122
|
+
if status == 0:
|
|
123
|
+
return
|
|
124
|
+
raw = self._library.strategy_get_last_error(self._state)
|
|
125
|
+
detail = raw.decode("utf-8", "replace") if raw else "unknown engine error"
|
|
126
|
+
raise EngineStreamError(f"{operation}: {detail}")
|
|
127
|
+
|
|
128
|
+
def begin(
|
|
129
|
+
self,
|
|
130
|
+
warmup_bars: Sequence[Bar],
|
|
131
|
+
*,
|
|
132
|
+
input_timeframe: str,
|
|
133
|
+
script_timeframe: str,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Warm the strategy on confirmed bars and start realtime streaming."""
|
|
136
|
+
|
|
137
|
+
if not warmup_bars:
|
|
138
|
+
raise ValueError("warmup_bars must not be empty")
|
|
139
|
+
if not input_timeframe or not script_timeframe:
|
|
140
|
+
raise ValueError("input_timeframe and script_timeframe must not be empty")
|
|
141
|
+
packed = pack_bars(warmup_bars, instrument=self.instrument)
|
|
142
|
+
status = self._library.strategy_stream_begin(
|
|
143
|
+
self._state,
|
|
144
|
+
packed,
|
|
145
|
+
len(packed),
|
|
146
|
+
input_timeframe.encode(),
|
|
147
|
+
script_timeframe.encode(),
|
|
148
|
+
)
|
|
149
|
+
self._check(status, "stream begin")
|
|
150
|
+
|
|
151
|
+
def push_tick(self, tick: TradeTick) -> None:
|
|
152
|
+
"""Push one normalized executed trade."""
|
|
153
|
+
|
|
154
|
+
if tick.instrument != self.instrument:
|
|
155
|
+
raise ValueError("tick does not belong to the sink instrument")
|
|
156
|
+
packed = PfTradeTick(tick.timestamp_ms, tick.sequence, tick.price, tick.quantity)
|
|
157
|
+
status = self._library.strategy_stream_push_tick(self._state, ctypes.byref(packed))
|
|
158
|
+
self._check(status, "stream tick")
|
|
159
|
+
|
|
160
|
+
def push_ticks(self, ticks: Sequence[TradeTick]) -> None:
|
|
161
|
+
"""Push normalized executed trades in one contiguous ABI call."""
|
|
162
|
+
|
|
163
|
+
if not ticks:
|
|
164
|
+
return
|
|
165
|
+
packed = pack_trade_ticks(ticks, instrument=self.instrument)
|
|
166
|
+
status = self._library.strategy_stream_push_ticks(self._state, packed, len(packed))
|
|
167
|
+
self._check(status, "stream ticks")
|
|
168
|
+
|
|
169
|
+
def advance_time(self, timestamp_ms: int) -> None:
|
|
170
|
+
"""Advance the stream clock so elapsed input bars can close."""
|
|
171
|
+
|
|
172
|
+
if timestamp_ms < 0:
|
|
173
|
+
raise ValueError("timestamp_ms must be non-negative")
|
|
174
|
+
status = self._library.strategy_stream_advance_time(self._state, timestamp_ms)
|
|
175
|
+
self._check(status, "stream advance")
|
|
176
|
+
|
|
177
|
+
def end(self, *, finalize_partial_input_bar: bool = False) -> None:
|
|
178
|
+
"""End the stream, optionally dispatching its partial input bar."""
|
|
179
|
+
|
|
180
|
+
status = self._library.strategy_stream_end(self._state, int(finalize_partial_input_bar))
|
|
181
|
+
self._check(status, "stream end")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if ctypes.sizeof(PfBar) != 48 or ctypes.sizeof(PfTradeTick) != 32:
|
|
185
|
+
raise RuntimeError("unsupported platform ABI layout for PineForge streaming records")
|
pineforge_data/errors.py
ADDED
pineforge_data/models.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Provider-neutral records shared by adapters and engine sinks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from math import isfinite
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _non_empty(value: str, field: str) -> None:
|
|
11
|
+
if not value.strip():
|
|
12
|
+
raise ValueError(f"{field} must not be empty")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _non_negative(value: int, field: str) -> None:
|
|
16
|
+
if value < 0:
|
|
17
|
+
raise ValueError(f"{field} must be non-negative")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _unix_ms(value: int, field: str) -> None:
|
|
21
|
+
_non_negative(value, field)
|
|
22
|
+
if value > 2**63 - 1:
|
|
23
|
+
raise ValueError(f"{field} must fit a signed 64-bit integer")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _optional_text(value: str, field: str) -> None:
|
|
27
|
+
if value and not value.strip():
|
|
28
|
+
raise ValueError(f"{field} must not contain only whitespace")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AssetClass(StrEnum):
|
|
32
|
+
"""Broad economic asset class, independent of execution venue."""
|
|
33
|
+
|
|
34
|
+
UNKNOWN = "unknown"
|
|
35
|
+
CRYPTO = "crypto"
|
|
36
|
+
EQUITY = "equity"
|
|
37
|
+
FOREX = "forex"
|
|
38
|
+
COMMODITY = "commodity"
|
|
39
|
+
INDEX = "index"
|
|
40
|
+
FUND = "fund"
|
|
41
|
+
BOND = "bond"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MarketType(StrEnum):
|
|
45
|
+
"""Trading-market structure for an instrument listing."""
|
|
46
|
+
|
|
47
|
+
UNKNOWN = "unknown"
|
|
48
|
+
SPOT = "spot"
|
|
49
|
+
CASH = "cash"
|
|
50
|
+
SWAP = "swap"
|
|
51
|
+
FUTURE = "future"
|
|
52
|
+
OPTION = "option"
|
|
53
|
+
CFD = "cfd"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OptionType(StrEnum):
|
|
57
|
+
CALL = "call"
|
|
58
|
+
PUT = "put"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class ContractSpec:
|
|
63
|
+
"""Normalized derivative terms supplied by a market catalog."""
|
|
64
|
+
|
|
65
|
+
contract_size: float | None = None
|
|
66
|
+
linear: bool | None = None
|
|
67
|
+
inverse: bool | None = None
|
|
68
|
+
expiry_ms: int | None = None
|
|
69
|
+
strike: float | None = None
|
|
70
|
+
option_type: OptionType | None = None
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
if self.contract_size is not None and (
|
|
74
|
+
not isfinite(self.contract_size) or self.contract_size <= 0
|
|
75
|
+
):
|
|
76
|
+
raise ValueError("contract_size must be finite and positive")
|
|
77
|
+
if self.linear is True and self.inverse is True:
|
|
78
|
+
raise ValueError("a contract cannot be both linear and inverse")
|
|
79
|
+
if self.expiry_ms is not None:
|
|
80
|
+
_unix_ms(self.expiry_ms, "expiry_ms")
|
|
81
|
+
if self.strike is not None and (not isfinite(self.strike) or self.strike <= 0):
|
|
82
|
+
raise ValueError("strike must be finite and positive")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class Instrument:
|
|
87
|
+
"""A normalized market instrument with provider identity kept explicit."""
|
|
88
|
+
|
|
89
|
+
symbol: str
|
|
90
|
+
venue: str = ""
|
|
91
|
+
timezone: str = "UTC"
|
|
92
|
+
session: str = "24x7"
|
|
93
|
+
volume_unit: str = "base"
|
|
94
|
+
asset_class: AssetClass = AssetClass.UNKNOWN
|
|
95
|
+
market_type: MarketType = MarketType.UNKNOWN
|
|
96
|
+
base: str = ""
|
|
97
|
+
quote: str = ""
|
|
98
|
+
settle: str = ""
|
|
99
|
+
provider_id: str = ""
|
|
100
|
+
contract: ContractSpec | None = None
|
|
101
|
+
|
|
102
|
+
def __post_init__(self) -> None:
|
|
103
|
+
_non_empty(self.symbol, "symbol")
|
|
104
|
+
_non_empty(self.timezone, "timezone")
|
|
105
|
+
_non_empty(self.session, "session")
|
|
106
|
+
_non_empty(self.volume_unit, "volume_unit")
|
|
107
|
+
_optional_text(self.venue, "venue")
|
|
108
|
+
_optional_text(self.base, "base")
|
|
109
|
+
_optional_text(self.quote, "quote")
|
|
110
|
+
_optional_text(self.settle, "settle")
|
|
111
|
+
_optional_text(self.provider_id, "provider_id")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True, slots=True)
|
|
115
|
+
class MarketListing:
|
|
116
|
+
"""One instrument as listed by one provider-bound venue."""
|
|
117
|
+
|
|
118
|
+
instrument: Instrument
|
|
119
|
+
active: bool | None = None
|
|
120
|
+
margin_supported: bool | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True, slots=True)
|
|
124
|
+
class Bar:
|
|
125
|
+
"""One confirmed, provider-normalized OHLCV bar."""
|
|
126
|
+
|
|
127
|
+
instrument: Instrument
|
|
128
|
+
timestamp_ms: int
|
|
129
|
+
open: float
|
|
130
|
+
high: float
|
|
131
|
+
low: float
|
|
132
|
+
close: float
|
|
133
|
+
volume: float
|
|
134
|
+
source: str
|
|
135
|
+
|
|
136
|
+
def __post_init__(self) -> None:
|
|
137
|
+
_unix_ms(self.timestamp_ms, "timestamp_ms")
|
|
138
|
+
_non_empty(self.source, "source")
|
|
139
|
+
prices = (self.open, self.high, self.low, self.close)
|
|
140
|
+
if not all(isfinite(value) and value > 0 for value in prices):
|
|
141
|
+
raise ValueError("OHLC prices must be finite and positive")
|
|
142
|
+
if self.high < max(self.open, self.close, self.low):
|
|
143
|
+
raise ValueError("high must be greater than or equal to OHLC values")
|
|
144
|
+
if self.low > min(self.open, self.close, self.high):
|
|
145
|
+
raise ValueError("low must be less than or equal to OHLC values")
|
|
146
|
+
if not isfinite(self.volume) or self.volume < 0:
|
|
147
|
+
raise ValueError("volume must be finite and non-negative")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True)
|
|
151
|
+
class TradeTick:
|
|
152
|
+
"""One normalized executed trade ready for the PineForge stream ABI."""
|
|
153
|
+
|
|
154
|
+
instrument: Instrument
|
|
155
|
+
timestamp_ms: int
|
|
156
|
+
sequence: int
|
|
157
|
+
price: float
|
|
158
|
+
quantity: float
|
|
159
|
+
source: str
|
|
160
|
+
|
|
161
|
+
def __post_init__(self) -> None:
|
|
162
|
+
_unix_ms(self.timestamp_ms, "timestamp_ms")
|
|
163
|
+
_non_negative(self.sequence, "sequence")
|
|
164
|
+
if self.sequence > 2**64 - 1:
|
|
165
|
+
raise ValueError("sequence must fit an unsigned 64-bit integer")
|
|
166
|
+
_non_empty(self.source, "source")
|
|
167
|
+
if not isfinite(self.price) or self.price <= 0:
|
|
168
|
+
raise ValueError("price must be finite and positive")
|
|
169
|
+
if not isfinite(self.quantity) or self.quantity < 0:
|
|
170
|
+
raise ValueError("quantity must be finite and non-negative")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True, slots=True)
|
|
174
|
+
class MacroObservation:
|
|
175
|
+
"""A vintage-aware macro value safe to align without revised-data lookahead."""
|
|
176
|
+
|
|
177
|
+
key: str
|
|
178
|
+
currency: str
|
|
179
|
+
period_end_ms: int
|
|
180
|
+
released_at_ms: int
|
|
181
|
+
vintage_at_ms: int
|
|
182
|
+
value: float
|
|
183
|
+
unit: str
|
|
184
|
+
source: str
|
|
185
|
+
|
|
186
|
+
def __post_init__(self) -> None:
|
|
187
|
+
_non_empty(self.key, "key")
|
|
188
|
+
_non_empty(self.currency, "currency")
|
|
189
|
+
_non_empty(self.unit, "unit")
|
|
190
|
+
_non_empty(self.source, "source")
|
|
191
|
+
_unix_ms(self.period_end_ms, "period_end_ms")
|
|
192
|
+
_unix_ms(self.released_at_ms, "released_at_ms")
|
|
193
|
+
_unix_ms(self.vintage_at_ms, "vintage_at_ms")
|
|
194
|
+
if self.released_at_ms < self.period_end_ms:
|
|
195
|
+
raise ValueError("released_at_ms must not precede period_end_ms")
|
|
196
|
+
if self.vintage_at_ms < self.released_at_ms:
|
|
197
|
+
raise ValueError("vintage_at_ms must not precede released_at_ms")
|
|
198
|
+
if not isfinite(self.value):
|
|
199
|
+
raise ValueError("value must be finite")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Public provider protocols and registry."""
|
|
2
|
+
|
|
3
|
+
from .base import (
|
|
4
|
+
HistoricalBarProvider,
|
|
5
|
+
LiveTradeProvider,
|
|
6
|
+
MacroDataProvider,
|
|
7
|
+
MarketCatalogProvider,
|
|
8
|
+
MarketDataProvider,
|
|
9
|
+
MarketNotFoundError,
|
|
10
|
+
)
|
|
11
|
+
from .ccxt import (
|
|
12
|
+
CcxtCapabilityError,
|
|
13
|
+
CcxtDataError,
|
|
14
|
+
CcxtDependencyError,
|
|
15
|
+
CcxtError,
|
|
16
|
+
CcxtProvider,
|
|
17
|
+
)
|
|
18
|
+
from .local import CsvBarProvider, SqliteBarProvider
|
|
19
|
+
from .registry import (
|
|
20
|
+
ENTRY_POINT_GROUP,
|
|
21
|
+
ProviderFactory,
|
|
22
|
+
ProviderNotFoundError,
|
|
23
|
+
ProviderRegistry,
|
|
24
|
+
ProviderRegistryError,
|
|
25
|
+
create_provider,
|
|
26
|
+
default_registry,
|
|
27
|
+
)
|
|
28
|
+
from .sqlalchemy import SqlAlchemyBarProvider, SqlAlchemyDependencyError
|
|
29
|
+
from .tabular import (
|
|
30
|
+
BarColumnMapping,
|
|
31
|
+
SchemaMappingError,
|
|
32
|
+
SourceColumn,
|
|
33
|
+
TabularBarProvider,
|
|
34
|
+
TabularDataError,
|
|
35
|
+
TabularSchema,
|
|
36
|
+
TimestampUnit,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"ENTRY_POINT_GROUP",
|
|
41
|
+
"BarColumnMapping",
|
|
42
|
+
"CcxtCapabilityError",
|
|
43
|
+
"CcxtDataError",
|
|
44
|
+
"CcxtDependencyError",
|
|
45
|
+
"CcxtError",
|
|
46
|
+
"CcxtProvider",
|
|
47
|
+
"CsvBarProvider",
|
|
48
|
+
"HistoricalBarProvider",
|
|
49
|
+
"LiveTradeProvider",
|
|
50
|
+
"MacroDataProvider",
|
|
51
|
+
"MarketCatalogProvider",
|
|
52
|
+
"MarketDataProvider",
|
|
53
|
+
"MarketNotFoundError",
|
|
54
|
+
"ProviderFactory",
|
|
55
|
+
"ProviderNotFoundError",
|
|
56
|
+
"ProviderRegistry",
|
|
57
|
+
"ProviderRegistryError",
|
|
58
|
+
"SchemaMappingError",
|
|
59
|
+
"SourceColumn",
|
|
60
|
+
"SqlAlchemyBarProvider",
|
|
61
|
+
"SqlAlchemyDependencyError",
|
|
62
|
+
"SqliteBarProvider",
|
|
63
|
+
"TabularBarProvider",
|
|
64
|
+
"TabularDataError",
|
|
65
|
+
"TabularSchema",
|
|
66
|
+
"TimestampUnit",
|
|
67
|
+
"create_provider",
|
|
68
|
+
"default_registry",
|
|
69
|
+
]
|