Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 13 additions & 6 deletions oilpriceapi/async_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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}")
Expand Down Expand Up @@ -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, "
Expand Down
68 changes: 43 additions & 25 deletions oilpriceapi/resources/_futures_slug.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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",
}
Expand All @@ -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.
Expand All @@ -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]
Expand Down
40 changes: 23 additions & 17 deletions oilpriceapi/resources/futures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"])
Expand Down Expand Up @@ -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"
... )
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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}")
"""
Expand Down Expand Up @@ -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}")
"""
Expand Down Expand Up @@ -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, "
Expand Down
2 changes: 1 addition & 1 deletion oilpriceapi/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
19 changes: 14 additions & 5 deletions tests/integration/test_live_futures.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,27 +57,36 @@ 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).
assert 10 < price < 500, f"Brent price out of sane range: {price}"


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)
price = _front_price(curve)
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):
Expand Down
19 changes: 18 additions & 1 deletion tests/test_release_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
Loading