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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ cython_debug/
!.env.example
!schemas/*.json
!examples/snippets/*.json
# Verbatim API response bodies used as unit-test fixtures (#99).
!tests/unit/fixtures/**/*.json

# Keep example notebooks and GitHub Pages docs
!examples/*.ipynb
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil

### Added

- **Typed `client.spreads` and `client.indicators` resources (#99), sync and
async.** They cover the server-calculated `/v1/spreads/*` routes: `crack`,
`crack_historical`, `crack_all`, `gasoil_crack`, `basis`,
`basis_historical`, `basis_all`, `curve_structure`, `curve_structure_all`,
`margin`, `margin_historical`, `margin_all`, `physical_premium`,
`physical_premium_historical` and `physical_premium_all`. They also cover the
`/v1/indicators/*` routes: `fuel_switching`, `fuel_switching_historical`,
`price_context`, `storage_analytics`, `storage_analytics_all`,
`annotations`, `annotations_batch`, `cftc_positioning`,
`cftc_positioning_historical` and `cftc_positioning_all`.
- Each method returns a pydantic model from the new
`oilpriceapi.metrics_models` module. The models are typed from production
responses captured on 2026-09-13.
- Timestamps parse to timezone-aware `datetime` and calendar dates to `date`.
Units, full-precision values and nulls are kept exactly as sent.
- A key the server always emits is required. A 200 that drops it, changes its
type, or breaks the envelope raises
`OilPriceAPIError(code="MALFORMED_RESPONSE")` with the raw body. It is never
defaulted.
- History responses expose the server-applied `period` and, for crack
spreads, the `coverage` actually returned.
- Blank selectors, invalid dates, `start_date` after `end_date`, and more than
20 codes for `annotations_batch` are refused before any request is sent,
with `ValidationError` (an `OilPriceAPIError`) carrying `field`, `value`
and `status_code=None`.
The API would otherwise return a default window, or silently annotate only
the first 20 codes.
- `/v1/indicators/congressional-trades` is deliberately not exposed. It has
never returned data in production, so there is no response shape to type.
- **Subscription lifecycle: `get`, `update`, `pause`, `resume` (#100).** Sync
and async, against `GET`/`PATCH /v1/subscriptions/{id}` and
`POST /v1/subscriptions/{id}/pause|resume`. Each returns a typed
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,34 @@ print(
Use the raw first-request pattern when downstream logic requires the exact
source and timestamp-field semantics from the API response.

## Spreads and Indicators

`client.spreads` and `client.indicators` return typed models for the
server-calculated `/v1/spreads/*` and `/v1/indicators/*` routes: crack, gasoil
crack, basis, curve structure, refinery margin, physical premium, fuel-switching
parity, price context, storage analytics, market annotations, and CFTC
positioning. The async client exposes the same methods. These routes require a
paid plan; other plans receive `PermissionDeniedError` (`PREMIUM_REQUIRED`).

```python
import os

from oilpriceapi import OilPriceAPI

with OilPriceAPI(api_key=os.environ["OILPRICEAPI_KEY"]) as client:
crack = client.spreads.crack(spread_type="3-2-1")
history = client.spreads.crack_historical(start_date="2026-08-01")

print(crack.value, crack.unit, crack.timestamp.isoformat())
print(history.period.start, history.coverage.from_, history.coverage.observations)
```

Units, timestamps, and nulls are kept as sent. A history response reports the
window the server applied (`period`) separately from what it returned
(`coverage`, where available). A successful response that does not match its
model raises `OilPriceAPIError` with code `MALFORMED_RESPONSE`. See
[`examples/spreads_indicators.py`](examples/spreads_indicators.py).

## Permit To Production

Well-level production coverage is narrower than permit coverage. Check the
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/models.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Models

::: oilpriceapi.models

## Spreads and Indicators

::: oilpriceapi.metrics_models
8 changes: 8 additions & 0 deletions docs/reference/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@

::: oilpriceapi.resources.analytics.AnalyticsResource

## Spreads

::: oilpriceapi.resources.spreads.SpreadsResource

## Indicators

::: oilpriceapi.resources.indicators.IndicatorsResource

## Forecasts

::: oilpriceapi.resources.forecasts.ForecastsResource
Expand Down
60 changes: 60 additions & 0 deletions examples/spreads_indicators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""
Server-calculated spreads and market indicators (#99).

Requires OILPRICEAPI_KEY for an account on a paid plan (Developer and above);
other plans receive PermissionDeniedError with code PREMIUM_REQUIRED.

Run: python examples/spreads_indicators.py
"""

import os

from oilpriceapi import OilPriceAPI
from oilpriceapi.exceptions import DataNotFoundError, PermissionDeniedError


def main() -> None:
with OilPriceAPI(api_key=os.environ["OILPRICEAPI_KEY"]) as client:
try:
crack = client.spreads.crack(spread_type="3-2-1")
except PermissionDeniedError as error:
print(f"Calculated metrics are not enabled for this plan: {error.code}")
return

# Units and timestamps come from the response; staleness is only
# flagged when the server flags it (None means "not flagged").
print(f"3-2-1 crack: {crack.value} {crack.unit} as of {crack.timestamp.isoformat()}")
if crack.data_stale:
print(f" stale: {crack.stale_warning}")

history = client.spreads.crack_historical(start_date="2026-08-01")
print(
f"History requested {history.period.start}..{history.period.end}, "
f"returned {history.coverage.observations} days "
f"({history.coverage.from_}..{history.coverage.to})"
)

for pair in client.spreads.basis_all():
print(f"{pair.spread_name}: {pair.value} {pair.unit} ({pair.signal})")

parity = client.indicators.fuel_switching()
print(f"Gas at {parity.oil_parity.ratio_pct}% of oil parity: {parity.oil_parity.signal}")

try:
context = client.indicators.price_context("BRENT_CRUDE_USD", related_spreads=True)
except DataNotFoundError as error:
print(f"No price context: {error}")
else:
print(f"Brent 1y percentile: {context.context.percentile_1y}")
for spread in context.related_spreads or []:
print(f" related {spread.name}: {spread.value}")

cot = client.indicators.cftc_positioning(commodity="WTI")
print(
f"WTI managed-money net {cot.positioning.speculative.net} "
f"(report {cot.report_date}, signal {cot.signal})"
)


if __name__ == "__main__":
main()
5 changes: 5 additions & 0 deletions oilpriceapi/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
AsyncForecastsResource,
AsyncFuelSurchargeResource,
AsyncFuturesResource,
AsyncIndicatorsResource,
AsyncRigCountsResource,
AsyncSpreadsResource,
AsyncStorageResource,
AsyncSubscriptionsResource,
AsyncWebhooksResource,
Expand Down Expand Up @@ -179,6 +181,9 @@ def __init__(
self.data_sources = AsyncDataSourcesResource(self)
# Agent watch subscriptions + event polling (#3245 Phase 2).
self.subscriptions = AsyncSubscriptionsResource(self)
# Server-calculated spreads and market indicators (#99).
self.spreads = AsyncSpreadsResource(self)
self.indicators = AsyncIndicatorsResource(self)
# LTL + parcel carrier fuel surcharges (#101).
self.fuel_surcharge = AsyncFuelSurchargeResource(self)

Expand Down
Loading