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,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()
|