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
42 changes: 42 additions & 0 deletions .github/workflows/live-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Live API Tests

# Runs the live/integration suite against the real OilPriceAPI.
# Gated on the OILPRICEAPI_TEST_KEY repo secret so forks (which don't have it)
# don't fail.

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch: {}

jobs:
live-tests:
name: Live API tests
runs-on: ubuntu-latest
# Only run when the secret is available (skips on forks / PRs from forks).
if: ${{ github.event_name == 'workflow_dispatch' || github.repository == 'OilpriceAPI/python-sdk' }}

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e '.[dev]'

- name: Run live integration tests
env:
OILPRICEAPI_TEST_KEY: ${{ secrets.OILPRICEAPI_TEST_KEY }}
run: |
if [ -z "$OILPRICEAPI_TEST_KEY" ]; then
echo "OILPRICEAPI_TEST_KEY not set; skipping live tests."
exit 0
fi
pytest tests/integration -m live --no-cov -v
46 changes: 40 additions & 6 deletions oilpriceapi/async_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .exceptions import ValidationError
from .models import DieselPrice, DieselStationsResponse, PriceAlert
from .resource_validators import VALID_OPERATORS, format_date
from .resources._futures_slug import normalize_futures_slug


class AsyncDieselResource:
Expand Down Expand Up @@ -297,11 +298,24 @@ 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`.
"""

def __init__(self, client):
self.client = client

async def latest(self, contract: str) -> Dict[str, Any]:
response = await self.client.request(method="GET", path=f"/v1/futures/{contract}")
"""Get the latest futures curve. Accepts a slug or contract code.

Example:
>>> await client.futures.latest("ice-brent") # or "BZ"
"""
slug = normalize_futures_slug(contract)
response = await self.client.request(method="GET", path=f"/v1/futures/{slug}")
if "data" in response:
return response["data"]
return response
Expand All @@ -312,31 +326,34 @@ async def historical(
start_date: Optional[Union[str, date, datetime]] = None,
end_date: Optional[Union[str, date, datetime]] = None
) -> List[Dict[str, Any]]:
slug = normalize_futures_slug(contract)
params = {}
if start_date:
params["start_date"] = format_date(start_date)
if end_date:
params["end_date"] = format_date(end_date)
response = await self.client.request(
method="GET", path=f"/v1/futures/{contract}/historical", params=params
method="GET", path=f"/v1/futures/{slug}/historical", params=params
)
if "data" in response:
return response["data"]
return response

async def ohlc(self, contract: str, date: Optional[str] = None) -> Dict[str, Any]:
slug = normalize_futures_slug(contract)
params = {}
if date:
params["date"] = date
response = await self.client.request(
method="GET", path=f"/v1/futures/{contract}/ohlc", params=params
method="GET", path=f"/v1/futures/{slug}/ohlc", params=params
)
if "data" in response:
return response["data"]
return response

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

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

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

@staticmethod
def _continuous_slug(contract: str) -> str:
slug = normalize_futures_slug(contract)
if slug.startswith("continuous/"):
return slug
if slug == "ice-brent":
return "continuous/brent"
if slug == "ice-wti":
return "continuous/wti"
raise ValueError(
f"Continuous futures are only available for Brent and WTI, "
f"got {contract!r}. Use 'continuous/brent', 'continuous/wti', "
f"'BZ' or 'CL'."
)

class AsyncStorageResource:
def __init__(self, client):
self.client = client
Expand Down
106 changes: 106 additions & 0 deletions oilpriceapi/resources/_futures_slug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""
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
``/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 familiar exchange/contract code (``"BZ"``, ``"CL"``, ``"NG"``, ...),

and :func:`normalize_futures_slug` resolves it to the canonical slug the API
expects. Continuous slugs (``"continuous/brent"``, ``"continuous/wti"``) are
passed through unchanged.

Mappings verified against the Rails API
(``app/controllers/v1/futures_controller.rb`` + ``config/routes.rb``).
"""

from typing import Dict, Set

# Canonical slugs accepted by the API (latest-curve routes).
VALID_SLUGS: Set[str] = {
"ice-brent",
"ice-wti",
"ice-gasoil",
"natural-gas",
"ttf-gas",
"lng-jkm",
"eua-carbon",
"uk-carbon",
"continuous/brent",
"continuous/wti",
}

# Friendly exchange/contract codes -> canonical slug.
# Keys are matched case-insensitively against the leading contract symbol
# (e.g. "CL", "CL.1", "CL1!" all resolve to ice-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",
"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",
"UKA": "uk-carbon", # ICE UKA (UK) carbon
"UK_CARBON": "uk-carbon",
}


def normalize_futures_slug(contract: str) -> str:
"""Resolve a futures ``contract`` argument to the API's canonical slug.

Accepts a canonical slug (returned unchanged), a continuous slug, or a
friendly exchange/contract code (e.g. ``"BZ"``, ``"CL.1"``, ``"NG"``).

Args:
contract: A slug (``"ice-brent"``) or a contract code (``"BZ"``).

Returns:
The canonical slug the API expects (e.g. ``"ice-brent"``).

Raises:
ValueError: If ``contract`` is empty or cannot be resolved.
"""
if not contract or not str(contract).strip():
raise ValueError("futures contract/slug must be a non-empty string")

raw = str(contract).strip()
lowered = raw.lower()

# Already a canonical (or continuous) slug.
if lowered in VALID_SLUGS:
return lowered

# 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]
# Strip a trailing contract-order number (e.g. TradingView "CL1!" -> "CL1").
symbol = symbol.rstrip("0123456789").strip()

slug = CONTRACT_CODE_TO_SLUG.get(symbol)
if slug is not None:
return slug

valid = ", ".join(sorted(VALID_SLUGS))
codes = ", ".join(sorted(CONTRACT_CODE_TO_SLUG))
raise ValueError(
f"Unknown futures contract/slug {contract!r}. "
f"Pass a slug ({valid}) or a contract code ({codes})."
)
Loading
Loading