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,252 @@
1
+ """Provider factories and third-party entry-point discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+ from importlib.metadata import EntryPoint, entry_points
8
+ from typing import Protocol, cast
9
+
10
+ from ..models import Instrument
11
+ from .base import MarketDataProvider
12
+ from .ccxt import CcxtProvider
13
+ from .local import CsvBarProvider, SqliteBarProvider
14
+ from .sqlalchemy import SqlAlchemyBarProvider
15
+
16
+ ENTRY_POINT_GROUP = "pineforge_data.providers"
17
+
18
+
19
+ class ProviderRegistryError(RuntimeError):
20
+ """A provider factory cannot be registered or loaded."""
21
+
22
+
23
+ class ProviderNotFoundError(ProviderRegistryError):
24
+ """No built-in or installed provider has the requested name."""
25
+
26
+
27
+ class ProviderFactory(Protocol):
28
+ def __call__(self, venue: str, config: Mapping[str, object]) -> MarketDataProvider: ...
29
+
30
+
31
+ def _ccxt_factory(venue: str, config: Mapping[str, object]) -> MarketDataProvider:
32
+ return CcxtProvider(venue, config=config)
33
+
34
+
35
+ def _unknown_config(config: Mapping[str, object], allowed: set[str]) -> None:
36
+ unknown = sorted(config.keys() - allowed)
37
+ if unknown:
38
+ raise ValueError(f"unknown provider configuration key(s): {', '.join(unknown)}")
39
+
40
+
41
+ def _required_text(config: Mapping[str, object], key: str) -> str:
42
+ value = config.get(key)
43
+ if not isinstance(value, str) or not value:
44
+ raise ValueError(f"provider configuration {key!r} must be a non-empty string")
45
+ return value
46
+
47
+
48
+ def _optional_text(config: Mapping[str, object], key: str) -> str | None:
49
+ value = config.get(key)
50
+ if value is None:
51
+ return None
52
+ if not isinstance(value, str) or not value:
53
+ raise ValueError(f"provider configuration {key!r} must be a non-empty string")
54
+ return value
55
+
56
+
57
+ def _column_overrides(config: Mapping[str, object]) -> Mapping[str, str] | None:
58
+ value = config.get("columns")
59
+ if value is None:
60
+ return None
61
+ if not isinstance(value, Mapping):
62
+ raise ValueError("provider configuration 'columns' must be an object")
63
+ overrides: dict[str, str] = {}
64
+ allowed = {"timestamp", "open", "high", "low", "close", "volume", "symbol", "timeframe"}
65
+ for field, column in value.items():
66
+ if not isinstance(field, str) or not isinstance(column, str):
67
+ raise ValueError("provider column mappings must have string keys and values")
68
+ if field not in allowed:
69
+ raise ValueError(f"unknown canonical column field: {field}")
70
+ if not column:
71
+ raise ValueError(f"provider column mapping for {field!r} must not be empty")
72
+ overrides[field] = column
73
+ return overrides
74
+
75
+
76
+ def _instrument(venue: str, config: Mapping[str, object]) -> Instrument | None:
77
+ symbol = _optional_text(config, "symbol")
78
+ return None if symbol is None else Instrument(symbol, venue=venue, provider_id=symbol)
79
+
80
+
81
+ _COMMON_TABULAR_KEYS = {
82
+ "columns",
83
+ "timestamp_unit",
84
+ "timestamp_timezone",
85
+ "symbol",
86
+ "timeframe",
87
+ }
88
+
89
+
90
+ def _csv_factory(venue: str, config: Mapping[str, object]) -> MarketDataProvider:
91
+ _unknown_config(config, _COMMON_TABULAR_KEYS | {"path", "encoding", "delimiter"})
92
+ return CsvBarProvider(
93
+ _required_text(config, "path"),
94
+ venue=venue,
95
+ mapping=_column_overrides(config),
96
+ timestamp_unit=_optional_text(config, "timestamp_unit") or "milliseconds",
97
+ timestamp_timezone=_optional_text(config, "timestamp_timezone") or "UTC",
98
+ instrument=_instrument(venue, config),
99
+ timeframe=_optional_text(config, "timeframe"),
100
+ encoding=_optional_text(config, "encoding") or "utf-8-sig",
101
+ delimiter=_optional_text(config, "delimiter") or ",",
102
+ )
103
+
104
+
105
+ def _sqlite_factory(venue: str, config: Mapping[str, object]) -> MarketDataProvider:
106
+ _unknown_config(config, _COMMON_TABULAR_KEYS | {"path", "table"})
107
+ return SqliteBarProvider(
108
+ _required_text(config, "path"),
109
+ _required_text(config, "table"),
110
+ venue=venue,
111
+ mapping=_column_overrides(config),
112
+ timestamp_unit=_optional_text(config, "timestamp_unit") or "milliseconds",
113
+ timestamp_timezone=_optional_text(config, "timestamp_timezone") or "UTC",
114
+ instrument=_instrument(venue, config),
115
+ timeframe=_optional_text(config, "timeframe"),
116
+ )
117
+
118
+
119
+ def _sqlalchemy_factory(venue: str, config: Mapping[str, object]) -> MarketDataProvider:
120
+ _unknown_config(
121
+ config,
122
+ _COMMON_TABULAR_KEYS | {"url", "url_env", "table", "schema", "engine_options"},
123
+ )
124
+ url = _optional_text(config, "url")
125
+ url_env = _optional_text(config, "url_env")
126
+ if url is not None and url_env is not None:
127
+ raise ValueError("configure only one of 'url' and 'url_env'")
128
+ if url_env is not None:
129
+ url = os.environ.get(url_env)
130
+ if not url:
131
+ raise ValueError(f"database URL environment variable is unset: {url_env}")
132
+ if url is None:
133
+ raise ValueError("provider configuration requires 'url' or 'url_env'")
134
+ engine_options = config.get("engine_options")
135
+ normalized_engine_options: Mapping[str, object] | None = None
136
+ if engine_options is not None:
137
+ if not isinstance(engine_options, Mapping) or not all(
138
+ isinstance(key, str) for key in engine_options
139
+ ):
140
+ raise ValueError("provider configuration 'engine_options' must be an object")
141
+ normalized_engine_options = cast(Mapping[str, object], engine_options)
142
+ return SqlAlchemyBarProvider(
143
+ url,
144
+ _required_text(config, "table"),
145
+ venue=venue,
146
+ schema=_optional_text(config, "schema"),
147
+ mapping=_column_overrides(config),
148
+ timestamp_unit=_optional_text(config, "timestamp_unit") or "milliseconds",
149
+ timestamp_timezone=_optional_text(config, "timestamp_timezone") or "UTC",
150
+ instrument=_instrument(venue, config),
151
+ timeframe=_optional_text(config, "timeframe"),
152
+ engine_options=normalized_engine_options,
153
+ )
154
+
155
+
156
+ class ProviderRegistry:
157
+ """Resolve built-ins and externally installed provider factories by name."""
158
+
159
+ def __init__(self, *, include_builtin: bool = True) -> None:
160
+ self._factories: dict[str, ProviderFactory] = {}
161
+ if include_builtin:
162
+ self.register("ccxt", _ccxt_factory)
163
+ self.register("csv", _csv_factory)
164
+ self.register("sqlite", _sqlite_factory)
165
+ self.register("sqlalchemy", _sqlalchemy_factory)
166
+
167
+ @staticmethod
168
+ def _normalize_name(name: str) -> str:
169
+ normalized = name.strip().casefold()
170
+ if not normalized:
171
+ raise ValueError("provider name must not be empty")
172
+ return normalized
173
+
174
+ def register(self, name: str, factory: ProviderFactory, *, replace: bool = False) -> None:
175
+ """Register an in-process provider factory."""
176
+
177
+ normalized = self._normalize_name(name)
178
+ if normalized in self._factories and not replace:
179
+ raise ProviderRegistryError(f"provider already registered: {normalized}")
180
+ self._factories[normalized] = factory
181
+
182
+ def _matching_entry_point(self, name: str) -> EntryPoint | None:
183
+ matches = [
184
+ candidate
185
+ for candidate in entry_points().select(group=ENTRY_POINT_GROUP)
186
+ if candidate.name.casefold() == name
187
+ ]
188
+ if len(matches) > 1:
189
+ packages = ", ".join(sorted(candidate.value for candidate in matches))
190
+ raise ProviderRegistryError(
191
+ f"multiple entry points registered for provider {name!r}: {packages}"
192
+ )
193
+ return matches[0] if matches else None
194
+
195
+ def _load_external(self, name: str) -> ProviderFactory | None:
196
+ entry_point = self._matching_entry_point(name)
197
+ if entry_point is None:
198
+ return None
199
+ candidate = entry_point.load()
200
+ if not callable(candidate):
201
+ raise ProviderRegistryError(
202
+ f"provider entry point {entry_point.value!r} is not callable"
203
+ )
204
+ factory = cast(ProviderFactory, candidate)
205
+ self._factories[name] = factory
206
+ return factory
207
+
208
+ def create(
209
+ self,
210
+ name: str,
211
+ venue: str,
212
+ *,
213
+ config: Mapping[str, object] | None = None,
214
+ ) -> MarketDataProvider:
215
+ """Create one provider bound to a venue or broker environment."""
216
+
217
+ normalized = self._normalize_name(name)
218
+ factory = self._factories.get(normalized) or self._load_external(normalized)
219
+ if factory is None:
220
+ available = ", ".join(self.names()) or "none"
221
+ raise ProviderNotFoundError(
222
+ f"unknown provider {name!r}; available providers: {available}"
223
+ )
224
+ provider = factory(venue, config or {})
225
+ if not isinstance(provider, MarketDataProvider):
226
+ raise ProviderRegistryError(
227
+ f"provider {normalized!r} does not implement MarketDataProvider"
228
+ )
229
+ return provider
230
+
231
+ def names(self) -> tuple[str, ...]:
232
+ """List built-in, registered, and advertised provider names."""
233
+
234
+ advertised = {
235
+ candidate.name.casefold()
236
+ for candidate in entry_points().select(group=ENTRY_POINT_GROUP)
237
+ }
238
+ return tuple(sorted(self._factories.keys() | advertised))
239
+
240
+
241
+ default_registry = ProviderRegistry()
242
+
243
+
244
+ def create_provider(
245
+ name: str,
246
+ venue: str,
247
+ *,
248
+ config: Mapping[str, object] | None = None,
249
+ ) -> MarketDataProvider:
250
+ """Create a provider from the process-wide registry."""
251
+
252
+ return default_registry.create(name, venue, config=config)
@@ -0,0 +1,138 @@
1
+ """SQLAlchemy Core provider for reflected user-owned database tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from importlib import import_module
7
+ from threading import Lock
8
+ from types import ModuleType
9
+ from typing import Any, cast
10
+
11
+ from ..models import Instrument
12
+ from ..requests import BarRequest
13
+ from .tabular import (
14
+ BarColumnMapping,
15
+ ColumnMappingInput,
16
+ SourceColumn,
17
+ TabularBarProvider,
18
+ TabularRow,
19
+ TabularSchema,
20
+ TimestampUnit,
21
+ numeric_source_bounds,
22
+ )
23
+
24
+
25
+ class SqlAlchemyDependencyError(RuntimeError):
26
+ """SQLAlchemy support was requested without the optional dependency."""
27
+
28
+
29
+ def _load_sqlalchemy() -> ModuleType:
30
+ try:
31
+ return import_module("sqlalchemy")
32
+ except ModuleNotFoundError as exc:
33
+ if exc.name == "sqlalchemy" or (exc.name and exc.name.startswith("sqlalchemy.")):
34
+ raise SqlAlchemyDependencyError(
35
+ "SQLAlchemy is not installed; install pineforge-data[database]"
36
+ ) from exc
37
+ raise
38
+
39
+
40
+ class SqlAlchemyBarProvider(TabularBarProvider):
41
+ """Reflect and query one table or view through a synchronous SQLAlchemy engine."""
42
+
43
+ def __init__(
44
+ self,
45
+ url: str,
46
+ table: str,
47
+ *,
48
+ venue: str = "database",
49
+ schema: str | None = None,
50
+ mapping: ColumnMappingInput = None,
51
+ timestamp_unit: TimestampUnit | str = TimestampUnit.MILLISECONDS,
52
+ timestamp_timezone: str = "UTC",
53
+ instrument: Instrument | None = None,
54
+ timeframe: str | None = None,
55
+ engine_options: Mapping[str, object] | None = None,
56
+ ) -> None:
57
+ super().__init__(
58
+ venue=venue,
59
+ mapping=mapping,
60
+ timestamp_unit=timestamp_unit,
61
+ timestamp_timezone=timestamp_timezone,
62
+ instrument=instrument,
63
+ timeframe=timeframe,
64
+ )
65
+ if not url.strip():
66
+ raise ValueError("SQLAlchemy URL must not be empty")
67
+ if not table:
68
+ raise ValueError("table must not be empty")
69
+ if schema is not None and not schema:
70
+ raise ValueError("schema must be None or a non-empty string")
71
+
72
+ self._sa = _load_sqlalchemy()
73
+ options = dict(engine_options or {})
74
+ options.setdefault("hide_parameters", True)
75
+ self._engine: Any = self._sa.create_engine(url, **options)
76
+ self._table: Any | None = None
77
+ self._reflection_lock = Lock()
78
+ self.table = table
79
+ self.schema_name = schema
80
+ self.name = f"sqlalchemy:{venue}"
81
+
82
+ def _reflected_table(self) -> Any:
83
+ with self._reflection_lock:
84
+ if self._table is None:
85
+ metadata = self._sa.MetaData()
86
+ self._table = self._sa.Table(
87
+ self.table,
88
+ metadata,
89
+ schema=self.schema_name,
90
+ autoload_with=self._engine,
91
+ )
92
+ return self._table
93
+
94
+ def _inspect_schema_sync(self) -> TabularSchema:
95
+ table = self._reflected_table()
96
+ qualified_table = f"{self.schema_name}.{self.table}" if self.schema_name else self.table
97
+ return TabularSchema(
98
+ source=f"sqlalchemy:{self.venue}#{qualified_table}",
99
+ columns=tuple(
100
+ SourceColumn(
101
+ name=str(column.name),
102
+ data_type=str(column.type),
103
+ nullable=bool(column.nullable),
104
+ )
105
+ for column in table.columns
106
+ ),
107
+ )
108
+
109
+ def _read_rows_sync(
110
+ self, mapping: BarColumnMapping, request: BarRequest
111
+ ) -> tuple[TabularRow, ...]:
112
+ table = self._reflected_table()
113
+ columns = [table.c[column] for column in mapping.columns]
114
+ statement = self._sa.select(*columns)
115
+ if mapping.symbol is not None:
116
+ statement = statement.where(table.c[mapping.symbol] == request.instrument.symbol)
117
+ if mapping.timeframe is not None:
118
+ statement = statement.where(table.c[mapping.timeframe] == request.timeframe)
119
+ bounds = numeric_source_bounds(request.start_ms, request.end_ms, self.timestamp_unit)
120
+ if bounds is not None:
121
+ statement = statement.where(table.c[mapping.timestamp] >= bounds[0])
122
+ statement = statement.where(table.c[mapping.timestamp] < bounds[1])
123
+ statement = statement.order_by(table.c[mapping.timestamp])
124
+
125
+ with self._engine.connect() as connection:
126
+ result = connection.execute(statement).mappings().all()
127
+ return tuple(
128
+ cast(TabularRow, {str(key): value for key, value in row.items()}) for row in result
129
+ )
130
+
131
+ def _distinct_values_sync(self, column: str) -> tuple[object, ...]:
132
+ table = self._reflected_table()
133
+ statement = self._sa.select(table.c[column]).distinct().order_by(table.c[column])
134
+ with self._engine.connect() as connection:
135
+ return tuple(connection.execute(statement).scalars().all())
136
+
137
+ def _close_sync(self) -> None:
138
+ self._engine.dispose()