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,399 @@
1
+ """SQL the store runs."""
2
+
3
+ from collections.abc import Iterable
4
+ from datetime import UTC, date, datetime, time, timedelta
5
+ from pathlib import Path
6
+ from uuid import uuid4
7
+
8
+ from firstrate_data.domain import AssetType, BarType, OtherData
9
+
10
+ # What a metafile's rows hold, in the vendor's order. They arrive headerless,
11
+ # one file per ticker, so the sniffer has no schema to read. It takes the
12
+ # first data row for a header, and every payload then declares different
13
+ # columns from the last. This is a hard error the moment one read spans more
14
+ # than one of them.
15
+ #
16
+ # ``contin_audit`` is missing on purpose: futures-only, never yet served, so
17
+ # there is no row format to pin. It stays on the sniffer. See issue #15.
18
+ OTHER_DATA_SCHEMA: dict[OtherData, dict[str, str]] = {
19
+ OtherData.SPLITS: {"date": "DATE", "ratio": "DOUBLE"},
20
+ OtherData.DIVIDENDS: {"date": "DATE", "amount": "DOUBLE"},
21
+ # TODO add company profiles and contract dates
22
+ }
23
+
24
+ # The ticker is in the payload's *name* and nowhere in its rows, so it's the
25
+ # one column a metafile can't skip. The vendor suffixes the dividends
26
+ # payloads (``AAPL_divs.txt``) and leaves the splits ones bare
27
+ # (``AAPL.txt``). The suffix isn't part of the ticker.
28
+ OTHER_DATA_SUFFIX: dict[OtherData, str] = {OtherData.DIVIDENDS: "_divs"}
29
+
30
+ # What the catalog keeps. The key columns are BarType's own fields, in
31
+ # BarType's order, so a row addresses exactly what a path does.
32
+ CATALOG_SCHEMA: dict[str, str] = {
33
+ **dict.fromkeys(BarType.fields(), "VARCHAR"),
34
+ "first_ts": "TIMESTAMPTZ",
35
+ "last_ts": "TIMESTAMPTZ",
36
+ "rows": "BIGINT",
37
+ }
38
+
39
+ TICKER_LISTING_SCHEMA: dict[str, str] = {
40
+ "ticker": "VARCHAR",
41
+ "full_name": "VARCHAR",
42
+ "start_date": "DATE",
43
+ "end_date": "DATE",
44
+ "is_delisted": "BOOLEAN",
45
+ }
46
+
47
+ # The six columns every bar has, typed as the parquet tree holds them.
48
+ # ``open_interest`` is NULL where the source omits it rather than absent. A
49
+ # tree of divergent schemas resolves columns by whichever file the glob
50
+ # enumerates first, so a stock-first glob would drop open_interest without
51
+ # warning.
52
+ BAR_SCHEMA: dict[str, str] = {
53
+ "ts": "TIMESTAMPTZ",
54
+ "open": "DOUBLE",
55
+ "high": "DOUBLE",
56
+ "low": "DOUBLE",
57
+ "close": "DOUBLE",
58
+ "volume": "BIGINT",
59
+ }
60
+
61
+ OPEN_INTEREST = "open_interest"
62
+
63
+ # A bar as the tree hands it back: the payload's columns, then the levels the
64
+ # path carries. Named in this order rather than taken from ``SELECT *``, which
65
+ # appends the Hive keys alphabetically and agrees with nothing else here.
66
+ STORED_BAR_SCHEMA: dict[str, str] = {
67
+ **BAR_SCHEMA,
68
+ OPEN_INTEREST: "BIGINT",
69
+ **dict.fromkeys(BarType.fields(), "VARCHAR"),
70
+ }
71
+
72
+ # macOS drops these beside the payloads too, where they match ``*.txt``
73
+ SIDECAR_PREFIX = "._"
74
+
75
+ # The vendor packs a plain-text README into the metafile archives
76
+ # (``_splits_readme.txt``) explaining the row format. Read as a ticker's
77
+ # payload, it aborts the scan. No ticker starts with an underscore, which is
78
+ # what makes the prefix a safe rule.
79
+ OTHER_DATA_NOTE_PREFIX = "_"
80
+
81
+
82
+ def is_other_data_payload(name: str) -> bool:
83
+ """Return whether `name` is a metafile payload containing one ticker's rows.
84
+
85
+ Examples
86
+ --------
87
+ >>> is_other_data_payload("AAPL.txt")
88
+ True
89
+ >>> is_other_data_payload("_splits_readme.txt")
90
+ False
91
+ >>> is_other_data_payload("._AAPL.txt")
92
+ False
93
+
94
+ """
95
+ return not name.startswith((SIDECAR_PREFIX, OTHER_DATA_NOTE_PREFIX))
96
+
97
+
98
+ def sql_literal(value: str) -> str:
99
+ """Return `value` as an SQL text literal.
100
+
101
+ Examples
102
+ --------
103
+ >>> sql_literal("BRK.B")
104
+ "'BRK.B'"
105
+ >>> sql_literal("O'Reilly")
106
+ "'O''Reilly'"
107
+
108
+ """
109
+ escaped = value.replace("'", "''")
110
+ return f"'{escaped}'"
111
+
112
+
113
+ def sql_list(values: Iterable[str]) -> str:
114
+ """Return `values` as an SQL list literal.
115
+
116
+ Examples
117
+ --------
118
+ >>> sql_list(['/tmp/AAPL.txt', '/tmp/MSFT.txt'])
119
+ "['/tmp/AAPL.txt', '/tmp/MSFT.txt']"
120
+ >>> sql_list([])
121
+ '[]'
122
+
123
+ """
124
+ return f"[{', '.join(sql_literal(value) for value in values)}]"
125
+
126
+
127
+ def ticker_expression(column: str) -> str:
128
+ """Return the SQL expression extracting the ticker from the filename in `column`."""
129
+ return f"regexp_extract(parse_filename({column}), '^([^_]+)_', 1)"
130
+
131
+
132
+ def hive_ticker_expression(column: str) -> str:
133
+ """Return the SQL expression reading the ticker level of the path in `column`."""
134
+ # both separators: DuckDB reports a path on Windows with backslashes, and a
135
+ # level bounded by ``/`` alone would swallow the rest of the path
136
+ return f"regexp_extract({column}, 'ticker=([^/\\\\]+)', 1)"
137
+
138
+
139
+ def payload_tickers_select(directory: Path) -> str:
140
+ """Return a SELECT of ``(ticker, files)`` for every payload under `directory`."""
141
+ pattern = sql_literal((directory / "*.txt").as_posix())
142
+ ticker = ticker_expression("file")
143
+ return (
144
+ f"SELECT {ticker} AS ticker, list(file) AS files " # noqa: S608
145
+ f"FROM glob({pattern}) "
146
+ # an AppleDouble sidecar matches *.txt and isn't text. The vendor's own
147
+ # archives carry them when the zip came from a Mac
148
+ f"WHERE NOT starts_with(parse_filename(file), {sql_literal(SIDECAR_PREFIX)}) "
149
+ f"GROUP BY 1"
150
+ )
151
+
152
+
153
+ def bars_select(
154
+ payloads: Iterable[Path],
155
+ bar_type: BarType,
156
+ columns_in_payload: int,
157
+ ) -> str:
158
+ """Return a SELECT reading the ``.txt`` `payloads` as the store's bar schema.
159
+
160
+ `bar_type` must leave the ticker unstated, and `columns_in_payload` is the
161
+ count `payload_columns` returns. Timestamps are returned tz-aware.
162
+
163
+ Raises
164
+ ------
165
+ ValueError
166
+ If `bar_type` names no asset type.
167
+
168
+ """
169
+ asset_type = bar_type.asset_type
170
+ # every row's timestamp reflects the zone its asset type trades in, and a
171
+ # wildcarded level answers for none of them
172
+ if asset_type is None:
173
+ msg = "bars need a bar type naming an asset type"
174
+ raise ValueError(msg)
175
+
176
+ # the declared columns are positional, so both ends of the schema move with
177
+ # the payload's width: an index drops volume, futures add open_interest.
178
+ # Declaring six for a five-column file aborts the scan on the sniffer.
179
+ has_volume = columns_in_payload >= len(BAR_SCHEMA)
180
+ has_open_interest = columns_in_payload > len(BAR_SCHEMA)
181
+
182
+ # ``ts`` is the one column the payload doesn't hold as the store does: it
183
+ # arrives naive, and the timezone() below is what makes it an instant
184
+ declared = {**BAR_SCHEMA, "ts": "TIMESTAMP"}
185
+ if not has_volume:
186
+ del declared["volume"]
187
+ if has_open_interest:
188
+ declared[OPEN_INTEREST] = "BIGINT"
189
+ columns = ", ".join(f"'{name}': '{kind}'" for name, kind in declared.items())
190
+
191
+ volume = "volume" if has_volume else "NULL::BIGINT AS volume"
192
+ open_interest = (
193
+ OPEN_INTEREST if has_open_interest else f"NULL::BIGINT AS {OPEN_INTEREST}"
194
+ )
195
+ files = sql_list(str(payload) for payload in payloads)
196
+ stamped = sql_literal(asset_type.timezone())
197
+ ticker = ticker_expression("filename")
198
+ # the tree is the only record of these -- they're nowhere in the payload,
199
+ # so a level this stops selecting here would read back as NULL
200
+ levels = ",\n ".join(
201
+ f"{sql_literal(value)} AS {key}"
202
+ for key, value in bar_type.stated_levels().items()
203
+ )
204
+
205
+ return f"""
206
+ SELECT
207
+ -- the DST fall-back repeats an hour of naive stamps; both copies
208
+ -- land on the standard-time offset, which is ICU's pick
209
+ timezone({stamped}, ts) AS ts,
210
+ open, high, low, close, {volume},
211
+ {open_interest},
212
+ {levels},
213
+ {ticker} AS ticker
214
+ FROM read_csv(
215
+ {files},
216
+ header = false,
217
+ filename = true,
218
+ columns = {{{columns}}},
219
+ -- the vendor mixes line endings within a single payload, which the
220
+ -- sniffer refuses to pick a dialect for; strict mode off parses them
221
+ strict_mode = false
222
+ )
223
+ """ # noqa: S608
224
+
225
+
226
+ def other_data_ticker_expression(column: str, other_data: OtherData) -> str:
227
+ """Return the SQL expression extracting `other_data`'s ticker from `column`."""
228
+ stem = f"parse_filename({column}, true)"
229
+ suffix = OTHER_DATA_SUFFIX.get(other_data)
230
+ if suffix is None:
231
+ return stem
232
+ return f"regexp_replace({stem}, {sql_literal(f'{suffix}$')}, '')"
233
+
234
+
235
+ def other_data_select(
236
+ payloads: Iterable[Path],
237
+ other_data: OtherData,
238
+ per_ticker: bool, # noqa: FBT001 - store.py's only caller passes it positionally
239
+ ) -> str:
240
+ """Return a SELECT reading `other_data`'s `payloads` as its own table.
241
+
242
+ `per_ticker` reads one file per ticker and adds a ``ticker`` column taken
243
+ from each filename; otherwise the payloads are read as they come.
244
+ """
245
+ files = sql_list(str(payload) for payload in payloads)
246
+ declared = OTHER_DATA_SCHEMA.get(other_data)
247
+ if declared is None or not per_ticker:
248
+ return f"SELECT * FROM read_csv({files})" # noqa: S608
249
+
250
+ columns = ", ".join(f"'{name}': '{kind}'" for name, kind in declared.items())
251
+ return f"""
252
+ SELECT
253
+ {other_data_ticker_expression("filename", other_data)} AS ticker,
254
+ {", ".join(declared)}
255
+ FROM read_csv(
256
+ {files},
257
+ header = false,
258
+ filename = true,
259
+ columns = {{{columns}}},
260
+ -- the vendor mixes line endings here as it does in the bars
261
+ strict_mode = false
262
+ )
263
+ """ # noqa: S608
264
+
265
+
266
+ def ingest_id() -> str:
267
+ """Return a short id naming one ingest."""
268
+ return uuid4().hex[:8]
269
+
270
+
271
+ def filename_pattern(ingest_id: str) -> str:
272
+ """Return the filename pattern `ingest_id` writes its parquet files under."""
273
+ # APPEND under PARTITION_BY requires {uuid}: a constant pattern reuses
274
+ # data_0.parquet and overwrites with no error. The date leads so the file
275
+ # sorts by when it landed and no glob mistakes it for an AppleDouble
276
+ # sidecar. The ingest id between them is how an ingest finds its own files.
277
+ return f"{datetime.now(tz=UTC).date().isoformat()}_{ingest_id}_{{uuid}}"
278
+
279
+
280
+ def empty_select(schema: dict[str, str]) -> str:
281
+ """Return a SELECT with `schema`'s columns and no rows."""
282
+ columns = ", ".join(f"NULL::{kind} AS {name}" for name, kind in schema.items())
283
+ return f"SELECT {columns} WHERE FALSE"
284
+
285
+
286
+ # keyed by an optional asset type so a read that named none falls into the
287
+ # same "has no session" answer as one that trades around the clock
288
+ EXCHANGE_SESSION: dict[AssetType | None, tuple[time, time]] = dict.fromkeys(
289
+ (AssetType.STOCK, AssetType.ETF, AssetType.INDEX),
290
+ (time(9, 30), time(16, 0)),
291
+ )
292
+
293
+
294
+ def regular_trading_hours_where(asset_type: AssetType | None) -> str:
295
+ """Return the predicate keeping `asset_type`'s exchange session.
296
+
297
+ The predicate compares in the connection's timezone.
298
+
299
+ Raises
300
+ ------
301
+ ValueError
302
+ If `asset_type` is None or names no exchange session.
303
+
304
+ """
305
+ session = EXCHANGE_SESSION.get(asset_type)
306
+ if session is None:
307
+ msg = (
308
+ f"{asset_type} has no exchange session: it trades around the clock, "
309
+ f"or the read named no asset type to define one"
310
+ )
311
+ raise ValueError(msg)
312
+
313
+ opens, closes = session
314
+ return (
315
+ f"ts::TIMESTAMP::TIME >= '{opens:%H:%M:%S}' "
316
+ f"AND ts::TIMESTAMP::TIME < '{closes:%H:%M:%S}'"
317
+ )
318
+
319
+
320
+ def date_range_where(start: date | None, end: date | None) -> str | None:
321
+ """Return the predicate keeping days from `start` to `end`, or None for neither.
322
+
323
+ Both bounds name a whole day and both are kept.
324
+
325
+ Examples
326
+ --------
327
+ >>> date_range_where(date(2024, 1, 2), date(2024, 1, 3))
328
+ "ts >= '2024-01-02' AND ts < '2024-01-04'"
329
+ >>> date_range_where(None, None) is None
330
+ True
331
+
332
+ """
333
+ bounds = []
334
+ if start is not None:
335
+ bounds.append(f"ts >= '{start:%Y-%m-%d}'")
336
+ if end is not None:
337
+ # half-open on the right, so the whole of ``end`` stays in
338
+ bounds.append(f"ts < '{end + timedelta(days=1):%Y-%m-%d}'")
339
+ return " AND ".join(bounds) or None
340
+
341
+
342
+ def stored_bars_select(glob: str | list[str]) -> str:
343
+ """Return a SELECT reading the parquet files at `glob` as the store's columns.
344
+
345
+ A single glob reads every ticker under it; a list reads exactly the globs
346
+ named.
347
+ """
348
+ source = sql_literal(glob) if isinstance(glob, str) else sql_list(glob)
349
+ return (
350
+ f"SELECT {', '.join(STORED_BAR_SCHEMA)} " # noqa: S608
351
+ f"FROM read_parquet({source}, hive_partitioning = true)"
352
+ )
353
+
354
+
355
+ def footer_file_spans_select(files: Iterable[str]) -> str:
356
+ """Return a SELECT of ``(file, first_ts, last_ts, rows)`` from `files`' footers."""
357
+ return f"""
358
+ SELECT
359
+ file_name AS file,
360
+ -- stats_min is VARCHAR ('2010-03-27 07:59:00+00'); the offset
361
+ -- makes the cast unambiguous, but the cast is mandatory
362
+ min(stats_min::TIMESTAMPTZ) AS first_ts,
363
+ max(stats_max::TIMESTAMPTZ) AS last_ts,
364
+ -- one row per row group per column, so the aggregate is not optional
365
+ sum(row_group_num_rows)::BIGINT AS rows
366
+ FROM parquet_metadata({sql_list(files)})
367
+ WHERE path_in_schema = 'ts'
368
+ GROUP BY 1
369
+ """ # noqa: S608
370
+
371
+
372
+ def bar_type_where(bar_type: BarType) -> str:
373
+ """Return the predicate matching the catalog rows `bar_type` names.
374
+
375
+ A level left unstated matches every value of it.
376
+ """
377
+ tests = [
378
+ f"{level} = {sql_literal(value)}"
379
+ for level, value in bar_type.stated_levels().items()
380
+ ]
381
+ return " AND ".join(tests) or "TRUE"
382
+
383
+
384
+ def payload_columns(payloads: Iterable[Path]) -> int:
385
+ """Return the number of columns the `payloads` have.
386
+
387
+ The first non-empty line of the first non-empty payload decides.
388
+ """
389
+ # probed rather than ruled from (asset_type, timeframe), which would be a
390
+ # second source of truth about the vendor's file. Empty payloads are
391
+ # routine, so one non-empty payload speaks for the archive.
392
+ for payload in payloads:
393
+ # bytes, not text: a spliced payload holds bytes that aren't a
394
+ # character in the machine's locale encoding
395
+ with payload.open("rb") as lines:
396
+ for line in lines:
397
+ if line.strip():
398
+ return line.count(b",") + 1
399
+ return len(BAR_SCHEMA)