futures: lac synthétique enrichi (cycle de vie volume/OI, ETH, racine E6) et tests unitaires, intégration et performance
- make_fixtures.py : contrats ES/CL/NG/E6 avec échéances réalistes, volume/OI qui montent puis s'effondrent, bloc ETH 08:00–09:29, 30 séances intraday sur ES - test_futures_symbols/expiry/rolls : parsing + alias, 39 dates d'échéance réelles, calendriers de roll et maths d'ajustement sur séries synthétiques - test_futures_api : backfill → 7 endpoints → json/csv/parquet → curseur → erreurs → plafond de lignes par tier → OpenAPI - test_futures_perf : 10 000 barres (contrat, continu, parquet) < 300 ms (marqueur slow) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 changed files +867 −19
modified
pytest.ini
+2 −0
@@ -2,6 +2,8 @@ | ||
| 2 | 2 | testpaths = tests |
| 3 | 3 | pythonpath = hfmarketdata/api |
| 4 | 4 | asyncio_mode = auto |
| 5 | +markers = | |
| 6 | + slow: timing-sensitive checks (deselect with -m "not slow") | |
| 5 | 7 | filterwarnings = |
| 6 | 8 | ignore::DeprecationWarning |
| 7 | 9 | addopts = -q |
modified
tests/fixtures/make_fixtures.py
+62 −19
@@ -7,12 +7,15 @@ | ||
| 7 | 7 | meta/futures/futures.csv |
| 8 | 8 | |
| 9 | 9 | Every bar file carries a `ticker` column (root only for futures contracts — the contract identity is |
| 10 | −in the file name, exactly like the real lake). | |
| 10 | +in the file name, exactly like the real lake). Futures contracts get a realistic life-cycle: volume and | |
| 11 | +open interest ramp up towards expiry and collapse in the last sessions (so volume/OI rolls are testable), | |
| 12 | +daily data ends on the exchange expiry (3rd Friday for ES/E6, 20th of the preceding month for CL/NG), | |
| 13 | +intraday bars cover RTH 09:30–16:00 plus a pre-market ETH block 08:00–09:29 (Eastern, naive). | |
| 11 | 14 | """ |
| 12 | 15 | from __future__ import annotations |
| 13 | 16 | |
| 14 | 17 | import csv |
| 15 | −from datetime import date, datetime, timedelta | |
| 18 | +from datetime import date, timedelta | |
| 16 | 19 | from pathlib import Path |
| 17 | 20 | |
| 18 | 21 | import numpy as np |
@@ -22,27 +25,64 @@ TIMEFRAMES = ["1min", "5min", "30min", "1hour", "1day"] | ||
| 22 | 25 | TICKERS = {"stock": ["AAPL", "MSFT", "SMCP"], "etf": ["SPY"], "crypto": ["BTCUSD"], "index": ["SPX"], "fx": ["EURUSD"]} |
| 23 | 26 | ADJ = {"stock": ["adj_split", "adj_splitdiv", "UNADJUSTED"], "etf": ["adj_split", "adj_splitdiv", "UNADJUSTED"], |
| 24 | 27 | "crypto": ["none"], "index": ["none"], "fx": ["none"]} |
| 25 | −ROOTS = ["ES", "CL", "NG"] | |
| 28 | +ROOTS = ["ES", "CL", "NG", "E6"] | |
| 26 | 29 | MONTHS = {"F": 1, "G": 2, "H": 3, "J": 4, "K": 5, "M": 6, "N": 7, "Q": 8, "U": 9, "V": 10, "X": 11, "Z": 12} |
| 27 | −CYCLE = {"ES": "HMUZ", "CL": "FGHJKMNQUVXZ", "NG": "FGHJKMNQUVXZ"} | |
| 30 | +CYCLE = {"ES": "HMUZ", "CL": "FJNV", "NG": "FJNV", "E6": "HMUZ"} # CL/NG: quarterly subset to keep the lake small | |
| 31 | +INTRADAY_DAYS = {"ES": 30} # sessions of intraday history per contract (default 5) | |
| 28 | 32 | |
| 29 | 33 | |
| 30 | −def _bars(ticker: str, start: date, end: date, tf: str, seed: int, oi: bool = False) -> pd.DataFrame: | |
| 34 | +def _third_friday(y: int, m: int) -> date: | |
| 35 | + d = date(y, m, 15) | |
| 36 | + return d + timedelta(days=(4 - d.weekday()) % 7) | |
| 37 | + | |
| 38 | + | |
| 39 | +def contract_expiry(root: str, yy: int, month_code: str) -> date: | |
| 40 | + y, m = 2000 + yy, MONTHS[month_code] | |
| 41 | + if root in ("ES", "E6"): | |
| 42 | + return _third_friday(y, m) | |
| 43 | + pm = m - 1 or 12 | |
| 44 | + return date(y - (m == 1), pm, 20) | |
| 45 | + | |
| 46 | + | |
| 47 | +def _lifecycle(dte: np.ndarray, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]: | |
| 48 | + """(volume, open_interest) as a function of days to expiry: ramp up, collapse in the last sessions.""" | |
| 49 | + w = np.where(dte > 8, (150.0 - dte) / 100.0, 1.42 * (dte / 8.0) ** 2 + 0.02) | |
| 50 | + w = np.clip(w, 0.02, None) * rng.uniform(0.97, 1.03, len(dte)) | |
| 51 | + vol = np.round(10_000 * w) | |
| 52 | + oi = np.clip((200.0 - dte) / 150.0, 0.05, 1.0) * np.where(dte > 10, 1.0, dte / 10.0) | |
| 53 | + oi = np.round(100_000 * oi * rng.uniform(0.98, 1.02, len(dte))) | |
| 54 | + return vol, oi | |
| 55 | + | |
| 56 | + | |
| 57 | +def _bars(ticker: str, start: date, end: date, tf: str, seed: int, oi: bool = False, base: float = 100.0, | |
| 58 | + expiry: date | None = None, eth: bool = False, days: int = 5) -> pd.DataFrame: | |
| 31 | 59 | rng = np.random.default_rng(seed) |
| 32 | 60 | if tf == "1day": |
| 33 | 61 | idx = pd.bdate_range(start, end) |
| 34 | 62 | else: |
| 35 | 63 | step = {"1min": 1, "5min": 5, "30min": 30, "1hour": 60}[tf] |
| 36 | − days = pd.bdate_range(start, end)[-5:] # intraday: last 5 sessions only, RTH 09:30-16:00 ET | |
| 37 | − idx = pd.DatetimeIndex([d + timedelta(hours=9, minutes=30) + timedelta(minutes=step * i) | |
| 38 | − for d in days for i in range(int(390 / step))]) | |
| 64 | + sessions = pd.bdate_range(start, end)[-days:] | |
| 65 | + starts = [timedelta(hours=9, minutes=30)] | |
| 66 | + counts = [int(390 / step)] | |
| 67 | + if eth and step <= 30: # pre-market block 08:00–09:29 | |
| 68 | + starts.insert(0, timedelta(hours=8)) | |
| 69 | + counts.insert(0, int(90 / step)) | |
| 70 | + idx = pd.DatetimeIndex([d + s + timedelta(minutes=step * i) for d in sessions for s, n in zip(starts, counts) for i in range(n)]) | |
| 39 | 71 | n = len(idx) |
| 40 | − close = 100 + np.cumsum(rng.normal(0, 1, n)) | |
| 72 | + close = base + np.cumsum(rng.normal(0, 1, n)) | |
| 41 | 73 | df = pd.DataFrame({"ticker": ticker, "datetime": idx, "open": close + rng.normal(0, .2, n), |
| 42 | 74 | "high": close + abs(rng.normal(0, .5, n)), "low": close - abs(rng.normal(0, .5, n)), |
| 43 | − "close": close, "volume": rng.integers(100, 10_000, n).astype(float)}) | |
| 44 | − if oi: | |
| 45 | − df["open_interest"] = rng.integers(1_000, 100_000, n).astype(float) | |
| 75 | + "close": close}) | |
| 76 | + if expiry is not None: | |
| 77 | + dte = np.array([(expiry - d.date()).days for d in idx], dtype=float) | |
| 78 | + vol, oi_v = _lifecycle(dte, rng) | |
| 79 | + df["volume"] = vol if tf == "1day" else np.round(vol / 400) | |
| 80 | + if oi: | |
| 81 | + df["open_interest"] = oi_v | |
| 82 | + else: | |
| 83 | + df["volume"] = rng.integers(100, 10_000, n).astype(float) | |
| 84 | + if oi: | |
| 85 | + df["open_interest"] = rng.integers(1_000, 100_000, n).astype(float) | |
| 46 | 86 | return df |
| 47 | 87 | |
| 48 | 88 | |
@@ -69,10 +109,10 @@ def build_lake(root: Path, start: date = date(2023, 1, 2), end: date = date(2025 | ||
| 69 | 109 | # individual contracts: archive = up to 2024, update = 2025+ (with overlap for 2025 contracts) |
| 70 | 110 | for tf in TIMEFRAMES: |
| 71 | 111 | for r in ROOTS: |
| 112 | + base = {"ES": 5000.0, "CL": 70.0, "NG": 3.0, "E6": 1.08}[r] | |
| 72 | 113 | for yy in (23, 24, 25, 26): |
| 73 | − for mc in CYCLE[r][:: (1 if r == "ES" else 3)]: | |
| 74 | − exp_month = MONTHS[mc] | |
| 75 | − exp = date(2000 + yy, exp_month, 15) | |
| 114 | + for mc in CYCLE[r]: | |
| 115 | + exp = contract_expiry(r, yy, mc) | |
| 76 | 116 | first = exp - timedelta(days=400) |
| 77 | 117 | last = min(exp, end) |
| 78 | 118 | if first > end: |
@@ -81,12 +121,13 @@ def build_lake(root: Path, start: date = date(2023, 1, 2), end: date = date(2025 | ||
| 81 | 121 | d = pq / "futures_contracts" / tf / bucket |
| 82 | 122 | d.mkdir(parents=True, exist_ok=True) |
| 83 | 123 | seed += 1 |
| 84 | − _bars(r, max(first, date(2022, 1, 3)), last, tf, seed, oi=(tf == "1day")).to_parquet( | |
| 85 | − d / f"{r}_{mc}{yy}_{tf}.parquet", index=False) | |
| 86 | − if bucket == "update" and yy == 25: # archive also holds the first half of 2025 contracts | |
| 124 | + kw = dict(oi=(tf == "1day"), base=base + (yy * 4 + MONTHS[mc] / 3) * base / 400, expiry=exp, eth=True, | |
| 125 | + days=INTRADAY_DAYS.get(r, 5)) | |
| 126 | + _bars(r, max(first, date(2022, 1, 3)), last, tf, seed, **kw).to_parquet(d / f"{r}_{mc}{yy}_{tf}.parquet", index=False) | |
| 127 | + if bucket == "update" and yy == 25: # archive also holds the first part of 2025 contracts | |
| 87 | 128 | d2 = pq / "futures_contracts" / tf / "archive" |
| 88 | 129 | d2.mkdir(parents=True, exist_ok=True) |
| 89 | − _bars(r, max(first, date(2022, 1, 3)), min(last, date(2024, 12, 31)), tf, seed, oi=(tf == "1day")).to_parquet( | |
| 130 | + _bars(r, max(first, date(2022, 1, 3)), min(last, date(2024, 12, 31)), tf, seed, **kw).to_parquet( | |
| 90 | 131 | d2 / f"{r}_{mc}{yy}_{tf}.parquet", index=False) |
| 91 | 132 | # options: one quarter, one ticker |
| 92 | 133 | d = pq / "options" / "2025_q2" |
@@ -109,6 +150,8 @@ def build_lake(root: Path, start: date = date(2023, 1, 2), end: date = date(2025 | ||
| 109 | 150 | w.writerow(["ES", "E-mini S&P 500 (CME) ", "2008-01-02", str(end)]) |
| 110 | 151 | w.writerow(["CL", "Crude Oil WTI (NYMEX) ", "2008-01-02", str(end)]) |
| 111 | 152 | w.writerow(["NG", "Natural Gas (NYMEX) ", "2008-01-02", str(end)]) |
| 153 | + w.writerow(["E6", "Euro FX Futures (CME) ", "2008-01-02", str(end)]) | |
| 154 | + w.writerow(["ZK", "Unknown Product (XXX) ", "2008-01-02", str(end)]) | |
| 112 | 155 | (root / "state").mkdir(exist_ok=True) |
| 113 | 156 | |
| 114 | 157 | |
added
tests/test_futures_api.py
+384 −0
@@ -0,0 +1,384 @@ | ||
| 1 | +"""Integration tests — backfill + the 7 `/v1/futures/*` endpoints on the synthetic lake.""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +import io | |
| 5 | +import os | |
| 6 | +import subprocess | |
| 7 | +import sys | |
| 8 | +from datetime import date | |
| 9 | +from pathlib import Path | |
| 10 | + | |
| 11 | +import pandas as pd | |
| 12 | +import pytest | |
| 13 | + | |
| 14 | +TODAY = date(2025, 7, 1) # fixture lake ends 2025-06-30 | |
| 15 | + | |
| 16 | + | |
| 17 | +@pytest.fixture(scope="module") | |
| 18 | +def backfilled(app, lake): | |
| 19 | + from futures.backfill import run_backfill | |
| 20 | + s1 = run_backfill(today=TODAY) | |
| 21 | + s2 = run_backfill(today=TODAY) # idempotent | |
| 22 | + assert s1["contracts"] == s2["contracts"] > 0 | |
| 23 | + return s1 | |
| 24 | + | |
| 25 | + | |
| 26 | +# ---- backfill ------------------------------------------------------------------------------------------------ | |
| 27 | + | |
| 28 | +def test_backfill_summary_and_tables(backfilled, app): | |
| 29 | + from core.db import session | |
| 30 | + from futures.models import FuturesContract, FuturesRoot | |
| 31 | + from sqlalchemy import select | |
| 32 | + assert backfilled["rule_based"] == backfilled["contracts"] # ES, CL, NG, E6 all have exchange rules | |
| 33 | + assert backfilled["active"] > 0 and backfilled["seconds"] < 30 | |
| 34 | + with session() as s: | |
| 35 | + es = s.execute(select(FuturesContract).where(FuturesContract.symbol == "ESH25")).scalar_one() | |
| 36 | + assert es.expiration_date == date(2025, 3, 21) and es.expiration_source == "rule" | |
| 37 | + assert es.first_data_date is not None and es.last_data_date == date(2025, 3, 21) | |
| 38 | + assert es.status == "expired" and es.volume_avg_daily > 0 and es.open_interest_last > 0 | |
| 39 | + cl = s.execute(select(FuturesContract).where(FuturesContract.symbol == "CLJ25")).scalar_one() | |
| 40 | + assert cl.first_notice_date == date(2025, 3, 21) and cl.expiration_date == date(2025, 3, 20) | |
| 41 | + root = s.get(FuturesRoot, "ES") | |
| 42 | + assert root.source == "reference" and root.contracts_count >= 14 and root.exchange == "CME" | |
| 43 | + zk = s.get(FuturesRoot, "ZK") # from meta csv only → derived | |
| 44 | + assert zk.source == "derived" and zk.name == "Unknown Product" and zk.exchange == "XXX" and zk.expiry_rule == "data" | |
| 45 | + e6 = s.get(FuturesRoot, "E6") | |
| 46 | + assert '"6E"' in e6.aliases | |
| 47 | + | |
| 48 | + | |
| 49 | +def test_backfill_partial_roots(backfilled, app): | |
| 50 | + from futures.backfill import run_backfill | |
| 51 | + s = run_backfill(roots=["6E"], today=TODAY) # alias accepted through normalize in CLI; here lake root E6 expected | |
| 52 | + assert s["contracts"] == 0 # "6E" is not a lake root: nothing scanned… | |
| 53 | + s = run_backfill(roots=["E6"], today=TODAY) | |
| 54 | + assert s["contracts"] >= 8 and s["roots"] == 1 | |
| 55 | + | |
| 56 | + | |
| 57 | +def test_backfill_cli(lake): | |
| 58 | + env = {**os.environ, "HFMD_DATA_ROOT": str(lake), "HFMD_STATE_DB": str(lake / "state" / "hfmd.db"), "HFMD_ENV": "test"} | |
| 59 | + script = Path(__file__).resolve().parents[1] / "scripts" / "backfill_contracts.py" | |
| 60 | + r = subprocess.run([sys.executable, str(script), "--roots", "6E,ES", "--today", str(TODAY), "--json"], env=env, | |
| 61 | + capture_output=True, text=True, timeout=120, check=False) | |
| 62 | + assert r.returncode == 0, r.stderr | |
| 63 | + import json | |
| 64 | + out = json.loads(r.stdout.strip().splitlines()[-1]) | |
| 65 | + assert out["contracts"] >= 20 and out["rule_based"] == out["contracts"] | |
| 66 | + | |
| 67 | + | |
| 68 | +# ---- roots / contracts ---------------------------------------------------------------------------------------- | |
| 69 | + | |
| 70 | +def test_roots(client, backfilled): | |
| 71 | + r = client.get("/v1/futures/roots") | |
| 72 | + assert r.status_code == 200 | |
| 73 | + body = r.json() | |
| 74 | + roots = {x["root"]: x for x in body["data"]} | |
| 75 | + assert body["meta"]["count"] == len(roots) >= 120 | |
| 76 | + assert roots["ES"]["name"] == "E-mini S&P 500" and roots["ES"]["tick_value"] == 12.5 and roots["ES"]["expiry_rule"] == "third_friday" | |
| 77 | + assert roots["ES"]["contracts_count"] >= 14 and roots["ES"]["first_data_date"] and roots["ES"]["source"] == "reference" | |
| 78 | + assert roots["E6"]["aliases"] == ["6E"] and roots["US"]["aliases"] == ["ZB"] | |
| 79 | + assert roots["ZK"]["source"] == "derived" and roots["ZK"]["asset_class"] is None | |
| 80 | + r = client.get("/v1/futures/roots?asset_class=energy&search=crude") | |
| 81 | + assert {x["root"] for x in r.json()["data"]} == {"CL", "MCL", "B"} | |
| 82 | + r = client.get("/v1/futures/roots?exchange=NYMEX") | |
| 83 | + assert {x["root"] for x in r.json()["data"]} >= {"CL", "BZ", "NG", "HO", "RB"} | |
| 84 | + r = client.get("/v1/futures/roots?format=csv") | |
| 85 | + assert r.status_code == 200 and r.text.startswith("root,name,exchange") | |
| 86 | + | |
| 87 | + | |
| 88 | +def test_contracts_list_filters_and_sort(client, backfilled): | |
| 89 | + r = client.get("/v1/futures/ES/contracts") | |
| 90 | + assert r.status_code == 200 | |
| 91 | + rows = r.json()["data"] | |
| 92 | + exps = [x["expiration_date"] for x in rows] | |
| 93 | + assert exps == sorted(exps) and len(rows) >= 14 | |
| 94 | + assert rows[0]["expiration_source"] == "rule" and rows[0]["timeframes"]["1day"]["rows"] > 0 | |
| 95 | + assert rows[0]["files"]["1day"][0].endswith("_1day.parquet") | |
| 96 | + r = client.get("/v1/futures/es/contracts?status=active&sort=-expiration_date") | |
| 97 | + act = r.json()["data"] | |
| 98 | + assert {x["status"] for x in act} == {"active"} and act[0]["expiration_date"] > act[-1]["expiration_date"] | |
| 99 | + assert {x["symbol"] for x in act} == {"ESU25", "ESZ25", "ESH26", "ESM26"} | |
| 100 | + r = client.get("/v1/futures/ES/contracts?from=2024-01-01&to=2024-12-31") | |
| 101 | + assert {x["symbol"] for x in r.json()["data"]} == {"ESH24", "ESM24", "ESU24", "ESZ24"} | |
| 102 | + r = client.get("/v1/futures/6E/contracts") | |
| 103 | + assert r.status_code == 200 and r.json()["meta"]["root"] == "E6" and r.json()["meta"]["count"] >= 8 | |
| 104 | + assert client.get("/v1/futures/ES/contracts?sort=bogus").json()["error"]["code"] == "INVALID_PARAMETER" | |
| 105 | + assert client.get("/v1/futures/ES/contracts?status=maybe").json()["error"]["code"] == "INVALID_PARAMETER" | |
| 106 | + r = client.get("/v1/futures/ES/contracts?format=csv") | |
| 107 | + assert r.status_code == 200 and r.headers["X-Row-Count"] == str(len(rows)) | |
| 108 | + | |
| 109 | + | |
| 110 | +# ---- bars ------------------------------------------------------------------------------------------------------ | |
| 111 | + | |
| 112 | +def test_contract_bars_daily_json(client, backfilled): | |
| 113 | + r = client.get("/v1/futures/contract/ESH25/bars?interval=1d&from=2025-01-01&to=2025-01-10") | |
| 114 | + assert r.status_code == 200 | |
| 115 | + body = r.json() | |
| 116 | + assert body["meta"]["symbol"] == "ESH25" and body["meta"]["interval"] == "1d" and body["meta"]["next_cursor"] is None | |
| 117 | + assert body["meta"]["expiration_date"] == "2025-03-21" and body["meta"]["expiration_source"] == "rule" | |
| 118 | + dates = [x["datetime"] for x in body["data"]] | |
| 119 | + assert dates == sorted(dates) and dates[0] == "2025-01-01" and dates[-1] == "2025-01-10" # fixture = plain weekdays | |
| 120 | + assert set(body["data"][0]) == {"symbol", "datetime", "open", "high", "low", "close", "volume", "open_interest"} | |
| 121 | + assert r.headers["X-Row-Count"] == str(len(dates)) | |
| 122 | + | |
| 123 | + | |
| 124 | +def test_contract_bars_intraday_utc_and_sessions(client, backfilled): | |
| 125 | + # November 2024 (EST, UTC-5): RTH starts 09:30 ET = 14:30Z; ETH block 08:00 ET = 13:00Z | |
| 126 | + r = client.get("/v1/futures/contract/es_h25/bars?interval=1m&session=rth&limit=2") | |
| 127 | + d = r.json()["data"] | |
| 128 | + assert d[0]["datetime"].endswith("T14:30:00Z") and d[1]["datetime"].endswith("T14:31:00Z") | |
| 129 | + assert "open_interest" not in d[0] | |
| 130 | + r = client.get("/v1/futures/contract/ESH2025/bars?interval=1m&session=eth&limit=1") | |
| 131 | + assert r.json()["data"][0]["datetime"].endswith("T13:00:00Z") | |
| 132 | + r = client.get("/v1/futures/contract/ES_H25/bars?interval=1m&session=all&limit=1") | |
| 133 | + assert r.json()["data"][0]["datetime"].endswith("T13:00:00Z") | |
| 134 | + # June 2025 (EDT, UTC-4): 09:30 ET = 13:30Z — DST handled | |
| 135 | + r = client.get("/v1/futures/contract/ESU25/bars?interval=1m&session=rth&from=2025-06-16&limit=1") | |
| 136 | + assert r.json()["data"][0]["datetime"] == "2025-06-16T13:30:00Z" | |
| 137 | + # timeframe alias and 1hour interval | |
| 138 | + r = client.get("/v1/futures/contract/ESU25/bars?timeframe=1hour&limit=1&from=2025-06-16") | |
| 139 | + assert r.status_code == 200 and r.json()["meta"]["interval"] == "1h" | |
| 140 | + # FX root uses its own RTH window (08:20 ET → 13:20Z in December) | |
| 141 | + r = client.get("/v1/futures/contract/6EZ25/bars?interval=1m&session=rth&limit=1") | |
| 142 | + assert r.json()["meta"]["symbol"] == "E6Z25" and r.json()["data"][0]["datetime"].endswith("T13:20:00Z") | |
| 143 | + | |
| 144 | + | |
| 145 | +def test_contract_bars_from_to_datetime_bounds(client, backfilled): | |
| 146 | + r = client.get("/v1/futures/contract/ESU25/bars?interval=1m&from=2025-06-16T13:30:00Z&to=2025-06-16T13:35:00Z") | |
| 147 | + d = r.json()["data"] | |
| 148 | + assert [x["datetime"] for x in d] == [f"2025-06-16T13:3{i}:00Z" for i in range(5)] | |
| 149 | + r = client.get("/v1/futures/contract/ESU25/bars?interval=1m&from=not-a-date") | |
| 150 | + assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER" | |
| 151 | + | |
| 152 | + | |
| 153 | +def test_cursor_pagination(client, backfilled): | |
| 154 | + r1 = client.get("/v1/futures/contract/ESH25/bars?interval=5m&limit=7") | |
| 155 | + b1 = r1.json() | |
| 156 | + assert len(b1["data"]) == 7 and b1["meta"]["next_cursor"] | |
| 157 | + r2 = client.get(f"/v1/futures/contract/ESH25/bars?interval=5m&limit=7&cursor={b1['meta']['next_cursor']}") | |
| 158 | + b2 = r2.json() | |
| 159 | + assert len(b2["data"]) == 7 and b2["data"][0]["datetime"] > b1["data"][-1]["datetime"] | |
| 160 | + full = client.get("/v1/futures/contract/ESH25/bars?interval=5m&limit=14").json()["data"] | |
| 161 | + assert [x["datetime"] for x in b1["data"] + b2["data"]] == [x["datetime"] for x in full] | |
| 162 | + last = client.get("/v1/futures/contract/ESH25/bars?interval=1d&limit=100000").json() | |
| 163 | + assert last["meta"]["next_cursor"] is None | |
| 164 | + assert client.get("/v1/futures/contract/ESH25/bars?cursor=%%%").json()["error"]["code"] == "INVALID_PARAMETER" | |
| 165 | + assert client.get("/v1/futures/contract/ESH25/bars?limit=0").json()["error"]["code"] == "INVALID_PARAMETER" | |
| 166 | + | |
| 167 | + | |
| 168 | +def test_bars_csv_and_parquet(client, backfilled): | |
| 169 | + r = client.get("/v1/futures/contract/ESH25/bars?interval=1m&limit=3&format=csv") | |
| 170 | + assert r.status_code == 200 and r.headers["content-type"].startswith("text/csv") and r.headers["X-Row-Count"] == "3" | |
| 171 | + lines = r.text.strip().splitlines() | |
| 172 | + assert lines[0] == "symbol,datetime,open,high,low,close,volume" and lines[1].split(",")[1].endswith("Z") | |
| 173 | + r = client.get("/v1/futures/contract/ESH25/bars?interval=1d&limit=2&format=csv") | |
| 174 | + assert r.text.splitlines()[1].split(",")[1] == "2024-02-15" # daily = plain date | |
| 175 | + r = client.get("/v1/futures/contract/ESH25/bars?interval=1m&limit=50&format=parquet") | |
| 176 | + assert r.status_code == 200 and r.headers["content-type"] == "application/vnd.apache.parquet" and r.headers["X-Row-Count"] == "50" | |
| 177 | + df = pd.read_parquet(io.BytesIO(r.content)) | |
| 178 | + assert len(df) == 50 and str(df["datetime"].dtype).endswith("UTC]") and list(df.columns)[:2] == ["symbol", "datetime"] | |
| 179 | + | |
| 180 | + | |
| 181 | +def test_bars_merge_archive_and_update_dedup(client, backfilled): | |
| 182 | + """ESH25 exists in both buckets (archive ≤ 2024-12-31, update full): no duplicate dates.""" | |
| 183 | + d = client.get("/v1/futures/contract/ESH25/bars?interval=1d&limit=100000").json()["data"] | |
| 184 | + dates = [x["datetime"] for x in d] | |
| 185 | + assert len(dates) == len(set(dates)) and dates[0] < "2024-12-31" < dates[-1] | |
| 186 | + | |
| 187 | + | |
| 188 | +# ---- coverage ------------------------------------------------------------------------------------------------ | |
| 189 | + | |
| 190 | +def test_coverage(client, backfilled): | |
| 191 | + r = client.get("/v1/futures/contract/ESH25/coverage") | |
| 192 | + assert r.status_code == 200 | |
| 193 | + c = r.json()["data"] | |
| 194 | + assert c["symbol"] == "ESH25" and c["expiration_source"] == "rule" and c["expiration_date"] == "2025-03-21" | |
| 195 | + assert c["intervals"]["1d"]["available"] and c["intervals"]["1d"]["open_interest"] is True and c["intervals"]["1d"]["rows"] > 200 | |
| 196 | + assert c["intervals"]["1m"]["available"] and c["intervals"]["1m"]["open_interest"] is False | |
| 197 | + assert set(c["intervals"]["1m"]["files"]) == {"archive", "update"} | |
| 198 | + assert c["gaps"] == [] and isinstance(c["notes"], list) | |
| 199 | + | |
| 200 | + | |
| 201 | +# ---- chain / term structure ------------------------------------------------------------------------------------ | |
| 202 | + | |
| 203 | +def test_chain(client, backfilled): | |
| 204 | + r = client.get("/v1/futures/ES/chain?as_of=2024-09-10") | |
| 205 | + assert r.status_code == 200 | |
| 206 | + body = r.json() | |
| 207 | + rows = body["data"] | |
| 208 | + assert body["meta"]["front"] == "ESU24" and rows[0]["position"] == 1 and rows[0]["days_to_expiry"] == 10 | |
| 209 | + assert [x["symbol"] for x in rows] == ["ESU24", "ESZ24", "ESH25", "ESM25", "ESU25"] | |
| 210 | + assert rows[0]["close"] and rows[0]["last_date"] == "2024-09-10" and rows[0]["open_interest"] > 0 | |
| 211 | + assert all(x["expiration_date"] >= "2024-09-10" for x in rows) | |
| 212 | + r = client.get("/v1/futures/ES/chain?as_of=2010-01-01") | |
| 213 | + assert r.json()["data"] == [] and r.json()["meta"]["note"] | |
| 214 | + assert client.get("/v1/futures/ES/chain?as_of=yesterday").json()["error"]["code"] == "INVALID_PARAMETER" | |
| 215 | + assert client.get("/v1/futures/ES/chain?as_of=2024-09-10&format=csv").status_code == 200 | |
| 216 | + | |
| 217 | + | |
| 218 | +def test_term_structure(client, backfilled): | |
| 219 | + r = client.get("/v1/futures/cl/term-structure?as_of=2025-06-30") | |
| 220 | + assert r.status_code == 200 | |
| 221 | + body = r.json() | |
| 222 | + rows, meta = body["data"], body["meta"] | |
| 223 | + assert meta["root"] == "CL" and meta["structure"] in ("contango", "backwardation", "flat") | |
| 224 | + assert rows[0]["slope_annualized"] is None and rows[0]["settle"] == meta["front_settle"] | |
| 225 | + assert rows[1]["slope_annualized"] is not None and rows[1]["spread_vs_front"] == pytest.approx(rows[1]["settle"] - rows[0]["settle"]) | |
| 226 | + dte = [x["days_to_expiry"] for x in rows] | |
| 227 | + assert dte == sorted(dte) and dte[0] >= 0 | |
| 228 | + if meta["structure"] == "contango": | |
| 229 | + assert rows[1]["settle"] > rows[0]["settle"] | |
| 230 | + | |
| 231 | + | |
| 232 | +# ---- continuous ------------------------------------------------------------------------------------------------ | |
| 233 | + | |
| 234 | +def test_continuous_calendar_roll_and_back_adjustment(client, backfilled): | |
| 235 | + r = client.get("/v1/futures/ES/continuous?roll=calendar&adjust=none&from=2024-03-01&to=2024-03-31") | |
| 236 | + assert r.status_code == 200 | |
| 237 | + raw = r.json() | |
| 238 | + syms = [x["symbol"] for x in raw["data"]] | |
| 239 | + assert syms[0] == "ESH24" and syms[-1] == "ESM24" and syms == sorted(syms, key=syms.index) | |
| 240 | + assert raw["meta"]["roll_dates"] == [pytest.approx(raw["meta"]["roll_dates"][0])] | |
| 241 | + rd = raw["meta"]["roll_dates"][0] | |
| 242 | + assert rd["date"] == "2024-03-18" and rd["from_symbol"] == "ESH24" and rd["to_symbol"] == "ESM24" and rd["adjusted"] | |
| 243 | + assert rd["gap_session"] == "2024-03-15" and rd["gap"] == pytest.approx(rd["ratio"] * 0 + rd["gap"]) | |
| 244 | + # switch day: last ESH24 bar is its expiry (Fri 15), first ESM24 bar is Mon 18 | |
| 245 | + last_h = max(x["datetime"] for x in raw["data"] if x["symbol"] == "ESH24") | |
| 246 | + first_m = min(x["datetime"] for x in raw["data"] if x["symbol"] == "ESM24") | |
| 247 | + assert last_h == "2024-03-15" and first_m == "2024-03-18" | |
| 248 | + adj = client.get("/v1/futures/ES/continuous?roll=calendar&adjust=back_adjusted&from=2024-03-01&to=2024-03-31").json() | |
| 249 | + raw_close = {(x["symbol"], x["datetime"]): x["close"] for x in raw["data"]} | |
| 250 | + offsets = {} | |
| 251 | + for x in adj["data"]: | |
| 252 | + offsets.setdefault(x["symbol"], set()).add(round(x["close"] - raw_close[(x["symbol"], x["datetime"])], 6)) | |
| 253 | + assert len(offsets["ESH24"]) == 1 and len(offsets["ESM24"]) == 1 # constant shift per segment | |
| 254 | + assert (offsets["ESH24"].pop() - offsets["ESM24"].pop()) == pytest.approx(rd["gap"], abs=1e-6) | |
| 255 | + assert adj["data"][0]["volume"] == raw["data"][0]["volume"] # volume never adjusted | |
| 256 | + # the last segment (latest contract) is unadjusted | |
| 257 | + tail = client.get("/v1/futures/ES/continuous?roll=calendar&adjust=back_adjusted&from=2025-06-25").json() | |
| 258 | + tail_raw = client.get("/v1/futures/contract/ESU25/bars?interval=1d&from=2025-06-25").json() | |
| 259 | + assert tail["meta"]["unadjusted_symbol"] == "ESM26" | |
| 260 | + assert {x["symbol"] for x in tail["data"]} == {"ESU25"} or tail["data"][-1]["symbol"] == tail["meta"]["unadjusted_symbol"] | |
| 261 | + if {x["symbol"] for x in tail["data"]} == {"ESU25"}: | |
| 262 | + assert tail["data"][-1]["close"] != tail_raw["data"][-1]["close"] or tail["meta"]["rolls_total"] > 0 | |
| 263 | + | |
| 264 | + | |
| 265 | +def test_continuous_volume_roll_before_expiry_and_ratio(client, backfilled): | |
| 266 | + r = client.get("/v1/futures/ES/continuous?roll=volume&adjust=ratio_adjusted&from=2024-03-01&to=2024-03-31").json() | |
| 267 | + rd = r["meta"]["roll_dates"] | |
| 268 | + assert len(rd) == 1 and rd[0]["from_symbol"] == "ESH24" and rd[0]["to_symbol"] == "ESM24" | |
| 269 | + assert "2024-03-08" < rd[0]["date"] < "2024-03-15" # volume roll a few sessions before expiry | |
| 270 | + assert rd[0]["ratio"] and rd[0]["ratio"] != 1.0 | |
| 271 | + assert r["meta"]["roll"] == "volume" and r["meta"]["adjust"] == "ratio_adjusted" | |
| 272 | + oi = client.get("/v1/futures/ES/continuous?roll=open_interest&from=2024-03-01&to=2024-03-31").json() | |
| 273 | + assert oi["meta"]["roll_dates"][0]["to_symbol"] == "ESM24" and oi["meta"]["roll_dates"][0]["date"] < "2024-03-18" | |
| 274 | + | |
| 275 | + | |
| 276 | +def test_continuous_first_notice_roll(client, backfilled): | |
| 277 | + r = client.get("/v1/futures/CL/continuous?roll=first_notice&from=2024-03-15&to=2024-03-25").json() | |
| 278 | + rd = r["meta"]["roll_dates"] | |
| 279 | + assert len(rd) == 1 and rd[0]["date"] == "2024-03-21" and rd[0]["from_symbol"] == "CLJ24" and rd[0]["to_symbol"] == "CLN24" | |
| 280 | + | |
| 281 | + | |
| 282 | +def test_continuous_depth(client, backfilled): | |
| 283 | + r1 = client.get("/v1/futures/ES/continuous?roll=calendar&from=2024-03-01&to=2024-03-31").json() | |
| 284 | + r2 = client.get("/v1/futures/ES/continuous?roll=calendar&depth=2&from=2024-03-01&to=2024-03-31").json() | |
| 285 | + r3 = client.get("/v1/futures/ES/continuous?roll=calendar&depth=3&from=2024-03-01&to=2024-03-31").json() | |
| 286 | + assert [x["symbol"] for x in r2["data"]][0] == "ESM24" and r2["data"][-1]["symbol"] == "ESU24" | |
| 287 | + assert r3["data"][0]["symbol"] == "ESU24" and r3["data"][-1]["symbol"] == "ESZ24" | |
| 288 | + assert len(r1["data"]) == len(r2["data"]) == len(r3["data"]) | |
| 289 | + assert r2["meta"]["roll_dates"][0]["from_symbol"] == "ESM24" and r2["meta"]["roll_dates"][0]["to_symbol"] == "ESU24" | |
| 290 | + assert client.get("/v1/futures/ES/continuous?depth=4").json()["error"]["code"] == "INVALID_PARAMETER" | |
| 291 | + | |
| 292 | + | |
| 293 | +def test_continuous_intraday_applies_daily_schedule(client, backfilled): | |
| 294 | + r = client.get("/v1/futures/ES/continuous?interval=1m&roll=volume&from=2025-06-10&to=2025-06-25&session=rth&limit=100000") | |
| 295 | + assert r.status_code == 200 | |
| 296 | + body = r.json() | |
| 297 | + syms = [x["symbol"] for x in body["data"]] | |
| 298 | + assert set(syms) == {"ESM25", "ESU25"} and syms == sorted(syms, key=syms.index) | |
| 299 | + ts = [x["datetime"] for x in body["data"]] | |
| 300 | + assert ts == sorted(ts) and all(t.endswith("Z") for t in ts) and ts[0] >= "2025-06-10T13:30:00Z" | |
| 301 | + assert len(body["meta"]["roll_dates"]) == 1 and body["meta"]["roll_dates"][0]["to_symbol"] == "ESU25" | |
| 302 | + assert "open_interest" not in body["data"][0] | |
| 303 | + page = client.get("/v1/futures/ES/continuous?interval=1m&roll=volume&from=2025-06-10&to=2025-06-25&session=rth&limit=100").json() | |
| 304 | + nxt = client.get(f"/v1/futures/ES/continuous?interval=1m&roll=volume&from=2025-06-10&to=2025-06-25&session=rth&limit=100&cursor={page['meta']['next_cursor']}").json() | |
| 305 | + assert nxt["data"][0]["datetime"] > page["data"][-1]["datetime"] | |
| 306 | + r = client.get("/v1/futures/ES/continuous?interval=1m&from=2025-06-10&to=2025-06-12&format=parquet") | |
| 307 | + assert r.status_code == 200 and int(r.headers["X-Row-Count"]) > 0 | |
| 308 | + r = client.get("/v1/futures/ES/continuous?from=2025-06-10&to=2025-06-12&format=csv") | |
| 309 | + assert r.text.splitlines()[0].startswith("symbol,datetime") | |
| 310 | + | |
| 311 | + | |
| 312 | +def test_continuous_alias_root_and_empty_window(client, backfilled): | |
| 313 | + r = client.get("/v1/futures/6E/continuous?roll=calendar&from=2025-06-20&to=2025-06-30") | |
| 314 | + assert r.status_code == 200 and r.json()["meta"]["root"] == "E6" and r.json()["data"] | |
| 315 | + r = client.get("/v1/futures/ES/continuous?from=2010-01-01&to=2010-01-31") | |
| 316 | + assert r.status_code == 200 and r.json()["data"] == [] and r.json()["meta"]["roll_dates"] == [] | |
| 317 | + | |
| 318 | + | |
| 319 | +# ---- errors ------------------------------------------------------------------------------------------------------ | |
| 320 | + | |
| 321 | +@pytest.mark.parametrize("url, status, code", [ | |
| 322 | + ("/v1/futures/contract/BOGUS/bars", 400, "INVALID_CONTRACT_SYMBOL"), | |
| 323 | + ("/v1/futures/contract/ESZ5/coverage", 400, "INVALID_CONTRACT_SYMBOL"), | |
| 324 | + ("/v1/futures/contract/ESZ99/bars", 404, "CONTRACT_NOT_FOUND"), | |
| 325 | + ("/v1/futures/contract/ESZ99/coverage", 404, "CONTRACT_NOT_FOUND"), | |
| 326 | + ("/v1/futures/NOPE/chain", 404, "ROOT_NOT_FOUND"), | |
| 327 | + ("/v1/futures/NOPE/contracts", 404, "ROOT_NOT_FOUND"), | |
| 328 | + ("/v1/futures/NOPE/continuous", 404, "ROOT_NOT_FOUND"), | |
| 329 | + ("/v1/futures/NOPE/term-structure", 404, "ROOT_NOT_FOUND"), | |
| 330 | + ("/v1/futures/ES/continuous?roll=weird", 400, "INVALID_PARAMETER"), | |
| 331 | + ("/v1/futures/ES/continuous?adjust=weird", 400, "INVALID_PARAMETER"), | |
| 332 | + ("/v1/futures/contract/ESH25/bars?interval=2h", 400, "INVALID_PARAMETER"), | |
| 333 | + ("/v1/futures/contract/ESH25/bars?session=lunch", 400, "INVALID_PARAMETER"), | |
| 334 | + ("/v1/futures/contract/ESH25/bars?format=xml", 400, "INVALID_PARAMETER"), | |
| 335 | +]) | |
| 336 | +def test_error_envelopes(client, backfilled, url, status, code): | |
| 337 | + r = client.get(url) | |
| 338 | + assert r.status_code == status | |
| 339 | + body = r.json() | |
| 340 | + assert body["error"]["code"] == code and body["error"]["docs"].endswith(f"#{code.lower()}") and body["detail"] | |
| 341 | + | |
| 342 | + | |
| 343 | +def test_tier_row_cap_is_enforced_via_request_state(client, backfilled, app): | |
| 344 | + """clamp_limit(..., request=request) honours request.state.max_rows set by a limiter middleware.""" | |
| 345 | + from starlette.middleware.base import BaseHTTPMiddleware | |
| 346 | + | |
| 347 | + class Cap(BaseHTTPMiddleware): | |
| 348 | + async def dispatch(self, request, call_next): | |
| 349 | + request.state.max_rows = 10 | |
| 350 | + return await call_next(request) | |
| 351 | + # build a throwaway app sharing the routes | |
| 352 | + from core import errors | |
| 353 | + from fastapi import FastAPI | |
| 354 | + from fastapi.testclient import TestClient | |
| 355 | + from futures.routes import router | |
| 356 | + a = FastAPI() | |
| 357 | + errors.install(a) | |
| 358 | + a.add_middleware(Cap) | |
| 359 | + a.include_router(router) | |
| 360 | + with TestClient(a) as c: | |
| 361 | + r = c.get("/v1/futures/contract/ESH25/bars?limit=50") | |
| 362 | + assert r.status_code == 400 and r.json()["error"]["code"] == "ROW_LIMIT_EXCEEDED" and r.json()["error"]["details"]["max_rows"] == 10 | |
| 363 | + r = c.get("/v1/futures/contract/ESH25/bars?limit=5") | |
| 364 | + assert r.status_code == 200 and r.headers["X-Row-Count"] == "5" | |
| 365 | + | |
| 366 | + | |
| 367 | +# ---- OpenAPI ----------------------------------------------------------------------------------------------------- | |
| 368 | + | |
| 369 | +def test_openapi_documents_the_seven_endpoints(client, backfilled): | |
| 370 | + spec = client.get("/openapi.json").json() | |
| 371 | + paths = spec["paths"] | |
| 372 | + expected = ["/v1/futures/roots", "/v1/futures/{root}/contracts", "/v1/futures/contract/{symbol}/bars", | |
| 373 | + "/v1/futures/contract/{symbol}/coverage", "/v1/futures/{root}/chain", "/v1/futures/{root}/continuous", | |
| 374 | + "/v1/futures/{root}/term-structure"] | |
| 375 | + for p in expected: | |
| 376 | + op = paths[p]["get"] | |
| 377 | + assert op["tags"] == ["futures"] and op["summary"] and len(op["description"]) > 80 | |
| 378 | + assert "200" in op["responses"] and "429" in op["responses"] | |
| 379 | + bars = paths["/v1/futures/contract/{symbol}/bars"]["get"] | |
| 380 | + assert "CONTRACT_NOT_FOUND" in bars["responses"]["404"]["description"] | |
| 381 | + assert "INVALID_CONTRACT_SYMBOL" in bars["responses"]["400"]["description"] | |
| 382 | + assert "example" in bars["responses"]["200"]["content"]["application/json"] | |
| 383 | + names = {p["name"] for p in bars["parameters"]} | |
| 384 | + assert {"interval", "from", "to", "session", "cursor", "limit", "format"} <= names | |
added
tests/test_futures_expiry.py
+132 −0
@@ -0,0 +1,132 @@ | ||
| 1 | +"""Unit tests — holiday calendar and every expiry rule against real exchange dates. | |
| 2 | + | |
| 3 | +Reference dates were cross-checked with the last daily bar of each contract in the production lake | |
| 4 | +(FirstRate Data) and the exchange rulebooks (CME Group, ICE, Eurex, Cboe). | |
| 5 | +""" | |
| 6 | +from __future__ import annotations | |
| 7 | + | |
| 8 | +from datetime import date | |
| 9 | + | |
| 10 | +import pytest | |
| 11 | +from futures import calendar_us as cal | |
| 12 | +from futures import expiry | |
| 13 | +from futures.specs import SPEC_BY_ROOT, SPECS | |
| 14 | +from futures.symbols import parse_symbol | |
| 15 | + | |
| 16 | +# ---- calendar ------------------------------------------------------------------------------------------- | |
| 17 | + | |
| 18 | +def test_us_holidays_2024(): | |
| 19 | + assert sorted(cal.us_holidays(2024)) == [date(2024, 1, 1), date(2024, 1, 15), date(2024, 2, 19), date(2024, 3, 29), | |
| 20 | + date(2024, 5, 27), date(2024, 6, 19), date(2024, 7, 4), date(2024, 9, 2), | |
| 21 | + date(2024, 11, 28), date(2024, 12, 25)] | |
| 22 | + | |
| 23 | + | |
| 24 | +def test_observed_rules(): | |
| 25 | + assert date(2021, 12, 24) in cal.us_holidays(2021) # Christmas 2021 on Saturday → Friday | |
| 26 | + assert date(2021, 12, 31) not in cal.us_holidays(2021) # New Year 2022 on Saturday → no observance | |
| 27 | + assert date(2022, 1, 1) not in cal.us_holidays(2022) | |
| 28 | + assert date(2023, 1, 2) in cal.us_holidays(2023) # New Year 2023 on Sunday → Monday | |
| 29 | + assert date(2022, 6, 20) in cal.us_holidays(2022) # Juneteenth 2022 on Sunday → Monday | |
| 30 | + assert date(2021, 6, 18) not in cal.us_holidays(2021) # Juneteenth not observed before 2022 | |
| 31 | + assert date(2026, 7, 3) in cal.us_holidays(2026) # July 4 2026 on Saturday → Friday | |
| 32 | + | |
| 33 | + | |
| 34 | +def test_easter_and_eurex(): | |
| 35 | + assert cal.easter(2024) == date(2024, 3, 31) and cal.easter(2025) == date(2025, 4, 20) and cal.easter(2030) == date(2030, 4, 21) | |
| 36 | + hs = cal.eurex_holidays(2025) | |
| 37 | + assert {date(2025, 4, 18), date(2025, 4, 21), date(2025, 5, 1), date(2025, 12, 24), date(2025, 12, 26), date(2025, 12, 31)} <= hs | |
| 38 | + | |
| 39 | + | |
| 40 | +def test_business_day_helpers(): | |
| 41 | + assert not cal.is_business_day(date(2024, 12, 25)) | |
| 42 | + assert not cal.is_business_day(date(2024, 12, 28)) | |
| 43 | + assert cal.add_business_days(date(2024, 12, 24), 1) == date(2024, 12, 26) | |
| 44 | + assert cal.add_business_days(date(2024, 12, 31), -7) == date(2024, 12, 19) | |
| 45 | + assert cal.last_business_day_of_month(2024, 11) == date(2024, 11, 29) | |
| 46 | + assert cal.first_business_day_of_month(2025, 1) == date(2025, 1, 2) | |
| 47 | + assert cal.nth_business_day_of_month(2024, 12, 10) == date(2024, 12, 13) | |
| 48 | + assert cal.business_days_between(date(2024, 12, 20), date(2024, 12, 27)) == 3 # 23, 24, 26 | |
| 49 | + assert cal.previous_business_day(date(2024, 12, 15)) == date(2024, 12, 13) | |
| 50 | + assert cal.next_business_day(date(2024, 12, 25)) == date(2024, 12, 26) | |
| 51 | + assert cal.shift_month(2024, 1, -1) == (2023, 12) and cal.shift_month(2024, 12, 1) == (2025, 1) | |
| 52 | + | |
| 53 | + | |
| 54 | +# ---- rules vs real dates ---------------------------------------------------------------------------------- | |
| 55 | + | |
| 56 | +REAL = [ | |
| 57 | + # symbol, rule, last trading date, justification | |
| 58 | + ("ESZ24", "third_friday", "2024-12-20", "3rd Friday of December 2024"), | |
| 59 | + ("ESM24", "third_friday", "2024-06-21", "3rd Friday of June 2024"), | |
| 60 | + ("FDAXZ24", "third_friday_eurex", "2024-12-20", "3rd Friday (Eurex calendar)"), | |
| 61 | + ("CLZ24", "cl_rule", "2024-11-20", "25 Nov 2024 (Mon) is a business day → 3 business days before = Wed 20 Nov"), | |
| 62 | + ("CLF25", "cl_rule", "2024-12-19", "25 Dec is a holiday → business day before = 24 Dec → 3 business days before = 19 Dec"), | |
| 63 | + ("CLG25", "cl_rule", "2025-01-21", "25 Jan 2025 (Sat) → Fri 24 Jan → 3 business days before (MLK 20 Jan excluded) = Tue 21 Jan"), | |
| 64 | + ("MCLZ24", "cl_minus_1", "2024-11-19", "one business day before CLZ24"), | |
| 65 | + ("NGZ24", "ng_rule", "2024-11-26", "3 business days before 1 Dec 2024 (Thanksgiving 28 Nov excluded)"), | |
| 66 | + ("NGF25", "ng_rule", "2024-12-27", "3 business days before 1 Jan 2025"), | |
| 67 | + ("GCZ24", "metals_rule", "2024-12-27", "3rd last business day of December 2024 (31, 30, 27)"), | |
| 68 | + ("GCG25", "metals_rule", "2025-02-26", "3rd last business day of February 2025"), | |
| 69 | + ("PLF25", "metals_rule", "2025-01-29", "3rd last business day of January 2025"), | |
| 70 | + ("ZNZ24", "treasury_rule", "2024-12-19", "7th business day before the last business day (31 Dec) — 25 Dec excluded"), | |
| 71 | + ("ZNH25", "treasury_rule", "2025-03-20", "7th business day before 31 Mar 2025"), | |
| 72 | + ("USZ24", "treasury_rule", "2024-12-19", "same rule for the 30-year bond (lake root US = ZB)"), | |
| 73 | + ("ZTZ24", "last_business_day", "2024-12-31", "2-year note: last business day of the contract month"), | |
| 74 | + ("ZCZ24", "grains_rule", "2024-12-13", "business day prior to 15 Dec 2024 (Sunday) → Fri 13 Dec"), | |
| 75 | + ("ZSX24", "grains_rule", "2024-11-14", "business day prior to Fri 15 Nov 2024"), | |
| 76 | + ("E6Z24", "fx_rule", "2024-12-16", "2 business days before the 3rd Wednesday (18 Dec 2024)"), | |
| 77 | + ("E6H25", "fx_rule", "2025-03-17", "2 business days before Wed 19 Mar 2025"), | |
| 78 | + ("DXZ24", "fx_rule", "2024-12-16", "ICE dollar index follows the same rule"), | |
| 79 | + ("VXZ24", "vx_rule", "2024-12-18", "Wednesday 30 days before the 3rd Friday of January 2025 (17 Jan)"), | |
| 80 | + ("VXF25", "vx_rule", "2025-01-22", "30 days before Fri 21 Feb 2025"), | |
| 81 | + ("VXH25", "vx_rule", "2025-03-18", "3rd Friday of April 2025 is Good Friday → SPX expires Thu 17 Apr → 30 days before = Tue 18 Mar"), | |
| 82 | + ("BTCZ24", "last_friday", "2024-12-27", "last Friday of December 2024"), | |
| 83 | + ("HOZ24", "prior_month_last_business_day", "2024-11-29", "last business day of November 2024"), | |
| 84 | + ("SBH25", "prior_month_last_business_day", "2025-02-28", "Sugar #11: last business day of the month preceding"), | |
| 85 | + ("BZZ24", "bz_rule", "2024-10-31", "last business day of the 2nd month preceding December"), | |
| 86 | + ("LEZ24", "last_business_day", "2024-12-31", "Live cattle: last business day of the contract month"), | |
| 87 | + ("HEZ24", "he_rule", "2024-12-13", "Lean hogs: 10th business day of December 2024"), | |
| 88 | + ("GFX24", "gf_rule", "2024-11-21", "Feeder cattle: last Thursday is Thanksgiving → Thursday before"), | |
| 89 | + ("GFQ24", "gf_rule", "2024-08-29", "Feeder cattle: last Thursday of August 2024"), | |
| 90 | + ("KCZ24", "kc_rule", "2024-12-18", "Coffee: 8 business days before 31 Dec 2024 (25 Dec excluded)"), | |
| 91 | + ("CCZ24", "cc_rule", "2024-12-13", "Cocoa: 11 business days before 31 Dec 2024"), | |
| 92 | + ("CTZ24", "ct_rule", "2024-12-06", "Cotton: 17 business days from the end of the spot month"), | |
| 93 | + ("OJF25", "oj_rule", "2025-01-10", "FCOJ: 14th business day before 31 Jan 2025 (MLK excluded)"), | |
| 94 | + ("FGBLZ24", "bund_rule", "2024-12-06", "Bund: delivery 10 Dec 2024 → 2 exchange days before"), | |
| 95 | + ("SR3Z24", "sr3_rule", "2025-03-18", "3-month SOFR: business day before the 3rd Wednesday of March 2025"), | |
| 96 | + ("NKDZ24", "second_friday_minus_1", "2024-12-12", "Nikkei: business day before the 2nd Friday (13 Dec 2024)"), | |
| 97 | +] | |
| 98 | + | |
| 99 | + | |
| 100 | +@pytest.mark.parametrize("symbol, rule, expected, why", REAL, ids=[r[0] for r in REAL]) | |
| 101 | +def test_rules_against_real_dates(symbol, rule, expected, why): | |
| 102 | + cs = parse_symbol(symbol) | |
| 103 | + assert expiry.compute(rule, cs.year, cs.month) == date.fromisoformat(expected), why | |
| 104 | + | |
| 105 | + | |
| 106 | +def test_spec_rule_matches_expected_family(): | |
| 107 | + """The reference table must point each verified root at the verified rule.""" | |
| 108 | + for symbol, rule, _, _ in REAL: | |
| 109 | + root = parse_symbol(symbol).root | |
| 110 | + assert SPEC_BY_ROOT[root].expiry_rule == rule, root | |
| 111 | + | |
| 112 | + | |
| 113 | +def test_first_notice_rules(): | |
| 114 | + assert expiry.compute_first_notice("prior_month_last_business_day", 2024, 12) == date(2024, 11, 29) # ZN/GC/ZC Z24 | |
| 115 | + assert expiry.compute_first_notice("cl", 2024, 12) == date(2024, 11, 21) # CLZ24: day after LTD | |
| 116 | + assert expiry.compute_first_notice("kc", 2024, 12) == date(2024, 11, 20) # 7 business days before 2 Dec | |
| 117 | + assert expiry.compute_first_notice(None, 2024, 12) is None | |
| 118 | + | |
| 119 | + | |
| 120 | +def test_unknown_rule_returns_none(): | |
| 121 | + assert expiry.compute("data", 2024, 12) is None | |
| 122 | + assert expiry.compute(None, 2024, 12) is None | |
| 123 | + assert expiry.compute("nope", 2024, 12) is None | |
| 124 | + | |
| 125 | + | |
| 126 | +def test_every_spec_rule_exists(): | |
| 127 | + for s in SPECS: | |
| 128 | + assert s.expiry_rule == "data" or s.expiry_rule in expiry.RULES, s.root | |
| 129 | + assert s.first_notice_rule is None or s.first_notice_rule in expiry.FIRST_NOTICE_RULES, s.root | |
| 130 | + assert s.calendar in ("us", "eurex") | |
| 131 | + assert len(s.rth) == 2 | |
| 132 | + assert len({s.root for s in SPECS}) == len(SPECS) | |
added
tests/test_futures_perf.py
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +"""Performance smoke check — 10 000 bars of one contract (and of a continuous series) under 300 ms (p95 target).""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +import time | |
| 5 | +from datetime import date | |
| 6 | + | |
| 7 | +import pytest | |
| 8 | + | |
| 9 | +BUDGET_MS = 300 | |
| 10 | + | |
| 11 | + | |
| 12 | +@pytest.fixture(scope="module") | |
| 13 | +def warm(client, app): | |
| 14 | + from futures.backfill import run_backfill | |
| 15 | + run_backfill(today=date(2025, 7, 1)) | |
| 16 | + client.get("/v1/futures/contract/ESU25/bars?interval=1m&limit=10") # warm DuckDB / caches | |
| 17 | + client.get("/v1/futures/ES/continuous?interval=1m&from=2025-05-01&limit=10") | |
| 18 | + return True | |
| 19 | + | |
| 20 | + | |
| 21 | +def _timed(client, url: str, runs: int = 5) -> float: | |
| 22 | + best = float("inf") | |
| 23 | + for _ in range(runs): | |
| 24 | + t = time.perf_counter() | |
| 25 | + r = client.get(url) | |
| 26 | + best = min(best, (time.perf_counter() - t) * 1000) | |
| 27 | + assert r.status_code == 200 and r.headers["X-Row-Count"] == "10000" | |
| 28 | + return best | |
| 29 | + | |
| 30 | + | |
| 31 | +@pytest.mark.slow | |
| 32 | +def test_contract_10k_bars_under_budget(client, warm): | |
| 33 | + ms = _timed(client, "/v1/futures/contract/ESU25/bars?interval=1m&limit=10000") | |
| 34 | + assert ms < BUDGET_MS, f"10 000 contract bars took {ms:.0f} ms" | |
| 35 | + | |
| 36 | + | |
| 37 | +@pytest.mark.slow | |
| 38 | +def test_continuous_10k_bars_under_budget(client, warm): | |
| 39 | + ms = _timed(client, "/v1/futures/ES/continuous?interval=1m&from=2025-05-01&limit=10000") | |
| 40 | + assert ms < BUDGET_MS, f"10 000 continuous bars took {ms:.0f} ms" | |
| 41 | + | |
| 42 | + | |
| 43 | +@pytest.mark.slow | |
| 44 | +def test_parquet_10k_under_budget(client, warm): | |
| 45 | + ms = _timed(client, "/v1/futures/contract/ESU25/bars?interval=1m&limit=10000&format=parquet") | |
| 46 | + assert ms < BUDGET_MS, f"10 000 parquet bars took {ms:.0f} ms" | |
added
tests/test_futures_rolls.py
+159 −0
@@ -0,0 +1,159 @@ | ||
| 1 | +"""Unit tests — roll schedules, depth, gap computation and back/ratio adjustment on synthetic series.""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +from datetime import date | |
| 5 | + | |
| 6 | +import numpy as np | |
| 7 | +import pandas as pd | |
| 8 | +import pytest | |
| 9 | +from futures.rolls import ( | |
| 10 | + Segment, | |
| 11 | + adjustment_offsets, | |
| 12 | + apply_adjustment, | |
| 13 | + depth_schedule, | |
| 14 | + roll_gaps, | |
| 15 | + roll_schedule, | |
| 16 | + stitch_daily, | |
| 17 | +) | |
| 18 | + | |
| 19 | +A, B, C = "XXH24", "XXM24", "XXU24" | |
| 20 | +CONTRACTS = [ | |
| 21 | + {"symbol": A, "expiration_date": date(2024, 3, 15), "first_notice_date": date(2024, 3, 1), "first_data_date": date(2024, 1, 2), "last_data_date": date(2024, 3, 15)}, | |
| 22 | + {"symbol": B, "expiration_date": date(2024, 6, 21), "first_notice_date": date(2024, 6, 3), "first_data_date": date(2024, 1, 2), "last_data_date": date(2024, 6, 21)}, | |
| 23 | + {"symbol": C, "expiration_date": date(2024, 9, 20), "first_notice_date": None, "first_data_date": date(2024, 1, 2), "last_data_date": date(2024, 6, 30)}, | |
| 24 | +] | |
| 25 | + | |
| 26 | + | |
| 27 | +def _daily() -> pd.DataFrame: | |
| 28 | + """Sessions 2024-01-02 → 2024-06-28; A: close 100 flat, B: 105, C: 110. Volume: A dominates until 03-08, | |
| 29 | + B > A on 03-11 and 03-12 (2 consecutive) → volume roll on 03-13. OI: B > A on 03-06/03-07 → roll 03-08.""" | |
| 30 | + sessions = pd.bdate_range("2024-01-02", "2024-06-28") | |
| 31 | + rows = [] | |
| 32 | + for d in sessions: | |
| 33 | + dd = d.date() | |
| 34 | + if dd <= date(2024, 3, 15): | |
| 35 | + vol_a = 1000 if dd < date(2024, 3, 11) else 100 | |
| 36 | + oi_a = 5000 if dd < date(2024, 3, 6) else 50 | |
| 37 | + rows.append({"symbol": A, "date": d, "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.0, "volume": vol_a, "open_interest": oi_a}) | |
| 38 | + if dd <= date(2024, 6, 21): | |
| 39 | + rows.append({"symbol": B, "date": d, "open": 105.0, "high": 106.0, "low": 104.0, "close": 105.0, "volume": 500, "open_interest": 2000}) | |
| 40 | + rows.append({"symbol": C, "date": d, "open": 110.0, "high": 111.0, "low": 109.0, "close": 110.0, "volume": 10, "open_interest": 100}) | |
| 41 | + return pd.DataFrame(rows) | |
| 42 | + | |
| 43 | + | |
| 44 | +def test_calendar_roll(): | |
| 45 | + segs = roll_schedule(CONTRACTS, _daily(), "calendar") | |
| 46 | + assert [s.symbol for s in segs] == [A, B, C] | |
| 47 | + assert segs[0].start == date(2024, 1, 2) and segs[0].end == date(2024, 3, 17) # held through expiry (Fri 15), roll Mon 18 | |
| 48 | + assert segs[1].start == date(2024, 3, 18) and segs[1].end == date(2024, 6, 23) | |
| 49 | + assert segs[2].start == date(2024, 6, 24) and segs[2].end is None | |
| 50 | + | |
| 51 | + | |
| 52 | +def test_first_notice_roll_falls_back_to_calendar_when_null(): | |
| 53 | + segs = roll_schedule(CONTRACTS, _daily(), "first_notice") | |
| 54 | + assert segs[0].end == date(2024, 2, 29) and segs[1].start == date(2024, 3, 1) # roll ON the FND | |
| 55 | + assert segs[1].end == date(2024, 6, 2) and segs[2].start == date(2024, 6, 3) | |
| 56 | + | |
| 57 | + | |
| 58 | +def test_volume_and_oi_rolls(): | |
| 59 | + segs = roll_schedule(CONTRACTS, _daily(), "volume") | |
| 60 | + assert segs[0].end == date(2024, 3, 12) and segs[1].start == date(2024, 3, 13) | |
| 61 | + segs = roll_schedule(CONTRACTS, _daily(), "open_interest") | |
| 62 | + assert segs[0].end == date(2024, 3, 7) and segs[1].start == date(2024, 3, 8) | |
| 63 | + # B → C: C never beats B on volume before B's expiry → calendar fallback | |
| 64 | + segs = roll_schedule(CONTRACTS, _daily(), "volume") | |
| 65 | + assert segs[2].start == date(2024, 6, 24) | |
| 66 | + | |
| 67 | + | |
| 68 | +def test_volume_roll_ignores_noise_far_from_expiry(): | |
| 69 | + daily = _daily() | |
| 70 | + # a single noisy session in January where B > A must not roll (needs 2 consecutive AND inside the roll window) | |
| 71 | + daily.loc[(daily.symbol == A) & (daily.date == "2024-01-10"), "volume"] = 1 | |
| 72 | + segs = roll_schedule(CONTRACTS, daily, "volume") | |
| 73 | + assert segs[1].start == date(2024, 3, 13) | |
| 74 | + | |
| 75 | + | |
| 76 | +def test_skips_contracts_expired_before_previous_roll(): | |
| 77 | + contracts = CONTRACTS + [{"symbol": "XXG24", "expiration_date": date(2024, 2, 16), "first_notice_date": None, | |
| 78 | + "first_data_date": date(2024, 1, 2), "last_data_date": date(2024, 2, 16)}] | |
| 79 | + segs = roll_schedule(contracts, _daily(), "calendar") | |
| 80 | + assert [s.symbol for s in segs] == ["XXG24", A, B, C] | |
| 81 | + contracts = CONTRACTS + [{"symbol": "XXZ99", "expiration_date": None, "first_notice_date": None, "first_data_date": None, "last_data_date": None}] | |
| 82 | + assert [s.symbol for s in roll_schedule(contracts, _daily(), "calendar")] == [A, B, C] | |
| 83 | + | |
| 84 | + | |
| 85 | +def test_depth_schedule(): | |
| 86 | + front = roll_schedule(CONTRACTS, _daily(), "calendar") | |
| 87 | + d2 = depth_schedule(front, CONTRACTS, 2) | |
| 88 | + assert [(s.symbol, s.start) for s in d2] == [(B, date(2024, 1, 2)), (C, date(2024, 3, 18))] | |
| 89 | + d3 = depth_schedule(front, CONTRACTS, 3) | |
| 90 | + assert [(s.symbol, s.start, s.end) for s in d3] == [(C, date(2024, 1, 2), date(2024, 3, 17))] | |
| 91 | + assert depth_schedule(front, CONTRACTS, 1) is front | |
| 92 | + | |
| 93 | + | |
| 94 | +def test_roll_gaps_and_offsets(): | |
| 95 | + daily = _daily() | |
| 96 | + segs = roll_schedule(CONTRACTS, daily, "calendar") | |
| 97 | + rolls = roll_gaps(segs, daily) | |
| 98 | + assert len(rolls) == 2 | |
| 99 | + assert rolls[0] == {"date": "2024-03-18", "from_symbol": A, "to_symbol": B, "gap": 5.0, "ratio": 1.05, "gap_session": "2024-03-15", "adjusted": True} | |
| 100 | + assert rolls[1]["gap"] == 5.0 and rolls[1]["from_symbol"] == B and rolls[1]["to_symbol"] == C | |
| 101 | + add, mul = adjustment_offsets(rolls, "back_adjusted") | |
| 102 | + assert add == [10.0, 5.0, 0.0] and mul == [1.0, 1.0, 1.0] | |
| 103 | + add, mul = adjustment_offsets(rolls, "ratio_adjusted") | |
| 104 | + assert add == [0.0, 0.0, 0.0] | |
| 105 | + assert mul[2] == 1.0 and mul[1] == pytest.approx(110 / 105) and mul[0] == pytest.approx(1.05 * 110 / 105) | |
| 106 | + add, mul = adjustment_offsets(rolls, "none") | |
| 107 | + assert add == [0.0, 0.0, 0.0] and mul == [1.0, 1.0, 1.0] | |
| 108 | + | |
| 109 | + | |
| 110 | +def test_stitch_back_adjusted_makes_series_continuous(): | |
| 111 | + daily = _daily() | |
| 112 | + segs = roll_schedule(CONTRACTS, daily, "calendar") | |
| 113 | + df, rolls = stitch_daily(segs, daily, "back_adjusted") | |
| 114 | + assert len(rolls) == 2 | |
| 115 | + assert df["datetime"].is_monotonic_increasing and df["datetime"].is_unique | |
| 116 | + # latest contract unadjusted, older ones shifted so closes are all 110 → no jump at the rolls | |
| 117 | + assert (df["close"].round(9) == 110.0).all() | |
| 118 | + assert df.loc[df.symbol == A, "volume"].iloc[0] == 1000 # volume untouched | |
| 119 | + df_ratio, _ = stitch_daily(segs, daily, "ratio_adjusted") | |
| 120 | + assert np.allclose(df_ratio["close"], 110.0) | |
| 121 | + df_none, _ = stitch_daily(segs, daily, "none") | |
| 122 | + assert set(df_none["close"].round(6)) == {100.0, 105.0, 110.0} | |
| 123 | + # additive adjustment also shifts open/high/low by the same offset | |
| 124 | + a_rows = df[df.symbol == A].iloc[0] | |
| 125 | + assert a_rows["high"] == pytest.approx(111.0) and a_rows["low"] == pytest.approx(109.0) | |
| 126 | + | |
| 127 | + | |
| 128 | +def test_gap_null_when_no_common_session(): | |
| 129 | + daily = _daily() | |
| 130 | + daily = daily[~((daily.symbol == B) & (daily.date < "2024-03-20"))] # B has no bars during A's life | |
| 131 | + segs = roll_schedule(CONTRACTS, daily, "calendar") | |
| 132 | + rolls = roll_gaps(segs, daily) | |
| 133 | + assert rolls[0]["gap"] is None and rolls[0]["adjusted"] is False | |
| 134 | + add, _ = adjustment_offsets(rolls, "back_adjusted") | |
| 135 | + assert add[0] == add[1] == 5.0 # only the B→C gap is applied | |
| 136 | + | |
| 137 | + | |
| 138 | +def test_apply_adjustment_handles_empty_frames(): | |
| 139 | + df = apply_adjustment([None, pd.DataFrame(columns=["symbol", "datetime", "open", "high", "low", "close", "volume"])], [0, 0], [1, 1]) | |
| 140 | + assert df.empty | |
| 141 | + seg = Segment("X", date(2024, 1, 1), None, 0) | |
| 142 | + assert seg.end is None | |
| 143 | + | |
| 144 | + | |
| 145 | +def test_backfill_gap_detection_and_status(app): | |
| 146 | + from futures.backfill import ( # needs the test env (SQLite path) set by the app fixture | |
| 147 | + _gaps, | |
| 148 | + _status, | |
| 149 | + ) | |
| 150 | + days = [date(2024, 12, 20), date(2024, 12, 23), date(2025, 1, 6), date(2025, 1, 7)] | |
| 151 | + gaps = _gaps(days, "us") | |
| 152 | + assert gaps == [(date(2024, 12, 24), date(2025, 1, 5), 7)] # 24, 26, 27, 30, 31 Dec, 2, 3 Jan | |
| 153 | + assert _gaps([date(2024, 12, 20), date(2024, 12, 27)], "us") == [] # 23, 24, 26 = 3 missing → not a gap | |
| 154 | + assert _gaps([], "us") == [] and _gaps([date(2024, 1, 2)], "us") == [] | |
| 155 | + today = date(2025, 7, 1) | |
| 156 | + assert _status(date(2025, 6, 30), date(2025, 9, 19), 2025, 9, today) == "active" | |
| 157 | + assert _status(date(2025, 3, 21), date(2025, 3, 21), 2025, 3, today) == "expired" | |
| 158 | + assert _status(date(2025, 6, 28), date(2025, 6, 20), 2025, 6, today) == "active" # recent data → still active | |
| 159 | + assert _status(None, None, 2025, 6, today) == "expired" | |
added
tests/test_futures_symbols.py
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +"""Unit tests — contract symbol parsing and root aliases.""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +import pytest | |
| 5 | +from core.errors import ApiError | |
| 6 | +from futures.symbols import ( | |
| 7 | + ALIASES_OF_ROOT, | |
| 8 | + ROOT_ALIASES, | |
| 9 | + canonical_symbol, | |
| 10 | + expand_year, | |
| 11 | + normalize_root, | |
| 12 | + parse_symbol, | |
| 13 | + symbol_from_file_stem, | |
| 14 | +) | |
| 15 | + | |
| 16 | + | |
| 17 | +@pytest.mark.parametrize("raw, root, mc, year", [ | |
| 18 | + ("ESZ25", "ES", "Z", 2025), | |
| 19 | + ("ESZ2025", "ES", "Z", 2025), | |
| 20 | + ("ES_Z25", "ES", "Z", 2025), | |
| 21 | + ("ES-Z25", "ES", "Z", 2025), | |
| 22 | + ("esz25", "ES", "Z", 2025), | |
| 23 | + (" esz2025 ", "ES", "Z", 2025), | |
| 24 | + ("CLF26", "CL", "F", 2026), | |
| 25 | + ("M2KH25", "M2K", "H", 2025), | |
| 26 | + ("FDAXZ2024", "FDAX", "Z", 2024), | |
| 27 | + ("SR3Z24", "SR3", "Z", 2024), | |
| 28 | + ("ZKZ25", "ZK", "Z", 2025), # root ending with a month letter | |
| 29 | + ("HHZ25", "HH", "Z", 2025), | |
| 30 | + ("CZ25", "C", "Z", 2025), # 1-char root (London cocoa) | |
| 31 | + ("USZ24", "US", "Z", 2024), | |
| 32 | + ("ESZ99", "ES", "Z", 1999), | |
| 33 | + ("ESZ79", "ES", "Z", 2079), | |
| 34 | + ("ESZ80", "ES", "Z", 1980), | |
| 35 | +]) | |
| 36 | +def test_parse_symbol(raw, root, mc, year): | |
| 37 | + cs = parse_symbol(raw) | |
| 38 | + assert (cs.root, cs.month_code, cs.year) == (root, mc, year) | |
| 39 | + assert cs.short == f"{root}{mc}{year % 100:02d}" | |
| 40 | + assert cs.long == f"{root}{mc}{year}" | |
| 41 | + assert cs.file_stem == f"{root}_{mc}{year % 100:02d}" | |
| 42 | + | |
| 43 | + | |
| 44 | +@pytest.mark.parametrize("raw, root", [ | |
| 45 | + ("6EZ25", "E6"), ("6JZ25", "J1"), ("6BZ25", "B6"), ("6AZ25", "A6"), ("6CZ25", "AD"), ("6SZ25", "E1"), | |
| 46 | + ("6NZ25", "N6"), ("6MZ25", "MP"), ("6LZ25", "BR"), ("6ZZ25", "T6"), ("ZBZ25", "US"), ("E6Z25", "E6"), | |
| 47 | +]) | |
| 48 | +def test_aliases_resolve_to_lake_roots(raw, root): | |
| 49 | + assert parse_symbol(raw).root == root | |
| 50 | + assert canonical_symbol(raw) == f"{root}Z25" | |
| 51 | + | |
| 52 | + | |
| 53 | +def test_alias_tables_are_consistent(): | |
| 54 | + for alias, root in ROOT_ALIASES.items(): | |
| 55 | + assert alias in ALIASES_OF_ROOT[root] | |
| 56 | + assert normalize_root(alias) == root | |
| 57 | + assert normalize_root(alias.lower()) == root | |
| 58 | + assert normalize_root("es") == "ES" | |
| 59 | + assert "6E" in ALIASES_OF_ROOT["E6"] and "ZB" in ALIASES_OF_ROOT["US"] | |
| 60 | + | |
| 61 | + | |
| 62 | +@pytest.mark.parametrize("bad", ["", "ES", "ESA25", "ESZ5", "ESZ2025X", "TOOLONGZ25", "ES Z 25", "Z25", "ES25", "ESZ125", | |
| 63 | + "ES_Z_25", "ÉSZ25", "BOGUS", "ESZ1800"]) | |
| 64 | +def test_invalid_symbols_raise_400(bad): | |
| 65 | + with pytest.raises(ApiError) as e: | |
| 66 | + parse_symbol(bad) | |
| 67 | + assert e.value.status == 400 and e.value.code == "INVALID_CONTRACT_SYMBOL" | |
| 68 | + assert e.value.payload()["error"]["docs"].endswith("#invalid_contract_symbol") | |
| 69 | + | |
| 70 | + | |
| 71 | +def test_expand_year(): | |
| 72 | + assert expand_year(0) == 2000 and expand_year(79) == 2079 and expand_year(80) == 1980 and expand_year(99) == 1999 | |
| 73 | + assert expand_year(2031) == 2031 | |
| 74 | + | |
| 75 | + | |
| 76 | +def test_symbol_from_file_stem(): | |
| 77 | + cs = symbol_from_file_stem("ES_Z24_1day") | |
| 78 | + assert cs is not None and cs.short == "ESZ24" and cs.year == 2024 | |
| 79 | + assert symbol_from_file_stem("ES_Z24").short == "ESZ24" | |
| 80 | + assert symbol_from_file_stem("ES_1day") is None | |
| 81 | + assert symbol_from_file_stem("AAPL") is None | |
| 82 | + assert symbol_from_file_stem("ES_ZZ4_1day") is None | |
| 83 | ||