Skip to content

Commit a5304b3

Browse files
karlwaldmanclaude
andauthored
feat(spreads,indicators): typed sync+async Spreads and Indicators resources (#99) (#146)
* feat(spreads,indicators): typed sync+async resources for /v1/spreads and /v1/indicators (#99) Adds client.spreads (15 methods) and client.indicators (10 methods) on both clients, typed from production responses captured 2026-09-13. Request building, argument validation and envelope parsing live once in resources/_calculated_metrics.py so sync and async cannot drift. A key the server always emits is required; a malformed 200 raises OilPriceAPIError(code="MALFORMED_RESPONSE") with the raw body. Blank selectors, invalid dates, start>end and >20 batch codes are refused before any request. congressional-trades is not exposed (never returned data). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo * fix(spreads,indicators): raise ValidationError for local refusals, not ValueError (#99) A builtin ValueError escaped the documented `except OilPriceAPIError` catch-all (#123). Local refusals now go through one helper matching _url._reject: ValidationError(message, field, value, status_code=None), since no request was sent (#134). format_date's ValueError is re-raised as ValidationError with the right field. All methods are new, so no dual-base subclass is needed. Tests assert the exact type, status_code None and field; proven red by temporarily restoring ValueError (35 failed). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 3b3cdfa commit a5304b3

45 files changed

Lines changed: 2748 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ cython_debug/
168168
!.env.example
169169
!schemas/*.json
170170
!examples/snippets/*.json
171+
# Verbatim API response bodies used as unit-test fixtures (#99).
172+
!tests/unit/fixtures/**/*.json
171173

172174
# Keep example notebooks and GitHub Pages docs
173175
!examples/*.ipynb

‎CHANGELOG.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,35 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil
66

77
### Added
88

9+
- **Typed `client.spreads` and `client.indicators` resources (#99), sync and
10+
async.** They cover the server-calculated `/v1/spreads/*` routes: `crack`,
11+
`crack_historical`, `crack_all`, `gasoil_crack`, `basis`,
12+
`basis_historical`, `basis_all`, `curve_structure`, `curve_structure_all`,
13+
`margin`, `margin_historical`, `margin_all`, `physical_premium`,
14+
`physical_premium_historical` and `physical_premium_all`. They also cover the
15+
`/v1/indicators/*` routes: `fuel_switching`, `fuel_switching_historical`,
16+
`price_context`, `storage_analytics`, `storage_analytics_all`,
17+
`annotations`, `annotations_batch`, `cftc_positioning`,
18+
`cftc_positioning_historical` and `cftc_positioning_all`.
19+
- Each method returns a pydantic model from the new
20+
`oilpriceapi.metrics_models` module. The models are typed from production
21+
responses captured on 2026-09-13.
22+
- Timestamps parse to timezone-aware `datetime` and calendar dates to `date`.
23+
Units, full-precision values and nulls are kept exactly as sent.
24+
- A key the server always emits is required. A 200 that drops it, changes its
25+
type, or breaks the envelope raises
26+
`OilPriceAPIError(code="MALFORMED_RESPONSE")` with the raw body. It is never
27+
defaulted.
28+
- History responses expose the server-applied `period` and, for crack
29+
spreads, the `coverage` actually returned.
30+
- Blank selectors, invalid dates, `start_date` after `end_date`, and more than
31+
20 codes for `annotations_batch` are refused before any request is sent,
32+
with `ValidationError` (an `OilPriceAPIError`) carrying `field`, `value`
33+
and `status_code=None`.
34+
The API would otherwise return a default window, or silently annotate only
35+
the first 20 codes.
36+
- `/v1/indicators/congressional-trades` is deliberately not exposed. It has
37+
never returned data in production, so there is no response shape to type.
938
- **Subscription lifecycle: `get`, `update`, `pause`, `resume` (#100).** Sync
1039
and async, against `GET`/`PATCH /v1/subscriptions/{id}` and
1140
`POST /v1/subscriptions/{id}/pause|resume`. Each returns a typed

‎README.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,34 @@ print(
137137
Use the raw first-request pattern when downstream logic requires the exact
138138
source and timestamp-field semantics from the API response.
139139

140+
## Spreads and Indicators
141+
142+
`client.spreads` and `client.indicators` return typed models for the
143+
server-calculated `/v1/spreads/*` and `/v1/indicators/*` routes: crack, gasoil
144+
crack, basis, curve structure, refinery margin, physical premium, fuel-switching
145+
parity, price context, storage analytics, market annotations, and CFTC
146+
positioning. The async client exposes the same methods. These routes require a
147+
paid plan; other plans receive `PermissionDeniedError` (`PREMIUM_REQUIRED`).
148+
149+
```python
150+
import os
151+
152+
from oilpriceapi import OilPriceAPI
153+
154+
with OilPriceAPI(api_key=os.environ["OILPRICEAPI_KEY"]) as client:
155+
crack = client.spreads.crack(spread_type="3-2-1")
156+
history = client.spreads.crack_historical(start_date="2026-08-01")
157+
158+
print(crack.value, crack.unit, crack.timestamp.isoformat())
159+
print(history.period.start, history.coverage.from_, history.coverage.observations)
160+
```
161+
162+
Units, timestamps, and nulls are kept as sent. A history response reports the
163+
window the server applied (`period`) separately from what it returned
164+
(`coverage`, where available). A successful response that does not match its
165+
model raises `OilPriceAPIError` with code `MALFORMED_RESPONSE`. See
166+
[`examples/spreads_indicators.py`](examples/spreads_indicators.py).
167+
140168
## Permit To Production
141169

142170
Well-level production coverage is narrower than permit coverage. Check the

‎docs/reference/models.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
# Models
22

33
::: oilpriceapi.models
4+
5+
## Spreads and Indicators
6+
7+
::: oilpriceapi.metrics_models

‎docs/reference/resources.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@
4040

4141
::: oilpriceapi.resources.analytics.AnalyticsResource
4242

43+
## Spreads
44+
45+
::: oilpriceapi.resources.spreads.SpreadsResource
46+
47+
## Indicators
48+
49+
::: oilpriceapi.resources.indicators.IndicatorsResource
50+
4351
## Forecasts
4452

4553
::: oilpriceapi.resources.forecasts.ForecastsResource

‎examples/spreads_indicators.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""
2+
Server-calculated spreads and market indicators (#99).
3+
4+
Requires OILPRICEAPI_KEY for an account on a paid plan (Developer and above);
5+
other plans receive PermissionDeniedError with code PREMIUM_REQUIRED.
6+
7+
Run: python examples/spreads_indicators.py
8+
"""
9+
10+
import os
11+
12+
from oilpriceapi import OilPriceAPI
13+
from oilpriceapi.exceptions import DataNotFoundError, PermissionDeniedError
14+
15+
16+
def main() -> None:
17+
with OilPriceAPI(api_key=os.environ["OILPRICEAPI_KEY"]) as client:
18+
try:
19+
crack = client.spreads.crack(spread_type="3-2-1")
20+
except PermissionDeniedError as error:
21+
print(f"Calculated metrics are not enabled for this plan: {error.code}")
22+
return
23+
24+
# Units and timestamps come from the response; staleness is only
25+
# flagged when the server flags it (None means "not flagged").
26+
print(f"3-2-1 crack: {crack.value} {crack.unit} as of {crack.timestamp.isoformat()}")
27+
if crack.data_stale:
28+
print(f" stale: {crack.stale_warning}")
29+
30+
history = client.spreads.crack_historical(start_date="2026-08-01")
31+
print(
32+
f"History requested {history.period.start}..{history.period.end}, "
33+
f"returned {history.coverage.observations} days "
34+
f"({history.coverage.from_}..{history.coverage.to})"
35+
)
36+
37+
for pair in client.spreads.basis_all():
38+
print(f"{pair.spread_name}: {pair.value} {pair.unit} ({pair.signal})")
39+
40+
parity = client.indicators.fuel_switching()
41+
print(f"Gas at {parity.oil_parity.ratio_pct}% of oil parity: {parity.oil_parity.signal}")
42+
43+
try:
44+
context = client.indicators.price_context("BRENT_CRUDE_USD", related_spreads=True)
45+
except DataNotFoundError as error:
46+
print(f"No price context: {error}")
47+
else:
48+
print(f"Brent 1y percentile: {context.context.percentile_1y}")
49+
for spread in context.related_spreads or []:
50+
print(f" related {spread.name}: {spread.value}")
51+
52+
cot = client.indicators.cftc_positioning(commodity="WTI")
53+
print(
54+
f"WTI managed-money net {cot.positioning.speculative.net} "
55+
f"(report {cot.report_date}, signal {cot.signal})"
56+
)
57+
58+
59+
if __name__ == "__main__":
60+
main()

‎oilpriceapi/async_client.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
AsyncForecastsResource,
3333
AsyncFuelSurchargeResource,
3434
AsyncFuturesResource,
35+
AsyncIndicatorsResource,
3536
AsyncRigCountsResource,
37+
AsyncSpreadsResource,
3638
AsyncStorageResource,
3739
AsyncSubscriptionsResource,
3840
AsyncWebhooksResource,
@@ -179,6 +181,9 @@ def __init__(
179181
self.data_sources = AsyncDataSourcesResource(self)
180182
# Agent watch subscriptions + event polling (#3245 Phase 2).
181183
self.subscriptions = AsyncSubscriptionsResource(self)
184+
# Server-calculated spreads and market indicators (#99).
185+
self.spreads = AsyncSpreadsResource(self)
186+
self.indicators = AsyncIndicatorsResource(self)
182187
# LTL + parcel carrier fuel surcharges (#101).
183188
self.fuel_surcharge = AsyncFuelSurchargeResource(self)
184189

0 commit comments

Comments
 (0)