|
1 |
+"""Idempotent backfill of `futures_roots`, `futures_contracts` and `futures_contract_gaps` from the lake. |
|
2 |
+ |
|
3 |
+Strategy (must finish in minutes on ~90 k files): |
|
4 |
+* one DuckDB aggregate per `{tf}/{bucket}` directory (`read_parquet(..., filename=true)`) for |
|
5 |
+ first/last datetime + row counts — intraday directories only need the `datetime` column; |
|
6 |
+* one DuckDB scan of all `1day` files (archive ∪ update, dedup on datetime) grouped per contract for |
|
7 |
+ the last 20 sessions volume, last non-zero open interest and the list of session dates (gaps); |
|
8 |
+* SQLite upserts in one transaction. |
|
9 |
+ |
|
10 |
+`run_backfill(roots=None, since=None)` is the library entry point (PM2 cron / CLI). |
|
11 |
+ |
|
12 |
+Author: Simon-Pierre Boucher <contact@spboucher.ai> |
|
13 |
+""" |
|
14 |
+from __future__ import annotations |
|
15 |
+ |
|
16 |
+import csv |
|
17 |
+import json |
|
18 |
+import logging |
|
19 |
+import os |
|
20 |
+import time |
|
21 |
+from collections import defaultdict |
|
22 |
+from dataclasses import dataclass, field |
|
23 |
+from datetime import date, datetime, timedelta |
|
24 |
+from pathlib import Path |
|
25 |
+from typing import Any |
|
26 |
+ |
|
27 |
+import numpy as np |
|
28 |
+from core.config import settings |
|
29 |
+from core.db import session |
|
30 |
+from core.duck import con, invalidate |
|
31 |
+from sqlalchemy import delete, select |
|
32 |
+from sqlalchemy.dialects.sqlite import insert |
|
33 |
+ |
|
34 |
+from . import calendar_us as cal |
|
35 |
+from . import expiry |
|
36 |
+from .lake import BUCKETS, TIMEFRAMES, contracts_root, symbol_from_file_stem |
|
37 |
+from .models import FuturesContract, FuturesContractGap, FuturesRoot |
|
38 |
+from .specs import SPEC_BY_ROOT, UNKNOWN_LAKE_ROOTS, derived_spec, spec_for |
|
39 |
+from .symbols import ContractSymbol |
|
40 |
+ |
|
41 |
+log = logging.getLogger("hfmarketdata.futures.backfill") |
|
42 |
+ |
|
43 |
+GAP_BUSINESS_DAYS = 3 # a gap is > 3 business days without a daily bar |
|
44 |
+ACTIVE_GRACE_DAYS = 7 |
|
45 |
+ |
|
46 |
+ |
|
47 |
+@dataclass |
|
48 |
+class ContractAgg: |
|
49 |
+ symbol: ContractSymbol |
|
50 |
+ files: dict[str, dict[str, str]] = field(default_factory=dict) # tf -> bucket -> path |
|
51 |
+ stats: dict[str, dict[str, Any]] = field(default_factory=dict) # tf -> {first,last,rows} |
|
52 |
+ |
|
53 |
+ |
|
54 |
+def _dir_stats(directory: Path, tf: str) -> dict[str, tuple[Any, Any, int]]: |
|
55 |
+ """filename -> (min datetime, max datetime, rows) for every parquet file in a directory.""" |
|
56 |
+ if not directory.is_dir(): |
|
57 |
+ return {} |
|
58 |
+ pattern = str(directory / "*.parquet") |
|
59 |
+ rows = con().execute( |
|
60 |
+ "SELECT filename, min(datetime), max(datetime), count(*) FROM read_parquet(?, filename=true, union_by_name=true) " |
|
61 |
+ "GROUP BY filename", [pattern]).fetchall() |
|
62 |
+ return {os.path.basename(r[0]): (r[1], r[2], int(r[3])) for r in rows} |
|
63 |
+ |
|
64 |
+ |
|
65 |
+def _daily_metrics(agg: dict[str, ContractAgg]) -> dict[str, dict[str, Any]]: |
|
66 |
+ """Per contract: volume_avg_daily (last 20 sessions), open_interest_last, session dates (for gaps). |
|
67 |
+ Single DuckDB query over all 1day files with archive/update dedup.""" |
|
68 |
+ paths: list[tuple[str, str, int]] = [] # (symbol, path, priority) |
|
69 |
+ for sym, a in agg.items(): |
|
70 |
+ for prio, bucket in enumerate(BUCKETS): |
|
71 |
+ p = a.files.get("1day", {}).get(bucket) |
|
72 |
+ if p: |
|
73 |
+ paths.append((sym, p, prio)) |
|
74 |
+ if not paths: |
|
75 |
+ return {} |
|
76 |
+ c = con() |
|
77 |
+ c.execute("CREATE OR REPLACE TEMP TABLE _bf_files(sym VARCHAR, path VARCHAR, prio INTEGER)") |
|
78 |
+ c.executemany("INSERT INTO _bf_files VALUES (?, ?, ?)", paths) |
|
79 |
+ sql = """ |
|
80 |
+ WITH raw AS ( |
|
81 |
+ SELECT f.sym, f.prio, b.datetime, b.volume, b.open_interest |
|
82 |
+ FROM read_parquet(?, filename=true, union_by_name=true) b |
|
83 |
+ JOIN _bf_files f ON f.path = b.filename |
|
84 |
+ ), dedup AS ( |
|
85 |
+ SELECT sym, datetime, volume, open_interest FROM raw |
|
86 |
+ QUALIFY row_number() OVER (PARTITION BY sym, datetime ORDER BY prio) = 1 |
|
87 |
+ ), ranked AS ( |
|
88 |
+ SELECT *, row_number() OVER (PARTITION BY sym ORDER BY datetime DESC) AS rn FROM dedup |
|
89 |
+ ) |
|
90 |
+ SELECT sym, |
|
91 |
+ avg(volume) FILTER (WHERE rn <= 20) AS vol20, |
|
92 |
+ arg_max(open_interest, datetime) FILTER (WHERE open_interest > 0) AS oi_last, |
|
93 |
+ list(CAST(datetime AS DATE) ORDER BY datetime) AS days, |
|
94 |
+ count(*) AS n |
|
95 |
+ FROM ranked GROUP BY sym |
|
96 |
+ """ |
|
97 |
+ out: dict[str, dict[str, Any]] = {} |
|
98 |
+ for sym, vol20, oi_last, days, n in c.execute(sql, [[p for _, p, _ in paths]]).fetchall(): |
|
99 |
+ out[sym] = {"vol20": float(vol20) if vol20 is not None else None, |
|
100 |
+ "oi_last": float(oi_last) if oi_last is not None else None, |
|
101 |
+ "days": days, "n": int(n)} |
|
102 |
+ c.execute("DROP TABLE IF EXISTS _bf_files") |
|
103 |
+ return out |
|
104 |
+ |
|
105 |
+ |
|
106 |
+def _gaps(days: list[date], calendar: str) -> list[tuple[date, date, int]]: |
|
107 |
+ """(gap_start, gap_end, business days missing) for holes > GAP_BUSINESS_DAYS between consecutive sessions.""" |
|
108 |
+ if len(days) < 2: |
|
109 |
+ return [] |
|
110 |
+ arr = np.array(days, dtype="datetime64[D]") |
|
111 |
+ diffs = (arr[1:] - arr[:-1]).astype(int) |
|
112 |
+ out = [] |
|
113 |
+ for i in np.nonzero(diffs > GAP_BUSINESS_DAYS + 1)[0]: # ≥ 5 calendar days is the first candidate |
|
114 |
+ a, b = days[int(i)], days[int(i) + 1] |
|
115 |
+ missing = cal.business_days_between(a, b, calendar) |
|
116 |
+ if missing > GAP_BUSINESS_DAYS: |
|
117 |
+ out.append((a + timedelta(days=1), b - timedelta(days=1), missing)) |
|
118 |
+ return out |
|
119 |
+ |
|
120 |
+ |
|
121 |
+def _status(last_data: date | None, expiration: date | None, year: int, month: int, today: date) -> str: |
|
122 |
+ if last_data is None: |
|
123 |
+ return "expired" |
|
124 |
+ month_passed = (year, month) < (today.year, today.month) or (expiration is not None and expiration < today) |
|
125 |
+ return "expired" if last_data < today - timedelta(days=ACTIVE_GRACE_DAYS) and month_passed else "active" |
|
126 |
+ |
|
127 |
+ |
|
128 |
+def _read_meta_csv() -> dict[str, dict[str, str]]: |
|
129 |
+ p = settings.data_root / "meta" / "futures" / "futures.csv" |
|
130 |
+ if not p.is_file(): |
|
131 |
+ return {} |
|
132 |
+ out = {} |
|
133 |
+ with open(p, newline="", encoding="utf-8", errors="replace") as f: |
|
134 |
+ for row in csv.DictReader(f): |
|
135 |
+ t = (row.get("Ticker") or "").strip().upper() |
|
136 |
+ if t: |
|
137 |
+ out[t] = {k.strip(): (v or "").strip() for k, v in row.items() if k} |
|
138 |
+ return out |
|
139 |
+ |
|
140 |
+ |
|
141 |
+def _exchange_from_name(name: str | None) -> str | None: |
|
142 |
+ if name and name.rstrip().endswith(")") and "(" in name: |
|
143 |
+ return name.rstrip()[name.rfind("(") + 1:-1].strip() or None |
|
144 |
+ return None |
|
145 |
+ |
|
146 |
+ |
|
147 |
+def _clean_name(name: str | None) -> str | None: |
|
148 |
+ if not name: |
|
149 |
+ return None |
|
150 |
+ n = name.replace("Â", "").replace("\xa0", " ").strip() |
|
151 |
+ if n.endswith(")") and "(" in n: |
|
152 |
+ n = n[:n.rfind("(")].strip() |
|
153 |
+ return n or None |
|
154 |
+ |
|
155 |
+ |
|
156 |
+def run_backfill(roots: list[str] | None = None, since: date | None = None, today: date | None = None, |
|
157 |
+ verbose: bool = False) -> dict[str, Any]: |
|
158 |
+ """Scan the lake and upsert roots/contracts/gaps. Returns a summary dict. |
|
159 |
+ |
|
160 |
+ roots: restrict to these lake roots (e.g. ["ES", "CL"]). |
|
161 |
+ since: only (re)process contract files modified on/after this date (incremental cron mode). |
|
162 |
+ """ |
|
163 |
+ t0 = time.time() |
|
164 |
+ today = today or date.today() |
|
165 |
+ roots_filter = {r.upper() for r in roots} if roots else None |
|
166 |
+ since_ts = datetime.combine(since, datetime.min.time()).timestamp() if since else None |
|
167 |
+ base = contracts_root() |
|
168 |
+ |
|
169 |
+ # 1. file discovery + per-directory stats ------------------------------------------------------- |
|
170 |
+ # `since` mode: a contract touched by ANY modified file is re-aggregated over ALL its files, so that |
|
171 |
+ # `files`/`timeframes` never shrink after an incremental run. |
|
172 |
+ touched: set[str] | None = None |
|
173 |
+ if since_ts: |
|
174 |
+ touched = set() |
|
175 |
+ for tf in TIMEFRAMES: |
|
176 |
+ for bucket in BUCKETS: |
|
177 |
+ d = base / tf / bucket |
|
178 |
+ if not d.is_dir(): |
|
179 |
+ continue |
|
180 |
+ with os.scandir(d) as it: |
|
181 |
+ for e in it: |
|
182 |
+ if e.name.endswith(".parquet") and e.stat().st_mtime >= since_ts: |
|
183 |
+ cs = symbol_from_file_stem(e.name[:-8]) |
|
184 |
+ if cs is not None and (not roots_filter or cs.root in roots_filter): |
|
185 |
+ touched.add(cs.short) |
|
186 |
+ agg: dict[str, ContractAgg] = {} |
|
187 |
+ n_files = 0 |
|
188 |
+ for tf in TIMEFRAMES: |
|
189 |
+ for bucket in BUCKETS: |
|
190 |
+ d = base / tf / bucket |
|
191 |
+ if not d.is_dir(): |
|
192 |
+ continue |
|
193 |
+ wanted: dict[str, ContractSymbol] = {} |
|
194 |
+ with os.scandir(d) as it: |
|
195 |
+ for e in it: |
|
196 |
+ if not e.name.endswith(".parquet"): |
|
197 |
+ continue |
|
198 |
+ cs = symbol_from_file_stem(e.name[:-8]) |
|
199 |
+ if cs is None or (roots_filter and cs.root not in roots_filter): |
|
200 |
+ continue |
|
201 |
+ if touched is not None and cs.short not in touched: |
|
202 |
+ continue |
|
203 |
+ wanted[e.name] = cs |
|
204 |
+ if not wanted: |
|
205 |
+ continue |
|
206 |
+ if roots_filter or touched is not None: |
|
207 |
+ # partial scan: aggregate only the selected files (list of paths) |
|
208 |
+ paths = [str(d / n) for n in wanted] |
|
209 |
+ rows = con().execute( |
|
210 |
+ "SELECT filename, min(datetime), max(datetime), count(*) FROM read_parquet(?, filename=true, union_by_name=true) " |
|
211 |
+ "GROUP BY filename", [paths]).fetchall() |
|
212 |
+ stats = {os.path.basename(r[0]): (r[1], r[2], int(r[3])) for r in rows} |
|
213 |
+ else: |
|
214 |
+ stats = _dir_stats(d, tf) |
|
215 |
+ for name, cs in wanted.items(): |
|
216 |
+ st = stats.get(name) |
|
217 |
+ if not st or st[2] == 0: |
|
218 |
+ continue |
|
219 |
+ n_files += 1 |
|
220 |
+ a = agg.setdefault(cs.short, ContractAgg(cs)) |
|
221 |
+ a.files.setdefault(tf, {})[bucket] = str(d / name) |
|
222 |
+ cur = a.stats.get(tf) |
|
223 |
+ first, last = st[0], st[1] |
|
224 |
+ if cur is None: |
|
225 |
+ a.stats[tf] = {"first": first, "last": last, "rows": st[2]} |
|
226 |
+ else: |
|
227 |
+ cur["first"] = min(cur["first"], first) |
|
228 |
+ cur["last"] = max(cur["last"], last) |
|
229 |
+ cur["rows"] = max(cur["rows"], st[2]) # overlap → the larger file approximates the merged count |
|
230 |
+ if verbose: |
|
231 |
+ log.info("%s/%s: %d files", tf, bucket, len(wanted)) |
|
232 |
+ if verbose: |
|
233 |
+ log.info("discovery done: %d contracts, %d files in %.1fs", len(agg), n_files, time.time() - t0) |
|
234 |
+ |
|
235 |
+ # 2. daily metrics (volume, OI, gaps) ---------------------------------------------------------------- |
|
236 |
+ daily = _daily_metrics(agg) |
|
237 |
+ if verbose: |
|
238 |
+ log.info("daily metrics done in %.1fs", time.time() - t0) |
|
239 |
+ |
|
240 |
+ # 3. build rows ---------------------------------------------------------------------------------------- |
|
241 |
+ meta = _read_meta_csv() |
|
242 |
+ now = datetime.utcnow().replace(microsecond=0) |
|
243 |
+ contract_rows: list[dict[str, Any]] = [] |
|
244 |
+ gap_rows: list[dict[str, Any]] = [] |
|
245 |
+ per_root: dict[str, dict[str, Any]] = defaultdict(lambda: {"first": None, "last": None, "n": 0}) |
|
246 |
+ for sym, a in agg.items(): |
|
247 |
+ cs = a.symbol |
|
248 |
+ spec = spec_for(cs.root) |
|
249 |
+ d1 = a.stats.get("1day") |
|
250 |
+ any_first = min(s["first"] for s in a.stats.values()) |
|
251 |
+ any_last = max(s["last"] for s in a.stats.values()) |
|
252 |
+ first_data = (d1["first"] if d1 else any_first) |
|
253 |
+ last_data = (d1["last"] if d1 else any_last) |
|
254 |
+ first_data = first_data.date() if isinstance(first_data, datetime) else first_data |
|
255 |
+ last_data = last_data.date() if isinstance(last_data, datetime) else last_data |
|
256 |
+ ltd = expiry.compute(spec.expiry_rule, cs.year, cs.month) if spec.expiry_rule != "data" else None |
|
257 |
+ if ltd is not None: |
|
258 |
+ expiration, source = ltd, "rule" |
|
259 |
+ else: |
|
260 |
+ expiration, source = last_data, "data" |
|
261 |
+ fnd = expiry.compute_first_notice(spec.first_notice_rule, cs.year, cs.month) if spec.settlement_type == "physical" else None |
|
262 |
+ dm = daily.get(sym, {}) |
|
263 |
+ timeframes = {tf: {"first": str(s["first"]), "last": str(s["last"]), "rows": s["rows"]} for tf, s in a.stats.items()} |
|
264 |
+ files = {tf: [b.get("archive"), b.get("update")] for tf, b in a.files.items()} |
|
265 |
+ contract_rows.append({ |
|
266 |
+ "symbol": sym, "root": cs.root, "month_code": cs.month_code, "contract_month": cs.month, "contract_year": cs.year, |
|
267 |
+ "expiration_date": expiration, "expiration_source": source, "last_trading_date": ltd, |
|
268 |
+ "first_notice_date": fnd, "settlement_type": spec.settlement_type, "contract_size": spec.contract_size, |
|
269 |
+ "tick_size": spec.tick_size, "tick_value": spec.tick_value, "currency": spec.currency, "exchange": spec.exchange, |
|
270 |
+ "first_data_date": first_data, "last_data_date": last_data, |
|
271 |
+ "status": _status(last_data, expiration, cs.year, cs.month, today), |
|
272 |
+ "volume_avg_daily": dm.get("vol20"), "open_interest_last": dm.get("oi_last"), |
|
273 |
+ "bars_1day": dm.get("n") if dm else None, |
|
274 |
+ "timeframes": json.dumps(timeframes), "files": json.dumps(files), "updated_at": now, |
|
275 |
+ }) |
|
276 |
+ for gs, ge, missing in _gaps(dm.get("days") or [], spec.calendar): |
|
277 |
+ gap_rows.append({"symbol": sym, "timeframe": "1day", "gap_start": gs, "gap_end": ge, "bars_missing": missing}) |
|
278 |
+ pr = per_root[cs.root] |
|
279 |
+ pr["first"] = first_data if pr["first"] is None else min(pr["first"], first_data) |
|
280 |
+ pr["last"] = last_data if pr["last"] is None else max(pr["last"], last_data) |
|
281 |
+ pr["n"] += 1 |
|
282 |
+ |
|
283 |
+ # roots: every reference spec + every lake root (+ meta csv names for derived ones) |
|
284 |
+ root_rows: list[dict[str, Any]] = [] |
|
285 |
+ all_roots = set(SPEC_BY_ROOT) | set(per_root) | set(meta) if not roots_filter else set(per_root) | (roots_filter & set(SPEC_BY_ROOT)) |
|
286 |
+ for r in sorted(all_roots): |
|
287 |
+ spec = SPEC_BY_ROOT.get(r) |
|
288 |
+ m = meta.get(r, {}) |
|
289 |
+ if spec is None: |
|
290 |
+ spec = derived_spec(r, _clean_name(m.get("Name")), _exchange_from_name(m.get("Name"))) |
|
291 |
+ d = spec.as_dict() |
|
292 |
+ pr = per_root.get(r) |
|
293 |
+ root_rows.append({**{k: v for k, v in d.items() if k not in ("aliases",)}, "aliases": json.dumps(d["aliases"]), |
|
294 |
+ "first_data_date": pr["first"] if pr else None, "last_data_date": pr["last"] if pr else None, |
|
295 |
+ "contracts_count": pr["n"] if pr else 0, "updated_at": now}) |
|
296 |
+ |
|
297 |
+ # 4. upsert ------------------------------------------------------------------------------------------- |
|
298 |
+ with session() as s: |
|
299 |
+ if root_rows: |
|
300 |
+ stmt = insert(FuturesRoot).values(root_rows) |
|
301 |
+ upd = {c.name: getattr(stmt.excluded, c.name) for c in FuturesRoot.__table__.columns if c.name != "root"} |
|
302 |
+ if since_ts: |
|
303 |
+ # incremental run: only some contracts were scanned → don't clobber the root coverage/count |
|
304 |
+ for k in ("first_data_date", "last_data_date", "contracts_count"): |
|
305 |
+ upd.pop(k, None) |
|
306 |
+ s.execute(stmt.on_conflict_do_update(index_elements=["root"], set_=upd)) |
|
307 |
+ for i in range(0, len(contract_rows), 500): |
|
308 |
+ chunk = contract_rows[i:i + 500] |
|
309 |
+ stmt = insert(FuturesContract).values(chunk) |
|
310 |
+ upd = {c.name: getattr(stmt.excluded, c.name) for c in FuturesContract.__table__.columns if c.name != "symbol"} |
|
311 |
+ s.execute(stmt.on_conflict_do_update(index_elements=["symbol"], set_=upd)) |
|
312 |
+ syms = [r["symbol"] for r in contract_rows] |
|
313 |
+ for i in range(0, len(syms), 500): |
|
314 |
+ s.execute(delete(FuturesContractGap).where(FuturesContractGap.symbol.in_(syms[i:i + 500]))) |
|
315 |
+ for i in range(0, len(gap_rows), 500): |
|
316 |
+ s.execute(insert(FuturesContractGap).values(gap_rows[i:i + 500])) |
|
317 |
+ invalidate("futures|") |
|
318 |
+ summary = {"contracts": len(contract_rows), "files": n_files, "roots": len(root_rows), "gaps": len(gap_rows), |
|
319 |
+ "active": sum(1 for r in contract_rows if r["status"] == "active"), |
|
320 |
+ "rule_based": sum(1 for r in contract_rows if r["expiration_source"] == "rule"), |
|
321 |
+ "seconds": round(time.time() - t0, 1), "today": str(today)} |
|
322 |
+ log.info("futures backfill: %s", summary) |
|
323 |
+ return summary |
|
324 |
+ |
|
325 |
+ |
|
326 |
+def contracts_in_db() -> int: |
|
327 |
+ with session() as s: |
|
328 |
+ return len(s.execute(select(FuturesContract.symbol)).all()) |
|
329 |
+ |
|
330 |
+ |
|
331 |
+__all__ = ["UNKNOWN_LAKE_ROOTS", "contracts_in_db", "run_backfill"] |