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,630 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import zipfile
|
|
3
|
+
from collections.abc import Generator, Iterable
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from itertools import batched
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from tempfile import mkdtemp
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Self
|
|
12
|
+
|
|
13
|
+
import duckdb
|
|
14
|
+
|
|
15
|
+
from firstrate_data import config
|
|
16
|
+
from firstrate_data.domain import (
|
|
17
|
+
Adjustment,
|
|
18
|
+
AssetType,
|
|
19
|
+
BarType,
|
|
20
|
+
OtherData,
|
|
21
|
+
TickerListing,
|
|
22
|
+
Timeframe,
|
|
23
|
+
TradingHours,
|
|
24
|
+
)
|
|
25
|
+
from firstrate_data.download.requests import (
|
|
26
|
+
BarsRequest,
|
|
27
|
+
OtherDataRequest,
|
|
28
|
+
Request,
|
|
29
|
+
)
|
|
30
|
+
from firstrate_data.store import _sql
|
|
31
|
+
from firstrate_data.store._parquet_table import ParquetTable
|
|
32
|
+
|
|
33
|
+
# How many tickers one ``COPY`` may write. DuckDB buffers every open partition,
|
|
34
|
+
# so peak memory tracks distinct bar types rather than input size: 10,000 in one
|
|
35
|
+
# statement exhausted 12.7 GiB, 250 completed comfortably.
|
|
36
|
+
# See docs/notes/duckdb/partitioned-copy-ooms-past-a-few-thousand-partitions.md.
|
|
37
|
+
_TICKERS_PER_COPY = 250
|
|
38
|
+
|
|
39
|
+
# macOS drops an AppleDouble sidecar (``._name``) beside every file written to
|
|
40
|
+
# a non-native filesystem (exFAT, NTFS, network shares); it matches a bare
|
|
41
|
+
# ``*.parquet`` glob but isn't parquet. Store file names always start with the
|
|
42
|
+
# date that produced them, so anchoring on that digit excludes the sidecar.
|
|
43
|
+
PARQUET_FILES = "[0-9]*.parquet"
|
|
44
|
+
|
|
45
|
+
TIMEZONE = "America/New_York"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Ingested:
|
|
50
|
+
# tickers is None for a metafile, which has no ticker dimension to count
|
|
51
|
+
tickers: int | None
|
|
52
|
+
rows: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class TickerSpan:
|
|
57
|
+
"""The bars the store holds for one ticker, as one unbroken span.
|
|
58
|
+
|
|
59
|
+
``first_ts`` and ``last_ts`` are the ends of what is filed, not of what
|
|
60
|
+
the vendor has: a store holding two disjoint stretches of a recycled
|
|
61
|
+
symbol reports one span covering the gap between them.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
ticker: str
|
|
65
|
+
first_ts: datetime
|
|
66
|
+
last_ts: datetime
|
|
67
|
+
rows: int
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ConflictingBarsError(ValueError):
|
|
71
|
+
"""An archive's bars are not an update of the bars already filed for a ticker."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
bar_type: BarType,
|
|
76
|
+
conflicts: Iterable[tuple[TickerSpan, TickerSpan]],
|
|
77
|
+
) -> None:
|
|
78
|
+
self.bar_type = bar_type
|
|
79
|
+
self.conflicts = tuple(conflicts)
|
|
80
|
+
named = "; ".join(
|
|
81
|
+
f"{held.ticker} holds {held.first_ts:%Y-%m-%d}..{held.last_ts:%Y-%m-%d}, "
|
|
82
|
+
f"archive carries {arriving.first_ts:%Y-%m-%d}..{arriving.last_ts:%Y-%m-%d}"
|
|
83
|
+
for held, arriving in self.conflicts
|
|
84
|
+
)
|
|
85
|
+
super().__init__(
|
|
86
|
+
f"{len(self.conflicts)} tickers hold bars this archive neither starts "
|
|
87
|
+
f"with nor reaches past: {named}. Nothing was filed.",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Store:
|
|
92
|
+
"""A parquet store of market data, created at ``directory/firstrate_data``."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, directory: Path) -> None:
|
|
95
|
+
self._directory = directory / "firstrate_data"
|
|
96
|
+
self._bars_directory = self._directory / "bars"
|
|
97
|
+
self._spool = self._directory / "spool"
|
|
98
|
+
|
|
99
|
+
self._bars_directory.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
self._spool.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
|
|
102
|
+
# DuckDB spills to `.tmp` in the working directory by default, which is
|
|
103
|
+
# the machine's boot disk. We make it spill inside the store.
|
|
104
|
+
# https://duckdb.org/docs/stable/guides/performance/how_to_tune_workloads#spilling-to-disk
|
|
105
|
+
# https://duckdb.org/docs/stable/configuration/overview#global-configuration-options
|
|
106
|
+
spill = self._directory / ".duckdb_temp"
|
|
107
|
+
spill.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
|
|
109
|
+
self._connection = duckdb.connect()
|
|
110
|
+
self._connection.execute(f"SET temp_directory = '{spill}'")
|
|
111
|
+
# nothing here reads the tree in input order, so preserving that order
|
|
112
|
+
# only costs memory -- and it costs it in proportion to the archive
|
|
113
|
+
self._connection.execute("SET preserve_insertion_order = false")
|
|
114
|
+
self._connection.execute(f"SET TimeZone = '{TIMEZONE}'")
|
|
115
|
+
|
|
116
|
+
self._catalog_table = ParquetTable(
|
|
117
|
+
self._connection,
|
|
118
|
+
self._directory / "catalog.parquet",
|
|
119
|
+
_sql.CATALOG_SCHEMA,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_env(cls) -> Self:
|
|
124
|
+
"""Return a Store with directory set from the .env file.
|
|
125
|
+
|
|
126
|
+
Raises
|
|
127
|
+
------
|
|
128
|
+
MissingSettingError
|
|
129
|
+
If ``FIRSTRATE_DATA_PATH`` is not set.
|
|
130
|
+
|
|
131
|
+
"""
|
|
132
|
+
return cls(config.firstrate_data_path())
|
|
133
|
+
|
|
134
|
+
def close(self) -> None:
|
|
135
|
+
self._connection.close()
|
|
136
|
+
|
|
137
|
+
def __enter__(self) -> Self:
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def __exit__(
|
|
141
|
+
self,
|
|
142
|
+
exc_type: type[BaseException] | None,
|
|
143
|
+
exc: BaseException | None,
|
|
144
|
+
traceback: TracebackType | None,
|
|
145
|
+
) -> None:
|
|
146
|
+
self.close()
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def spool(self) -> Path:
|
|
150
|
+
return self._spool
|
|
151
|
+
|
|
152
|
+
def write(self, file: Path, request: Request) -> Ingested:
|
|
153
|
+
try:
|
|
154
|
+
if isinstance(request, OtherDataRequest):
|
|
155
|
+
return self._write_other_data(file, request.other_data)
|
|
156
|
+
return self._write_bars(file, request)
|
|
157
|
+
finally:
|
|
158
|
+
# parquet is the only copy
|
|
159
|
+
file.unlink(missing_ok=True)
|
|
160
|
+
|
|
161
|
+
def write_ticker_listing(
|
|
162
|
+
self,
|
|
163
|
+
asset_type: AssetType,
|
|
164
|
+
listing: Iterable[TickerListing],
|
|
165
|
+
) -> int:
|
|
166
|
+
table = self._ticker_listing_table(asset_type)
|
|
167
|
+
staged = table.stage(
|
|
168
|
+
(
|
|
169
|
+
row.ticker,
|
|
170
|
+
row.full_name,
|
|
171
|
+
row.start_date,
|
|
172
|
+
row.end_date,
|
|
173
|
+
row.is_delisted,
|
|
174
|
+
)
|
|
175
|
+
for row in listing
|
|
176
|
+
)
|
|
177
|
+
if staged is None:
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
# the staging table's name is the store's own
|
|
181
|
+
return table.rewrite(f"SELECT * FROM {staged}") # noqa: S608
|
|
182
|
+
|
|
183
|
+
def _write_bars(self, archive: Path, request: BarsRequest) -> Ingested:
|
|
184
|
+
bar_type = request.bar_type
|
|
185
|
+
|
|
186
|
+
# minted once for the whole archive, not per batch: the point of the id
|
|
187
|
+
# is that one glob afterwards finds everything this archive wrote
|
|
188
|
+
ingest = _sql.ingest_id()
|
|
189
|
+
|
|
190
|
+
with self._unzipped(archive) as staging:
|
|
191
|
+
payloads = self._payloads_by_ticker(staging)
|
|
192
|
+
held = self._held_spans(bar_type, payloads)
|
|
193
|
+
columns_in_payload = _sql.payload_columns(
|
|
194
|
+
payload for files in payloads.values() for payload in files
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
staged_tree = staging / "bars"
|
|
198
|
+
rows = 0
|
|
199
|
+
for batch in batched(payloads.items(), _TICKERS_PER_COPY, strict=False):
|
|
200
|
+
rows += self._copy_batch(
|
|
201
|
+
bar_type,
|
|
202
|
+
staged_tree,
|
|
203
|
+
payloads=dict(batch),
|
|
204
|
+
columns_in_payload=columns_in_payload,
|
|
205
|
+
ingest=ingest,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
arriving = self._written_spans(staged_tree, bar_type, ingest)
|
|
209
|
+
self._file_over(bar_type, held, arriving, staged_tree)
|
|
210
|
+
|
|
211
|
+
return Ingested(len(payloads), rows)
|
|
212
|
+
|
|
213
|
+
def _write_other_data(self, content: Path, other_data: OtherData) -> Ingested:
|
|
214
|
+
# replaced whole rather than reconciled: a metafile is small, the vendor
|
|
215
|
+
# serves the entire history each time, and it carries no key to merge on
|
|
216
|
+
with self._other_data_payloads(content, other_data) as (payloads, per_ticker):
|
|
217
|
+
rows = self._other_data_table(other_data).rewrite(
|
|
218
|
+
_sql.other_data_select(payloads, other_data, per_ticker),
|
|
219
|
+
)
|
|
220
|
+
return Ingested(None, rows)
|
|
221
|
+
|
|
222
|
+
def bars( # noqa: PLR0913
|
|
223
|
+
self,
|
|
224
|
+
*,
|
|
225
|
+
asset_type: AssetType | None = None,
|
|
226
|
+
timeframe: Timeframe | None = None,
|
|
227
|
+
adjustment: Adjustment | None = None,
|
|
228
|
+
ticker: str | Iterable[str] | None = None,
|
|
229
|
+
start: date | None = None,
|
|
230
|
+
end: date | None = None,
|
|
231
|
+
hours: TradingHours = TradingHours.ALL,
|
|
232
|
+
) -> duckdb.DuckDBPyRelation:
|
|
233
|
+
"""Read bars across the tree; every omitted selector spans all its values.
|
|
234
|
+
|
|
235
|
+
Raises
|
|
236
|
+
------
|
|
237
|
+
ValueError
|
|
238
|
+
If ``hours`` names a session and ``asset_type`` defines none.
|
|
239
|
+
|
|
240
|
+
"""
|
|
241
|
+
return self._read(
|
|
242
|
+
BarType(asset_type, timeframe=timeframe, adjustment=adjustment),
|
|
243
|
+
ticker,
|
|
244
|
+
start=start,
|
|
245
|
+
end=end,
|
|
246
|
+
hours=hours,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def last_bar(
|
|
250
|
+
self,
|
|
251
|
+
bar_type: BarType,
|
|
252
|
+
ticker_prefix: str | None = None,
|
|
253
|
+
) -> datetime | None:
|
|
254
|
+
"""Return the timestamp of the newest bar under `bar_type`, narrowed to `ticker_prefix`, or None."""
|
|
255
|
+
# max, not min: an ingest writes an archive as one unit, so the newest
|
|
256
|
+
# bar dates the whole ingest. A min would return the last bar of the
|
|
257
|
+
# most long-dead ticker in the range, which no later download updates
|
|
258
|
+
# https://duckdb.org/docs/stable/sql/functions/aggregates#maxarg
|
|
259
|
+
named = (
|
|
260
|
+
""
|
|
261
|
+
if ticker_prefix is None
|
|
262
|
+
else f" AND starts_with(ticker, {_sql.sql_literal(ticker_prefix.upper())})"
|
|
263
|
+
)
|
|
264
|
+
latest = self._connection.sql(
|
|
265
|
+
f"SELECT max(last_ts) FROM ({self._catalog_table.select()}) " # noqa: S608
|
|
266
|
+
f"WHERE {_sql.bar_type_where(bar_type)}{named}",
|
|
267
|
+
).fetchone()
|
|
268
|
+
return latest[0] if latest else None
|
|
269
|
+
|
|
270
|
+
def catalog(self) -> duckdb.DuckDBPyRelation:
|
|
271
|
+
return self._catalog_table.relation()
|
|
272
|
+
|
|
273
|
+
def ticker_listing(
|
|
274
|
+
self,
|
|
275
|
+
*,
|
|
276
|
+
asset_type: AssetType | None = None,
|
|
277
|
+
ticker: str | None = None,
|
|
278
|
+
) -> duckdb.DuckDBPyRelation:
|
|
279
|
+
"""Read the ticker listing.
|
|
280
|
+
|
|
281
|
+
Raises
|
|
282
|
+
------
|
|
283
|
+
FileNotFoundError
|
|
284
|
+
If no ticker listing is in the store.
|
|
285
|
+
|
|
286
|
+
"""
|
|
287
|
+
asset_types = [asset_type] if asset_type is not None else list(AssetType)
|
|
288
|
+
stored = [
|
|
289
|
+
table
|
|
290
|
+
for one in asset_types
|
|
291
|
+
if (table := self._ticker_listing_table(one)).exists()
|
|
292
|
+
]
|
|
293
|
+
if not stored:
|
|
294
|
+
msg = "no ticker listing in this store -- download it first"
|
|
295
|
+
raise FileNotFoundError(msg)
|
|
296
|
+
|
|
297
|
+
# the asset type is a level of the path rather than a column of the
|
|
298
|
+
# file, and DuckDB reads that level back as a column of the relation
|
|
299
|
+
listing = self._connection.sql(
|
|
300
|
+
"\nUNION ALL BY NAME\n".join(
|
|
301
|
+
f"SELECT * FROM ({table.select()})" # noqa: S608
|
|
302
|
+
for table in stored
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
if ticker is not None:
|
|
306
|
+
listing = listing.filter(f"ticker = {_sql.sql_literal(ticker)}")
|
|
307
|
+
return listing
|
|
308
|
+
|
|
309
|
+
def splits(self) -> duckdb.DuckDBPyRelation:
|
|
310
|
+
return self._other_data(OtherData.SPLITS)
|
|
311
|
+
|
|
312
|
+
def dividends(self) -> duckdb.DuckDBPyRelation:
|
|
313
|
+
return self._other_data(OtherData.DIVIDENDS)
|
|
314
|
+
|
|
315
|
+
def contract_dates(self) -> duckdb.DuckDBPyRelation:
|
|
316
|
+
return self._other_data(OtherData.CONTRACT_DATES)
|
|
317
|
+
|
|
318
|
+
@contextmanager
|
|
319
|
+
def _unzipped(self, archive: Path) -> Generator[Path]:
|
|
320
|
+
staging = Path(mkdtemp(dir=self._directory, prefix=".ingest-"))
|
|
321
|
+
try:
|
|
322
|
+
with zipfile.ZipFile(archive) as opened:
|
|
323
|
+
opened.extractall(staging)
|
|
324
|
+
yield staging
|
|
325
|
+
finally:
|
|
326
|
+
# only once the write has committed or failed -- an ingest that
|
|
327
|
+
# unlinked first would be reading files it had already promised to
|
|
328
|
+
# delete. A failed ingest costs a re-unzip, not a re-fetch.
|
|
329
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
330
|
+
|
|
331
|
+
def _payloads_by_ticker(self, directory: Path) -> dict[str, list[Path]]:
|
|
332
|
+
return {
|
|
333
|
+
ticker: [Path(file) for file in files]
|
|
334
|
+
for ticker, files in self._connection.sql(
|
|
335
|
+
_sql.payload_tickers_select(directory),
|
|
336
|
+
).fetchall()
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
def _copy_batch(
|
|
340
|
+
self,
|
|
341
|
+
bar_type: BarType,
|
|
342
|
+
destination: Path,
|
|
343
|
+
*,
|
|
344
|
+
payloads: dict[str, list[Path]],
|
|
345
|
+
columns_in_payload: int,
|
|
346
|
+
ingest: str,
|
|
347
|
+
) -> int:
|
|
348
|
+
select = _sql.bars_select(
|
|
349
|
+
[payload for files in payloads.values() for payload in files],
|
|
350
|
+
# no ticker: one COPY writes a batch of them, and each row's is
|
|
351
|
+
# read back out of the payload that carried it
|
|
352
|
+
bar_type,
|
|
353
|
+
columns_in_payload,
|
|
354
|
+
)
|
|
355
|
+
levels = ", ".join(bar_type.levels())
|
|
356
|
+
written = self._connection.execute(f"""
|
|
357
|
+
COPY ({select})
|
|
358
|
+
TO {_sql.sql_literal(str(destination))}
|
|
359
|
+
(FORMAT PARQUET,
|
|
360
|
+
COMPRESSION ZSTD,
|
|
361
|
+
PARTITION_BY ({levels}),
|
|
362
|
+
FILENAME_PATTERN '{_sql.filename_pattern(ingest)}',
|
|
363
|
+
APPEND)
|
|
364
|
+
""").fetchall()
|
|
365
|
+
return int(written[0][0]) if written else 0
|
|
366
|
+
|
|
367
|
+
def _held_spans(
|
|
368
|
+
self,
|
|
369
|
+
bar_type: BarType,
|
|
370
|
+
tickers: Iterable[str],
|
|
371
|
+
) -> dict[str, TickerSpan]:
|
|
372
|
+
"""The span the catalog holds for each of `tickers` under `bar_type`."""
|
|
373
|
+
named = _sql.sql_list(tickers)
|
|
374
|
+
held = self._connection.sql(
|
|
375
|
+
f"SELECT ticker, first_ts, last_ts, rows " # noqa: S608
|
|
376
|
+
f"FROM ({self._catalog_table.select()}) "
|
|
377
|
+
f"WHERE {_sql.bar_type_where(bar_type)} AND ticker IN {named}",
|
|
378
|
+
).fetchall()
|
|
379
|
+
return {
|
|
380
|
+
ticker: TickerSpan(ticker, first_ts, last_ts, int(rows))
|
|
381
|
+
for ticker, first_ts, last_ts, rows in held
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
def _written_spans(
|
|
385
|
+
self,
|
|
386
|
+
root: Path,
|
|
387
|
+
bar_type: BarType,
|
|
388
|
+
ingest: str,
|
|
389
|
+
) -> dict[str, TickerSpan]:
|
|
390
|
+
# leads with the same [0-9] as PARQUET_FILES, so an ingest's files are
|
|
391
|
+
# ordinary files of the tree that a read finds without knowing this exists
|
|
392
|
+
files = sorted(
|
|
393
|
+
str(file)
|
|
394
|
+
for file in self._bar_files(
|
|
395
|
+
bar_type,
|
|
396
|
+
files=f"[0-9]*_{ingest}_*.parquet",
|
|
397
|
+
root=root,
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
if not files:
|
|
401
|
+
return {}
|
|
402
|
+
ticker = _sql.hive_ticker_expression("file")
|
|
403
|
+
spans = self._connection.sql(
|
|
404
|
+
f"SELECT {ticker} AS ticker, min(first_ts), max(last_ts), sum(rows) " # noqa: S608
|
|
405
|
+
f"FROM ({_sql.footer_file_spans_select(files)}) GROUP BY 1",
|
|
406
|
+
).fetchall()
|
|
407
|
+
return {
|
|
408
|
+
named: TickerSpan(named, first_ts, last_ts, int(rows))
|
|
409
|
+
for named, first_ts, last_ts, rows in spans
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
def _file_over(
|
|
413
|
+
self,
|
|
414
|
+
bar_type: BarType,
|
|
415
|
+
held: dict[str, TickerSpan],
|
|
416
|
+
arriving: dict[str, TickerSpan],
|
|
417
|
+
staged_tree: Path,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Move a staged ingest into the tree, or refuse it before it gets there.
|
|
420
|
+
|
|
421
|
+
Raises
|
|
422
|
+
------
|
|
423
|
+
ConflictingBarsError
|
|
424
|
+
If any ticker's arriving bars are not an update of the filed ones.
|
|
425
|
+
The tree and the catalog are untouched.
|
|
426
|
+
|
|
427
|
+
"""
|
|
428
|
+
# an update is the same history downloaded again: the same first bar,
|
|
429
|
+
# and a last one no earlier than the held one. A different start is a
|
|
430
|
+
# different history under one name, and an archive ending before what
|
|
431
|
+
# is filed is less than the store holds. An archive ending on the same
|
|
432
|
+
# bar is the same archive, re-fetched -- filing it over replaces the
|
|
433
|
+
# ticker's files with identical ones, which is what makes a re-run of
|
|
434
|
+
# an interrupted bundle resume rather than refuse
|
|
435
|
+
conflicts = [
|
|
436
|
+
(held[ticker], span)
|
|
437
|
+
for ticker, span in sorted(arriving.items())
|
|
438
|
+
if ticker in held
|
|
439
|
+
and not (
|
|
440
|
+
span.first_ts == held[ticker].first_ts
|
|
441
|
+
and span.last_ts >= held[ticker].last_ts
|
|
442
|
+
)
|
|
443
|
+
]
|
|
444
|
+
if conflicts:
|
|
445
|
+
raise ConflictingBarsError(bar_type, conflicts)
|
|
446
|
+
|
|
447
|
+
# the ticker's held bars go only once their replacement is in the tree,
|
|
448
|
+
# and only the files this ingest did not write: an update carries the
|
|
449
|
+
# whole history again, so what it lands beside is the previous copy of it
|
|
450
|
+
landed = self._move_into_tree(staged_tree)
|
|
451
|
+
for ticker in arriving.keys() & held.keys():
|
|
452
|
+
for file in self._bar_files(bar_type.from_ticker(ticker)):
|
|
453
|
+
if file not in landed:
|
|
454
|
+
file.unlink(missing_ok=True)
|
|
455
|
+
|
|
456
|
+
self._record_spans(bar_type, [span for _, span in sorted(arriving.items())])
|
|
457
|
+
|
|
458
|
+
def _record_spans(self, bar_type: BarType, spans: Iterable[TickerSpan]) -> None:
|
|
459
|
+
"""File `spans` under `bar_type`, over whatever the catalog held for them.
|
|
460
|
+
|
|
461
|
+
`bar_type` names every level but the ticker, which each span carries.
|
|
462
|
+
"""
|
|
463
|
+
levels = bar_type.from_ticker(None).levels()
|
|
464
|
+
staged = self._catalog_table.stage(
|
|
465
|
+
(
|
|
466
|
+
*(levels[level] for level in BarType.fields() if level != "ticker"),
|
|
467
|
+
span.ticker,
|
|
468
|
+
span.first_ts,
|
|
469
|
+
span.last_ts,
|
|
470
|
+
span.rows,
|
|
471
|
+
)
|
|
472
|
+
for span in spans
|
|
473
|
+
)
|
|
474
|
+
if staged is None:
|
|
475
|
+
return
|
|
476
|
+
|
|
477
|
+
matched = " AND ".join(
|
|
478
|
+
f"held.{level} IS NOT DISTINCT FROM staged.{level}"
|
|
479
|
+
for level in BarType.fields()
|
|
480
|
+
)
|
|
481
|
+
self._catalog_table.rewrite(f"""
|
|
482
|
+
SELECT * FROM ({self._catalog_table.select()}) AS held
|
|
483
|
+
WHERE NOT EXISTS (
|
|
484
|
+
SELECT 1 FROM {staged} AS staged WHERE {matched}
|
|
485
|
+
)
|
|
486
|
+
UNION ALL BY NAME
|
|
487
|
+
SELECT * FROM {staged}
|
|
488
|
+
""") # noqa: S608
|
|
489
|
+
|
|
490
|
+
def _move_into_tree(self, staged_tree: Path) -> set[Path]:
|
|
491
|
+
# a rename rather than a copy: the staging directory lives inside the
|
|
492
|
+
# store, so the bars never cross a filesystem however large the archive
|
|
493
|
+
landed = set()
|
|
494
|
+
for staged in staged_tree.rglob(PARQUET_FILES):
|
|
495
|
+
landing = self._bars_directory / staged.relative_to(staged_tree)
|
|
496
|
+
landing.parent.mkdir(parents=True, exist_ok=True)
|
|
497
|
+
staged.replace(landing)
|
|
498
|
+
landed.add(landing)
|
|
499
|
+
return landed
|
|
500
|
+
|
|
501
|
+
@contextmanager
|
|
502
|
+
def _other_data_payloads(
|
|
503
|
+
self,
|
|
504
|
+
content: Path,
|
|
505
|
+
other_data: OtherData,
|
|
506
|
+
) -> Generator[tuple[list[Path], bool]]:
|
|
507
|
+
# yields the payloads and whether each is named for one ticker -- an
|
|
508
|
+
# archive's are, a bare CSV's is not -- which decides whether the ticker
|
|
509
|
+
# can be read back at all
|
|
510
|
+
staging = Path(mkdtemp(dir=self._directory, prefix=".ingest-"))
|
|
511
|
+
try:
|
|
512
|
+
if not zipfile.is_zipfile(content):
|
|
513
|
+
# copied rather than read in place: the staging directory is
|
|
514
|
+
# what this scope promises to clean up, and the spooled file is
|
|
515
|
+
# the caller's
|
|
516
|
+
bare = staging / f"{other_data.value}.csv"
|
|
517
|
+
shutil.copyfile(content, bare)
|
|
518
|
+
yield [bare], False
|
|
519
|
+
else:
|
|
520
|
+
with zipfile.ZipFile(content) as opened:
|
|
521
|
+
opened.extractall(staging)
|
|
522
|
+
payloads = sorted(
|
|
523
|
+
path
|
|
524
|
+
for path in staging.iterdir()
|
|
525
|
+
if path.is_file() and _sql.is_other_data_payload(path.name)
|
|
526
|
+
)
|
|
527
|
+
if not payloads:
|
|
528
|
+
msg = f"{other_data.value} archive holds no payload"
|
|
529
|
+
raise ValueError(msg)
|
|
530
|
+
yield payloads, True
|
|
531
|
+
finally:
|
|
532
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
533
|
+
|
|
534
|
+
def _read(
|
|
535
|
+
self,
|
|
536
|
+
bar_type: BarType,
|
|
537
|
+
ticker: str | Iterable[str] | None,
|
|
538
|
+
*,
|
|
539
|
+
start: date | None = None,
|
|
540
|
+
end: date | None = None,
|
|
541
|
+
hours: TradingHours = TradingHours.ALL,
|
|
542
|
+
) -> duckdb.DuckDBPyRelation:
|
|
543
|
+
# DuckDB raises on a glob matching nothing, and an empty store, an
|
|
544
|
+
# unwritten bar type, or an unheld ticker matches nothing
|
|
545
|
+
glob = self._matching_glob(bar_type, ticker)
|
|
546
|
+
if glob is None:
|
|
547
|
+
return self._connection.sql(_sql.empty_select(_sql.STORED_BAR_SCHEMA))
|
|
548
|
+
|
|
549
|
+
bars_relation = self._connection.sql(_sql.stored_bars_select(glob))
|
|
550
|
+
|
|
551
|
+
# both are ``WHERE``, not selectors: neither the clock nor the calendar
|
|
552
|
+
# is a level of the tree, so no glob can narrow them
|
|
553
|
+
dates = _sql.date_range_where(start, end)
|
|
554
|
+
if dates is not None:
|
|
555
|
+
bars_relation = bars_relation.filter(dates)
|
|
556
|
+
if hours is TradingHours.REGULAR:
|
|
557
|
+
bars_relation = bars_relation.filter(
|
|
558
|
+
_sql.regular_trading_hours_where(bar_type.asset_type),
|
|
559
|
+
)
|
|
560
|
+
return bars_relation
|
|
561
|
+
|
|
562
|
+
def _ticker_listing_table(self, asset_type: AssetType) -> ParquetTable:
|
|
563
|
+
return ParquetTable(
|
|
564
|
+
self._connection,
|
|
565
|
+
self._bars_directory
|
|
566
|
+
/ f"asset_type={asset_type.value}"
|
|
567
|
+
/ "ticker_listing.parquet",
|
|
568
|
+
_sql.TICKER_LISTING_SCHEMA,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
def _other_data_table(self, other_data: OtherData) -> ParquetTable:
|
|
572
|
+
# no declared schema: the vendor's columns are whatever the sniffer
|
|
573
|
+
# read, so `_other_data` refuses a missing metafile rather than
|
|
574
|
+
# answering with an empty relation of a shape it cannot know
|
|
575
|
+
return ParquetTable(
|
|
576
|
+
self._connection,
|
|
577
|
+
self._directory / f"{other_data.value}.parquet",
|
|
578
|
+
{},
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
def _other_data(self, other_data: OtherData) -> duckdb.DuckDBPyRelation:
|
|
582
|
+
table = self._other_data_table(other_data)
|
|
583
|
+
if not table.exists():
|
|
584
|
+
msg = f"no {other_data.value} metafile in this store -- fetch it first"
|
|
585
|
+
raise FileNotFoundError(msg)
|
|
586
|
+
return table.relation()
|
|
587
|
+
|
|
588
|
+
def _matching_glob(
|
|
589
|
+
self,
|
|
590
|
+
bar_type: BarType,
|
|
591
|
+
ticker: str | Iterable[str] | None,
|
|
592
|
+
) -> str | list[str] | None:
|
|
593
|
+
"""AI: Return the glob(s) for the files `bar_type` and `ticker` match.
|
|
594
|
+
|
|
595
|
+
Returns ``None`` when the store has no file for any of them.
|
|
596
|
+
"""
|
|
597
|
+
if ticker is None:
|
|
598
|
+
if next(self._bar_files(bar_type), None) is None:
|
|
599
|
+
return None
|
|
600
|
+
return self._bar_glob(bar_type)
|
|
601
|
+
|
|
602
|
+
named = [ticker] if isinstance(ticker, str) else list(ticker)
|
|
603
|
+
scoped = [bar_type.from_ticker(one) for one in named]
|
|
604
|
+
held = [one for one in scoped if next(self._bar_files(one), None) is not None]
|
|
605
|
+
return [self._bar_glob(one) for one in held] or None
|
|
606
|
+
|
|
607
|
+
def _bar_files(
|
|
608
|
+
self,
|
|
609
|
+
bar_type: BarType,
|
|
610
|
+
files: str = PARQUET_FILES,
|
|
611
|
+
root: Path | None = None,
|
|
612
|
+
) -> Generator[Path]:
|
|
613
|
+
# `root` names a staged tree an ingest is still being validated in
|
|
614
|
+
return (root or self._bars_directory).glob(_bar_pattern(bar_type, files))
|
|
615
|
+
|
|
616
|
+
def _bar_glob(self, bar_type: BarType) -> str:
|
|
617
|
+
path = self._bars_directory / _bar_pattern(bar_type, PARQUET_FILES)
|
|
618
|
+
# forward slashes, which DuckDB's glob and Python's both read on every
|
|
619
|
+
# platform. A backslash is the pattern language's escape character, so a
|
|
620
|
+
# native Windows path would read ``\[0-9]`` as the literal ``[0-9]``.
|
|
621
|
+
return path.as_posix()
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _bar_pattern(bar_type: BarType, files: str) -> str:
|
|
625
|
+
"""Build the tree-relative glob matching every file `bar_type` addresses."""
|
|
626
|
+
levels = (
|
|
627
|
+
f"{key}={'*' if value is None else value}"
|
|
628
|
+
for key, value in bar_type.levels().items()
|
|
629
|
+
)
|
|
630
|
+
return "/".join([*levels, files])
|