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,530 @@
|
|
|
1
|
+
"""Schema discovery and normalization shared by user-owned tabular sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from datetime import date, datetime, time
|
|
10
|
+
from decimal import Decimal, InvalidOperation
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from math import isfinite
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
from typing import TypeAlias
|
|
15
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
16
|
+
|
|
17
|
+
from ..models import Bar, Instrument, MarketListing
|
|
18
|
+
from ..requests import BarRequest, MarketQuery
|
|
19
|
+
from .base import MarketNotFoundError
|
|
20
|
+
|
|
21
|
+
TabularRow: TypeAlias = Mapping[str, object]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TabularDataError(RuntimeError):
|
|
25
|
+
"""A user-owned tabular source cannot be normalized safely."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SchemaMappingError(TabularDataError):
|
|
29
|
+
"""Source columns cannot be mapped unambiguously to OHLCV fields."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TimestampUnit(StrEnum):
|
|
33
|
+
"""Storage unit used by numeric timestamps."""
|
|
34
|
+
|
|
35
|
+
SECONDS = "seconds"
|
|
36
|
+
MILLISECONDS = "milliseconds"
|
|
37
|
+
MICROSECONDS = "microseconds"
|
|
38
|
+
NANOSECONDS = "nanoseconds"
|
|
39
|
+
ISO8601 = "iso8601"
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def parse(cls, value: TimestampUnit | str) -> TimestampUnit:
|
|
43
|
+
if isinstance(value, cls):
|
|
44
|
+
return value
|
|
45
|
+
aliases = {
|
|
46
|
+
"s": cls.SECONDS,
|
|
47
|
+
"sec": cls.SECONDS,
|
|
48
|
+
"second": cls.SECONDS,
|
|
49
|
+
"seconds": cls.SECONDS,
|
|
50
|
+
"ms": cls.MILLISECONDS,
|
|
51
|
+
"millisecond": cls.MILLISECONDS,
|
|
52
|
+
"milliseconds": cls.MILLISECONDS,
|
|
53
|
+
"us": cls.MICROSECONDS,
|
|
54
|
+
"microsecond": cls.MICROSECONDS,
|
|
55
|
+
"microseconds": cls.MICROSECONDS,
|
|
56
|
+
"ns": cls.NANOSECONDS,
|
|
57
|
+
"nanosecond": cls.NANOSECONDS,
|
|
58
|
+
"nanoseconds": cls.NANOSECONDS,
|
|
59
|
+
"iso": cls.ISO8601,
|
|
60
|
+
"iso8601": cls.ISO8601,
|
|
61
|
+
"datetime": cls.ISO8601,
|
|
62
|
+
}
|
|
63
|
+
normalized = value.strip().casefold()
|
|
64
|
+
try:
|
|
65
|
+
return aliases[normalized]
|
|
66
|
+
except KeyError as exc:
|
|
67
|
+
choices = ", ".join(unit.value for unit in cls)
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"unknown timestamp unit {value!r}; expected one of: {choices}"
|
|
70
|
+
) from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class SourceColumn:
|
|
75
|
+
"""One column discovered from a file header or database reflection."""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
data_type: str = ""
|
|
79
|
+
nullable: bool | None = None
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
if not self.name:
|
|
83
|
+
raise ValueError("source column name must not be empty")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_REQUIRED_FIELDS = ("timestamp", "open", "high", "low", "close", "volume")
|
|
87
|
+
_OPTIONAL_FIELDS = ("symbol", "timeframe")
|
|
88
|
+
_MAPPING_FIELDS = _REQUIRED_FIELDS + _OPTIONAL_FIELDS
|
|
89
|
+
_ALIASES: Mapping[str, tuple[str, ...]] = MappingProxyType(
|
|
90
|
+
{
|
|
91
|
+
"timestamp": (
|
|
92
|
+
"timestamp",
|
|
93
|
+
"timestamp_ms",
|
|
94
|
+
"time",
|
|
95
|
+
"datetime",
|
|
96
|
+
"date",
|
|
97
|
+
"ts",
|
|
98
|
+
"open_time",
|
|
99
|
+
"bar_time",
|
|
100
|
+
),
|
|
101
|
+
"open": ("open", "open_price", "price_open", "px_open", "o"),
|
|
102
|
+
"high": ("high", "high_price", "price_high", "px_high", "h"),
|
|
103
|
+
"low": ("low", "low_price", "price_low", "px_low", "l"),
|
|
104
|
+
"close": ("close", "close_price", "price_close", "px_close", "c"),
|
|
105
|
+
"volume": ("volume", "vol", "base_volume", "quantity", "qty", "v"),
|
|
106
|
+
"symbol": ("symbol", "ticker", "instrument", "market", "security"),
|
|
107
|
+
"timeframe": ("timeframe", "interval", "resolution", "bar_size"),
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _normalized_column_name(value: str) -> str:
|
|
113
|
+
return "".join(character for character in value.casefold() if character.isalnum())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _validate_overrides(overrides: Mapping[str, str] | None) -> dict[str, str]:
|
|
117
|
+
normalized = dict(overrides or {})
|
|
118
|
+
unknown = sorted(normalized.keys() - set(_MAPPING_FIELDS))
|
|
119
|
+
if unknown:
|
|
120
|
+
raise SchemaMappingError(
|
|
121
|
+
f"unknown canonical column field(s): {', '.join(unknown)}; "
|
|
122
|
+
f"expected: {', '.join(_MAPPING_FIELDS)}"
|
|
123
|
+
)
|
|
124
|
+
for field, column in normalized.items():
|
|
125
|
+
if not isinstance(column, str) or not column:
|
|
126
|
+
raise SchemaMappingError(f"column override for {field!r} must be a non-empty string")
|
|
127
|
+
return normalized
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True, slots=True)
|
|
131
|
+
class BarColumnMapping:
|
|
132
|
+
"""Map canonical PineForge bar fields to arbitrary source column names."""
|
|
133
|
+
|
|
134
|
+
timestamp: str
|
|
135
|
+
open: str
|
|
136
|
+
high: str
|
|
137
|
+
low: str
|
|
138
|
+
close: str
|
|
139
|
+
volume: str
|
|
140
|
+
symbol: str | None = None
|
|
141
|
+
timeframe: str | None = None
|
|
142
|
+
|
|
143
|
+
def __post_init__(self) -> None:
|
|
144
|
+
for field in _REQUIRED_FIELDS:
|
|
145
|
+
column = getattr(self, field)
|
|
146
|
+
if not isinstance(column, str) or not column:
|
|
147
|
+
raise ValueError(f"{field} column must be a non-empty string")
|
|
148
|
+
for field in _OPTIONAL_FIELDS:
|
|
149
|
+
column = getattr(self, field)
|
|
150
|
+
if column is not None and not column:
|
|
151
|
+
raise ValueError(f"{field} column must be None or a non-empty string")
|
|
152
|
+
|
|
153
|
+
selected = [
|
|
154
|
+
column for field in _MAPPING_FIELDS if (column := getattr(self, field)) is not None
|
|
155
|
+
]
|
|
156
|
+
duplicates = sorted({column for column in selected if selected.count(column) > 1})
|
|
157
|
+
if duplicates:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
"one source column cannot map to multiple fields: " + ", ".join(duplicates)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def columns(self) -> tuple[str, ...]:
|
|
164
|
+
"""Return mapped source columns in canonical order without duplicates."""
|
|
165
|
+
|
|
166
|
+
return tuple(
|
|
167
|
+
column for field in _MAPPING_FIELDS if (column := getattr(self, field)) is not None
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def infer(
|
|
172
|
+
cls,
|
|
173
|
+
columns: Sequence[str],
|
|
174
|
+
overrides: Mapping[str, str] | None = None,
|
|
175
|
+
) -> BarColumnMapping:
|
|
176
|
+
"""Infer common OHLCV names while requiring explicit ambiguous choices."""
|
|
177
|
+
|
|
178
|
+
available = tuple(columns)
|
|
179
|
+
if not available:
|
|
180
|
+
raise SchemaMappingError("source exposes no columns")
|
|
181
|
+
if len(set(available)) != len(available):
|
|
182
|
+
duplicates = sorted({name for name in available if available.count(name) > 1})
|
|
183
|
+
raise SchemaMappingError(f"source has duplicate column names: {', '.join(duplicates)}")
|
|
184
|
+
|
|
185
|
+
selected = _validate_overrides(overrides)
|
|
186
|
+
missing_overrides = sorted(set(selected.values()) - set(available))
|
|
187
|
+
if missing_overrides:
|
|
188
|
+
raise SchemaMappingError(
|
|
189
|
+
"mapped column(s) not found: "
|
|
190
|
+
f"{', '.join(missing_overrides)}; available columns: {', '.join(available)}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
used = set(selected.values())
|
|
194
|
+
ambiguous: dict[str, list[str]] = {}
|
|
195
|
+
for field in _MAPPING_FIELDS:
|
|
196
|
+
if field in selected:
|
|
197
|
+
continue
|
|
198
|
+
aliases = {_normalized_column_name(alias) for alias in _ALIASES[field]}
|
|
199
|
+
matches = [
|
|
200
|
+
column
|
|
201
|
+
for column in available
|
|
202
|
+
if column not in used and _normalized_column_name(column) in aliases
|
|
203
|
+
]
|
|
204
|
+
if len(matches) == 1:
|
|
205
|
+
selected[field] = matches[0]
|
|
206
|
+
used.add(matches[0])
|
|
207
|
+
elif len(matches) > 1:
|
|
208
|
+
ambiguous[field] = matches
|
|
209
|
+
|
|
210
|
+
missing = [field for field in _REQUIRED_FIELDS if field not in selected]
|
|
211
|
+
if missing or ambiguous:
|
|
212
|
+
details: list[str] = []
|
|
213
|
+
if missing:
|
|
214
|
+
details.append("missing mappings for " + ", ".join(missing))
|
|
215
|
+
if ambiguous:
|
|
216
|
+
choices = "; ".join(
|
|
217
|
+
f"{field}: {', '.join(matches)}" for field, matches in sorted(ambiguous.items())
|
|
218
|
+
)
|
|
219
|
+
details.append("ambiguous mappings for " + choices)
|
|
220
|
+
details.append("available columns: " + ", ".join(available))
|
|
221
|
+
details.append(
|
|
222
|
+
"pass mapping={'timestamp': 'your_time', ...} to override only unusual names"
|
|
223
|
+
)
|
|
224
|
+
raise SchemaMappingError("; ".join(details))
|
|
225
|
+
|
|
226
|
+
return cls(
|
|
227
|
+
timestamp=selected["timestamp"],
|
|
228
|
+
open=selected["open"],
|
|
229
|
+
high=selected["high"],
|
|
230
|
+
low=selected["low"],
|
|
231
|
+
close=selected["close"],
|
|
232
|
+
volume=selected["volume"],
|
|
233
|
+
symbol=selected.get("symbol"),
|
|
234
|
+
timeframe=selected.get("timeframe"),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def validate(self, columns: Sequence[str]) -> None:
|
|
238
|
+
"""Require every selected name to exist in the reflected source schema."""
|
|
239
|
+
|
|
240
|
+
missing = sorted(set(self.columns) - set(columns))
|
|
241
|
+
if missing:
|
|
242
|
+
raise SchemaMappingError(
|
|
243
|
+
f"mapped column(s) not found: {', '.join(missing)}; "
|
|
244
|
+
f"available columns: {', '.join(columns)}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
ColumnMappingInput: TypeAlias = BarColumnMapping | Mapping[str, str] | None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass(frozen=True, slots=True)
|
|
252
|
+
class TabularSchema:
|
|
253
|
+
"""Runtime description of a user-owned file, table, or view."""
|
|
254
|
+
|
|
255
|
+
source: str
|
|
256
|
+
columns: tuple[SourceColumn, ...]
|
|
257
|
+
|
|
258
|
+
def __post_init__(self) -> None:
|
|
259
|
+
names = self.column_names
|
|
260
|
+
if not self.source:
|
|
261
|
+
raise ValueError("schema source must not be empty")
|
|
262
|
+
if len(set(names)) != len(names):
|
|
263
|
+
duplicates = sorted({name for name in names if names.count(name) > 1})
|
|
264
|
+
raise SchemaMappingError(f"source has duplicate column names: {', '.join(duplicates)}")
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def column_names(self) -> tuple[str, ...]:
|
|
268
|
+
return tuple(column.name for column in self.columns)
|
|
269
|
+
|
|
270
|
+
def infer_bar_mapping(self, overrides: Mapping[str, str] | None = None) -> BarColumnMapping:
|
|
271
|
+
"""Build a validated mapping from reflected columns and optional overrides."""
|
|
272
|
+
|
|
273
|
+
return BarColumnMapping.infer(self.column_names, overrides)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _numeric_timestamp_ms(value: object, unit: TimestampUnit) -> int:
|
|
277
|
+
try:
|
|
278
|
+
numeric = Decimal(str(value).strip())
|
|
279
|
+
except (InvalidOperation, AttributeError) as exc:
|
|
280
|
+
raise ValueError(f"invalid numeric timestamp: {value!r}") from exc
|
|
281
|
+
if not numeric.is_finite():
|
|
282
|
+
raise ValueError("timestamp must be finite")
|
|
283
|
+
scale = {
|
|
284
|
+
TimestampUnit.SECONDS: Decimal(1_000),
|
|
285
|
+
TimestampUnit.MILLISECONDS: Decimal(1),
|
|
286
|
+
TimestampUnit.MICROSECONDS: Decimal("0.001"),
|
|
287
|
+
TimestampUnit.NANOSECONDS: Decimal("0.000001"),
|
|
288
|
+
}[unit]
|
|
289
|
+
milliseconds = numeric * scale
|
|
290
|
+
if milliseconds != milliseconds.to_integral_value():
|
|
291
|
+
raise ValueError("timestamp cannot be represented exactly at millisecond precision")
|
|
292
|
+
normalized = int(milliseconds)
|
|
293
|
+
if normalized < 0 or normalized > 2**63 - 1:
|
|
294
|
+
raise ValueError("timestamp must fit a non-negative signed 64-bit millisecond value")
|
|
295
|
+
return normalized
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _timestamp_ms(value: object, unit: TimestampUnit, timezone: ZoneInfo) -> int:
|
|
299
|
+
if isinstance(value, bool) or value is None:
|
|
300
|
+
raise ValueError("timestamp must be numeric, ISO-8601 text, or a datetime")
|
|
301
|
+
|
|
302
|
+
if isinstance(value, datetime):
|
|
303
|
+
parsed = value
|
|
304
|
+
elif isinstance(value, date):
|
|
305
|
+
parsed = datetime.combine(value, time.min)
|
|
306
|
+
elif isinstance(value, str):
|
|
307
|
+
text = value.strip()
|
|
308
|
+
if not text:
|
|
309
|
+
raise ValueError("timestamp must not be empty")
|
|
310
|
+
if unit is not TimestampUnit.ISO8601:
|
|
311
|
+
try:
|
|
312
|
+
Decimal(text)
|
|
313
|
+
except InvalidOperation:
|
|
314
|
+
pass
|
|
315
|
+
else:
|
|
316
|
+
return _numeric_timestamp_ms(text, unit)
|
|
317
|
+
try:
|
|
318
|
+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
319
|
+
except ValueError as exc:
|
|
320
|
+
raise ValueError(f"invalid ISO-8601 timestamp: {value!r}") from exc
|
|
321
|
+
else:
|
|
322
|
+
if unit is TimestampUnit.ISO8601:
|
|
323
|
+
raise ValueError("timestamp_unit=iso8601 requires text or datetime values")
|
|
324
|
+
return _numeric_timestamp_ms(value, unit)
|
|
325
|
+
|
|
326
|
+
if parsed.tzinfo is None:
|
|
327
|
+
parsed = parsed.replace(tzinfo=timezone)
|
|
328
|
+
normalized = int(parsed.timestamp() * 1_000)
|
|
329
|
+
if normalized < 0 or normalized > 2**63 - 1:
|
|
330
|
+
raise ValueError("timestamp must fit a non-negative signed 64-bit millisecond value")
|
|
331
|
+
return normalized
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _number(value: object, field: str) -> float:
|
|
335
|
+
if isinstance(value, bool) or value is None:
|
|
336
|
+
raise ValueError(f"{field} must be numeric")
|
|
337
|
+
try:
|
|
338
|
+
normalized = float(value if isinstance(value, (int, float, str)) else str(value))
|
|
339
|
+
except (TypeError, ValueError) as exc:
|
|
340
|
+
raise ValueError(f"{field} must be numeric") from exc
|
|
341
|
+
if not isfinite(normalized):
|
|
342
|
+
raise ValueError(f"{field} must be finite")
|
|
343
|
+
return normalized
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def numeric_source_bounds(
|
|
347
|
+
start_ms: int, end_ms: int, unit: TimestampUnit
|
|
348
|
+
) -> tuple[int | float, int | float] | None:
|
|
349
|
+
"""Translate millisecond request bounds for safe numeric SQL pushdown."""
|
|
350
|
+
|
|
351
|
+
if unit is TimestampUnit.ISO8601:
|
|
352
|
+
return None
|
|
353
|
+
if unit is TimestampUnit.SECONDS:
|
|
354
|
+
return start_ms / 1_000, end_ms / 1_000
|
|
355
|
+
if unit is TimestampUnit.MILLISECONDS:
|
|
356
|
+
return start_ms, end_ms
|
|
357
|
+
if unit is TimestampUnit.MICROSECONDS:
|
|
358
|
+
return start_ms * 1_000, end_ms * 1_000
|
|
359
|
+
return start_ms * 1_000_000, end_ms * 1_000_000
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class TabularBarProvider(ABC):
|
|
363
|
+
"""Common catalog and OHLCV behavior for schema-mapped tabular sources."""
|
|
364
|
+
|
|
365
|
+
name: str
|
|
366
|
+
venue: str
|
|
367
|
+
|
|
368
|
+
def __init__(
|
|
369
|
+
self,
|
|
370
|
+
*,
|
|
371
|
+
venue: str,
|
|
372
|
+
mapping: ColumnMappingInput = None,
|
|
373
|
+
timestamp_unit: TimestampUnit | str = TimestampUnit.MILLISECONDS,
|
|
374
|
+
timestamp_timezone: str = "UTC",
|
|
375
|
+
instrument: Instrument | None = None,
|
|
376
|
+
timeframe: str | None = None,
|
|
377
|
+
) -> None:
|
|
378
|
+
if not venue.strip():
|
|
379
|
+
raise ValueError("venue must not be empty")
|
|
380
|
+
if timeframe is not None and not timeframe.strip():
|
|
381
|
+
raise ValueError("timeframe must not be empty")
|
|
382
|
+
if instrument is not None and instrument.venue and instrument.venue != venue:
|
|
383
|
+
raise ValueError(
|
|
384
|
+
f"instrument venue {instrument.venue!r} does not match provider venue {venue!r}"
|
|
385
|
+
)
|
|
386
|
+
try:
|
|
387
|
+
self._timestamp_timezone = ZoneInfo(timestamp_timezone)
|
|
388
|
+
except ZoneInfoNotFoundError as exc:
|
|
389
|
+
raise ValueError(f"unknown IANA timestamp timezone: {timestamp_timezone}") from exc
|
|
390
|
+
|
|
391
|
+
self.venue = venue
|
|
392
|
+
self.timestamp_unit = TimestampUnit.parse(timestamp_unit)
|
|
393
|
+
self.timestamp_timezone = timestamp_timezone
|
|
394
|
+
self._instrument_template = instrument
|
|
395
|
+
self._timeframe = timeframe
|
|
396
|
+
self._explicit_mapping: BarColumnMapping | None = None
|
|
397
|
+
self._mapping_overrides: dict[str, str] = {}
|
|
398
|
+
if isinstance(mapping, BarColumnMapping):
|
|
399
|
+
self._explicit_mapping = mapping
|
|
400
|
+
elif mapping is not None:
|
|
401
|
+
self._mapping_overrides = _validate_overrides(mapping)
|
|
402
|
+
|
|
403
|
+
async def inspect_schema(self) -> TabularSchema:
|
|
404
|
+
"""Reflect columns without reading or normalizing the full dataset."""
|
|
405
|
+
|
|
406
|
+
return await asyncio.to_thread(self._inspect_schema_sync)
|
|
407
|
+
|
|
408
|
+
def _resolved_mapping_sync(self) -> BarColumnMapping:
|
|
409
|
+
schema = self._inspect_schema_sync()
|
|
410
|
+
if self._explicit_mapping is not None:
|
|
411
|
+
self._explicit_mapping.validate(schema.column_names)
|
|
412
|
+
return self._explicit_mapping
|
|
413
|
+
return schema.infer_bar_mapping(self._mapping_overrides)
|
|
414
|
+
|
|
415
|
+
def _instrument(self, symbol: str) -> Instrument:
|
|
416
|
+
template = self._instrument_template
|
|
417
|
+
if template is not None:
|
|
418
|
+
if template.symbol != symbol:
|
|
419
|
+
raise MarketNotFoundError(
|
|
420
|
+
f"{self.name} is bound to {template.symbol!r}, not {symbol!r}"
|
|
421
|
+
)
|
|
422
|
+
return replace(
|
|
423
|
+
template,
|
|
424
|
+
venue=template.venue or self.venue,
|
|
425
|
+
provider_id=template.provider_id or symbol,
|
|
426
|
+
)
|
|
427
|
+
return Instrument(symbol=symbol, venue=self.venue, provider_id=symbol)
|
|
428
|
+
|
|
429
|
+
def _symbols_sync(self, mapping: BarColumnMapping) -> tuple[str, ...]:
|
|
430
|
+
if mapping.symbol is None:
|
|
431
|
+
if self._instrument_template is None:
|
|
432
|
+
return ()
|
|
433
|
+
return (self._instrument_template.symbol,)
|
|
434
|
+
values = self._distinct_values_sync(mapping.symbol)
|
|
435
|
+
symbols = {
|
|
436
|
+
str(value).strip() for value in values if value is not None and str(value).strip()
|
|
437
|
+
}
|
|
438
|
+
return tuple(sorted(symbols))
|
|
439
|
+
|
|
440
|
+
async def list_markets(self, query: MarketQuery | None = None) -> Sequence[MarketListing]:
|
|
441
|
+
mapping = await asyncio.to_thread(self._resolved_mapping_sync)
|
|
442
|
+
symbols = await asyncio.to_thread(self._symbols_sync, mapping)
|
|
443
|
+
listings = [MarketListing(self._instrument(symbol)) for symbol in symbols]
|
|
444
|
+
return listings if query is None else [item for item in listings if query.matches(item)]
|
|
445
|
+
|
|
446
|
+
async def resolve_market(self, symbol: str) -> MarketListing:
|
|
447
|
+
if not symbol.strip():
|
|
448
|
+
raise ValueError("symbol must not be empty")
|
|
449
|
+
mapping = await asyncio.to_thread(self._resolved_mapping_sync)
|
|
450
|
+
if mapping.symbol is not None:
|
|
451
|
+
symbols = await asyncio.to_thread(self._symbols_sync, mapping)
|
|
452
|
+
if symbol not in symbols:
|
|
453
|
+
raise MarketNotFoundError(f"{self.name} has no exact symbol {symbol!r}")
|
|
454
|
+
return MarketListing(self._instrument(symbol))
|
|
455
|
+
|
|
456
|
+
def _fetch_bars_sync(self, request: BarRequest) -> Sequence[Bar]:
|
|
457
|
+
if request.instrument.venue and request.instrument.venue != self.venue:
|
|
458
|
+
raise ValueError(
|
|
459
|
+
f"instrument venue {request.instrument.venue!r} does not match provider "
|
|
460
|
+
f"venue {self.venue!r}"
|
|
461
|
+
)
|
|
462
|
+
if self._instrument_template is not None:
|
|
463
|
+
self._instrument(request.instrument.symbol)
|
|
464
|
+
if self._timeframe is not None and request.timeframe != self._timeframe:
|
|
465
|
+
raise ValueError(
|
|
466
|
+
f"source timeframe {self._timeframe!r} does not match request {request.timeframe!r}"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
mapping = self._resolved_mapping_sync()
|
|
470
|
+
rows = self._read_rows_sync(mapping, request)
|
|
471
|
+
by_timestamp: dict[int, Bar] = {}
|
|
472
|
+
for index, row in enumerate(rows, start=1):
|
|
473
|
+
try:
|
|
474
|
+
if mapping.symbol is not None:
|
|
475
|
+
row_symbol = str(row[mapping.symbol]).strip()
|
|
476
|
+
if row_symbol != request.instrument.symbol:
|
|
477
|
+
continue
|
|
478
|
+
if mapping.timeframe is not None:
|
|
479
|
+
row_timeframe = str(row[mapping.timeframe]).strip()
|
|
480
|
+
if row_timeframe != request.timeframe:
|
|
481
|
+
continue
|
|
482
|
+
timestamp_ms = _timestamp_ms(
|
|
483
|
+
row[mapping.timestamp], self.timestamp_unit, self._timestamp_timezone
|
|
484
|
+
)
|
|
485
|
+
if not request.start_ms <= timestamp_ms < request.end_ms:
|
|
486
|
+
continue
|
|
487
|
+
if timestamp_ms in by_timestamp:
|
|
488
|
+
raise TabularDataError(
|
|
489
|
+
f"duplicate bar timestamp {timestamp_ms} for "
|
|
490
|
+
f"{request.instrument.symbol!r} and {request.timeframe!r}"
|
|
491
|
+
)
|
|
492
|
+
by_timestamp[timestamp_ms] = Bar(
|
|
493
|
+
instrument=request.instrument,
|
|
494
|
+
timestamp_ms=timestamp_ms,
|
|
495
|
+
open=_number(row[mapping.open], "open"),
|
|
496
|
+
high=_number(row[mapping.high], "high"),
|
|
497
|
+
low=_number(row[mapping.low], "low"),
|
|
498
|
+
close=_number(row[mapping.close], "close"),
|
|
499
|
+
volume=_number(row[mapping.volume], "volume"),
|
|
500
|
+
source=self.name,
|
|
501
|
+
)
|
|
502
|
+
except TabularDataError:
|
|
503
|
+
raise
|
|
504
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
505
|
+
raise TabularDataError(f"cannot normalize source row {index}: {exc}") from exc
|
|
506
|
+
|
|
507
|
+
bars = [by_timestamp[timestamp] for timestamp in sorted(by_timestamp)]
|
|
508
|
+
return bars if request.limit is None else bars[: request.limit]
|
|
509
|
+
|
|
510
|
+
async def fetch_bars(self, request: BarRequest) -> Sequence[Bar]:
|
|
511
|
+
"""Read, validate, sort, and normalize bars for one exact request."""
|
|
512
|
+
|
|
513
|
+
return await asyncio.to_thread(self._fetch_bars_sync, request)
|
|
514
|
+
|
|
515
|
+
async def close(self) -> None:
|
|
516
|
+
await asyncio.to_thread(self._close_sync)
|
|
517
|
+
|
|
518
|
+
@abstractmethod
|
|
519
|
+
def _inspect_schema_sync(self) -> TabularSchema: ...
|
|
520
|
+
|
|
521
|
+
@abstractmethod
|
|
522
|
+
def _read_rows_sync(
|
|
523
|
+
self, mapping: BarColumnMapping, request: BarRequest
|
|
524
|
+
) -> Sequence[TabularRow]: ...
|
|
525
|
+
|
|
526
|
+
@abstractmethod
|
|
527
|
+
def _distinct_values_sync(self, column: str) -> Sequence[object]: ...
|
|
528
|
+
|
|
529
|
+
def _close_sync(self) -> None:
|
|
530
|
+
return None
|
pineforge_data/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Shared input and output contract for the published PineForge release image."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from itertools import pairwise
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .backtest import BacktestOptions, JsonValue
|
|
12
|
+
from .models import Bar, Instrument
|
|
13
|
+
|
|
14
|
+
DEFAULT_RELEASE_IMAGE = (
|
|
15
|
+
"ghcr.io/pineforge-4pass/pineforge-release:0.1.12@"
|
|
16
|
+
"sha256:312b9d908390b828484617472c749d5815feb75507da87eae2f6902cfe3d47b1"
|
|
17
|
+
)
|
|
18
|
+
RELEASE_ENTRYPOINT = "/opt/pineforge/bin/entrypoint.sh"
|
|
19
|
+
RESPONSE_SCHEMA_VERSION = 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReleaseContractError(ValueError):
|
|
23
|
+
"""A request or release-image response violates the integration contract."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _validate_request(pine_source: str, bars: Sequence[Bar]) -> None:
|
|
27
|
+
if not pine_source.strip():
|
|
28
|
+
raise ReleaseContractError("PineScript source must not be empty")
|
|
29
|
+
if not bars:
|
|
30
|
+
raise ReleaseContractError("bars must not be empty")
|
|
31
|
+
if any(left.timestamp_ms >= right.timestamp_ms for left, right in pairwise(bars)):
|
|
32
|
+
raise ReleaseContractError("bar timestamps must be strictly increasing")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def write_release_inputs(
|
|
36
|
+
workspace: Path,
|
|
37
|
+
pine_source: str,
|
|
38
|
+
bars: Sequence[Bar],
|
|
39
|
+
instrument: Instrument,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Write the immutable `/in` files consumed by pineforge-release."""
|
|
42
|
+
|
|
43
|
+
_validate_request(pine_source, bars)
|
|
44
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
pine_path = workspace / "strategy.pine"
|
|
46
|
+
ohlcv_path = workspace / "ohlcv.csv"
|
|
47
|
+
syminfo_path = workspace / "syminfo.json"
|
|
48
|
+
pine_path.write_text(pine_source, encoding="utf-8")
|
|
49
|
+
with ohlcv_path.open("w", newline="", encoding="utf-8") as handle:
|
|
50
|
+
writer = csv.writer(handle)
|
|
51
|
+
writer.writerow(("timestamp", "open", "high", "low", "close", "volume"))
|
|
52
|
+
for bar in bars:
|
|
53
|
+
writer.writerow(
|
|
54
|
+
(
|
|
55
|
+
bar.timestamp_ms,
|
|
56
|
+
bar.open,
|
|
57
|
+
bar.high,
|
|
58
|
+
bar.low,
|
|
59
|
+
bar.close,
|
|
60
|
+
bar.volume,
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
syminfo_path.write_text(
|
|
64
|
+
json.dumps(
|
|
65
|
+
{
|
|
66
|
+
"syminfo": {
|
|
67
|
+
"ticker": instrument.symbol,
|
|
68
|
+
"timezone": instrument.timezone,
|
|
69
|
+
"session": instrument.session,
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
separators=(",", ":"),
|
|
73
|
+
),
|
|
74
|
+
encoding="utf-8",
|
|
75
|
+
)
|
|
76
|
+
for path in (pine_path, ohlcv_path, syminfo_path):
|
|
77
|
+
path.chmod(0o644)
|
|
78
|
+
workspace.chmod(0o755)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def release_environment(
|
|
82
|
+
input_directory: str,
|
|
83
|
+
instrument: Instrument,
|
|
84
|
+
options: BacktestOptions,
|
|
85
|
+
strategy_params: Mapping[str, JsonValue] | None = None,
|
|
86
|
+
strategy_overrides: Mapping[str, JsonValue] | None = None,
|
|
87
|
+
) -> dict[str, str]:
|
|
88
|
+
"""Translate PineForge options into the release entrypoint environment."""
|
|
89
|
+
|
|
90
|
+
if options.trace_enabled:
|
|
91
|
+
raise ReleaseContractError("pineforge-release 0.1.12 does not expose trace collection")
|
|
92
|
+
if options.bar_magnifier and options.magnifier_samples < 2:
|
|
93
|
+
raise ReleaseContractError("bar magnifier requires at least two samples")
|
|
94
|
+
environment = {
|
|
95
|
+
"PINEFORGE_IN_DIR": input_directory,
|
|
96
|
+
"PINEFORGE_INPUTS": json.dumps(
|
|
97
|
+
dict(strategy_params or {}), separators=(",", ":"), allow_nan=False
|
|
98
|
+
),
|
|
99
|
+
"PINEFORGE_OVERRIDES": json.dumps(
|
|
100
|
+
dict(strategy_overrides or {}), separators=(",", ":"), allow_nan=False
|
|
101
|
+
),
|
|
102
|
+
"PINEFORGE_INPUT_TF": options.input_timeframe,
|
|
103
|
+
"PINEFORGE_SCRIPT_TF": options.script_timeframe,
|
|
104
|
+
"PINEFORGE_BAR_MAGNIFIER": "true" if options.bar_magnifier else "false",
|
|
105
|
+
"PINEFORGE_MAGNIFIER_SAMPLES": str(options.magnifier_samples),
|
|
106
|
+
"PINEFORGE_MAGNIFIER_DIST": options.magnifier_distribution.name.lower(),
|
|
107
|
+
"PINEFORGE_CHART_TZ": options.chart_timezone or "",
|
|
108
|
+
"PINEFORGE_SYMINFO": f"{input_directory.rstrip('/')}/syminfo.json",
|
|
109
|
+
"PINEFORGE_DATA_SYMBOL": instrument.symbol,
|
|
110
|
+
"PINEFORGE_DATA_VENUE": instrument.venue,
|
|
111
|
+
}
|
|
112
|
+
if options.trade_start_time_ms is not None:
|
|
113
|
+
environment["PINEFORGE_TRADE_START_MS"] = str(options.trade_start_time_ms)
|
|
114
|
+
return environment
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_release_report(stdout: str) -> dict[str, object]:
|
|
118
|
+
"""Parse and minimally validate the canonical release-image report."""
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
value = json.loads(stdout)
|
|
122
|
+
except json.JSONDecodeError as exc:
|
|
123
|
+
raise ReleaseContractError("pineforge-release returned invalid JSON") from exc
|
|
124
|
+
if not isinstance(value, dict):
|
|
125
|
+
raise ReleaseContractError("pineforge-release report must be a JSON object")
|
|
126
|
+
required = ("summary", "trades", "metrics", "diagnostics")
|
|
127
|
+
missing = [field for field in required if field not in value]
|
|
128
|
+
if missing:
|
|
129
|
+
raise ReleaseContractError(f"pineforge-release report is missing: {', '.join(missing)}")
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def release_response(
|
|
134
|
+
report: Mapping[str, object],
|
|
135
|
+
*,
|
|
136
|
+
release_image: str,
|
|
137
|
+
mode: str,
|
|
138
|
+
request_id: str | None,
|
|
139
|
+
runtime_metadata: Mapping[str, object] | None = None,
|
|
140
|
+
) -> dict[str, object]:
|
|
141
|
+
"""Wrap the release report in the stable pineforge-data transport envelope."""
|
|
142
|
+
|
|
143
|
+
runtime: dict[str, object] = {
|
|
144
|
+
"mode": mode,
|
|
145
|
+
"release_image": release_image,
|
|
146
|
+
}
|
|
147
|
+
runtime.update(runtime_metadata or {})
|
|
148
|
+
return {
|
|
149
|
+
"schema_version": RESPONSE_SCHEMA_VERSION,
|
|
150
|
+
"request_id": request_id,
|
|
151
|
+
"runtime": runtime,
|
|
152
|
+
"backtest": dict(report),
|
|
153
|
+
}
|