SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

futures: service de données, moteur de rolls/ajustements et les 7 endpoints /v1/futures

- rolls.py : calendriers de roll calendar/first_notice/volume/open_interest (fenêtre 30 séances), profondeur 1–3, écarts par roll, ajustements additif/ratio (dernier contrat non ajusté, écart null jamais inventé)
- service.py : barres d'un contrat (fusion archive+update, session rth/eth sur l'horloge ET, curseur, UTC ISO 8601 DST-aware), chaîne, continu (schedule quotidien appliqué à l'intraday), structure à terme, couverture, racines, contrats
- routes.py : /roots, /{root}/contracts, /contract/{symbol}/bars, /contract/{symbol}/coverage, /{root}/chain, /{root}/continuous, /{root}/term-structure — OpenAPI riche, x-errors, clamp_limit par tier, json/csv/parquet

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 20 days ago (Sep 4, 2026) parent f48ce8a

3 changed files +1,037 −0

added hfmarketdata/api/futures/rolls.py +242 −0
@@ -0,0 +1,242 @@
1 +"""Roll schedules and continuous-series stitching (pure pandas, no I/O).
2 +
3 +Inputs: the contracts of a root ordered by expiration, and their merged *daily* bars
4 +(`symbol, date, open, high, low, close, volume, open_interest`). Output: front-month segments
5 +(`Segment(symbol, start, end)`), depth-N schedules and back/ratio-adjusted series.
6 +
7 +Roll methods (`roll_dates[i]` = first session on which the *new* contract is the front):
8 +* `calendar` — the old contract is held through its `expiration_date`; roll the next session.
9 +* `first_notice` — roll on `first_notice_date` (held through the session before FND); falls back to
10 + calendar when FND is null (cash-settled products).
11 +* `volume` — roll after the 2nd consecutive session where the next contract's volume is greater
12 + than the front's (never later than the calendar roll).
13 +* `open_interest` — same test on open interest.
14 +
15 +Adjustments (latest contract always unadjusted):
16 +* `back_adjusted` — additive: gap_k = close_new − close_old on the last session of the old segment
17 + (nearest common session within 5 sessions); every bar before roll k gets
18 + `+ Σ gap_j (j ≥ k)`.
19 +* `ratio_adjusted` — multiplicative: factor_k = close_new / close_old; bars before roll k are
20 + multiplied by `Π factor_j (j ≥ k)`.
21 +Volume and open interest are never adjusted. If no common session exists, the gap is null and the
22 +roll is flagged (`adjusted: false`) — no value is invented.
23 +
24 +Author: Simon-Pierre Boucher <contact@spboucher.ai>
25 +"""
26 +from __future__ import annotations
27 +
28 +from dataclasses import dataclass
29 +from datetime import date, timedelta
30 +from typing import Any, Literal
31 +
32 +import numpy as np
33 +import pandas as pd
34 +
35 +from . import calendar_us as cal
36 +
37 +RollMethod = Literal["calendar", "first_notice", "volume", "open_interest"]
38 +Adjust = Literal["none", "back_adjusted", "ratio_adjusted"]
39 +ROLL_METHODS = ("calendar", "first_notice", "volume", "open_interest")
40 +ADJUSTMENTS = ("none", "back_adjusted", "ratio_adjusted")
41 +PRICE_COLS = ("open", "high", "low", "close")
42 +
43 +
44 +@dataclass(frozen=True)
45 +class Segment:
46 + symbol: str
47 + start: date # inclusive
48 + end: date | None # inclusive; None = open-ended (current front)
49 + position: int # index in the contract sequence
50 +
51 +
52 +def _d(v: Any) -> date | None:
53 + if v is None or (isinstance(v, float) and np.isnan(v)):
54 + return None
55 + if isinstance(v, pd.Timestamp):
56 + return v.date()
57 + if isinstance(v, date):
58 + return v
59 + return pd.Timestamp(v).date()
60 +
61 +
62 +def _sessions(daily: pd.DataFrame) -> np.ndarray:
63 + """Sorted unique session dates across all contracts (datetime64[D])."""
64 + if daily is None or daily.empty:
65 + return np.array([], dtype="datetime64[D]")
66 + return np.unique(daily["date"].values.astype("datetime64[D]"))
67 +
68 +
69 +def _next_session(sessions: np.ndarray, d: date, calendar: str) -> date:
70 + """First session strictly after d (from the data), else the next business day."""
71 + if len(sessions):
72 + i = np.searchsorted(sessions, np.datetime64(d, "D"), side="right")
73 + if i < len(sessions):
74 + return sessions[i].astype("datetime64[D]").astype(date)
75 + return cal.next_business_day(d, calendar, inclusive=False)
76 +
77 +
78 +ROLL_WINDOW_SESSIONS = 30 # volume/OI tests are only evaluated in the last N sessions of the front contract
79 +
80 +
81 +def _metric_roll_date(daily: pd.DataFrame, old: str, new: str, metric: str, lower: date, upper: date,
82 + window: int = ROLL_WINDOW_SESSIONS) -> date | None:
83 + """First session after the 2nd consecutive session in (lower, upper] where metric(new) > metric(old).
84 + Only the last `window` sessions of the old contract (≤ upper) are examined: far-dated months with
85 + near-zero, noisy volume must not trigger a roll a year early."""
86 + a = daily.loc[daily["symbol"] == old, ["date", metric]].set_index("date")[metric]
87 + b = daily.loc[daily["symbol"] == new, ["date", metric]].set_index("date")[metric]
88 + a = a[a.index <= pd.Timestamp(upper)].sort_index().tail(window)
89 + both = pd.concat([a.rename("old"), b.rename("new")], axis=1).dropna().sort_index()
90 + both = both[(both.index > pd.Timestamp(lower)) & (both.index <= pd.Timestamp(upper))]
91 + if both.empty:
92 + return None
93 + hit = (both["new"] > both["old"]).to_numpy()
94 + for i in range(1, len(hit)):
95 + if hit[i] and hit[i - 1]:
96 + # the roll takes effect on the next session (any contract) after the 2nd confirming session
97 + nxt = daily.loc[daily["date"] > both.index[i], "date"]
98 + return _d(nxt.min()) if not nxt.empty else None
99 + return None
100 +
101 +
102 +def roll_schedule(contracts: list[dict[str, Any]], daily: pd.DataFrame | None, method: str = "calendar",
103 + calendar: str = "us") -> list[Segment]:
104 + """Front-month segments for a root.
105 +
106 + `contracts`: dicts with symbol, expiration_date, first_notice_date, first_data_date, last_data_date,
107 + ordered by expiration then symbol. Contracts already expired when the previous roll happens are skipped.
108 + """
109 + if method not in ROLL_METHODS:
110 + raise ValueError(f"unknown roll method {method}")
111 + seq = [c for c in contracts if _d(c.get("expiration_date")) is not None and _d(c.get("first_data_date")) is not None]
112 + seq.sort(key=lambda c: (_d(c["expiration_date"]), c["symbol"]))
113 + if not seq:
114 + return []
115 + sessions = _sessions(daily) if daily is not None else np.array([], dtype="datetime64[D]")
116 + segments: list[Segment] = []
117 + start = _d(seq[0]["first_data_date"])
118 + k = 0
119 + pos = 0
120 + while k < len(seq):
121 + cur = seq[k]
122 + exp = _d(cur["expiration_date"])
123 + # next contract = first later contract that is still alive after `exp`
124 + j = k + 1
125 + while j < len(seq) and _d(seq[j]["expiration_date"]) <= exp:
126 + j += 1
127 + if j >= len(seq):
128 + segments.append(Segment(cur["symbol"], start, None, pos))
129 + break
130 + nxt = seq[j]
131 + cal_roll = _next_session(sessions, exp, calendar)
132 + roll = cal_roll
133 + if method == "first_notice":
134 + fnd = _d(cur.get("first_notice_date"))
135 + if fnd is not None and start < fnd < cal_roll:
136 + roll = fnd
137 + elif method in ("volume", "open_interest") and daily is not None and not daily.empty:
138 + m = _metric_roll_date(daily, cur["symbol"], nxt["symbol"], method, start, exp)
139 + if m is not None and start < m < cal_roll:
140 + roll = m
141 + if roll <= start: # degenerate (contract with data only after its own expiry)
142 + roll = _next_session(sessions, start, calendar)
143 + segments.append(Segment(cur["symbol"], start, roll - timedelta(days=1), pos))
144 + start = roll
145 + k = j
146 + pos += 1
147 + return segments
148 +
149 +
150 +def depth_schedule(front: list[Segment], contracts: list[dict[str, Any]], depth: int) -> list[Segment]:
151 + """Depth-N segments: same windows as the front schedule, symbol = N-th contract in the sequence
152 + used by the front (front = 1). Segments with no such contract are dropped."""
153 + if depth == 1:
154 + return front
155 + order = [s.symbol for s in front]
156 + # contracts after the last front symbol (for the tail of deeper schedules)
157 + seq = [c["symbol"] for c in sorted(
158 + (c for c in contracts if _d(c.get("expiration_date")) is not None), key=lambda c: (_d(c["expiration_date"]), c["symbol"]))]
159 + tail = [s for s in seq if s not in set(order) and (not order or seq.index(s) > seq.index(order[-1]))]
160 + chain = order + tail
161 + out: list[Segment] = []
162 + for s in front:
163 + i = chain.index(s.symbol) + depth - 1
164 + if i < len(chain):
165 + out.append(Segment(chain[i], s.start, s.end, s.position))
166 + return out
167 +
168 +
169 +def roll_gaps(segments: list[Segment], daily: pd.DataFrame, lookback: int = 5) -> list[dict[str, Any]]:
170 + """One entry per roll (between consecutive segments): date, from_symbol, to_symbol, gap, ratio."""
171 + out: list[dict[str, Any]] = []
172 + if daily is None or daily.empty:
173 + daily = pd.DataFrame(columns=["symbol", "date", "close"])
174 + closes = daily.pivot_table(index="date", columns="symbol", values="close", aggfunc="last") if not daily.empty else pd.DataFrame()
175 + for prev, nxt in zip(segments, segments[1:]):
176 + roll_date = nxt.start
177 + gap = ratio = None
178 + common = None
179 + if not closes.empty and prev.symbol in closes.columns and nxt.symbol in closes.columns:
180 + # last `lookback` sessions of the old contract up to the end of its segment
181 + old = closes[prev.symbol].dropna()
182 + old = old[old.index <= pd.Timestamp(prev.end)]
183 + if not old.empty:
184 + both = closes.loc[old.index[-lookback:], [prev.symbol, nxt.symbol]].dropna()
185 + if not both.empty: # nearest common session
186 + common = both.index[-1]
187 + co, cn = float(both.iloc[-1][prev.symbol]), float(both.iloc[-1][nxt.symbol])
188 + gap = cn - co
189 + ratio = cn / co if co else None
190 + out.append({"date": str(roll_date), "from_symbol": prev.symbol, "to_symbol": nxt.symbol,
191 + "gap": None if gap is None else round(gap, 10), "ratio": None if ratio is None else round(ratio, 12),
192 + "gap_session": None if common is None else str(_d(common)), "adjusted": gap is not None})
193 + return out
194 +
195 +
196 +def adjustment_offsets(rolls: list[dict[str, Any]], adjust: str) -> tuple[list[float], list[float]]:
197 + """Per-segment additive offset and multiplicative factor (segment i has rolls i.. after it)."""
198 + n = len(rolls) + 1
199 + add = [0.0] * n
200 + mul = [1.0] * n
201 + if adjust == "none":
202 + return add, mul
203 + cum_add, cum_mul = 0.0, 1.0
204 + for i in range(n - 2, -1, -1):
205 + r = rolls[i]
206 + if adjust == "back_adjusted" and r["gap"] is not None:
207 + cum_add += r["gap"]
208 + if adjust == "ratio_adjusted" and r["ratio"] is not None:
209 + cum_mul *= r["ratio"]
210 + add[i], mul[i] = cum_add, cum_mul
211 + return add, mul
212 +
213 +
214 +def apply_adjustment(frames: list[pd.DataFrame], add: list[float], mul: list[float]) -> pd.DataFrame:
215 + """Concatenate per-segment bar frames applying each segment's offset/factor to OHLC."""
216 + parts = []
217 + for i, f in enumerate(frames):
218 + if f is None or f.empty:
219 + continue
220 + f = f.copy()
221 + if add[i] or mul[i] != 1.0:
222 + for c in PRICE_COLS:
223 + if c in f.columns:
224 + f[c] = f[c] * mul[i] + add[i]
225 + parts.append(f)
226 + if not parts:
227 + return pd.DataFrame(columns=["symbol", "datetime", *PRICE_COLS, "volume"])
228 + return pd.concat(parts, ignore_index=True).sort_values("datetime", kind="stable").reset_index(drop=True)
229 +
230 +
231 +def stitch_daily(segments: list[Segment], daily: pd.DataFrame, adjust: str = "none") -> tuple[pd.DataFrame, list[dict[str, Any]]]:
232 + """Convenience for daily series/tests: build the continuous daily frame from the merged daily bars."""
233 + rolls = roll_gaps(segments, daily)
234 + add, mul = adjustment_offsets(rolls, adjust)
235 + frames = []
236 + for s in segments:
237 + f = daily[daily["symbol"] == s.symbol]
238 + f = f[f["date"] >= pd.Timestamp(s.start)]
239 + if s.end is not None:
240 + f = f[f["date"] <= pd.Timestamp(s.end)]
241 + frames.append(f.rename(columns={"date": "datetime"}))
242 + return apply_adjustment(frames, add, mul), rolls
added hfmarketdata/api/futures/routes.py +276 −0
@@ -0,0 +1,276 @@
1 +"""`/v1/futures/*` — individual contracts, chains, continuous series, term structure (UPGRADE-PLAN §2).
2 +
3 +Author: Simon-Pierre Boucher <contact@spboucher.ai>
4 +"""
5 +from __future__ import annotations
6 +
7 +import pandas as pd
8 +from core.responses import clamp_limit, frame_response, json_response, parse_format
9 +from fastapi import APIRouter, Path, Query, Request
10 +
11 +from . import (
12 + models, # noqa: F401 (create_all at import)
13 + service,
14 +)
15 +
16 +router = APIRouter(prefix="/v1/futures", tags=["futures"])
17 +
18 +LIMIT_DEFAULT = 5_000
19 +LIMIT_JSON_MAX = 200_000
20 +LIMIT_FILE_MAX = 2_000_000
21 +
22 +_INTERVAL_DOC = "Bar interval: `1m`, `5m`, `30m`, `1h`, `1d` (aliases `1min`, `5min`, `30min`, `1hour`, `1day` accepted)."
23 +_SESSION_DOC = ("Intraday session filter on the US/Eastern clock: `rth` = regular hours of the product "
24 + "(09:30–16:00 for equity indices, 09:00–14:30 CL/NG, 08:20–13:30 GC, 08:20–15:00 ZN/FX…), "
25 + "`eth` = everything else, `all` (default). Ignored for `1d`.")
26 +_FROM_DOC = "Lower bound (inclusive): `YYYY-MM-DD` (Eastern trading date) or ISO 8601 datetime (`Z`/offset → converted; naive = UTC)."
27 +_TO_DOC = "Upper bound (inclusive date / exclusive datetime), same formats as `from`."
28 +_FORMAT_DOC = "`json` (envelope `{data, meta}`), `csv` or `parquet` (up to 2 000 000 rows)."
29 +_LIMIT_DOC = "Rows per page (default 5 000; max 200 000 JSON / 2 000 000 CSV-Parquet, capped by your tier)."
30 +_CURSOR_DOC = "Opaque cursor from `meta.next_cursor` to fetch the next page."
31 +
32 +_BARS_EXAMPLE = {"data": [
33 + {"symbol": "ESZ24", "datetime": "2024-12-19T14:30:00Z", "open": 5921.25, "high": 5923.0, "low": 5919.5, "close": 5922.0, "volume": 1834.0},
34 + {"symbol": "ESZ24", "datetime": "2024-12-19T14:31:00Z", "open": 5922.0, "high": 5922.75, "low": 5920.0, "close": 5920.25, "volume": 1211.0}],
35 + "meta": {"count": 2, "symbol": "ESZ24", "root": "ES", "interval": "1m", "session": "rth", "timezone": "UTC",
36 + "next_cursor": "IjIwMjQtMTItMTkgMDk6MzE6MDAi", "expiration_date": "2024-12-20", "expiration_source": "rule", "status": "expired"}}
37 +
38 +
39 +def _limit(limit: int | None, fmt: str, request: Request) -> int:
40 + return clamp_limit(limit, LIMIT_DEFAULT, LIMIT_FILE_MAX if fmt in ("csv", "parquet") else LIMIT_JSON_MAX, request=request)
41 +
42 +
43 +@router.get(
44 + "/roots",
45 + summary="List futures roots",
46 + description="""Every futures product available as individual contracts (142 roots) with its reference
47 +specification: name, exchange, asset class, currency, contract size, tick size/value, settlement type,
48 +expiry rule key, month cycle, RTH window (US/Eastern) and the CME aliases accepted in symbols
49 +(`6E` → lake root `E6`, `ZB` → `US`…). `source` is `reference` when the specification comes from the
50 +exchange table, `derived` when only the name (FirstRate) is known — nulls are never invented.
51 +
52 +Coverage columns (`first_data_date`, `last_data_date`, `contracts_count`) are computed by the contracts backfill.""",
53 + responses={200: {"description": "Roots", "content": {"application/json": {"example": {"data": [
54 + {"root": "ES", "name": "E-mini S&P 500", "exchange": "CME", "asset_class": "equity_index", "currency": "USD",
55 + "contract_size": 50.0, "contract_size_unit": "USD x index", "tick_size": 0.25, "tick_value": 12.5,
56 + "settlement_type": "cash", "expiry_rule": "third_friday", "first_notice_rule": None, "calendar": "us",
57 + "month_cycle": "HMUZ", "rth_start": "09:30", "rth_end": "16:00", "aliases": [], "first_data_date": "2008-01-02",
58 + "last_data_date": "2026-09-03", "contracts_count": 78, "source": "reference"}],
59 + "meta": {"count": 1}}}}}},
60 + openapi_extra={"x-errors": []},
61 +)
62 +def list_roots(request: Request,
63 + asset_class: str | None = Query(None, description="Filter: equity_index, energy, metals, rates, ags, fx, crypto, volatility, softs, livestock"),
64 + exchange: str | None = Query(None, description="Filter by exchange code (CME, CBOT, NYMEX, COMEX, ICE US, EUREX…)"),
65 + search: str | None = Query(None, description="Substring on root or name (case-insensitive)"),
66 + format: str = Query("json", description=_FORMAT_DOC)):
67 + fmt = parse_format(format)
68 + rows = service.roots()
69 + if asset_class:
70 + rows = [r for r in rows if (r.get("asset_class") or "").lower() == asset_class.lower()]
71 + if exchange:
72 + rows = [r for r in rows if (r.get("exchange") or "").lower() == exchange.lower()]
73 + if search:
74 + s = search.lower()
75 + rows = [r for r in rows if s in r["root"].lower() or s in (r.get("name") or "").lower()]
76 + for r in rows:
77 + r.pop("updated_at", None)
78 + if fmt != "json":
79 + return frame_response(pd.DataFrame(rows), fmt, request=request, filename="futures_roots")
80 + return json_response(service.jsonable(rows))
81 +
82 +
83 +@router.get(
84 + "/{root}/contracts",
85 + summary="List the contracts of a root",
86 + description="""All individual contracts of a root (e.g. `ES`, `CL`, `6E`/`E6`) with expiration
87 +(`expiration_source` = `rule` from the exchange rule, or `data` = last bar date), last trading and first
88 +notice dates, real data range, status (`active`/`expired`), 20-session average volume, last open interest,
89 +available intervals and lake files. Filter by `status` and by expiration window, sort with `sort`
90 +(prefix `-` for descending).""",
91 + responses={200: {"description": "Contracts", "content": {"application/json": {"example": {"data": [
92 + {"symbol": "ESZ24", "root": "ES", "month_code": "Z", "contract_month": 12, "contract_year": 2024,
93 + "expiration_date": "2024-12-20", "expiration_source": "rule", "last_trading_date": "2024-12-20", "first_notice_date": None,
94 + "settlement_type": "cash", "contract_size": 50.0, "tick_size": 0.25, "tick_value": 12.5, "currency": "USD", "exchange": "CME",
95 + "first_data_date": "2021-06-04", "last_data_date": "2024-12-20", "status": "expired", "volume_avg_daily": 1418211.4,
96 + "open_interest_last": 506519.0, "bars_1day": 895,
97 + "timeframes": {"1min": {"first": "2023-09-27 08:14:00", "last": "2024-12-20 09:29:00", "rows": 123342}},
98 + "files": {"1day": ["…/futures_contracts/1day/archive/ES_Z24_1day.parquet", None]}}],
99 + "meta": {"count": 1, "root": "ES"}}}}}},
100 + openapi_extra={"x-errors": ["ROOT_NOT_FOUND", "INVALID_PARAMETER"]},
101 +)
102 +def list_contracts(request: Request,
103 + root: str = Path(..., description="Root code (lake or CME alias, case-insensitive)"),
104 + status: str | None = Query(None, description="`active` or `expired`"),
105 + from_: str | None = Query(None, alias="from", description="Expiration date ≥ (YYYY-MM-DD)"),
106 + to: str | None = Query(None, description="Expiration date ≤ (YYYY-MM-DD)"),
107 + sort: str = Query("expiration_date", description="expiration_date | symbol | first_data_date | last_data_date | volume_avg_daily | open_interest_last (prefix `-` = desc)"),
108 + format: str = Query("json", description=_FORMAT_DOC)):
109 + fmt = parse_format(format)
110 + rows = service.contracts(root, status, service.parse_date(from_, "from"), service.parse_date(to, "to"), sort)
111 + for r in rows:
112 + r.pop("updated_at", None)
113 + if fmt != "json":
114 + flat = [{**r, "timeframes": ",".join(r["timeframes"] or {}) if isinstance(r.get("timeframes"), dict) else r.get("timeframes"),
115 + "files": None} for r in rows]
116 + return frame_response(pd.DataFrame(flat), fmt, request=request, filename=f"{service.normalize_root(root)}_contracts")
117 + return json_response(service.jsonable(rows), meta={"root": service.normalize_root(root), "status": status, "sort": sort})
118 +
119 +
120 +@router.get(
121 + "/contract/{symbol}/bars",
122 + summary="Bars of one individual contract",
123 + description="""OHLCV bars of a single futures contract (`ESZ24`, `ESZ2024`, `ES_Z24`, `6EZ24`…), 1-minute to daily.
124 +
125 +* Archive and update files are merged and deduplicated on the bar timestamp (update wins).
126 +* Intraday timestamps are converted from the exchange's US/Eastern clock to **UTC** (`…Z`), DST-aware;
127 + daily bars are dates and carry `open_interest`.
128 +* `session=rth|eth` filters on the product's regular-hours window (see `/v1/futures/roots`).
129 +* Paginate with `limit` + `meta.next_cursor`; for large extracts use `format=parquet` (half the row cost).""",
130 + responses={200: {"description": "Bars", "content": {"application/json": {"example": _BARS_EXAMPLE},
131 + "text/csv": {"example": "symbol,datetime,open,high,low,close,volume\nESZ24,2024-12-19T14:30:00Z,5921.25,5923.0,5919.5,5922.0,1834.0\n"}}}},
132 + openapi_extra={"x-errors": ["INVALID_CONTRACT_SYMBOL", "CONTRACT_NOT_FOUND", "INVALID_PARAMETER", "ROW_LIMIT_EXCEEDED"]},
133 +)
134 +def contract_bars(request: Request,
135 + symbol: str = Path(..., description="Contract symbol: `ESZ24`, `ESZ2024`, `ES_Z24`, `6EZ24` (aliases resolved)"),
136 + interval: str | None = Query(None, description=_INTERVAL_DOC + " Default `1d`."),
137 + timeframe: str | None = Query(None, description="Alias of `interval` (legacy names `1min`…`1day`)."),
138 + from_: str | None = Query(None, alias="from", description=_FROM_DOC),
139 + to: str | None = Query(None, description=_TO_DOC),
140 + session: str | None = Query(None, description=_SESSION_DOC),
141 + cursor: str | None = Query(None, description=_CURSOR_DOC),
142 + limit: int | None = Query(None, description=_LIMIT_DOC),
143 + format: str = Query("json", description=_FORMAT_DOC)):
144 + fmt = parse_format(format)
145 + lim = _limit(limit, fmt, request)
146 + df, meta = service.contract_bars(symbol, interval, from_, to, session, cursor, lim, timeframe=timeframe)
147 + tf = service.parse_interval(interval, timeframe)
148 + return frame_response(service.format_for_output(df, tf, fmt), fmt, meta=meta, request=request, filename=f"{meta['symbol']}_{meta['interval']}")
149 +
150 +
151 +@router.get(
152 + "/contract/{symbol}/coverage",
153 + summary="Coverage report of one contract",
154 + description="""What the lake really holds for a contract: per interval availability, first/last bar, row
155 +count and files; expiration/last-trading/first-notice dates with their source (`rule` vs `data`); daily
156 +gaps (> 3 business days without a bar) computed by the backfill; and explicit `notes` for every null.""",
157 + responses={200: {"description": "Coverage", "content": {"application/json": {"example": {"data": {
158 + "symbol": "CLZ24", "root": "CL", "status": "expired", "expiration_date": "2024-11-20", "expiration_source": "rule",
159 + "last_trading_date": "2024-11-20", "first_notice_date": "2024-11-21", "first_data_date": "2019-12-03", "last_data_date": "2024-11-20",
160 + "bars_1day": 1251, "volume_avg_daily": 402113.0, "open_interest_last": 8082.0,
161 + "intervals": {"1m": {"available": True, "first": "2020-01-02 09:00:00", "last": "2024-11-20 14:29:00", "rows": 1204411,
162 + "files": {"archive": "…/1min/archive/CL_Z24_1min.parquet"}, "open_interest": False},
163 + "1d": {"available": True, "first": "2019-12-03 00:00:00", "last": "2024-11-20 00:00:00", "rows": 1251,
164 + "files": {"archive": "…/1day/archive/CL_Z24_1day.parquet"}, "open_interest": True}},
165 + "gaps": [{"interval": "1d", "start": "2020-03-16", "end": "2020-03-27", "business_days_missing": 10}],
166 + "notes": [], "timezone_note": "intraday bars are converted from US/Eastern to UTC; daily bars are dates"},
167 + "meta": {"count": 1}}}}}},
168 + openapi_extra={"x-errors": ["INVALID_CONTRACT_SYMBOL", "CONTRACT_NOT_FOUND"]},
169 +)
170 +def contract_coverage(request: Request, symbol: str = Path(..., description="Contract symbol")):
171 + return json_response(service.coverage(symbol))
172 +
173 +
174 +@router.get(
175 + "/{root}/chain",
176 + summary="Contract chain as of a date",
177 + description="""The contracts of a root that were live on `as_of` (data started, not yet expired), ordered by
178 +expiration: position (1 = front), expiration/first-notice dates, days to expiry, and the last daily
179 +close/volume/open interest at or before `as_of` (null if older than 7 days). Default `as_of` = today.""",
180 + responses={200: {"description": "Chain", "content": {"application/json": {"example": {"data": [
181 + {"position": 1, "symbol": "CLF25", "contract_month": 1, "contract_year": 2025, "expiration_date": "2024-12-19",
182 + "expiration_source": "rule", "first_notice_date": "2024-12-20", "days_to_expiry": 17, "status": "expired",
183 + "last_date": "2024-12-02", "close": 68.1, "volume": 312560.0, "open_interest": 301224.0, "volume_avg_daily": 289112.0},
184 + {"position": 2, "symbol": "CLG25", "contract_month": 2, "contract_year": 2025, "expiration_date": "2025-01-21",
185 + "expiration_source": "rule", "first_notice_date": "2025-01-22", "days_to_expiry": 50, "status": "expired",
186 + "last_date": "2024-12-02", "close": 67.9, "volume": 120014.0, "open_interest": 210998.0, "volume_avg_daily": 101223.0}],
187 + "meta": {"count": 2, "root": "CL", "as_of": "2024-12-02", "front": "CLF25", "note": None}}}}}},
188 + openapi_extra={"x-errors": ["ROOT_NOT_FOUND", "INVALID_PARAMETER"]},
189 +)
190 +def root_chain(request: Request, root: str = Path(..., description="Root code (lake or CME alias)"),
191 + as_of: str | None = Query(None, description="YYYY-MM-DD (default today)"),
192 + format: str = Query("json", description=_FORMAT_DOC)):
193 + fmt = parse_format(format)
194 + rows, meta = service.chain(root, as_of)
195 + if fmt != "json":
196 + return frame_response(pd.DataFrame(rows), fmt, meta=meta, request=request, filename=f"{meta['root']}_chain_{meta['as_of']}")
197 + return json_response(rows, meta=meta)
198 +
199 +
200 +@router.get(
201 + "/{root}/continuous",
202 + summary="Continuous series built from individual contracts",
203 + description="""A continuous front-month (or 2nd/3rd month, `depth`) series stitched from the individual contracts
204 +with an explicit, reproducible roll schedule and optional price adjustment.
205 +
206 +**Roll methods** (`roll`): `calendar` (hold through the expiration date), `first_notice` (roll on the first
207 +notice day, else calendar), `volume` (default — roll after 2 consecutive sessions where the next contract's
208 +volume exceeds the front's), `open_interest` (same test on OI). Rolls are decided on daily data and applied to
209 +intraday intervals by trading date.
210 +
211 +**Adjustments** (`adjust`): `none`, `back_adjusted` (additive — every bar before a roll is shifted by the
212 +cumulative close gap `new − old`, latest contract unadjusted), `ratio_adjusted` (multiplicative — cumulative
213 +close ratio `new / old`). Volume/OI are never adjusted. `meta.roll_dates` lists each roll in the returned window
214 +(`date`, `from_symbol`, `to_symbol`, `gap`, `ratio`, `gap_session`, `adjusted`); a roll without a common
215 +session has `gap: null` and is not adjusted (nothing is invented). Every row carries the source `symbol`.
216 +
217 +`request cost = 2` (multi-file scan).""",
218 + responses={200: {"description": "Continuous bars", "content": {"application/json": {"example": {"data": [
219 + {"symbol": "ESU24", "datetime": "2024-09-19", "open": 5670.0, "high": 5721.25, "low": 5657.0, "close": 5713.5, "volume": 1650211.0, "open_interest": 1650002.0},
220 + {"symbol": "ESZ24", "datetime": "2024-09-20", "open": 5766.5, "high": 5805.0, "low": 5750.25, "close": 5766.75, "volume": 1520114.0, "open_interest": 2090144.0}],
221 + "meta": {"count": 2, "root": "ES", "interval": "1d", "roll": "volume", "adjust": "back_adjusted", "depth": 1, "session": "all",
222 + "timezone": "UTC", "next_cursor": None,
223 + "roll_dates": [{"date": "2024-09-20", "from_symbol": "ESU24", "to_symbol": "ESZ24", "gap": 55.5, "ratio": 1.00971,
224 + "gap_session": "2024-09-19", "adjusted": True}],
225 + "rolls_total": 70, "segments": [{"symbol": "ESU24", "start": "2024-06-14", "end": "2024-09-19"}, {"symbol": "ESZ24", "start": "2024-09-20", "end": "2024-12-16"}],
226 + "unadjusted_symbol": "ESZ26", "adjustment_note": "additive: …"}}}}}},
227 + openapi_extra={"x-errors": ["ROOT_NOT_FOUND", "INVALID_PARAMETER", "ROW_LIMIT_EXCEEDED"]},
228 +)
229 +def root_continuous(request: Request, root: str = Path(..., description="Root code (lake or CME alias)"),
230 + roll: str | None = Query(None, description="`calendar` | `first_notice` | `volume` (default) | `open_interest`"),
231 + adjust: str | None = Query(None, description="`none` (default) | `back_adjusted` | `ratio_adjusted`"),
232 + depth: int | None = Query(None, description="1 = front month (default), 2 = second, 3 = third"),
233 + interval: str | None = Query(None, description=_INTERVAL_DOC + " Default `1d`."),
234 + timeframe: str | None = Query(None, description="Alias of `interval`."),
235 + from_: str | None = Query(None, alias="from", description=_FROM_DOC),
236 + to: str | None = Query(None, description=_TO_DOC),
237 + session: str | None = Query(None, description=_SESSION_DOC),
238 + cursor: str | None = Query(None, description=_CURSOR_DOC),
239 + limit: int | None = Query(None, description=_LIMIT_DOC),
240 + format: str = Query("json", description=_FORMAT_DOC)):
241 + fmt = parse_format(format)
242 + lim = _limit(limit, fmt, request)
243 + request.state.request_cost = 2
244 + df, meta = service.continuous(root, roll, adjust, depth, interval, from_, to, session, cursor, lim, timeframe=timeframe)
245 + tf = service.parse_interval(interval, timeframe)
246 + return frame_response(service.format_for_output(df, tf, fmt), fmt, meta=meta, request=request,
247 + filename=f"{meta['root']}_continuous_{meta['roll']}_{meta['adjust']}_{meta['interval']}")
248 +
249 +
250 +@router.get(
251 + "/{root}/term-structure",
252 + summary="Term structure (forward curve) as of a date",
253 + description="""Forward curve of a root on `as_of`: every live contract with its settle (last daily close at or
254 +before `as_of`, ≤ 7 days old, else null), days to expiry, spread vs the front and the annualised slope
255 +`(settle_i / settle_front − 1) × 365 / (dte_i − dte_front)`. `meta.structure` flags `contango`
256 +(2nd > front), `backwardation` or `flat`. Default `as_of` = today.""",
257 + responses={200: {"description": "Term structure", "content": {"application/json": {"example": {"data": [
258 + {"position": 1, "symbol": "CLF25", "contract_month": 1, "contract_year": 2025, "expiration_date": "2024-12-19", "expiration_source": "rule",
259 + "first_notice_date": "2024-12-20", "days_to_expiry": 17, "status": "expired", "last_date": "2024-12-02", "settle": 68.1,
260 + "volume": 312560.0, "open_interest": 301224.0, "slope_annualized": None, "spread_vs_front": None},
261 + {"position": 2, "symbol": "CLG25", "contract_month": 2, "contract_year": 2025, "expiration_date": "2025-01-21", "expiration_source": "rule",
262 + "first_notice_date": "2025-01-22", "days_to_expiry": 50, "status": "expired", "last_date": "2024-12-02", "settle": 67.9,
263 + "volume": 120014.0, "open_interest": 210998.0, "slope_annualized": -0.03248, "spread_vs_front": -0.2}],
264 + "meta": {"count": 2, "root": "CL", "as_of": "2024-12-02", "front": "CLF25", "front_settle": 68.1, "structure": "backwardation",
265 + "curve_slope_annualized": -0.03248, "priced_contracts": 2,
266 + "settle_note": "settle = last daily close at or before as_of (≤ 7 days old), else null"}}}}}},
267 + openapi_extra={"x-errors": ["ROOT_NOT_FOUND", "INVALID_PARAMETER"]},
268 +)
269 +def root_term_structure(request: Request, root: str = Path(..., description="Root code (lake or CME alias)"),
270 + as_of: str | None = Query(None, description="YYYY-MM-DD (default today)"),
271 + format: str = Query("json", description=_FORMAT_DOC)):
272 + fmt = parse_format(format)
273 + rows, meta = service.term_structure(root, as_of)
274 + if fmt != "json":
275 + return frame_response(pd.DataFrame(rows), fmt, meta=meta, request=request, filename=f"{meta['root']}_term_structure_{meta['as_of']}")
276 + return json_response(rows, meta=meta)
added hfmarketdata/api/futures/service.py +519 −0
@@ -0,0 +1,519 @@
1 +"""Data access for the futures module: contract bars, chains, continuous series, term structure, coverage.
2 +
3 +Conventions
4 +* Lake timestamps are naive US/Eastern; intraday output is converted to UTC (`zoneinfo`, DST-aware);
5 + daily bars stay calendar dates.
6 +* `from`/`to`: `YYYY-MM-DD` = whole Eastern trading dates; ISO datetimes with an offset/`Z` are converted
7 + to Eastern; naive datetimes are treated as UTC.
8 +* `session`: `rth` keeps bars whose start time falls in the root's RTH window (Eastern, see specs);
9 + `eth` keeps the complement; `all` (default) keeps everything. Ignored for daily bars.
10 +* Cursor pagination on the (Eastern) bar datetime; `limit + 1` rows are fetched to detect `next_cursor`.
11 +
12 +Author: Simon-Pierre Boucher <contact@spboucher.ai>
13 +"""
14 +from __future__ import annotations
15 +
16 +import json
17 +import re
18 +from datetime import date, datetime, timedelta, timezone
19 +from typing import Any
20 +from zoneinfo import ZoneInfo
21 +
22 +import numpy as np
23 +import pandas as pd
24 +from core.db import session
25 +from core.duck import cached, con
26 +from core.errors import ApiError
27 +from core.responses import decode_cursor, encode_cursor
28 +from sqlalchemy import select
29 +
30 +from . import lake
31 +from .lake import INTERVAL_ALIASES, INTERVAL_OF_TF, TIMEFRAMES, files_for, merged_sql
32 +from .models import FuturesContract, FuturesContractGap, FuturesRoot
33 +from .rolls import (
34 + ADJUSTMENTS,
35 + ROLL_METHODS,
36 + Segment,
37 + adjustment_offsets,
38 + apply_adjustment,
39 + depth_schedule,
40 + roll_gaps,
41 + roll_schedule,
42 +)
43 +from .specs import spec_for
44 +from .symbols import ContractSymbol, normalize_root, parse_symbol
45 +
46 +ET = ZoneInfo("America/New_York")
47 +UTC = timezone.utc
48 +SESSIONS = ("rth", "eth", "all")
49 +BAR_COLS = ["datetime", "open", "high", "low", "close", "volume"]
50 +_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
51 +
52 +
53 +# ---- parameter parsing ---------------------------------------------------------------------------------
54 +
55 +def parse_interval(interval: str | None, timeframe: str | None = None) -> str:
56 + v = (interval or timeframe or "1d").lower()
57 + tf = INTERVAL_ALIASES.get(v)
58 + if not tf:
59 + raise ApiError(400, "INVALID_PARAMETER", f"interval must be one of 1m, 5m, 30m, 1h, 1d (got '{v}')",
60 + details={"interval": v})
61 + return tf
62 +
63 +
64 +def parse_session(session_: str | None) -> str:
65 + v = (session_ or "all").lower()
66 + if v not in SESSIONS:
67 + raise ApiError(400, "INVALID_PARAMETER", "session must be rth, eth or all", details={"session": v})
68 + return v
69 +
70 +
71 +def parse_date(value: str | None, name: str) -> date | None:
72 + if value in (None, ""):
73 + return None
74 + try:
75 + return date.fromisoformat(value[:10]) if _DATE_RE.match(value.strip()) else datetime.fromisoformat(value).date()
76 + except ValueError:
77 + raise ApiError(400, "INVALID_PARAMETER", f"{name} must be an ISO date (YYYY-MM-DD)", details={name: value})
78 +
79 +
80 +def parse_bound(value: str | None, name: str, end: bool = False) -> datetime | None:
81 + """`from`/`to` → naive Eastern datetime bound (inclusive lower / exclusive upper)."""
82 + if value in (None, ""):
83 + return None
84 + v = value.strip()
85 + try:
86 + if _DATE_RE.match(v):
87 + d = datetime.fromisoformat(v)
88 + return d + timedelta(days=1) if end else d
89 + dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
90 + except ValueError:
91 + raise ApiError(400, "INVALID_PARAMETER", f"{name} must be an ISO 8601 date or datetime", details={name: value})
92 + if dt.tzinfo is None:
93 + dt = dt.replace(tzinfo=UTC)
94 + return dt.astimezone(ET).replace(tzinfo=None)
95 +
96 +
97 +def parse_choice(value: str | None, name: str, choices: tuple[str, ...], default: str) -> str:
98 + v = (value or default).lower()
99 + if v not in choices:
100 + raise ApiError(400, "INVALID_PARAMETER", f"{name} must be one of {', '.join(choices)}", details={name: v})
101 + return v
102 +
103 +
104 +def parse_depth(value: int | None) -> int:
105 + d = 1 if value is None else int(value)
106 + if not 1 <= d <= 3:
107 + raise ApiError(400, "INVALID_PARAMETER", "depth must be 1, 2 or 3", details={"depth": value})
108 + return d
109 +
110 +
111 +# ---- lookups --------------------------------------------------------------------------------------------
112 +
113 +def _row(obj) -> dict[str, Any]:
114 + """ORM row → plain dict (dates kept as `date` objects; JSON columns decoded)."""
115 + d = {c.name: getattr(obj, c.name) for c in obj.__table__.columns}
116 + for k in ("timeframes", "files", "aliases"):
117 + if k in d and isinstance(d[k], str):
118 + try:
119 + d[k] = json.loads(d[k])
120 + except ValueError:
121 + pass
122 + return d
123 +
124 +
125 +def jsonable(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
126 + """ISO strings for date/datetime values (JSONResponse does not serialise them)."""
127 + return [{k: (v.isoformat() if isinstance(v, (date, datetime)) else v) for k, v in r.items()} for r in rows]
128 +
129 +
130 +def resolve_root(root: str) -> str:
131 + """Canonical lake root or 404 ROOT_NOT_FOUND."""
132 + r = normalize_root(root)
133 + if not re.fullmatch(r"[A-Z0-9]{1,4}", r or ""):
134 + raise ApiError(404, "ROOT_NOT_FOUND", f"Unknown futures root '{root}'.", details={"root": root})
135 + with session() as s:
136 + row = s.get(FuturesRoot, r)
137 + if row is None and not lake.symbols_of_root(r):
138 + raise ApiError(404, "ROOT_NOT_FOUND", f"Unknown futures root '{root}'. See /v1/futures/roots.", details={"root": root})
139 + return r
140 +
141 +
142 +def get_contract(symbol: str) -> tuple[ContractSymbol, dict[str, Any]]:
143 + """Parse + load the contract row; 404 CONTRACT_NOT_FOUND if neither the DB nor the lake knows it."""
144 + cs = parse_symbol(symbol)
145 + with session() as s:
146 + row = s.get(FuturesContract, cs.short)
147 + if row is None:
148 + idx = lake.file_index().get(cs.short)
149 + if not idx:
150 + raise ApiError(404, "CONTRACT_NOT_FOUND", f"No data for contract {cs.short}.",
151 + details={"symbol": cs.short, "root": cs.root})
152 + # lake has files but the metadata backfill has not run yet: minimal on-the-fly row
153 + return cs, {"symbol": cs.short, "root": cs.root, "month_code": cs.month_code, "contract_month": cs.month,
154 + "contract_year": cs.year, "timeframes": {tf: {} for tf in idx}, "files": None, "status": None,
155 + "expiration_date": None, "expiration_source": None}
156 + return cs, _row(row)
157 +
158 +
159 +def roots() -> list[dict[str, Any]]:
160 + with session() as s:
161 + rows = s.execute(select(FuturesRoot).order_by(FuturesRoot.root)).scalars().all()
162 + return [_row(r) for r in rows]
163 +
164 +
165 +def contracts(root: str, status: str | None = None, from_: date | None = None, to: date | None = None,
166 + sort: str = "expiration_date") -> list[dict[str, Any]]:
167 + r = resolve_root(root)
168 + if status and status not in ("active", "expired"):
169 + raise ApiError(400, "INVALID_PARAMETER", "status must be active or expired", details={"status": status})
170 + sort_cols = {"expiration_date": FuturesContract.expiration_date, "symbol": FuturesContract.symbol,
171 + "first_data_date": FuturesContract.first_data_date, "last_data_date": FuturesContract.last_data_date,
172 + "volume_avg_daily": FuturesContract.volume_avg_daily, "open_interest_last": FuturesContract.open_interest_last}
173 + desc = sort.startswith("-")
174 + key = sort.lstrip("-")
175 + if key not in sort_cols:
176 + raise ApiError(400, "INVALID_PARAMETER", f"sort must be one of {', '.join(sort_cols)} (prefix - for descending)",
177 + details={"sort": sort})
178 + q = select(FuturesContract).where(FuturesContract.root == r)
179 + if status:
180 + q = q.where(FuturesContract.status == status)
181 + if from_:
182 + q = q.where(FuturesContract.expiration_date >= from_)
183 + if to:
184 + q = q.where(FuturesContract.expiration_date <= to)
185 + col = sort_cols[key]
186 + q = q.order_by(col.desc() if desc else col.asc(), FuturesContract.symbol)
187 + with session() as s:
188 + rows = s.execute(q).scalars().all()
189 + return [_row(x) for x in rows]
190 +
191 +
192 +def _root_contracts(root: str) -> list[dict[str, Any]]:
193 + with session() as s:
194 + rows = s.execute(select(FuturesContract).where(FuturesContract.root == root)
195 + .order_by(FuturesContract.expiration_date, FuturesContract.symbol)).scalars().all()
196 + return [_row(x) for x in rows]
197 +
198 +
199 +# ---- bars ------------------------------------------------------------------------------------------------
200 +
201 +def _session_sql(root: str, session_: str, tf: str) -> str:
202 + if session_ == "all" or tf == "1day":
203 + return ""
204 + start, end = spec_for(root).rth
205 + if start < end:
206 + cond = f"(CAST(datetime AS TIME) >= TIME '{start}' AND CAST(datetime AS TIME) < TIME '{end}')"
207 + else: # overnight window (e.g. cotton 21:00–14:20)
208 + cond = f"(CAST(datetime AS TIME) >= TIME '{start}' OR CAST(datetime AS TIME) < TIME '{end}')"
209 + return f" AND {cond}" if session_ == "rth" else f" AND NOT {cond}"
210 +
211 +
212 +def to_utc(df: pd.DataFrame, tf: str, col: str = "datetime") -> pd.DataFrame:
213 + """Naive Eastern → tz-aware UTC for intraday frames (daily frames stay naive dates)."""
214 + if tf == "1day" or df.empty or col not in df:
215 + return df
216 + s = pd.to_datetime(df[col])
217 + if s.dt.tz is None:
218 + s = s.dt.tz_localize(ET, ambiguous=np.zeros(len(s), dtype=bool), nonexistent="shift_forward")
219 + df = df.copy()
220 + # ns resolution: DuckDB yields µs and core.responses only formats `datetime64[ns, UTC]` with the `Z` suffix
221 + df[col] = s.dt.tz_convert("UTC").astype("datetime64[ns, UTC]")
222 + return df
223 +
224 +
225 +def format_for_output(df: pd.DataFrame, tf: str, fmt: str) -> pd.DataFrame:
226 + """CSV gets ISO strings (UTC `Z` / plain dates); JSON is handled by frame_response; Parquet keeps types."""
227 + if fmt != "csv" or df.empty or "datetime" not in df:
228 + return df
229 + df = df.copy()
230 + if tf == "1day":
231 + df["datetime"] = pd.to_datetime(df["datetime"]).dt.strftime("%Y-%m-%d")
232 + else:
233 + df["datetime"] = df["datetime"].dt.strftime("%Y-%m-%dT%H:%M:%SZ")
234 + return df
235 +
236 +
237 +def contract_bars(symbol: str, interval: str | None, from_: str | None, to: str | None, session_: str | None,
238 + cursor: str | None, limit: int, timeframe: str | None = None) -> tuple[pd.DataFrame, dict[str, Any]]:
239 + tf = parse_interval(interval, timeframe)
240 + sess = parse_session(session_)
241 + lo, hi = parse_bound(from_, "from"), parse_bound(to, "to", end=True)
242 + cs, row = get_contract(symbol)
243 + paths = files_for(cs, tf)
244 + if not paths:
245 + avail = sorted(lake.file_index().get(cs.short, {}), key=TIMEFRAMES.index)
246 + raise ApiError(404, "CONTRACT_NOT_FOUND",
247 + f"{cs.short} has no {INTERVAL_OF_TF[tf]} bars. Available intervals: "
248 + f"{', '.join(INTERVAL_OF_TF[t] for t in avail) or 'none'}.",
249 + details={"symbol": cs.short, "interval": INTERVAL_OF_TF[tf],
250 + "available_intervals": [INTERVAL_OF_TF[t] for t in avail]})
251 + after = decode_cursor(cursor)
252 + conds, params = [], list(paths)
253 + if lo is not None:
254 + conds.append("datetime >= ?"); params.append(lo)
255 + if hi is not None:
256 + conds.append("datetime < ?"); params.append(hi)
257 + if after is not None:
258 + conds.append("datetime > ?"); params.append(str(after))
259 + where = (" WHERE " + " AND ".join(conds)) if conds else " WHERE 1=1"
260 + cols = "* EXCLUDE (ticker)"
261 + sql = f"SELECT {cols} FROM ({merged_sql(paths)}){where}{_session_sql(cs.root, sess, tf)} ORDER BY datetime LIMIT ?"
262 + params.append(limit + 1)
263 + df = con().execute(sql, params).df()
264 + has_more = len(df) > limit
265 + df = df.iloc[:limit]
266 + next_cursor = encode_cursor(str(df["datetime"].iloc[-1])) if has_more and not df.empty else None
267 + meta = {"symbol": cs.short, "root": cs.root, "interval": INTERVAL_OF_TF[tf], "session": sess, "timezone": "UTC",
268 + "next_cursor": next_cursor, "expiration_date": str(row.get("expiration_date")) if row.get("expiration_date") else None,
269 + "expiration_source": row.get("expiration_source"), "status": row.get("status")}
270 + df.insert(0, "symbol", cs.short)
271 + return to_utc(df, tf), meta
272 +
273 +
274 +# ---- daily cache per root (chains, rolls, term structure) ----------------------------------------------------
275 +
276 +def root_daily(root: str) -> pd.DataFrame:
277 + """Merged daily bars of every contract of a root: symbol, date, open, high, low, close, volume, open_interest."""
278 + def build() -> pd.DataFrame:
279 + paths: list[str] = []
280 + for sym in lake.symbols_of_root(root):
281 + paths.extend(files_for(sym, "1day"))
282 + if not paths:
283 + return pd.DataFrame(columns=["symbol", "date", "open", "high", "low", "close", "volume", "open_interest"])
284 + sql = r"""
285 + WITH raw AS (
286 + SELECT replace(regexp_extract(filename, '([A-Z0-9]+_[FGHJKMNQUVXZ][0-9]{2})_1day\.parquet$', 1), '_', '') AS symbol,
287 + CASE WHEN filename LIKE '%/update/%' THEN 0 ELSE 1 END AS prio,
288 + datetime, open, high, low, close, volume, open_interest
289 + FROM read_parquet(?, filename=true, union_by_name=true)
290 + )
291 + SELECT symbol, CAST(datetime AS DATE) AS date, open, high, low, close, volume, open_interest FROM raw
292 + QUALIFY row_number() OVER (PARTITION BY symbol, datetime ORDER BY prio) = 1
293 + ORDER BY symbol, datetime
294 + """
295 + df = con().execute(sql, [paths]).df()
296 + df["date"] = pd.to_datetime(df["date"])
297 + return df
298 + return cached(f"futures|daily|{root}", build)
299 +
300 +
301 +def _as_of(value: str | None) -> date:
302 + return parse_date(value, "as_of") or date.today()
303 +
304 +
305 +def _live_contracts(root: str, as_of: date) -> list[dict[str, Any]]:
306 + """Contracts with data started on/before as_of and not yet expired at as_of (expiration ≥ as_of)."""
307 + out = []
308 + for c in _root_contracts(root):
309 + fd, ex = c.get("first_data_date"), c.get("expiration_date")
310 + if fd is None or ex is None:
311 + continue
312 + if fd <= as_of <= ex:
313 + out.append(c)
314 + out.sort(key=lambda c: (c["expiration_date"], c["symbol"]))
315 + return out
316 +
317 +
318 +def _last_bar_as_of(daily: pd.DataFrame, symbol: str, as_of: date, max_age_days: int = 7) -> dict[str, Any] | None:
319 + d = daily[(daily["symbol"] == symbol) & (daily["date"] <= pd.Timestamp(as_of))]
320 + if d.empty:
321 + return None
322 + last = d.iloc[-1]
323 + if (pd.Timestamp(as_of) - last["date"]).days > max_age_days:
324 + return None
325 + return {"date": last["date"].date(), "close": float(last["close"]), "volume": float(last["volume"]) if pd.notna(last["volume"]) else None,
326 + "open_interest": float(last["open_interest"]) if pd.notna(last.get("open_interest")) else None}
327 +
328 +
329 +def chain(root: str, as_of: str | None) -> tuple[list[dict[str, Any]], dict[str, Any]]:
330 + r = resolve_root(root)
331 + asof = _as_of(as_of)
332 + live = _live_contracts(r, asof)
333 + daily = root_daily(r)
334 + rows = []
335 + for i, c in enumerate(live, start=1):
336 + bar = _last_bar_as_of(daily, c["symbol"], asof)
337 + rows.append({"position": i, "symbol": c["symbol"], "contract_month": c["contract_month"], "contract_year": c["contract_year"],
338 + "expiration_date": str(c["expiration_date"]), "expiration_source": c["expiration_source"],
339 + "first_notice_date": str(c["first_notice_date"]) if c.get("first_notice_date") else None,
340 + "days_to_expiry": (c["expiration_date"] - asof).days, "status": c["status"],
341 + "last_date": str(bar["date"]) if bar else None, "close": bar["close"] if bar else None,
342 + "volume": bar["volume"] if bar else None, "open_interest": bar["open_interest"] if bar else None,
343 + "volume_avg_daily": c.get("volume_avg_daily")})
344 + meta = {"root": r, "as_of": str(asof), "front": rows[0]["symbol"] if rows else None,
345 + "note": None if rows else "no contract live at as_of (before first data or all expired)"}
346 + return rows, meta
347 +
348 +
349 +def term_structure(root: str, as_of: str | None) -> tuple[list[dict[str, Any]], dict[str, Any]]:
350 + rows, meta = chain(root, as_of)
351 + asof = date.fromisoformat(meta["as_of"])
352 + priced = [x for x in rows if x["close"] is not None]
353 + front = priced[0] if priced else None
354 + for x in rows:
355 + x["settle"] = x.pop("close")
356 + x["slope_annualized"] = None
357 + x["spread_vs_front"] = None
358 + if front and x["settle"] is not None and x is not front:
359 + dte_gap = x["days_to_expiry"] - front["days_to_expiry"]
360 + x["spread_vs_front"] = round(x["settle"] - front["settle"], 10)
361 + if dte_gap > 0 and front["settle"]:
362 + x["slope_annualized"] = round((x["settle"] / front["settle"] - 1.0) * 365.0 / dte_gap, 8)
363 + x.pop("volume_avg_daily", None)
364 + structure = None
365 + if len(priced) >= 2:
366 + structure = "contango" if priced[1]["settle"] > priced[0]["settle"] else ("backwardation" if priced[1]["settle"] < priced[0]["settle"] else "flat")
367 + last = priced[-1] if len(priced) >= 2 else None
368 + meta.update({"structure": structure, "front": front["symbol"] if front else None,
369 + "front_settle": front["settle"] if front else None,
370 + "curve_slope_annualized": last["slope_annualized"] if last else None,
371 + "priced_contracts": len(priced), "settle_note": "settle = last daily close at or before as_of (≤ 7 days old), else null",
372 + "as_of": str(asof)})
373 + return rows, meta
374 +
375 +
376 +# ---- continuous -------------------------------------------------------------------------------------------
377 +
378 +def _schedule(root: str, roll: str, depth: int) -> tuple[list[Segment], list[Segment], list[dict[str, Any]], list[dict[str, Any]]]:
379 + """(front segments, depth segments, contracts, roll_dates with gaps) — cached per root/roll/depth."""
380 + def build():
381 + contracts_ = _root_contracts(root)
382 + daily = root_daily(root)
383 + spec = spec_for(root)
384 + front = roll_schedule(contracts_, daily, roll, spec.calendar)
385 + segs = depth_schedule(front, contracts_, depth)
386 + rolls = roll_gaps(segs, daily)
387 + return front, segs, contracts_, rolls
388 + return cached(f"futures|schedule|{root}|{roll}|{depth}", build)
389 +
390 +
391 +def continuous(root: str, roll: str | None, adjust: str | None, depth: int | None, interval: str | None,
392 + from_: str | None, to: str | None, session_: str | None, cursor: str | None, limit: int,
393 + timeframe: str | None = None) -> tuple[pd.DataFrame, dict[str, Any]]:
394 + r = resolve_root(root)
395 + tf = parse_interval(interval, timeframe)
396 + sess = parse_session(session_)
397 + roll_m = parse_choice(roll, "roll", ROLL_METHODS, "volume")
398 + adj = parse_choice(adjust, "adjust", ADJUSTMENTS, "none")
399 + dep = parse_depth(depth)
400 + lo, hi = parse_bound(from_, "from"), parse_bound(to, "to", end=True)
401 + after = decode_cursor(cursor)
402 + _front, segs, _contracts, rolls = _schedule(r, roll_m, dep)
403 + if not segs:
404 + raise ApiError(404, "ROOT_NOT_FOUND", f"No contracts with metadata for root {r}. Run the contracts backfill.",
405 + details={"root": r})
406 + add, mul = adjustment_offsets(rolls, adj)
407 + # segments intersecting the requested window
408 + lo_d = lo.date() if lo else None
409 + hi_d = (hi - timedelta(microseconds=1)).date() if hi else None
410 + after_dt = pd.Timestamp(after) if after is not None else None
411 + frames: list[pd.DataFrame | None] = [None] * len(segs)
412 + if tf == "1day":
413 + daily = root_daily(r)
414 + for i, s in enumerate(segs):
415 + if (hi_d and s.start > hi_d) or (lo_d and s.end is not None and s.end < lo_d):
416 + continue
417 + f = daily[daily["symbol"] == s.symbol]
418 + f = f[f["date"] >= pd.Timestamp(s.start)]
419 + if s.end is not None:
420 + f = f[f["date"] <= pd.Timestamp(s.end)]
421 + if lo_d:
422 + f = f[f["date"] >= pd.Timestamp(lo_d)]
423 + if hi_d:
424 + f = f[f["date"] <= pd.Timestamp(hi_d)]
425 + if after_dt is not None:
426 + f = f[f["date"] > after_dt]
427 + frames[i] = f.rename(columns={"date": "datetime"})
428 + else:
429 + parts, params = [], []
430 + sess_sql = _session_sql(r, sess, tf)
431 + for i, s in enumerate(segs):
432 + if (hi_d and s.start > hi_d) or (lo_d and s.end is not None and s.end < lo_d):
433 + continue
434 + paths = files_for(s.symbol, tf)
435 + if not paths:
436 + continue
437 + seg_lo = datetime.combine(max(s.start, lo_d) if lo_d else s.start, datetime.min.time())
438 + seg_hi = None
439 + if s.end is not None or hi_d:
440 + e = s.end if hi_d is None else (hi_d if s.end is None else min(s.end, hi_d))
441 + seg_hi = datetime.combine(e + timedelta(days=1), datetime.min.time())
442 + conds = ["datetime >= ?"]
443 + p = [*paths, seg_lo]
444 + if seg_hi is not None:
445 + conds.append("datetime < ?"); p.append(seg_hi)
446 + if after is not None:
447 + conds.append("datetime > ?"); p.append(str(after))
448 + parts.append(f"SELECT {i} AS _seg, '{s.symbol}' AS symbol, datetime, open, high, low, close, volume "
449 + f"FROM ({merged_sql(paths)}) WHERE {' AND '.join(conds)}{sess_sql}")
450 + params.extend(p)
451 + if parts:
452 + sql = " UNION ALL ".join(parts) + " ORDER BY datetime LIMIT ?"
453 + params.append(limit + 1)
454 + df_all = con().execute(sql, params).df()
455 + for seg_idx, g in df_all.groupby("_seg", sort=False):
456 + frames[int(seg_idx)] = g.drop(columns="_seg")
457 + df = apply_adjustment(frames, add, mul)
458 + if "symbol" in df.columns:
459 + df = df[["symbol", *[c for c in df.columns if c != "symbol"]]]
460 + has_more = len(df) > limit
461 + df = df.iloc[:limit]
462 + next_cursor = encode_cursor(str(df["datetime"].iloc[-1])) if has_more and not df.empty else None
463 + # roll dates: those falling inside the returned window (plus adjustment info)
464 + if not df.empty:
465 + first_d, last_d = pd.Timestamp(df["datetime"].iloc[0]).date(), pd.Timestamp(df["datetime"].iloc[-1]).date()
466 + shown = [x for x in rolls if first_d <= date.fromisoformat(x["date"]) <= last_d + timedelta(days=1)]
467 + else:
468 + shown = []
469 + meta = {"root": r, "interval": INTERVAL_OF_TF[tf], "roll": roll_m, "adjust": adj, "depth": dep, "session": sess,
470 + "timezone": "UTC", "next_cursor": next_cursor, "roll_dates": shown, "rolls_total": len(rolls),
471 + "segments": [{"symbol": s.symbol, "start": str(s.start), "end": str(s.end) if s.end else None} for s in segs
472 + if (not df.empty and (s.end is None or s.end >= first_d) and s.start <= last_d)],
473 + "unadjusted_symbol": segs[-1].symbol,
474 + "adjustment_note": {"none": None,
475 + "back_adjusted": "additive: bars before each roll shifted by the cumulative close gap (new − old) so the latest contract is unadjusted",
476 + "ratio_adjusted": "multiplicative: bars before each roll scaled by the cumulative close ratio (new / old)"}[adj]}
477 + return to_utc(df, tf), meta
478 +
479 +
480 +# ---- coverage ----------------------------------------------------------------------------------------------
481 +
482 +def coverage(symbol: str) -> dict[str, Any]:
483 + cs, row = get_contract(symbol)
484 + idx = lake.file_index().get(cs.short, {})
485 + tfs = row.get("timeframes") or {}
486 + per_tf = {}
487 + for tf in TIMEFRAMES:
488 + info = tfs.get(tf) if isinstance(tfs, dict) else None
489 + files = idx.get(tf, {})
490 + if not files:
491 + per_tf[INTERVAL_OF_TF[tf]] = {"available": False, "reason": "no file in the lake for this timeframe"}
492 + continue
493 + per_tf[INTERVAL_OF_TF[tf]] = {"available": True, "first": (info or {}).get("first"), "last": (info or {}).get("last"),
494 + "rows": (info or {}).get("rows"),
495 + "files": {b: p for b, p in files.items()},
496 + "open_interest": tf == "1day"}
497 + with session() as s:
498 + gaps = s.execute(select(FuturesContractGap).where(FuturesContractGap.symbol == cs.short)
499 + .order_by(FuturesContractGap.gap_start)).scalars().all()
500 + notes = []
501 + if row.get("expiration_source") == "data":
502 + notes.append("expiration_date = last data date (no exchange rule for this root)")
503 + if row.get("first_notice_date") is None and row.get("settlement_type") == "physical":
504 + notes.append("first_notice_date unknown for this root (null)")
505 + if row.get("files") is None:
506 + notes.append("metadata backfill not run for this contract: only lake files are reported")
507 + return {"symbol": cs.short, "root": cs.root, "status": row.get("status"),
508 + "expiration_date": str(row["expiration_date"]) if row.get("expiration_date") else None,
509 + "expiration_source": row.get("expiration_source"),
510 + "last_trading_date": str(row["last_trading_date"]) if row.get("last_trading_date") else None,
511 + "first_notice_date": str(row["first_notice_date"]) if row.get("first_notice_date") else None,
512 + "first_data_date": str(row["first_data_date"]) if row.get("first_data_date") else None,
513 + "last_data_date": str(row["last_data_date"]) if row.get("last_data_date") else None,
514 + "bars_1day": row.get("bars_1day"), "volume_avg_daily": row.get("volume_avg_daily"),
515 + "open_interest_last": row.get("open_interest_last"),
516 + "intervals": per_tf,
517 + "gaps": [{"interval": INTERVAL_OF_TF.get(g.timeframe, g.timeframe), "start": str(g.gap_start), "end": str(g.gap_end),
518 + "business_days_missing": g.bars_missing} for g in gaps],
519 + "notes": notes, "timezone_note": "intraday bars are converted from US/Eastern to UTC; daily bars are dates"}
520