Futures v2 — individual contracts, chains, continuous series, term structure
Design notes for chantier 1 (hfmarketdata/api/futures/). The web team turns this into the
"Futures individual contracts" guide; the reference documentation is generated from /openapi.json.
1. Endpoints
| Endpoint | Purpose |
|---|---|
GET /v1/futures/roots |
142 roots with reference specs (name, exchange, asset class, size, tick, settlement, expiry rule, month cycle, RTH window, aliases, coverage). |
GET /v1/futures/{root}/contracts |
Contracts of a root: expiration (+ source), last trading / first notice, real data range, status, 20-session volume, last OI, intervals, files. |
GET /v1/futures/contract/{symbol}/bars |
OHLCV of one contract, `interval=1m |
GET /v1/futures/contract/{symbol}/coverage |
What the lake really holds (per interval), gaps, explicit reasons for nulls. |
GET /v1/futures/{root}/chain?as_of= |
Live contracts on a date, ordered by expiration, with last close/volume/OI. |
GET /v1/futures/{root}/continuous?roll=&adjust=&depth= |
Continuous series stitched from individual contracts with a reproducible roll schedule (meta.roll_dates). |
GET /v1/futures/{root}/term-structure?as_of= |
Forward curve: settle per contract, spread and annualised slope vs front, contango/backwardation flag. |
Common: {"data": [...], "meta": {"count", "next_cursor", ...}}, X-Row-Count, errors
400 INVALID_CONTRACT_SYMBOL, 404 CONTRACT_NOT_FOUND, 404 ROOT_NOT_FOUND, 400 INVALID_PARAMETER,
400 ROW_LIMIT_EXCEEDED (tier cap via request.state.max_rows). Limits: default 5 000 rows, max 200 000 (JSON),
2 000 000 (CSV/Parquet). continuous has request_cost = 2.
2. Symbols and root aliases (symbols.py)
Accepted: ESZ25, ESZ2025, ES_Z25, ES-Z25, lower-case. Roots are 1–4 chars incl. digits (C, 6E, M2K,
FDAX, SR3). Month codes F G H J K M N Q U V X Z. Two-digit years 00–79 → 20xx, 80–99 → 19xx.
The canonical root is the lake (FirstRate) code because the files are named that way. CME codes are aliases:
| CME | lake | product | CME | lake | product | |
|---|---|---|---|---|---|---|
| 6E | E6 | Euro FX | 6N | N6 | New Zealand dollar | |
| 6J | J1 | Japanese yen (full) | 6M | MP | Mexican peso | |
| 6B | B6 | British pound | 6L | BR | Brazilian real | |
| 6A | A6 | Australian dollar | 6Z | T6 | South African rand | |
| 6C | AD | Canadian dollar | ZB | US | 30-year T-bond | |
| 6S | E1 | Swiss franc | EMD | EW | E-mini S&P MidCap 400 | |
| BRN | B | ICE Brent |
J7 and E7 in the lake are the E-mini yen / euro (distinct products, not aliases of 6J/6E).
Unknown lake roots (no reference entry, not in meta/futures/futures.csv): CPO FBTM FID FNMY FOAM JB JG NK RU ST TWN ZK
→ source="derived", expiry_rule="data", name null.
Responses always show the canonical form (E6Z25); /roots lists aliases per root.
3. Reference table (specs.py)
~120 roots with exchange specs (contract size + unit, tick size/value, settlement, month cycle, expiry rule,
first-notice rule, business-day calendar us|eurex, RTH window in US/Eastern). Fields not known with certainty are
null — never invented. Roots present in the lake but absent from the table get a derived placeholder.
RTH windows (Eastern): equity index 09:30–16:00 (default), CL/NG/HO/RB 09:00–14:30, GC/SI/HG 08:20–13:30, treasuries & FX & DX 08:20–15:00, grains 09:30–14:20, livestock 09:30–14:05, Eurex 03:00–11:30, ICE softs product-specific (cotton is an overnight window 21:00–14:20, handled as wrap-around).
4. Expiry rules (expiry.py, calendar in calendar_us.py)
US calendar (CME/NYSE): New Year (Sun→Mon, Sat→none), MLK, Presidents, Good Friday, Memorial, Juneteenth (≥ 2022, observed), Independence (observed), Labor, Thanksgiving, Christmas (observed). Eurex: 1 Jan, Good Friday, Easter Monday, 1 May, 24–26 Dec, 31 Dec. Weather / state-funeral NYSE closures are not included (CME futures kept trading).
| rule | roots | definition | verified |
|---|---|---|---|
third_friday |
ES NQ YM RTY MES MNQ M2K EW ESG XA* MFS MME | 3rd Friday of the contract month (preceding business day if holiday) | ESZ24 → 2024-12-20 |
third_friday_eurex |
FDAX FESX FDXM FDXS FXXP FCE FTI FTUK … | same, Eurex calendar | FDAXZ24 → 2024-12-20 |
second_friday_minus_1 |
NKD NIY | business day before the 2nd Friday | NKDZ24 → 2024-12-12 |
cl_rule |
CL | 3 business days before the 25th of the preceding month (25th → prior business day if not one) | CLZ24 → 2024-11-20, CLF25 → 2024-12-19 |
cl_minus_1 |
MCL | one business day before CL | MCLZ24 → 2024-11-19 |
ng_rule |
NG HH | 3 business days before the 1st of the contract month | NGZ24 → 2024-11-26 |
ng_minus_1 |
QG | one business day before NG | — |
prior_month_last_business_day |
HO RB SB BR | last business day of the preceding month | HOZ24 → 2024-11-29, SBH25 → 2025-02-28 |
bz_rule |
BZ B | last business day of the 2nd preceding month | BZZ24 → 2024-10-31 |
metals_rule |
GC SI HG PL PA MGC SIL ALI | 3rd last business day of the contract month | GCZ24 → 2024-12-27, PLF25 → 2025-01-29 |
treasury_rule |
ZN US(ZB) UB TN | 7th business day before the last business day | ZNZ24 → 2024-12-19 |
last_business_day |
ZT ZF ZQ SR1 LE HRC | last business day of the contract month | ZTZ24 → 2024-12-31, LEZ24 → 2024-12-31 |
grains_rule |
ZC ZS ZW KE ZM ZL ZO ZR XC RS | business day before the 15th | ZCZ24 → 2024-12-13 |
fx_rule |
E6 J1 B6 A6 AD E1 N6 MP T6 E7 J7 NOK SEK RP RY PJY CNH DX | 2nd business day before the 3rd Wednesday | E6Z24 → 2024-12-16 |
vx_rule |
VX VXM FVSA | Wednesday 30 days before the 3rd Friday of the following month (holiday-adjusted) | VXZ24 → 2024-12-18, VXH25 → 2025-03-18 |
last_friday |
BTC MBT MET | last Friday of the contract month | BTCZ24 → 2024-12-27 |
he_rule |
HE PRK | 10th business day of the contract month | HEZ24 → 2024-12-13 |
gf_rule |
GF | last Thursday (Nov: Thursday before Thanksgiving) | GFX24 → 2024-11-21 |
kc_rule / cc_rule / ct_rule / oj_rule |
KC / CC C / CT / OJ | 8 / 11 / 16 / 14 business days before the last business day | KCZ24 12-18, CCZ24 12-13, CTZ24 12-06, OJF25 2025-01-10 |
bund_rule |
FGBL FGBM FGBS FGBX FOAT FBTP FBTS FBON | 2 exchange days before the 10th (delivery day) | FGBLZ24 → 2024-12-06 |
sr3_rule |
SR3 | business day before the 3rd Wednesday of the 3rd following month | SR3Z24 → 2025-03-18 |
data |
everything else | expiration_date = last_data_date, expiration_source="data" |
First notice: treasuries / grains / metals → last business day of the preceding month; CL → business day after the
last trade; KC → 7 business days before the first business day of the contract month; cash-settled → null.
Every contract row records expiration_source (rule | data) and last_trading_date (null when data).
Known lake quirks: FirstRate sometimes keeps 1–2 post-expiry rows (HEZ24 last bar 12-17 vs rule 12-13, BZZ24 11-01 vs
10-31, GFX24 11-22 vs 11-21, ZQZ24 2025-01-02 vs 12-31). The rule wins for expiration_date; last_data_date is
reported separately in /coverage.
5. Metadata tables (models.py) and backfill (backfill.py, scripts/backfill_contracts.py)
futures_roots, futures_contracts, futures_contract_gaps as in UPGRADE-PLAN §1.1–1.3, plus documented extras:
roots.aliases/contract_size_unit/first_notice_rule/calendar/rth_start/rth_end, contracts.expiration_source/bars_1day,
and contracts.timeframes is a JSON object {tf: {first, last, rows}} (needed by /coverage).
Backfill = one DuckDB aggregate per {tf}/{archive|update} directory (read_parquet('dir/*.parquet', filename=true),
min/max/count on datetime only), one scan of all 1day files (archive ∪ update, dedup on datetime, update wins)
for 20-session volume, last OI and session lists (gaps > 3 business days), then SQLite upserts. Status: expired when
last_data_date < today − 7 and the contract month/expiration has passed, else active. --roots ES,CL and
--since YYYY-MM-DD (mtime filter) give incremental runs; run_backfill() is the library entry point.
Production (M3U96b):
ssh M3U96b
cd ~/hfmarketdata && HFMD_DATA_ROOT=~/firstratedata venv/bin/python scripts/backfill_contracts.py -v
# nightly refresh (PM2 cron or launchd): --since $(date -v-2d +%F) 6. Sessions and time zones (service.py)
Lake timestamps are naive US/Eastern. Intraday output is localised with zoneinfo("America/New_York") (DST-aware)
and emitted in UTC (2024-12-19T14:30:00Z); daily bars stay dates. from/to: YYYY-MM-DD = Eastern trading
dates (inclusive), ISO datetimes with Z/offset are converted, naive datetimes are UTC. session=rth keeps bars whose
start time is in the root's RTH window; eth the complement; ignored for 1d. Cursor = last bar datetime.
7. Continuous series (rolls.py)
Contracts of the root are ordered by expiration_date; the front holds contract k from the previous roll until
roll_k, the first session on which contract k+1 is the front:
calendar— held through the expiration date; roll on the next session.first_notice— roll onfirst_notice_date(held through the session before), else calendar.volume(default) /open_interest— roll after the 2nd consecutive session where the next contract's metric exceeds the front's, evaluated on daily data in the last 30 sessions of the front (far-dated noise cannot trigger a roll a year early); never later than the calendar roll.
depth=2|3 uses the same windows with the 2nd/3rd contract of the sequence. Intraday intervals apply the daily
schedule by Eastern calendar date of the bar (evening-session bars of day D belong to D — documented simplification).
Contracts already expired when the previous roll happens are skipped.
Adjustments — worked example
Three contracts, closes: A = 100 (flat), B = 105, C = 110. Calendar rolls A→B on 2024-03-18 and B→C on 2024-06-24.
- gap_k = close_new − close_old on the last session of the old segment where both traded (nearest common session within 5 sessions): gap₁ = 105 − 100 = 5 (session 2024-03-15), gap₂ = 110 − 105 = 5.
back_adjusted(additive): segment offsets are the cumulative gaps of later rolls: C: 0, B: +5, A: +10. A bars become 110, B bars 110, C unchanged → no jump at the rolls; the latest contract is always unadjusted.ratio_adjusted(multiplicative): factors 105/100 and 110/105; A × 1.05 × 1.0476 = 110, B × 1.0476 = 110.- Volume and open interest are never adjusted. If no common session exists,
gap: null,adjusted: falseand that roll contributes nothing (no invented value).
meta.roll_dates lists {date, from_symbol, to_symbol, gap, ratio, gap_session, adjusted} for rolls inside the
returned window; meta.segments, meta.rolls_total and meta.unadjusted_symbol complete the picture; each row carries
its source symbol.
8. Term structure
For every live contract on as_of: settle = last daily close ≤ as_of (≤ 7 days old, else null),
spread_vs_front = settle − settle_front, slope_annualized = (settle / settle_front − 1) × 365 / (dte − dte_front).
meta.structure = contango if the 2nd contract settles above the front, backwardation below, flat equal.
9. Tests
tests/test_futures_symbols.py (parsing, aliases, bad input), tests/test_futures_expiry.py (calendar + 39 real dates),
tests/test_futures_rolls.py (schedules, depth, gaps, adjustment math, gap detection), tests/test_futures_api.py
(backfill → 7 endpoints → formats → pagination → errors → OpenAPI), tests/test_futures_perf.py (10 000 bars < 300 ms,
-m "not slow" to skip). Fixture lake: tests/fixtures/make_fixtures.py (ES/CL/NG/E6 contracts with realistic
volume/OI life-cycles and ETH bars).
10. Known limitations
- Expiry rules use one US (CME) calendar and one Eurex calendar; ICE Europe/UK, Euronext, HKEX bank holidays are not
modelled (rare 1-day differences possible on London cocoa
C, BrentB,CNH). Products without an implemented rule (Nikkei variants excepted) fall back todata. - First-notice dates are only computed for treasuries, grains, metals, CL and KC.
- Intraday continuous series attribute evening-session bars to the calendar date of the bar (not the CME trading date).
- Volume/OI rolls are evaluated inside the last 30 sessions of the front; products whose liquidity migrates earlier
(e.g. some ags around FND) should use
first_notice. contract_size/tickvalues are null for ~25 minor roots (select-sector minis, small Eurex indices, Euribor…).