diff --git a/CHANGELOG.md b/CHANGELOG.md index 205ce1f..295b694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.12.6] - 2026-08-11 + +### Changed + +- Route Brent, WTI, gasoil, and EU carbon futures through the API's + instrument-generic paths in sync and async clients. Existing venue-slug and + contract-code inputs remain compatible and normalize to those same paths. + ## [1.12.5] - 2026-08-11 ### Added diff --git a/oilpriceapi/async_resources.py b/oilpriceapi/async_resources.py index 03ec65a..a723920 100644 --- a/oilpriceapi/async_resources.py +++ b/oilpriceapi/async_resources.py @@ -316,9 +316,16 @@ async def categories(self) -> Dict[str, List[Dict[str, Any]]]: class AsyncFuturesResource: """Async resource for futures contract operations. - Endpoints are keyed by *slug* (e.g. ``"ice-brent"``). Methods accept either - a slug or a friendly contract code (``"BZ"``, ``"CL"``, ``"NG"``, ...), - normalized via :func:`normalize_futures_slug`. + Endpoints are keyed by instrument-generic slugs (e.g. ``"brent"``). + Methods accept either a slug or a friendly contract code (``"BZ"``, + ``"CL"``, ``"NG"``, ...), normalized via + :func:`normalize_futures_slug`. + + Examples: + >>> await client.futures.latest("brent") + >>> await client.futures.latest("wti") + >>> await client.futures.latest("gasoil") + >>> await client.futures.latest("eu-carbon") """ def __init__(self, client): @@ -328,7 +335,7 @@ async def latest(self, contract: str) -> Dict[str, Any]: """Get the latest futures curve. Accepts a slug or contract code. Example: - >>> await client.futures.latest("ice-brent") # or "BZ" + >>> await client.futures.latest("brent") # or "BZ" """ slug = normalize_futures_slug(contract) response = await self.client.request(method="GET", path=f"/v1/futures/{slug}") @@ -404,9 +411,9 @@ def _continuous_slug(contract: str) -> str: slug = normalize_futures_slug(contract) if slug.startswith("continuous/"): return slug - if slug == "ice-brent": + if slug == "brent": return "continuous/brent" - if slug == "ice-wti": + if slug == "wti": return "continuous/wti" raise ValueError( f"Continuous futures are only available for Brent and WTI, " diff --git a/oilpriceapi/resources/_futures_slug.py b/oilpriceapi/resources/_futures_slug.py index a986b6c..fca74cb 100644 --- a/oilpriceapi/resources/_futures_slug.py +++ b/oilpriceapi/resources/_futures_slug.py @@ -1,16 +1,17 @@ """ Futures slug normalization. -The OilPriceAPI futures endpoints are keyed by *slug*, not by exchange -contract code. The latest-curve route is ``GET /v1/futures/{slug}`` and the -sub-resources are ``/v1/futures/{slug}/curve``, ``/historical``, ``/ohlc``, -``/intraday`` and ``/spread-history``. There is no ``?contract=`` route, so a -caller passing a raw ticker such as ``"CL.1"`` would hit +The OilPriceAPI futures endpoints are keyed by instrument-generic slugs, not by +exchange contract codes. The latest-curve route is ``GET /v1/futures/{slug}`` +and the sub-resources are ``/v1/futures/{slug}/curve``, ``/historical``, +``/ohlc``, ``/intraday`` and ``/spread-history``. There is no ``?contract=`` +route, so a caller passing a raw ticker such as ``"CL.1"`` would hit ``/v1/futures/CL.1`` and get a 404. To keep the SDK friendly, callers may pass either: -* a canonical slug (``"ice-brent"``, ``"ice-wti"``, ``"natural-gas"``, ...), or +* a canonical slug (``"brent"``, ``"wti"``, ``"natural-gas"``, ...), +* a legacy venue slug (``"ice-brent"``, ``"ice-wti"``, ...), or * a familiar exchange/contract code (``"BZ"``, ``"CL"``, ``"NG"``, ...), and :func:`normalize_futures_slug` resolves it to the canonical slug the API @@ -23,38 +24,47 @@ from typing import Dict, Set -# Canonical slugs accepted by the API (latest-curve routes). +# Canonical slugs emitted by the SDK for latest-curve routes. VALID_SLUGS: Set[str] = { - "ice-brent", - "ice-wti", - "ice-gasoil", + "brent", + "wti", + "gasoil", "natural-gas", "ttf-gas", "lng-jkm", - "eua-carbon", + "eu-carbon", "uk-carbon", "continuous/brent", "continuous/wti", } -# Friendly exchange/contract codes -> canonical slug. +# Older public route names remain valid caller inputs, but the SDK emits the +# instrument-generic route so new traffic does not encode a reporting venue. +LEGACY_SLUG_TO_CANONICAL: Dict[str, str] = { + "ice-brent": "brent", + "ice-wti": "wti", + "ice-gasoil": "gasoil", + "eua-carbon": "eu-carbon", +} + +# Friendly exchange/contract codes -> canonical instrument slug. # Keys are matched case-insensitively against the leading contract symbol -# (e.g. "CL", "CL.1", "CL1!" all resolve to ice-wti). +# (e.g. "CL", "CL.1", "CL1!" all resolve to wti). CONTRACT_CODE_TO_SLUG: Dict[str, str] = { - "BZ": "ice-brent", # ICE Brent - "BRENT": "ice-brent", - "CL": "ice-wti", # WTI (NYMEX/ICE ticker) - "WTI": "ice-wti", - "G": "ice-gasoil", # ICE Gas Oil - "QS": "ice-gasoil", # ICE Gas Oil (alt ticker) - "GASOIL": "ice-gasoil", + "BZ": "brent", + "BRENT": "brent", + "CL": "wti", + "WTI": "wti", + "G": "gasoil", + "QS": "gasoil", + "GASOIL": "gasoil", "NG": "natural-gas", # NYMEX Henry Hub natural gas "NATGAS": "natural-gas", "TTF": "ttf-gas", # ICE TTF natural gas "JKM": "lng-jkm", # ICE/CME JKM LNG "LNG": "lng-jkm", - "EUA": "eua-carbon", # ICE EUA carbon - "EU_CARBON": "eua-carbon", + "EUA": "eu-carbon", + "EU_CARBON": "eu-carbon", "UKA": "uk-carbon", # ICE UKA (UK) carbon "UK_CARBON": "uk-carbon", } @@ -67,10 +77,10 @@ def normalize_futures_slug(contract: str) -> str: friendly exchange/contract code (e.g. ``"BZ"``, ``"CL.1"``, ``"NG"``). Args: - contract: A slug (``"ice-brent"``) or a contract code (``"BZ"``). + contract: A slug (``"brent"``) or a contract code (``"BZ"``). Returns: - The canonical slug the API expects (e.g. ``"ice-brent"``). + The instrument-generic slug the API expects (e.g. ``"brent"``). Raises: ValueError: If ``contract`` is empty or cannot be resolved. @@ -85,9 +95,17 @@ def normalize_futures_slug(contract: str) -> str: if lowered in VALID_SLUGS: return lowered + legacy_slug = LEGACY_SLUG_TO_CANONICAL.get(lowered) + if legacy_slug is not None: + return legacy_slug + + symbol = raw.upper() + exact_code_slug = CONTRACT_CODE_TO_SLUG.get(symbol) + if exact_code_slug is not None: + return exact_code_slug + # Contract code form: take the leading symbol before any month/order # suffix such as ".1", "1!", "-2025-12", "_2025_12". - symbol = raw.upper() for sep in (".", "!", "-", "_", " "): if sep in symbol: symbol = symbol.split(sep, 1)[0] diff --git a/oilpriceapi/resources/futures.py b/oilpriceapi/resources/futures.py index 3b51686..28cbcc8 100644 --- a/oilpriceapi/resources/futures.py +++ b/oilpriceapi/resources/futures.py @@ -3,16 +3,22 @@ Futures contract price operations. -Endpoints are keyed by *slug* (e.g. ``"ice-brent"``), not by raw exchange -contract code. The latest-curve route is ``GET /v1/futures/{slug}`` and the -sub-resources are ``/{slug}/curve``, ``/historical``, ``/ohlc``, ``/intraday`` -and ``/spread-history``. Each method accepts either a slug or a friendly -contract code (e.g. ``"BZ"``, ``"CL"``, ``"NG"``) and normalizes it via -:mod:`._futures_slug`. - -Valid slugs: ``ice-brent``, ``ice-wti``, ``ice-gasoil``, ``natural-gas``, -``ttf-gas``, ``lng-jkm``, ``eua-carbon``, ``uk-carbon`` (+ continuous slugs +Endpoints are keyed by instrument-generic slugs (e.g. ``"brent"``), not by raw +exchange contract code. The latest-curve route is ``GET /v1/futures/{slug}`` +and the sub-resources are ``/{slug}/curve``, ``/historical``, ``/ohlc``, +``/intraday`` and ``/spread-history``. Each method accepts either a slug or a +friendly contract code (e.g. ``"BZ"``, ``"CL"``, ``"NG"``) and normalizes it +via :mod:`._futures_slug`. + +Valid slugs: ``brent``, ``wti``, ``gasoil``, ``natural-gas``, +``ttf-gas``, ``lng-jkm``, ``eu-carbon``, ``uk-carbon`` (+ continuous slugs ``continuous/brent`` and ``continuous/wti``). + +Examples: + >>> client.futures.latest("brent") + >>> client.futures.latest("wti") + >>> client.futures.latest("gasoil") + >>> client.futures.latest("eu-carbon") """ from datetime import date, datetime @@ -37,14 +43,14 @@ def latest(self, contract: str) -> Dict[str, Any]: """Get the latest futures curve for a contract family. Args: - contract: Futures slug (e.g. ``"ice-brent"``, ``"ice-wti"``) or a + contract: Futures slug (e.g. ``"brent"``, ``"wti"``) or a friendly contract code (e.g. ``"BZ"``, ``"CL"``, ``"NG"``). Returns: Latest futures curve data (front month + forward contracts) Example: - >>> curve = client.futures.latest("ice-brent") + >>> curve = client.futures.latest("brent") >>> # Friendly code form also works: >>> curve = client.futures.latest("BZ") >>> print(curve["front_month"]["last_price"]) @@ -78,7 +84,7 @@ def historical( Example: >>> history = client.futures.historical( - ... contract="ice-wti", + ... contract="wti", ... start_date="2024-01-01", ... end_date="2024-12-31" ... ) @@ -114,7 +120,7 @@ def ohlc(self, contract: str, date: Optional[str] = None) -> Dict[str, Any]: OHLC data with open, high, low, close, and volume Example: - >>> ohlc = client.futures.ohlc("ice-wti") + >>> ohlc = client.futures.ohlc("wti") >>> print(f"Open: ${ohlc['open']:.2f}") >>> print(f"High: ${ohlc['high']:.2f}") >>> print(f"Low: ${ohlc['low']:.2f}") @@ -146,7 +152,7 @@ def intraday(self, contract: str) -> List[Dict[str, Any]]: List of intraday price records Example: - >>> intraday = client.futures.intraday("ice-wti") + >>> intraday = client.futures.intraday("wti") >>> for record in intraday: ... print(f"{record['time']}: ${record['price']:.2f}") """ @@ -199,7 +205,7 @@ def curve(self, contract: str) -> List[Dict[str, Any]]: List of futures curve data points Example: - >>> curve = client.futures.curve("ice-wti") + >>> curve = client.futures.curve("wti") >>> for point in curve: ... print(f"{point['month']}: ${point['price']:.2f}") """ @@ -251,9 +257,9 @@ def _continuous_slug(contract: str) -> str: slug = normalize_futures_slug(contract) if slug.startswith("continuous/"): return slug - if slug in ("ice-brent",): + if slug == "brent": return "continuous/brent" - if slug in ("ice-wti",): + if slug == "wti": return "continuous/wti" raise ValueError( f"Continuous futures are only available for Brent and WTI, " diff --git a/oilpriceapi/version.py b/oilpriceapi/version.py index ad90a92..0868701 100644 --- a/oilpriceapi/version.py +++ b/oilpriceapi/version.py @@ -5,6 +5,6 @@ Used in __init__.py, client.py, and async_client.py. """ -__version__ = "1.12.5" +__version__ = "1.12.6" SDK_VERSION = __version__ SDK_NAME = "oilpriceapi-python" diff --git a/pyproject.toml b/pyproject.toml index 50906ae..a548709 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "oilpriceapi" -version = "1.12.5" +version = "1.12.6" description = "Official Python SDK for source-timestamped OilPriceAPI energy data" authors = [ {name = "OilPriceAPI", email = "support@oilpriceapi.com"} diff --git a/tests/integration/test_live_futures.py b/tests/integration/test_live_futures.py index a5779c0..a7bd4a7 100644 --- a/tests/integration/test_live_futures.py +++ b/tests/integration/test_live_futures.py @@ -57,8 +57,8 @@ def live_client(): def test_latest_by_slug(live_client): - """futures.latest('ice-brent') returns 200 + a sane Brent price.""" - curve = live_client.futures.latest("ice-brent") + """futures.latest('brent') returns 200 + a sane Brent price.""" + curve = live_client.futures.latest("brent") assert isinstance(curve, dict) price = _front_price(curve) # Sanity range for Brent crude (USD/bbl). @@ -66,7 +66,7 @@ def test_latest_by_slug(live_client): def test_latest_by_contract_code(live_client): - """Friendly code 'BZ' normalizes to ice-brent and returns a sane price.""" + """Friendly code 'BZ' normalizes to brent and returns a sane price.""" time.sleep(RATE_LIMIT_SLEEP) curve = live_client.futures.latest("BZ") assert isinstance(curve, dict) @@ -74,10 +74,19 @@ def test_latest_by_contract_code(live_client): assert 10 < price < 500, f"Brent (BZ) price out of sane range: {price}" +def test_latest_by_legacy_slug(live_client): + """Legacy venue input remains compatible while using the generic route.""" + time.sleep(RATE_LIMIT_SLEEP) + curve = live_client.futures.latest("ice-brent") + assert isinstance(curve, dict) + price = _front_price(curve) + assert 10 < price < 500, f"Brent legacy input price out of sane range: {price}" + + def test_curve(live_client): - """futures.curve('ice-brent') returns 200 with curve data.""" + """futures.curve('brent') returns 200 with curve data.""" time.sleep(RATE_LIMIT_SLEEP) - curve = live_client.futures.curve("ice-brent") + curve = live_client.futures.curve("brent") # Curve responses may be a list of points or a dict wrapping them. assert curve is not None if isinstance(curve, dict): diff --git a/tests/test_release_readiness.py b/tests/test_release_readiness.py index 1d1f893..40a4efb 100644 --- a/tests/test_release_readiness.py +++ b/tests/test_release_readiness.py @@ -87,6 +87,23 @@ def test_examples_defer_mutable_allowances_to_product_facts() -> None: ) +def test_packaged_futures_examples_prefer_instrument_generic_slugs() -> None: + for path in ( + "oilpriceapi/resources/futures.py", + "oilpriceapi/async_resources.py", + ): + source = (ROOT / path).read_text() + example_lines = re.findall(r"(?m)^\s*(?:>>>|\.\.\.)\s+.*$", source) + examples = "\n".join(example_lines) + + for example_line in example_lines: + assert not re.search(r"[\"'](?:ice|eua)-", example_line) + for canonical_slug in ("brent", "wti", "gasoil", "eu-carbon"): + assert canonical_slug in examples, ( + f"{path} examples omit {canonical_slug}" + ) + + def test_publish_gate_audits_and_installs_the_built_wheel() -> None: workflow = (ROOT / ".github" / "workflows" / "publish.yml").read_text() smoke = (ROOT / "scripts" / "clean-wheel-smoke.sh").read_text() @@ -142,7 +159,7 @@ def test_package_version_helper_reads_the_project_version() -> None: capture_output=True, text=True, ) - assert result.stdout.strip() == "1.12.5" + assert result.stdout.strip() == "1.12.6" def test_every_workflow_pins_actions_and_hardens_each_checkout_step() -> None: diff --git a/tests/unit/test_async_futures_resource.py b/tests/unit/test_async_futures_resource.py new file mode 100644 index 0000000..03fdcc8 --- /dev/null +++ b/tests/unit/test_async_futures_resource.py @@ -0,0 +1,68 @@ +"""Async futures routes use instrument-generic API paths.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from oilpriceapi import AsyncOilPriceAPI + + +@pytest.fixture +def client(): + return AsyncOilPriceAPI(api_key="test_key") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method", ["latest", "historical", "ohlc", "intraday", "curve"] +) +@pytest.mark.parametrize( + "contract,canonical", + [ + ("brent", "brent"), + ("BZ", "brent"), + ("ice-brent", "brent"), + ("wti", "wti"), + ("CL", "wti"), + ("ice-wti", "wti"), + ("gasoil", "gasoil"), + ("G", "gasoil"), + ("ice-gasoil", "gasoil"), + ("eu-carbon", "eu-carbon"), + ("EUA", "eu-carbon"), + ("eua-carbon", "eu-carbon"), + ], +) +async def test_futures_methods_use_generic_routes( + client, method, contract, canonical +): + suffix = "" if method == "latest" else f"/{method}" + request = AsyncMock(return_value={"data": []}) + with patch.object(client, "request", new=request): + await getattr(client.futures, method)(contract) + + assert request.call_args.kwargs["path"] == f"/v1/futures/{canonical}{suffix}" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "contract,canonical", + [ + ("brent", "brent"), + ("BZ", "brent"), + ("ice-brent", "brent"), + ("wti", "wti"), + ("CL", "wti"), + ("ice-wti", "wti"), + ], +) +async def test_continuous_accepts_generic_code_and_legacy_inputs( + client, contract, canonical +): + request = AsyncMock(return_value={"data": []}) + with patch.object(client, "request", new=request): + await client.futures.continuous(contract) + + assert request.call_args.kwargs["path"] == ( + f"/v1/futures/continuous/{canonical}/historical" + ) diff --git a/tests/unit/test_futures_resource.py b/tests/unit/test_futures_resource.py index ff69135..2b12642 100644 --- a/tests/unit/test_futures_resource.py +++ b/tests/unit/test_futures_resource.py @@ -2,9 +2,11 @@ Unit tests for FuturesResource """ -import pytest -from unittest.mock import Mock, patch from datetime import date, datetime +from unittest.mock import patch + +import pytest + from oilpriceapi import OilPriceAPI @@ -26,6 +28,56 @@ def test_latest(self, client): assert price["contract"] == "CL.1" assert price["price"] == 75.50 + @pytest.mark.parametrize( + "method", ["latest", "historical", "ohlc", "intraday", "curve"] + ) + @pytest.mark.parametrize( + "contract,canonical", + [ + ("brent", "brent"), + ("BZ", "brent"), + ("ice-brent", "brent"), + ("wti", "wti"), + ("CL", "wti"), + ("ice-wti", "wti"), + ("gasoil", "gasoil"), + ("G", "gasoil"), + ("ice-gasoil", "gasoil"), + ("eu-carbon", "eu-carbon"), + ("EUA", "eu-carbon"), + ("eua-carbon", "eu-carbon"), + ], + ) + def test_futures_methods_use_generic_routes( + self, client, method, contract, canonical + ): + suffix = "" if method == "latest" else f"/{method}" + with patch.object(client, "request", return_value={"data": []}) as request: + getattr(client.futures, method)(contract) + + assert request.call_args.kwargs["path"] == f"/v1/futures/{canonical}{suffix}" + + @pytest.mark.parametrize( + "contract,canonical", + [ + ("brent", "brent"), + ("BZ", "brent"), + ("ice-brent", "brent"), + ("wti", "wti"), + ("CL", "wti"), + ("ice-wti", "wti"), + ], + ) + def test_continuous_accepts_generic_code_and_legacy_inputs( + self, client, contract, canonical + ): + with patch.object(client, "request", return_value={"data": []}) as request: + client.futures.continuous(contract) + + assert request.call_args.kwargs["path"] == ( + f"/v1/futures/continuous/{canonical}/historical" + ) + def test_historical(self, client): """Test getting historical futures prices""" mock_history = [ @@ -47,7 +99,7 @@ def test_historical_with_date_objects(self, client): start = date(2024, 1, 1) end = datetime(2024, 1, 31, 23, 59, 59) - history = client.futures.historical("CL.1", start_date=start, end_date=end) + client.futures.historical("CL.1", start_date=start, end_date=end) call_args = mock_req.call_args assert call_args[1]["params"]["start_date"] == "2024-01-01" diff --git a/tests/unit/test_futures_slug.py b/tests/unit/test_futures_slug.py index 210a037..aa2020e 100644 --- a/tests/unit/test_futures_slug.py +++ b/tests/unit/test_futures_slug.py @@ -15,21 +15,36 @@ def test_canonical_slug_passthrough(self): assert normalize_futures_slug(slug) == slug def test_slug_case_insensitive(self): - assert normalize_futures_slug("ICE-BRENT") == "ice-brent" + assert normalize_futures_slug("BRENT") == "brent" assert normalize_futures_slug("Natural-Gas") == "natural-gas" + @pytest.mark.parametrize( + "legacy,expected", + [ + ("ice-brent", "brent"), + ("ICE-BRENT", "brent"), + ("ice-wti", "wti"), + ("ice-gasoil", "gasoil"), + ("eua-carbon", "eu-carbon"), + ], + ) + def test_legacy_venue_slug_normalizes_to_generic_route(self, legacy, expected): + assert normalize_futures_slug(legacy) == expected + @pytest.mark.parametrize( "code,expected", [ - ("BZ", "ice-brent"), - ("CL", "ice-wti"), - ("G", "ice-gasoil"), - ("QS", "ice-gasoil"), + ("BZ", "brent"), + ("CL", "wti"), + ("G", "gasoil"), + ("QS", "gasoil"), ("NG", "natural-gas"), ("TTF", "ttf-gas"), ("JKM", "lng-jkm"), - ("EUA", "eua-carbon"), + ("EUA", "eu-carbon"), + ("EU_CARBON", "eu-carbon"), ("UKA", "uk-carbon"), + ("UK_CARBON", "uk-carbon"), ], ) def test_contract_code_to_slug(self, code, expected): @@ -39,11 +54,11 @@ def test_contract_code_to_slug(self, code, expected): @pytest.mark.parametrize( "code,expected", [ - ("CL.1", "ice-wti"), - ("BZ.1", "ice-brent"), - ("CL1!", "ice-wti"), + ("CL.1", "wti"), + ("BZ.1", "brent"), + ("CL1!", "wti"), ("NG-2025-12", "natural-gas"), - ("BZ_2026_01", "ice-brent"), + ("BZ_2026_01", "brent"), ], ) def test_contract_code_with_suffix(self, code, expected):