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.
@@ -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)