| |
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"] |