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,51 @@
|
|
|
1
|
+
"""Structural protocols implemented by community provider adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Sequence
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from ..models import Bar, MacroObservation, MarketListing, TradeTick
|
|
9
|
+
from ..requests import BarRequest, MacroRequest, MarketQuery, TradeSubscription
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MarketNotFoundError(LookupError):
|
|
13
|
+
"""A provider catalog has no exact match for a normalized symbol."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class HistoricalBarProvider(Protocol):
|
|
18
|
+
name: str
|
|
19
|
+
|
|
20
|
+
async def fetch_bars(self, request: BarRequest) -> Sequence[Bar]: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class MarketCatalogProvider(Protocol):
|
|
25
|
+
name: str
|
|
26
|
+
venue: str
|
|
27
|
+
|
|
28
|
+
async def list_markets(self, query: MarketQuery | None = None) -> Sequence[MarketListing]: ...
|
|
29
|
+
|
|
30
|
+
async def resolve_market(self, symbol: str) -> MarketListing: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@runtime_checkable
|
|
34
|
+
class LiveTradeProvider(Protocol):
|
|
35
|
+
name: str
|
|
36
|
+
|
|
37
|
+
def stream_trades(self, subscription: TradeSubscription) -> AsyncIterator[TradeTick]: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@runtime_checkable
|
|
41
|
+
class MacroDataProvider(Protocol):
|
|
42
|
+
name: str
|
|
43
|
+
|
|
44
|
+
async def fetch_observations(self, request: MacroRequest) -> Sequence[MacroObservation]: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@runtime_checkable
|
|
48
|
+
class MarketDataProvider(HistoricalBarProvider, MarketCatalogProvider, Protocol):
|
|
49
|
+
"""Catalog plus historical bars required by the backtest harness."""
|
|
50
|
+
|
|
51
|
+
async def close(self) -> None: ...
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""CCXT adapter for exchange-neutral crypto bars and public trades."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections import deque
|
|
7
|
+
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
from math import isfinite
|
|
10
|
+
from typing import Protocol, cast
|
|
11
|
+
|
|
12
|
+
from ..models import (
|
|
13
|
+
AssetClass,
|
|
14
|
+
Bar,
|
|
15
|
+
ContractSpec,
|
|
16
|
+
Instrument,
|
|
17
|
+
MarketListing,
|
|
18
|
+
MarketType,
|
|
19
|
+
OptionType,
|
|
20
|
+
TradeTick,
|
|
21
|
+
)
|
|
22
|
+
from ..requests import BarRequest, MarketQuery, TradeSubscription
|
|
23
|
+
from .base import MarketNotFoundError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CcxtError(RuntimeError):
|
|
27
|
+
"""Base error raised by the CCXT adapter."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CcxtDependencyError(CcxtError):
|
|
31
|
+
"""The optional CCXT dependency is unavailable."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CcxtCapabilityError(CcxtError):
|
|
35
|
+
"""The configured exchange lacks a required unified method."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CcxtDataError(CcxtError):
|
|
39
|
+
"""CCXT returned a record that cannot be normalized safely."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _AsyncCcxtExchange(Protocol):
|
|
43
|
+
id: str
|
|
44
|
+
has: Mapping[str, object]
|
|
45
|
+
|
|
46
|
+
def milliseconds(self) -> int: ...
|
|
47
|
+
|
|
48
|
+
def parse_timeframe(self, timeframe: str) -> float: ...
|
|
49
|
+
|
|
50
|
+
async def load_markets(
|
|
51
|
+
self,
|
|
52
|
+
reload: bool = False,
|
|
53
|
+
params: Mapping[str, object] | None = None,
|
|
54
|
+
) -> Mapping[str, Mapping[str, object]]: ...
|
|
55
|
+
|
|
56
|
+
async def fetch_ohlcv(
|
|
57
|
+
self,
|
|
58
|
+
symbol: str,
|
|
59
|
+
timeframe: str,
|
|
60
|
+
since: int | None,
|
|
61
|
+
limit: int | None,
|
|
62
|
+
params: Mapping[str, object],
|
|
63
|
+
) -> Sequence[Sequence[object]]: ...
|
|
64
|
+
|
|
65
|
+
async def fetch_trades(
|
|
66
|
+
self,
|
|
67
|
+
symbol: str,
|
|
68
|
+
since: int | None,
|
|
69
|
+
limit: int | None,
|
|
70
|
+
params: Mapping[str, object],
|
|
71
|
+
) -> Sequence[Mapping[str, object]]: ...
|
|
72
|
+
|
|
73
|
+
async def close(self) -> None: ...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_exchange(exchange_id: str, config: Mapping[str, object]) -> _AsyncCcxtExchange:
|
|
77
|
+
try:
|
|
78
|
+
module = import_module("ccxt.async_support")
|
|
79
|
+
except ModuleNotFoundError as exc:
|
|
80
|
+
if exc.name == "ccxt" or (exc.name and exc.name.startswith("ccxt.")):
|
|
81
|
+
raise CcxtDependencyError(
|
|
82
|
+
"CCXT is not installed; install pineforge-data[ccxt]"
|
|
83
|
+
) from exc
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
factory = getattr(module, exchange_id, None)
|
|
87
|
+
if factory is None or not callable(factory):
|
|
88
|
+
raise ValueError(f"unknown CCXT exchange: {exchange_id}")
|
|
89
|
+
return cast(_AsyncCcxtExchange, factory(dict(config)))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _number(value: object, field: str) -> float:
|
|
93
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
94
|
+
raise CcxtDataError(f"{field} must be numeric")
|
|
95
|
+
normalized = float(value)
|
|
96
|
+
if not isfinite(normalized):
|
|
97
|
+
raise CcxtDataError(f"{field} must be finite")
|
|
98
|
+
return normalized
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _timestamp(value: object, field: str = "timestamp") -> int:
|
|
102
|
+
normalized = _number(value, field)
|
|
103
|
+
if normalized < 0 or not normalized.is_integer():
|
|
104
|
+
raise CcxtDataError(f"{field} must be a non-negative integer")
|
|
105
|
+
return int(normalized)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _optional_number(value: object, field: str) -> float | None:
|
|
109
|
+
return None if value is None else _number(value, field)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _optional_timestamp(value: object, field: str) -> int | None:
|
|
113
|
+
return None if value is None else _timestamp(value, field)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _optional_bool(value: object, field: str) -> bool | None:
|
|
117
|
+
if value is None:
|
|
118
|
+
return None
|
|
119
|
+
if not isinstance(value, bool):
|
|
120
|
+
raise CcxtDataError(f"{field} must be boolean")
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _text(value: object, field: str) -> str:
|
|
125
|
+
if not isinstance(value, str) or not value.strip():
|
|
126
|
+
raise CcxtDataError(f"{field} must be a non-empty string")
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _optional_text(value: object, field: str) -> str:
|
|
131
|
+
if value is None:
|
|
132
|
+
return ""
|
|
133
|
+
return _text(value, field)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_CCXT_MARKET_TYPES = {
|
|
137
|
+
"spot": MarketType.SPOT,
|
|
138
|
+
"margin": MarketType.SPOT,
|
|
139
|
+
"swap": MarketType.SWAP,
|
|
140
|
+
"future": MarketType.FUTURE,
|
|
141
|
+
"option": MarketType.OPTION,
|
|
142
|
+
}
|
|
143
|
+
_CONTRACT_MARKET_TYPES = {MarketType.SWAP, MarketType.FUTURE, MarketType.OPTION}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class CcxtProvider:
|
|
147
|
+
"""Normalize one CCXT exchange into PineForge bars and trade ticks.
|
|
148
|
+
|
|
149
|
+
``Instrument.symbol`` must use CCXT's unified symbol spelling, for example
|
|
150
|
+
``BTC/USDT``. The adapter owns exchanges it constructs and leaves injected
|
|
151
|
+
exchanges open, which keeps tests and shared CCXT clients composable.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
exchange_id: str,
|
|
157
|
+
*,
|
|
158
|
+
config: Mapping[str, object] | None = None,
|
|
159
|
+
exchange: _AsyncCcxtExchange | None = None,
|
|
160
|
+
page_limit: int = 1_000,
|
|
161
|
+
poll_interval_ms: int = 1_000,
|
|
162
|
+
dedup_window: int = 10_000,
|
|
163
|
+
reload_markets: bool = False,
|
|
164
|
+
market_params: Mapping[str, object] | None = None,
|
|
165
|
+
ohlcv_params: Mapping[str, object] | None = None,
|
|
166
|
+
trade_params: Mapping[str, object] | None = None,
|
|
167
|
+
) -> None:
|
|
168
|
+
if not exchange_id.strip():
|
|
169
|
+
raise ValueError("exchange_id must not be empty")
|
|
170
|
+
if page_limit <= 0:
|
|
171
|
+
raise ValueError("page_limit must be positive")
|
|
172
|
+
if poll_interval_ms < 0:
|
|
173
|
+
raise ValueError("poll_interval_ms must be non-negative")
|
|
174
|
+
if dedup_window <= 0:
|
|
175
|
+
raise ValueError("dedup_window must be positive")
|
|
176
|
+
|
|
177
|
+
self._owns_exchange = exchange is None
|
|
178
|
+
self._exchange = exchange or _load_exchange(exchange_id, config or {})
|
|
179
|
+
if self._exchange.id != exchange_id:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"injected CCXT exchange id {self._exchange.id!r} does not match {exchange_id!r}"
|
|
182
|
+
)
|
|
183
|
+
self.exchange_id = exchange_id
|
|
184
|
+
self.venue = exchange_id
|
|
185
|
+
self.name = f"ccxt:{exchange_id}"
|
|
186
|
+
self.page_limit = page_limit
|
|
187
|
+
self.poll_interval_ms = poll_interval_ms
|
|
188
|
+
self.dedup_window = dedup_window
|
|
189
|
+
self.reload_markets = reload_markets
|
|
190
|
+
self.market_params = dict(market_params or {})
|
|
191
|
+
self.ohlcv_params = dict(ohlcv_params or {})
|
|
192
|
+
self.trade_params = dict(trade_params or {})
|
|
193
|
+
|
|
194
|
+
def _require_capability(self, name: str) -> None:
|
|
195
|
+
if not self._exchange.has.get(name):
|
|
196
|
+
raise CcxtCapabilityError(f"{self.exchange_id} does not support {name}")
|
|
197
|
+
|
|
198
|
+
def _validate_instrument_venue(self, instrument: Instrument) -> None:
|
|
199
|
+
if instrument.venue and instrument.venue != self.venue:
|
|
200
|
+
raise ValueError(
|
|
201
|
+
f"instrument venue {instrument.venue!r} does not match provider "
|
|
202
|
+
f"venue {self.venue!r}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _normalize_market(self, raw: Mapping[str, object]) -> MarketListing:
|
|
206
|
+
symbol = _text(raw.get("symbol"), "market.symbol")
|
|
207
|
+
provider_id = _text(raw.get("id"), "market.id")
|
|
208
|
+
raw_type = _optional_text(raw.get("type"), "market.type").casefold()
|
|
209
|
+
market_type = _CCXT_MARKET_TYPES.get(raw_type, MarketType.UNKNOWN)
|
|
210
|
+
declared_contract = _optional_bool(raw.get("contract"), "market.contract")
|
|
211
|
+
is_contract = declared_contract is True or market_type in _CONTRACT_MARKET_TYPES
|
|
212
|
+
|
|
213
|
+
option_type: OptionType | None = None
|
|
214
|
+
raw_option_type = _optional_text(raw.get("optionType"), "market.optionType")
|
|
215
|
+
if raw_option_type:
|
|
216
|
+
try:
|
|
217
|
+
option_type = OptionType(raw_option_type.casefold())
|
|
218
|
+
except ValueError as exc:
|
|
219
|
+
raise CcxtDataError(f"unsupported market.optionType: {raw_option_type!r}") from exc
|
|
220
|
+
|
|
221
|
+
contract = None
|
|
222
|
+
if is_contract:
|
|
223
|
+
try:
|
|
224
|
+
contract = ContractSpec(
|
|
225
|
+
contract_size=_optional_number(raw.get("contractSize"), "market.contractSize"),
|
|
226
|
+
linear=_optional_bool(raw.get("linear"), "market.linear"),
|
|
227
|
+
inverse=_optional_bool(raw.get("inverse"), "market.inverse"),
|
|
228
|
+
expiry_ms=_optional_timestamp(raw.get("expiry"), "market.expiry"),
|
|
229
|
+
strike=_optional_number(raw.get("strike"), "market.strike"),
|
|
230
|
+
option_type=option_type,
|
|
231
|
+
)
|
|
232
|
+
except ValueError as exc:
|
|
233
|
+
raise CcxtDataError(f"invalid contract metadata for {symbol}: {exc}") from exc
|
|
234
|
+
|
|
235
|
+
return MarketListing(
|
|
236
|
+
instrument=Instrument(
|
|
237
|
+
symbol=symbol,
|
|
238
|
+
venue=self.venue,
|
|
239
|
+
volume_unit="contracts" if is_contract else "base",
|
|
240
|
+
asset_class=AssetClass.CRYPTO,
|
|
241
|
+
market_type=market_type,
|
|
242
|
+
base=_optional_text(raw.get("base"), "market.base"),
|
|
243
|
+
quote=_optional_text(raw.get("quote"), "market.quote"),
|
|
244
|
+
settle=_optional_text(raw.get("settle"), "market.settle"),
|
|
245
|
+
provider_id=provider_id,
|
|
246
|
+
contract=contract,
|
|
247
|
+
),
|
|
248
|
+
active=_optional_bool(raw.get("active"), "market.active"),
|
|
249
|
+
margin_supported=_optional_bool(raw.get("margin"), "market.margin"),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def list_markets(self, query: MarketQuery | None = None) -> Sequence[MarketListing]:
|
|
253
|
+
"""Load and normalize every market advertised by this CCXT exchange."""
|
|
254
|
+
|
|
255
|
+
raw_markets = await self._exchange.load_markets(self.reload_markets, self.market_params)
|
|
256
|
+
listings = [self._normalize_market(raw) for raw in raw_markets.values()]
|
|
257
|
+
if query is not None:
|
|
258
|
+
listings = [listing for listing in listings if query.matches(listing)]
|
|
259
|
+
return sorted(listings, key=lambda listing: listing.instrument.symbol)
|
|
260
|
+
|
|
261
|
+
async def resolve_market(self, symbol: str) -> MarketListing:
|
|
262
|
+
"""Resolve one exact CCXT unified symbol into normalized market metadata."""
|
|
263
|
+
|
|
264
|
+
if not symbol.strip():
|
|
265
|
+
raise ValueError("symbol must not be empty")
|
|
266
|
+
raw_markets = await self._exchange.load_markets(self.reload_markets, self.market_params)
|
|
267
|
+
raw = raw_markets.get(symbol)
|
|
268
|
+
if raw is None:
|
|
269
|
+
raise MarketNotFoundError(f"{self.name} has no exact unified market symbol {symbol!r}")
|
|
270
|
+
return self._normalize_market(raw)
|
|
271
|
+
|
|
272
|
+
def _normalize_bar(self, raw: Sequence[object], request: BarRequest) -> Bar:
|
|
273
|
+
if len(raw) < 6:
|
|
274
|
+
raise CcxtDataError("OHLCV record must contain at least six fields")
|
|
275
|
+
return Bar(
|
|
276
|
+
instrument=request.instrument,
|
|
277
|
+
timestamp_ms=_timestamp(raw[0]),
|
|
278
|
+
open=_number(raw[1], "open"),
|
|
279
|
+
high=_number(raw[2], "high"),
|
|
280
|
+
low=_number(raw[3], "low"),
|
|
281
|
+
close=_number(raw[4], "close"),
|
|
282
|
+
volume=_number(raw[5], "volume"),
|
|
283
|
+
source=self.name,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
async def fetch_bars(self, request: BarRequest) -> Sequence[Bar]:
|
|
287
|
+
"""Fetch paginated, deduplicated, confirmed OHLCV bars."""
|
|
288
|
+
|
|
289
|
+
self._validate_instrument_venue(request.instrument)
|
|
290
|
+
self._require_capability("fetchOHLCV")
|
|
291
|
+
timeframe_seconds = self._exchange.parse_timeframe(request.timeframe)
|
|
292
|
+
timeframe_ms = int(_number(timeframe_seconds, "timeframe seconds") * 1_000)
|
|
293
|
+
if timeframe_ms <= 0:
|
|
294
|
+
raise CcxtDataError("timeframe duration must be positive")
|
|
295
|
+
|
|
296
|
+
observed_at_ms = min(request.end_ms, self._exchange.milliseconds())
|
|
297
|
+
cursor = request.start_ms
|
|
298
|
+
by_timestamp: dict[int, Bar] = {}
|
|
299
|
+
while cursor < request.end_ms:
|
|
300
|
+
remaining = None if request.limit is None else request.limit - len(by_timestamp)
|
|
301
|
+
if remaining is not None and remaining <= 0:
|
|
302
|
+
break
|
|
303
|
+
batch_limit = self.page_limit if remaining is None else min(self.page_limit, remaining)
|
|
304
|
+
raw_bars = await self._exchange.fetch_ohlcv(
|
|
305
|
+
request.instrument.symbol,
|
|
306
|
+
request.timeframe,
|
|
307
|
+
cursor,
|
|
308
|
+
batch_limit,
|
|
309
|
+
self.ohlcv_params,
|
|
310
|
+
)
|
|
311
|
+
if not raw_bars:
|
|
312
|
+
break
|
|
313
|
+
|
|
314
|
+
max_timestamp = cursor - 1
|
|
315
|
+
for raw in raw_bars:
|
|
316
|
+
bar = self._normalize_bar(raw, request)
|
|
317
|
+
max_timestamp = max(max_timestamp, bar.timestamp_ms)
|
|
318
|
+
if (
|
|
319
|
+
request.start_ms <= bar.timestamp_ms < request.end_ms
|
|
320
|
+
and bar.timestamp_ms + timeframe_ms <= observed_at_ms
|
|
321
|
+
):
|
|
322
|
+
by_timestamp[bar.timestamp_ms] = bar
|
|
323
|
+
|
|
324
|
+
next_cursor = max_timestamp + 1
|
|
325
|
+
if next_cursor <= cursor:
|
|
326
|
+
break
|
|
327
|
+
cursor = next_cursor
|
|
328
|
+
|
|
329
|
+
bars = sorted(by_timestamp.values(), key=lambda bar: bar.timestamp_ms)
|
|
330
|
+
return bars if request.limit is None else bars[: request.limit]
|
|
331
|
+
|
|
332
|
+
def _trade_key(self, raw: Mapping[str, object], tick: TradeTick) -> tuple[object, ...]:
|
|
333
|
+
trade_id = raw.get("id")
|
|
334
|
+
if trade_id is not None and str(trade_id):
|
|
335
|
+
return ("id", str(trade_id))
|
|
336
|
+
return ("values", tick.timestamp_ms, tick.price, tick.quantity, raw.get("side"))
|
|
337
|
+
|
|
338
|
+
def _normalize_trade(
|
|
339
|
+
self,
|
|
340
|
+
raw: Mapping[str, object],
|
|
341
|
+
subscription: TradeSubscription,
|
|
342
|
+
sequence: int,
|
|
343
|
+
) -> TradeTick:
|
|
344
|
+
return TradeTick(
|
|
345
|
+
instrument=subscription.instrument,
|
|
346
|
+
timestamp_ms=_timestamp(raw.get("timestamp")),
|
|
347
|
+
sequence=sequence,
|
|
348
|
+
price=_number(raw.get("price"), "price"),
|
|
349
|
+
quantity=_number(raw.get("amount"), "amount"),
|
|
350
|
+
source=self.name,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
async def stream_trades(self, subscription: TradeSubscription) -> AsyncIterator[TradeTick]:
|
|
354
|
+
"""Poll CCXT public trades and emit a strictly ordered local sequence."""
|
|
355
|
+
|
|
356
|
+
self._validate_instrument_venue(subscription.instrument)
|
|
357
|
+
self._require_capability("fetchTrades")
|
|
358
|
+
since = (
|
|
359
|
+
subscription.start_ms
|
|
360
|
+
if subscription.start_ms is not None
|
|
361
|
+
else self._exchange.milliseconds()
|
|
362
|
+
)
|
|
363
|
+
sequence = subscription.start_sequence
|
|
364
|
+
seen_order: deque[tuple[object, ...]] = deque()
|
|
365
|
+
seen: set[tuple[object, ...]] = set()
|
|
366
|
+
|
|
367
|
+
while True:
|
|
368
|
+
poll_since = since
|
|
369
|
+
raw_trades = await self._exchange.fetch_trades(
|
|
370
|
+
subscription.instrument.symbol,
|
|
371
|
+
since,
|
|
372
|
+
self.page_limit,
|
|
373
|
+
self.trade_params,
|
|
374
|
+
)
|
|
375
|
+
ordered = sorted(raw_trades, key=lambda raw: _timestamp(raw.get("timestamp")))
|
|
376
|
+
for raw in ordered:
|
|
377
|
+
candidate = self._normalize_trade(raw, subscription, sequence + 1)
|
|
378
|
+
if candidate.timestamp_ms < poll_since:
|
|
379
|
+
continue
|
|
380
|
+
since = max(since, candidate.timestamp_ms)
|
|
381
|
+
key = self._trade_key(raw, candidate)
|
|
382
|
+
if key in seen:
|
|
383
|
+
continue
|
|
384
|
+
sequence += 1
|
|
385
|
+
tick = TradeTick(
|
|
386
|
+
candidate.instrument,
|
|
387
|
+
candidate.timestamp_ms,
|
|
388
|
+
sequence,
|
|
389
|
+
candidate.price,
|
|
390
|
+
candidate.quantity,
|
|
391
|
+
candidate.source,
|
|
392
|
+
)
|
|
393
|
+
seen.add(key)
|
|
394
|
+
seen_order.append(key)
|
|
395
|
+
if len(seen_order) > self.dedup_window:
|
|
396
|
+
seen.remove(seen_order.popleft())
|
|
397
|
+
yield tick
|
|
398
|
+
|
|
399
|
+
await asyncio.sleep(self.poll_interval_ms / 1_000)
|
|
400
|
+
|
|
401
|
+
async def close(self) -> None:
|
|
402
|
+
"""Close an exchange constructed by this provider."""
|
|
403
|
+
|
|
404
|
+
if self._owns_exchange:
|
|
405
|
+
await self._exchange.close()
|
|
406
|
+
|
|
407
|
+
async def __aenter__(self) -> CcxtProvider:
|
|
408
|
+
return self
|
|
409
|
+
|
|
410
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
411
|
+
await self.close()
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""CSV and SQLite providers for user-owned historical bars."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import sqlite3
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TextIO
|
|
9
|
+
|
|
10
|
+
from ..models import Instrument
|
|
11
|
+
from ..requests import BarRequest
|
|
12
|
+
from .tabular import (
|
|
13
|
+
BarColumnMapping,
|
|
14
|
+
ColumnMappingInput,
|
|
15
|
+
SourceColumn,
|
|
16
|
+
TabularBarProvider,
|
|
17
|
+
TabularDataError,
|
|
18
|
+
TabularRow,
|
|
19
|
+
TabularSchema,
|
|
20
|
+
TimestampUnit,
|
|
21
|
+
numeric_source_bounds,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CsvBarProvider(TabularBarProvider):
|
|
26
|
+
"""Read historical bars from a local CSV file with a runtime column mapping."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
path: str | Path,
|
|
31
|
+
*,
|
|
32
|
+
venue: str = "local",
|
|
33
|
+
mapping: ColumnMappingInput = None,
|
|
34
|
+
timestamp_unit: TimestampUnit | str = TimestampUnit.MILLISECONDS,
|
|
35
|
+
timestamp_timezone: str = "UTC",
|
|
36
|
+
instrument: Instrument | None = None,
|
|
37
|
+
timeframe: str | None = None,
|
|
38
|
+
encoding: str = "utf-8-sig",
|
|
39
|
+
delimiter: str = ",",
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(
|
|
42
|
+
venue=venue,
|
|
43
|
+
mapping=mapping,
|
|
44
|
+
timestamp_unit=timestamp_unit,
|
|
45
|
+
timestamp_timezone=timestamp_timezone,
|
|
46
|
+
instrument=instrument,
|
|
47
|
+
timeframe=timeframe,
|
|
48
|
+
)
|
|
49
|
+
if not encoding:
|
|
50
|
+
raise ValueError("encoding must not be empty")
|
|
51
|
+
if len(delimiter) != 1:
|
|
52
|
+
raise ValueError("delimiter must be exactly one character")
|
|
53
|
+
self.path = Path(path).expanduser().resolve()
|
|
54
|
+
self.encoding = encoding
|
|
55
|
+
self.delimiter = delimiter
|
|
56
|
+
self.name = f"csv:{venue}"
|
|
57
|
+
|
|
58
|
+
def _reader(self) -> tuple[csv.DictReader[str], TextIO]:
|
|
59
|
+
if not self.path.is_file():
|
|
60
|
+
raise FileNotFoundError(f"CSV file not found: {self.path}")
|
|
61
|
+
handle = self.path.open("r", encoding=self.encoding, newline="")
|
|
62
|
+
reader = csv.DictReader(handle, delimiter=self.delimiter)
|
|
63
|
+
return reader, handle
|
|
64
|
+
|
|
65
|
+
def _inspect_schema_sync(self) -> TabularSchema:
|
|
66
|
+
reader, handle = self._reader()
|
|
67
|
+
try:
|
|
68
|
+
fieldnames = reader.fieldnames
|
|
69
|
+
if not fieldnames:
|
|
70
|
+
raise TabularDataError(f"CSV file has no header: {self.path}")
|
|
71
|
+
if any(not fieldname for fieldname in fieldnames):
|
|
72
|
+
raise TabularDataError("CSV header contains an empty column name")
|
|
73
|
+
return TabularSchema(
|
|
74
|
+
source=str(self.path),
|
|
75
|
+
columns=tuple(SourceColumn(fieldname, "text", None) for fieldname in fieldnames),
|
|
76
|
+
)
|
|
77
|
+
finally:
|
|
78
|
+
handle.close()
|
|
79
|
+
|
|
80
|
+
def _all_rows_sync(self) -> tuple[TabularRow, ...]:
|
|
81
|
+
reader, handle = self._reader()
|
|
82
|
+
try:
|
|
83
|
+
rows: list[TabularRow] = []
|
|
84
|
+
for line_number, row in enumerate(reader, start=2):
|
|
85
|
+
if None in row:
|
|
86
|
+
raise TabularDataError(f"CSV row {line_number} has more values than the header")
|
|
87
|
+
rows.append(dict(row))
|
|
88
|
+
return tuple(rows)
|
|
89
|
+
finally:
|
|
90
|
+
handle.close()
|
|
91
|
+
|
|
92
|
+
def _read_rows_sync(
|
|
93
|
+
self, mapping: BarColumnMapping, request: BarRequest
|
|
94
|
+
) -> tuple[TabularRow, ...]:
|
|
95
|
+
del mapping, request
|
|
96
|
+
return self._all_rows_sync()
|
|
97
|
+
|
|
98
|
+
def _distinct_values_sync(self, column: str) -> tuple[object, ...]:
|
|
99
|
+
return tuple(row[column] for row in self._all_rows_sync())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _quote_identifier(identifier: str) -> str:
|
|
103
|
+
return '"' + identifier.replace('"', '""') + '"'
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SqliteBarProvider(TabularBarProvider):
|
|
107
|
+
"""Read historical bars from one SQLite table or view in read-only mode."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
path: str | Path,
|
|
112
|
+
table: str,
|
|
113
|
+
*,
|
|
114
|
+
venue: str = "local",
|
|
115
|
+
mapping: ColumnMappingInput = None,
|
|
116
|
+
timestamp_unit: TimestampUnit | str = TimestampUnit.MILLISECONDS,
|
|
117
|
+
timestamp_timezone: str = "UTC",
|
|
118
|
+
instrument: Instrument | None = None,
|
|
119
|
+
timeframe: str | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
super().__init__(
|
|
122
|
+
venue=venue,
|
|
123
|
+
mapping=mapping,
|
|
124
|
+
timestamp_unit=timestamp_unit,
|
|
125
|
+
timestamp_timezone=timestamp_timezone,
|
|
126
|
+
instrument=instrument,
|
|
127
|
+
timeframe=timeframe,
|
|
128
|
+
)
|
|
129
|
+
if not table:
|
|
130
|
+
raise ValueError("table must not be empty")
|
|
131
|
+
self.path = Path(path).expanduser().resolve()
|
|
132
|
+
self.table = table
|
|
133
|
+
self.name = f"sqlite:{venue}"
|
|
134
|
+
|
|
135
|
+
def _connect(self) -> sqlite3.Connection:
|
|
136
|
+
if not self.path.is_file():
|
|
137
|
+
raise FileNotFoundError(f"SQLite database not found: {self.path}")
|
|
138
|
+
connection = sqlite3.connect(f"{self.path.as_uri()}?mode=ro", uri=True)
|
|
139
|
+
connection.row_factory = sqlite3.Row
|
|
140
|
+
return connection
|
|
141
|
+
|
|
142
|
+
def _require_table(self, connection: sqlite3.Connection) -> None:
|
|
143
|
+
found = connection.execute(
|
|
144
|
+
"SELECT 1 FROM sqlite_schema WHERE type IN ('table', 'view') AND name = ? LIMIT 1",
|
|
145
|
+
(self.table,),
|
|
146
|
+
).fetchone()
|
|
147
|
+
if found is None:
|
|
148
|
+
raise TabularDataError(f"SQLite table or view not found: {self.table}")
|
|
149
|
+
|
|
150
|
+
def _inspect_schema_sync(self) -> TabularSchema:
|
|
151
|
+
with self._connect() as connection:
|
|
152
|
+
self._require_table(connection)
|
|
153
|
+
rows = connection.execute(
|
|
154
|
+
f"PRAGMA table_info({_quote_identifier(self.table)})"
|
|
155
|
+
).fetchall()
|
|
156
|
+
if not rows:
|
|
157
|
+
raise TabularDataError(f"SQLite source has no columns: {self.table}")
|
|
158
|
+
return TabularSchema(
|
|
159
|
+
source=f"{self.path}#{self.table}",
|
|
160
|
+
columns=tuple(
|
|
161
|
+
SourceColumn(
|
|
162
|
+
name=str(row[1]),
|
|
163
|
+
data_type=str(row[2] or ""),
|
|
164
|
+
nullable=not bool(row[3]) and not bool(row[5]),
|
|
165
|
+
)
|
|
166
|
+
for row in rows
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _read_rows_sync(
|
|
171
|
+
self, mapping: BarColumnMapping, request: BarRequest
|
|
172
|
+
) -> tuple[TabularRow, ...]:
|
|
173
|
+
selected = ", ".join(_quote_identifier(column) for column in mapping.columns)
|
|
174
|
+
predicates: list[str] = []
|
|
175
|
+
parameters: list[object] = []
|
|
176
|
+
if mapping.symbol is not None:
|
|
177
|
+
predicates.append(f"{_quote_identifier(mapping.symbol)} = ?")
|
|
178
|
+
parameters.append(request.instrument.symbol)
|
|
179
|
+
if mapping.timeframe is not None:
|
|
180
|
+
predicates.append(f"{_quote_identifier(mapping.timeframe)} = ?")
|
|
181
|
+
parameters.append(request.timeframe)
|
|
182
|
+
bounds = numeric_source_bounds(request.start_ms, request.end_ms, self.timestamp_unit)
|
|
183
|
+
if bounds is not None:
|
|
184
|
+
predicates.append(f"{_quote_identifier(mapping.timestamp)} >= ?")
|
|
185
|
+
parameters.append(bounds[0])
|
|
186
|
+
predicates.append(f"{_quote_identifier(mapping.timestamp)} < ?")
|
|
187
|
+
parameters.append(bounds[1])
|
|
188
|
+
where = f" WHERE {' AND '.join(predicates)}" if predicates else ""
|
|
189
|
+
statement = (
|
|
190
|
+
f"SELECT {selected} FROM {_quote_identifier(self.table)}{where} "
|
|
191
|
+
f"ORDER BY {_quote_identifier(mapping.timestamp)}"
|
|
192
|
+
)
|
|
193
|
+
with self._connect() as connection:
|
|
194
|
+
self._require_table(connection)
|
|
195
|
+
rows = connection.execute(statement, parameters).fetchall()
|
|
196
|
+
return tuple(dict(row) for row in rows)
|
|
197
|
+
|
|
198
|
+
def _distinct_values_sync(self, column: str) -> tuple[object, ...]:
|
|
199
|
+
statement = (
|
|
200
|
+
f"SELECT DISTINCT {_quote_identifier(column)} "
|
|
201
|
+
f"FROM {_quote_identifier(self.table)} "
|
|
202
|
+
f"ORDER BY {_quote_identifier(column)}"
|
|
203
|
+
)
|
|
204
|
+
with self._connect() as connection:
|
|
205
|
+
self._require_table(connection)
|
|
206
|
+
rows = connection.execute(statement).fetchall()
|
|
207
|
+
return tuple(row[0] for row in rows)
|