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