firstrate-data 0.1.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.
- firstrate_data/__init__.py +38 -0
- firstrate_data/config.py +49 -0
- firstrate_data/domain/__init__.py +33 -0
- firstrate_data/domain/bar_type.py +34 -0
- firstrate_data/domain/enums.py +120 -0
- firstrate_data/domain/ticker_listing.py +78 -0
- firstrate_data/download/__init__.py +0 -0
- firstrate_data/download/bundles.py +201 -0
- firstrate_data/download/client.py +432 -0
- firstrate_data/download/progress.py +45 -0
- firstrate_data/download/requests.py +245 -0
- firstrate_data/store/__init__.py +0 -0
- firstrate_data/store/_parquet_table.py +78 -0
- firstrate_data/store/_sql.py +399 -0
- firstrate_data/store/store.py +630 -0
- firstrate_data-0.1.0.dist-info/METADATA +449 -0
- firstrate_data-0.1.0.dist-info/RECORD +20 -0
- firstrate_data-0.1.0.dist-info/WHEEL +5 -0
- firstrate_data-0.1.0.dist-info/licenses/LICENSE +201 -0
- firstrate_data-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from dataclasses import dataclass, field, replace
|
|
2
|
+
from typing import ClassVar, Literal, Protocol
|
|
3
|
+
|
|
4
|
+
from firstrate_data.domain import (
|
|
5
|
+
AssetType,
|
|
6
|
+
BarType,
|
|
7
|
+
ContractFiles,
|
|
8
|
+
DelistedArchive,
|
|
9
|
+
DelistedUpdate,
|
|
10
|
+
EquitiesAdjustment,
|
|
11
|
+
OtherData,
|
|
12
|
+
Period,
|
|
13
|
+
Timeframe,
|
|
14
|
+
)
|
|
15
|
+
from firstrate_data.domain.enums import Unadjusted
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Request(Protocol):
|
|
19
|
+
endpoint: ClassVar[str]
|
|
20
|
+
|
|
21
|
+
def to_params(self) -> dict[str, str]:
|
|
22
|
+
"""Return the query string minus ``userid``, which the transport adds."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotOfferedError(ValueError):
|
|
27
|
+
"""A combination of parameters the vendor doesn't serve."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class BarsRequest:
|
|
32
|
+
endpoint: ClassVar[str] = "data_file"
|
|
33
|
+
bar_type: BarType
|
|
34
|
+
period: Period | None = None
|
|
35
|
+
ticker_range: str | None = field(default=None, kw_only=True)
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
asset_type = self._raise_if_none(self.bar_type.asset_type, "asset type")
|
|
39
|
+
self._raise_if_none(self.period, "period")
|
|
40
|
+
|
|
41
|
+
equities = (AssetType.STOCK, AssetType.ETF)
|
|
42
|
+
if asset_type in equities:
|
|
43
|
+
self._check_equities_offer()
|
|
44
|
+
elif self.ticker_range is not None:
|
|
45
|
+
msg = (
|
|
46
|
+
f"ticker_range is equities-only: a {asset_type.value} "
|
|
47
|
+
"full archive is served whole"
|
|
48
|
+
)
|
|
49
|
+
raise NotOfferedError(msg)
|
|
50
|
+
|
|
51
|
+
# refused before the request goes out rather than at ingest: a restated
|
|
52
|
+
# increment is unusable no matter how long it took to arrive, and this
|
|
53
|
+
# one takes an hour
|
|
54
|
+
adjustment = self.bar_type.adjustment
|
|
55
|
+
if (
|
|
56
|
+
adjustment is not None
|
|
57
|
+
and adjustment.changes_past
|
|
58
|
+
and self.period is not Period.FULL
|
|
59
|
+
):
|
|
60
|
+
msg = (
|
|
61
|
+
f"{adjustment.value} is restated: the vendor rewrites "
|
|
62
|
+
"its history backwards when there's a split or dividend, so appending a "
|
|
63
|
+
f"{self._raise_if_none(self.period, 'period').value} would splice two "
|
|
64
|
+
"adjustment bases together. "
|
|
65
|
+
"Re-fetch it with period=full instead."
|
|
66
|
+
)
|
|
67
|
+
raise NotOfferedError(msg)
|
|
68
|
+
|
|
69
|
+
def to_params(self) -> dict[str, str]:
|
|
70
|
+
asset_type = self._raise_if_none(self.bar_type.asset_type, "asset type")
|
|
71
|
+
params = {
|
|
72
|
+
"type": asset_type.value,
|
|
73
|
+
# __post_init__ states period before anything can send this
|
|
74
|
+
"period": self._raise_if_none(self.period, "period").value,
|
|
75
|
+
"timeframe": self._raise_if_none(
|
|
76
|
+
self.bar_type.timeframe, "timeframe"
|
|
77
|
+
).value,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# index, FX and crypto list no adjustment at all, and sending one the
|
|
81
|
+
# endpoint doesn't document risks an unpredictable response body.
|
|
82
|
+
# ``Unadjusted`` exists for the bar type path, which names one at
|
|
83
|
+
# every level, and stops at the store's edge
|
|
84
|
+
if self.bar_type.adjustment is not None and not isinstance(
|
|
85
|
+
self.bar_type.adjustment, Unadjusted
|
|
86
|
+
):
|
|
87
|
+
params["adjustment"] = self.bar_type.adjustment.value
|
|
88
|
+
|
|
89
|
+
# equities-only, and the rules for it live on StockClient -- absent
|
|
90
|
+
# here means the asset type doesn't have one, not that someone forgot it
|
|
91
|
+
if self.ticker_range is not None:
|
|
92
|
+
params["ticker_range"] = self.ticker_range
|
|
93
|
+
|
|
94
|
+
return params
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# helpers
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def _raise_if_none[Named](self, value: Named | None, what: str) -> Named:
|
|
101
|
+
if value is None:
|
|
102
|
+
msg = f"{self.endpoint} is served per {what}: name one"
|
|
103
|
+
raise NotOfferedError(msg)
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
def _check_equities_offer(self) -> None:
|
|
107
|
+
self._check_unadjusted_timeframe()
|
|
108
|
+
self._check_and_normalise_ticker_range()
|
|
109
|
+
|
|
110
|
+
def _check_unadjusted_timeframe(self) -> None:
|
|
111
|
+
# unadjusted data for ETFs and stock is only available in the 1min and daily tf
|
|
112
|
+
if self.bar_type.adjustment is not EquitiesAdjustment.UNADJUSTED:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
tf = self._raise_if_none(self.bar_type.timeframe, "timeframe")
|
|
116
|
+
|
|
117
|
+
if tf not in (Timeframe.MIN_1, Timeframe.DAY_1):
|
|
118
|
+
msg = "UNADJUSTED data is only available in the 1min and 1day timeframes"
|
|
119
|
+
raise NotOfferedError(msg)
|
|
120
|
+
|
|
121
|
+
def _check_and_normalise_ticker_range(self) -> None:
|
|
122
|
+
if self.period is Period.FULL and self.ticker_range is None:
|
|
123
|
+
msg = "ticker_range (A-Z) is required when period=full"
|
|
124
|
+
raise NotOfferedError(msg)
|
|
125
|
+
if self.ticker_range is not None:
|
|
126
|
+
if self.period is not Period.FULL:
|
|
127
|
+
msg = "ticker_range can only be used when period=full"
|
|
128
|
+
raise NotOfferedError(msg)
|
|
129
|
+
letter = self.ticker_range.upper()
|
|
130
|
+
if len(letter) != 1 or not letter.isalpha():
|
|
131
|
+
msg = "ticker_range must be a single letter A-Z"
|
|
132
|
+
raise NotOfferedError(msg)
|
|
133
|
+
# the supported way to normalise a field of a frozen dataclass
|
|
134
|
+
object.__setattr__(self, "ticker_range", letter)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class DelistedBarsRequest(BarsRequest):
|
|
139
|
+
"""Bars for tickers that no longer trade. Stock-only, hence no ``type``.
|
|
140
|
+
|
|
141
|
+
Raises
|
|
142
|
+
------
|
|
143
|
+
NotOfferedError
|
|
144
|
+
If the vendor doesn't serve this combination.
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
endpoint: ClassVar[str] = "delisted_data_file"
|
|
149
|
+
|
|
150
|
+
selector: DelistedArchive | DelistedUpdate = field(kw_only=True)
|
|
151
|
+
|
|
152
|
+
def __post_init__(self) -> None:
|
|
153
|
+
self.raise_on_unavalaible_timeframes()
|
|
154
|
+
|
|
155
|
+
def raise_on_unavalaible_timeframes(self) -> None:
|
|
156
|
+
if self.bar_type.adjustment is not EquitiesAdjustment.UNADJUSTED:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
timeframe = self._raise_if_none(self.bar_type.timeframe, "timeframe")
|
|
160
|
+
if timeframe not in (Timeframe.MIN_1, Timeframe.DAY_1):
|
|
161
|
+
msg = "UNADJUSTED delisted data is only available in the 1min and daily timeframe"
|
|
162
|
+
raise NotOfferedError(msg)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def kind(self) -> Literal["archive", "update"]:
|
|
166
|
+
"""Which half of the delisted dataset the selector names."""
|
|
167
|
+
return "archive" if isinstance(self.selector, DelistedArchive) else "update"
|
|
168
|
+
|
|
169
|
+
def to_params(self) -> dict[str, str]:
|
|
170
|
+
selector_param = "archive_number" if self.kind == "archive" else "update"
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
selector_param: self.selector.value,
|
|
174
|
+
"timeframe": self._raise_if_none(
|
|
175
|
+
self.bar_type.timeframe, "timeframe"
|
|
176
|
+
).value,
|
|
177
|
+
"adjustment": self._raise_if_none(
|
|
178
|
+
self.bar_type.adjustment, "adjustment"
|
|
179
|
+
).value,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class ContractBarsRequest(BarsRequest):
|
|
185
|
+
endpoint: ClassVar[str] = "futures_contract"
|
|
186
|
+
contract_files: ContractFiles = field(kw_only=True)
|
|
187
|
+
|
|
188
|
+
def __post_init__(self) -> None:
|
|
189
|
+
# the endpoint serves all five timeframes on both halves, so there is
|
|
190
|
+
# nothing else here the vendor refuses
|
|
191
|
+
object.__setattr__(
|
|
192
|
+
self,
|
|
193
|
+
"bar_type",
|
|
194
|
+
replace(self.bar_type, adjustment=Unadjusted.UNADJUSTED),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def to_params(self) -> dict[str, str]:
|
|
198
|
+
return {
|
|
199
|
+
"contract_files": self.contract_files.value,
|
|
200
|
+
"timeframe": self._raise_if_none(
|
|
201
|
+
self.bar_type.timeframe, "timeframe"
|
|
202
|
+
).value,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True, slots=True)
|
|
207
|
+
class LastUpdateRequest:
|
|
208
|
+
endpoint: ClassVar[str] = "last_update"
|
|
209
|
+
asset_type: AssetType
|
|
210
|
+
is_full_update: bool | None = None
|
|
211
|
+
|
|
212
|
+
def to_params(self) -> dict[str, str]:
|
|
213
|
+
params = {"type": self.asset_type.value}
|
|
214
|
+
|
|
215
|
+
# documented optional, and the docs don't say which way it defaults.
|
|
216
|
+
# Leaving the question unanswered beats guessing on the caller's
|
|
217
|
+
# behalf
|
|
218
|
+
if self.is_full_update is not None:
|
|
219
|
+
params["is_full_update"] = "true" if self.is_full_update else "false"
|
|
220
|
+
|
|
221
|
+
return params
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True, slots=True)
|
|
225
|
+
class TickerListingRequest:
|
|
226
|
+
endpoint: ClassVar[str] = "ticker_listing"
|
|
227
|
+
asset_type: AssetType
|
|
228
|
+
|
|
229
|
+
def to_params(self) -> dict[str, str]:
|
|
230
|
+
# 'html=true' serves the same rows wrapped in a page. Nothing here reads
|
|
231
|
+
# a page, so this fixes the flag rather than exposing it
|
|
232
|
+
return {"type": self.asset_type.value, "html": "false"}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass(frozen=True, slots=True)
|
|
236
|
+
class OtherDataRequest:
|
|
237
|
+
endpoint: ClassVar[str] = "meta_file"
|
|
238
|
+
asset_type: AssetType
|
|
239
|
+
other_data: OtherData
|
|
240
|
+
|
|
241
|
+
def to_params(self) -> dict[str, str]:
|
|
242
|
+
return {
|
|
243
|
+
"type": self.asset_type.value,
|
|
244
|
+
"metafile_type": self.other_data.value,
|
|
245
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""A small relation the store keeps as one parquet file.
|
|
2
|
+
|
|
3
|
+
The catalog, the ticker listing and the metafiles are all this shape: a
|
|
4
|
+
handful of columns, small enough to be rewritten whole on every change.
|
|
5
|
+
Rewriting whole is what keeps parquet the only copy without a reader ever
|
|
6
|
+
seeing a half-written file -- the new table is written beside the old one and
|
|
7
|
+
swapped in.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable, Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import duckdb
|
|
15
|
+
|
|
16
|
+
from firstrate_data.store import _sql
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ParquetTable:
|
|
20
|
+
"""One parquet file, read as a relation and written whole."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
connection: duckdb.DuckDBPyConnection,
|
|
25
|
+
path: Path,
|
|
26
|
+
schema: dict[str, str],
|
|
27
|
+
) -> None:
|
|
28
|
+
self._connection = connection
|
|
29
|
+
self._path = path
|
|
30
|
+
self._schema = schema
|
|
31
|
+
# named after the file, so two tables staging an update at once cannot
|
|
32
|
+
# write into each other's rows
|
|
33
|
+
self._staging_table = f"staged_{path.stem}"
|
|
34
|
+
|
|
35
|
+
def exists(self) -> bool:
|
|
36
|
+
return self._path.exists()
|
|
37
|
+
|
|
38
|
+
def select(self) -> str:
|
|
39
|
+
"""A SELECT of the table as it stands, of the right shape when absent."""
|
|
40
|
+
if not self.exists():
|
|
41
|
+
return _sql.empty_select(self._schema)
|
|
42
|
+
return f"SELECT * FROM read_parquet({_sql.sql_literal(str(self._path))})" # noqa: S608
|
|
43
|
+
|
|
44
|
+
def relation(self) -> duckdb.DuckDBPyRelation:
|
|
45
|
+
"""The whole table, as a lazy relation. Empty where nothing is written."""
|
|
46
|
+
return self._connection.sql(self.select())
|
|
47
|
+
|
|
48
|
+
def stage(self, rows: Iterable[Sequence[Any]]) -> str | None:
|
|
49
|
+
"""Load the rows of one update into a temp table, named. None for no rows.
|
|
50
|
+
|
|
51
|
+
A temp table rather than a VALUES list: an ingest of a full stock
|
|
52
|
+
archive stages sixteen thousand rows at once.
|
|
53
|
+
"""
|
|
54
|
+
staged = list(rows)
|
|
55
|
+
if not staged:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
declared = ", ".join(f"{name} {kind}" for name, kind in self._schema.items())
|
|
59
|
+
self._connection.execute(
|
|
60
|
+
f"CREATE OR REPLACE TEMP TABLE {self._staging_table} ({declared})",
|
|
61
|
+
)
|
|
62
|
+
self._connection.executemany(
|
|
63
|
+
f"INSERT INTO {self._staging_table} " # noqa: S608
|
|
64
|
+
f"VALUES ({', '.join('?' * len(self._schema))})",
|
|
65
|
+
staged,
|
|
66
|
+
)
|
|
67
|
+
return self._staging_table
|
|
68
|
+
|
|
69
|
+
def rewrite(self, select: str) -> int:
|
|
70
|
+
"""Write what `select` answers with over the table, whole. Returns rows."""
|
|
71
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
staged = self._path.with_name(f"{self._path.name}.partial")
|
|
73
|
+
written = self._connection.execute(f"""
|
|
74
|
+
COPY ({select})
|
|
75
|
+
TO {_sql.sql_literal(str(staged))} (FORMAT PARQUET, COMPRESSION ZSTD)
|
|
76
|
+
""").fetchall()
|
|
77
|
+
staged.replace(self._path)
|
|
78
|
+
return int(written[0][0]) if written else 0
|