Skip to content

Commit ca7a267

Browse files
karlwaldmanclaude
andauthored
fix: futures slug normalization + live CI tests (v1.8.1) (#35)
* fix: futures slug normalization + live CI tests (v1.8.1) Futures endpoints are keyed by slug (ice-brent, ice-wti, ...), not raw exchange contract code. The old docstrings advertised "CL.1" which would hit /v1/futures/CL.1 and 404. Add contract-code -> slug normalization so callers can pass either a slug or a friendly code, and fix the misleading examples across the sync and async futures resources. - New oilpriceapi/resources/_futures_slug.py: normalize_futures_slug() maps BZ->ice-brent, CL->ice-wti, G/QS->ice-gasoil, NG->natural-gas, TTF->ttf-gas, JKM->lng-jkm, EUA->eua-carbon, UKA->uk-carbon; passes through canonical and continuous/{brent,wti} slugs; tolerates contract suffixes (CL.1, CL1!, NG-2025-12). Verified against the Rails API controller + routes. - futures.continuous() now targets /v1/futures/continuous/{brent,wti}/historical. - Add live integration test (tests/integration/test_live_futures.py, marked live, excluded from the unit gate) using OILPRICEAPI_TEST_KEY; skips when absent; spaced for the 1 req/sec limit. - Add unit tests for slug normalization. - Add live CI workflow (.github/workflows/live-tests.yml) gated on the OILPRICEAPI_TEST_KEY secret so forks don't fail. - Bump version 1.8.0 -> 1.8.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: remove unused type-ignore (CI mypy under --ignore-missing-imports) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: use unused-ignore so dateutil import passes mypy in both local and CI envs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 77f46f9 commit ca7a267

9 files changed

Lines changed: 407 additions & 33 deletions

File tree

.github/workflows/live-tests.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Live API Tests
2+
3+
# Runs the live/integration suite against the real OilPriceAPI.
4+
# Gated on the OILPRICEAPI_TEST_KEY repo secret so forks (which don't have it)
5+
# don't fail.
6+
7+
on:
8+
push:
9+
branches: [main]
10+
pull_request:
11+
branches: [main]
12+
workflow_dispatch: {}
13+
14+
jobs:
15+
live-tests:
16+
name: Live API tests
17+
runs-on: ubuntu-latest
18+
# Only run when the secret is available (skips on forks / PRs from forks).
19+
if: ${{ github.event_name == 'workflow_dispatch' || github.repository == 'OilpriceAPI/python-sdk' }}
20+
21+
steps:
22+
- uses: actions/checkout@v4
23+
24+
- name: Set up Python
25+
uses: actions/setup-python@v5
26+
with:
27+
python-version: "3.12"
28+
29+
- name: Install dependencies
30+
run: |
31+
python -m pip install --upgrade pip
32+
pip install -e '.[dev]'
33+
34+
- name: Run live integration tests
35+
env:
36+
OILPRICEAPI_TEST_KEY: ${{ secrets.OILPRICEAPI_TEST_KEY }}
37+
run: |
38+
if [ -z "$OILPRICEAPI_TEST_KEY" ]; then
39+
echo "OILPRICEAPI_TEST_KEY not set; skipping live tests."
40+
exit 0
41+
fi
42+
pytest tests/integration -m live --no-cov -v

oilpriceapi/async_resources.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .exceptions import ValidationError
77
from .models import DieselPrice, DieselStationsResponse, PriceAlert
88
from .resource_validators import VALID_OPERATORS, format_date
9+
from .resources._futures_slug import normalize_futures_slug
910

1011

1112
class AsyncDieselResource:
@@ -297,11 +298,24 @@ async def categories(self) -> Dict[str, List[Dict[str, Any]]]:
297298

298299

299300
class AsyncFuturesResource:
301+
"""Async resource for futures contract operations.
302+
303+
Endpoints are keyed by *slug* (e.g. ``"ice-brent"``). Methods accept either
304+
a slug or a friendly contract code (``"BZ"``, ``"CL"``, ``"NG"``, ...),
305+
normalized via :func:`normalize_futures_slug`.
306+
"""
307+
300308
def __init__(self, client):
301309
self.client = client
302310

303311
async def latest(self, contract: str) -> Dict[str, Any]:
304-
response = await self.client.request(method="GET", path=f"/v1/futures/{contract}")
312+
"""Get the latest futures curve. Accepts a slug or contract code.
313+
314+
Example:
315+
>>> await client.futures.latest("ice-brent") # or "BZ"
316+
"""
317+
slug = normalize_futures_slug(contract)
318+
response = await self.client.request(method="GET", path=f"/v1/futures/{slug}")
305319
if "data" in response:
306320
return response["data"]
307321
return response
@@ -312,31 +326,34 @@ async def historical(
312326
start_date: Optional[Union[str, date, datetime]] = None,
313327
end_date: Optional[Union[str, date, datetime]] = None
314328
) -> List[Dict[str, Any]]:
329+
slug = normalize_futures_slug(contract)
315330
params = {}
316331
if start_date:
317332
params["start_date"] = format_date(start_date)
318333
if end_date:
319334
params["end_date"] = format_date(end_date)
320335
response = await self.client.request(
321-
method="GET", path=f"/v1/futures/{contract}/historical", params=params
336+
method="GET", path=f"/v1/futures/{slug}/historical", params=params
322337
)
323338
if "data" in response:
324339
return response["data"]
325340
return response
326341

327342
async def ohlc(self, contract: str, date: Optional[str] = None) -> Dict[str, Any]:
343+
slug = normalize_futures_slug(contract)
328344
params = {}
329345
if date:
330346
params["date"] = date
331347
response = await self.client.request(
332-
method="GET", path=f"/v1/futures/{contract}/ohlc", params=params
348+
method="GET", path=f"/v1/futures/{slug}/ohlc", params=params
333349
)
334350
if "data" in response:
335351
return response["data"]
336352
return response
337353

338354
async def intraday(self, contract: str) -> List[Dict[str, Any]]:
339-
response = await self.client.request(method="GET", path=f"/v1/futures/{contract}/intraday")
355+
slug = normalize_futures_slug(contract)
356+
response = await self.client.request(method="GET", path=f"/v1/futures/{slug}/intraday")
340357
if "data" in response:
341358
return response["data"]
342359
return response
@@ -351,19 +368,36 @@ async def spreads(self, contract1: str, contract2: str) -> Dict[str, Any]:
351368
return response
352369

353370
async def curve(self, contract: str) -> List[Dict[str, Any]]:
354-
response = await self.client.request(method="GET", path=f"/v1/futures/{contract}/curve")
371+
slug = normalize_futures_slug(contract)
372+
response = await self.client.request(method="GET", path=f"/v1/futures/{slug}/curve")
355373
if "data" in response:
356374
return response["data"]
357375
return response
358376

359377
async def continuous(self, contract: str, months: int = 12) -> List[Dict[str, Any]]:
378+
slug = self._continuous_slug(contract)
360379
response = await self.client.request(
361-
method="GET", path=f"/v1/futures/{contract}/continuous", params={"months": months}
380+
method="GET", path=f"/v1/futures/{slug}/historical", params={"months": months}
362381
)
363382
if "data" in response:
364383
return response["data"]
365384
return response
366385

386+
@staticmethod
387+
def _continuous_slug(contract: str) -> str:
388+
slug = normalize_futures_slug(contract)
389+
if slug.startswith("continuous/"):
390+
return slug
391+
if slug == "ice-brent":
392+
return "continuous/brent"
393+
if slug == "ice-wti":
394+
return "continuous/wti"
395+
raise ValueError(
396+
f"Continuous futures are only available for Brent and WTI, "
397+
f"got {contract!r}. Use 'continuous/brent', 'continuous/wti', "
398+
f"'BZ' or 'CL'."
399+
)
400+
367401
class AsyncStorageResource:
368402
def __init__(self, client):
369403
self.client = client
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""
2+
Futures slug normalization.
3+
4+
The OilPriceAPI futures endpoints are keyed by *slug*, not by exchange
5+
contract code. The latest-curve route is ``GET /v1/futures/{slug}`` and the
6+
sub-resources are ``/v1/futures/{slug}/curve``, ``/historical``, ``/ohlc``,
7+
``/intraday`` and ``/spread-history``. There is no ``?contract=`` route, so a
8+
caller passing a raw ticker such as ``"CL.1"`` would hit
9+
``/v1/futures/CL.1`` and get a 404.
10+
11+
To keep the SDK friendly, callers may pass either:
12+
13+
* a canonical slug (``"ice-brent"``, ``"ice-wti"``, ``"natural-gas"``, ...), or
14+
* a familiar exchange/contract code (``"BZ"``, ``"CL"``, ``"NG"``, ...),
15+
16+
and :func:`normalize_futures_slug` resolves it to the canonical slug the API
17+
expects. Continuous slugs (``"continuous/brent"``, ``"continuous/wti"``) are
18+
passed through unchanged.
19+
20+
Mappings verified against the Rails API
21+
(``app/controllers/v1/futures_controller.rb`` + ``config/routes.rb``).
22+
"""
23+
24+
from typing import Dict, Set
25+
26+
# Canonical slugs accepted by the API (latest-curve routes).
27+
VALID_SLUGS: Set[str] = {
28+
"ice-brent",
29+
"ice-wti",
30+
"ice-gasoil",
31+
"natural-gas",
32+
"ttf-gas",
33+
"lng-jkm",
34+
"eua-carbon",
35+
"uk-carbon",
36+
"continuous/brent",
37+
"continuous/wti",
38+
}
39+
40+
# Friendly exchange/contract codes -> canonical slug.
41+
# Keys are matched case-insensitively against the leading contract symbol
42+
# (e.g. "CL", "CL.1", "CL1!" all resolve to ice-wti).
43+
CONTRACT_CODE_TO_SLUG: Dict[str, str] = {
44+
"BZ": "ice-brent", # ICE Brent
45+
"BRENT": "ice-brent",
46+
"CL": "ice-wti", # WTI (NYMEX/ICE ticker)
47+
"WTI": "ice-wti",
48+
"G": "ice-gasoil", # ICE Gas Oil
49+
"QS": "ice-gasoil", # ICE Gas Oil (alt ticker)
50+
"GASOIL": "ice-gasoil",
51+
"NG": "natural-gas", # NYMEX Henry Hub natural gas
52+
"NATGAS": "natural-gas",
53+
"TTF": "ttf-gas", # ICE TTF natural gas
54+
"JKM": "lng-jkm", # ICE/CME JKM LNG
55+
"LNG": "lng-jkm",
56+
"EUA": "eua-carbon", # ICE EUA carbon
57+
"EU_CARBON": "eua-carbon",
58+
"UKA": "uk-carbon", # ICE UKA (UK) carbon
59+
"UK_CARBON": "uk-carbon",
60+
}
61+
62+
63+
def normalize_futures_slug(contract: str) -> str:
64+
"""Resolve a futures ``contract`` argument to the API's canonical slug.
65+
66+
Accepts a canonical slug (returned unchanged), a continuous slug, or a
67+
friendly exchange/contract code (e.g. ``"BZ"``, ``"CL.1"``, ``"NG"``).
68+
69+
Args:
70+
contract: A slug (``"ice-brent"``) or a contract code (``"BZ"``).
71+
72+
Returns:
73+
The canonical slug the API expects (e.g. ``"ice-brent"``).
74+
75+
Raises:
76+
ValueError: If ``contract`` is empty or cannot be resolved.
77+
"""
78+
if not contract or not str(contract).strip():
79+
raise ValueError("futures contract/slug must be a non-empty string")
80+
81+
raw = str(contract).strip()
82+
lowered = raw.lower()
83+
84+
# Already a canonical (or continuous) slug.
85+
if lowered in VALID_SLUGS:
86+
return lowered
87+
88+
# Contract code form: take the leading symbol before any month/order
89+
# suffix such as ".1", "1!", "-2025-12", "_2025_12".
90+
symbol = raw.upper()
91+
for sep in (".", "!", "-", "_", " "):
92+
if sep in symbol:
93+
symbol = symbol.split(sep, 1)[0]
94+
# Strip a trailing contract-order number (e.g. TradingView "CL1!" -> "CL1").
95+
symbol = symbol.rstrip("0123456789").strip()
96+
97+
slug = CONTRACT_CODE_TO_SLUG.get(symbol)
98+
if slug is not None:
99+
return slug
100+
101+
valid = ", ".join(sorted(VALID_SLUGS))
102+
codes = ", ".join(sorted(CONTRACT_CODE_TO_SLUG))
103+
raise ValueError(
104+
f"Unknown futures contract/slug {contract!r}. "
105+
f"Pass a slug ({valid}) or a contract code ({codes})."
106+
)

0 commit comments

Comments
 (0)