diff --git a/.gitignore b/.gitignore index 2049bd7..4a67588 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index fedee73..12683e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index d46f9ca..4d72382 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/reference/models.md b/docs/reference/models.md index 330e5c3..c59cd8a 100644 --- a/docs/reference/models.md +++ b/docs/reference/models.md @@ -1,3 +1,7 @@ # Models ::: oilpriceapi.models + +## Spreads and Indicators + +::: oilpriceapi.metrics_models diff --git a/docs/reference/resources.md b/docs/reference/resources.md index d45fa98..9c4a25a 100644 --- a/docs/reference/resources.md +++ b/docs/reference/resources.md @@ -40,6 +40,14 @@ ::: oilpriceapi.resources.analytics.AnalyticsResource +## Spreads + +::: oilpriceapi.resources.spreads.SpreadsResource + +## Indicators + +::: oilpriceapi.resources.indicators.IndicatorsResource + ## Forecasts ::: oilpriceapi.resources.forecasts.ForecastsResource diff --git a/examples/spreads_indicators.py b/examples/spreads_indicators.py new file mode 100644 index 0000000..7d9e095 --- /dev/null +++ b/examples/spreads_indicators.py @@ -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() diff --git a/oilpriceapi/async_client.py b/oilpriceapi/async_client.py index 5867c2e..d811d36 100644 --- a/oilpriceapi/async_client.py +++ b/oilpriceapi/async_client.py @@ -32,7 +32,9 @@ AsyncForecastsResource, AsyncFuelSurchargeResource, AsyncFuturesResource, + AsyncIndicatorsResource, AsyncRigCountsResource, + AsyncSpreadsResource, AsyncStorageResource, AsyncSubscriptionsResource, AsyncWebhooksResource, @@ -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) diff --git a/oilpriceapi/async_resources.py b/oilpriceapi/async_resources.py index 3ae72ac..bd146d5 100644 --- a/oilpriceapi/async_resources.py +++ b/oilpriceapi/async_resources.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import date, datetime -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Sequence, Union from . import _fuel_surcharge_common as fs from ._subscriptions_common import ( @@ -13,6 +13,27 @@ validate_subscription_id, ) from .exceptions import ValidationError +from .metrics_models import ( + BasisSpread, + BasisSpreadHistory, + CftcPositioning, + CftcPositioningHistory, + CrackSpread, + CrackSpreadAll, + CrackSpreadHistory, + CurveStructure, + FuelSwitching, + FuelSwitchingHistory, + GasoilCrackSpread, + MarketAnnotations, + MarketAnnotationsBatch, + PhysicalPremium, + PhysicalPremiumHistory, + PriceContext, + RefineryMargin, + RefineryMarginHistory, + StorageAnalytics, +) from .models import ( DieselPrice, DieselStationsResponse, @@ -30,6 +51,7 @@ normalize_api_number, search_commodity_catalog, ) +from .resources import _calculated_metrics as metrics_ops from .resources._futures_slug import normalize_futures_slug from .resources.ei._envelopes import ei_data, unwrap_ei_collection, unwrap_ei_object from .resources.ei.well_permits import unwrap_well_permit_search_response @@ -1785,3 +1807,171 @@ async def parcel_history( carrier=carrier, service_level=service_level, ) + + +class AsyncSpreadsResource: + """Async resource for ``/v1/spreads/*`` (#99). + + Mirrors :class:`oilpriceapi.resources.spreads.SpreadsResource`: the same + request building, validation and typed parsing, shared through + ``oilpriceapi.resources._calculated_metrics``. + """ + + def __init__(self, client: Any) -> None: + self.client = client + + async def crack(self, spread_type: Optional[str] = None, crude: Optional[str] = None) -> CrackSpread: + """Latest crack spread. See ``SpreadsResource.crack``.""" + return await metrics_ops.run_async(self.client, metrics_ops.crack(spread_type, crude)) + + async def crack_historical( + self, + spread_type: Optional[str] = None, + crude: Optional[str] = None, + start_date: Optional[metrics_ops.DateInput] = None, + end_date: Optional[metrics_ops.DateInput] = None, + ) -> CrackSpreadHistory: + """Daily crack spread history. See ``SpreadsResource.crack_historical``.""" + return await metrics_ops.run_async( + self.client, metrics_ops.crack_historical(spread_type, crude, start_date, end_date) + ) + + async def crack_all(self, crude: Optional[str] = None) -> CrackSpreadAll: + """Every crack spread type for one crude benchmark.""" + return await metrics_ops.run_async(self.client, metrics_ops.crack_all(crude)) + + async def gasoil_crack(self) -> GasoilCrackSpread: + """European gasoil crack (ICE Low Sulphur Gasoil vs ICE Brent).""" + return await metrics_ops.run_async(self.client, metrics_ops.gasoil_crack()) + + async def basis(self, pair: str) -> BasisSpread: + """Latest basis spread for a pair. See ``SpreadsResource.basis``.""" + return await metrics_ops.run_async(self.client, metrics_ops.basis(pair)) + + async def basis_historical( + self, + pair: str, + start_date: Optional[metrics_ops.DateInput] = None, + end_date: Optional[metrics_ops.DateInput] = None, + ) -> BasisSpreadHistory: + """Daily basis spread history. See ``SpreadsResource.basis_historical``.""" + return await metrics_ops.run_async( + self.client, metrics_ops.basis_historical(pair, start_date, end_date) + ) + + async def basis_all(self) -> List[BasisSpread]: + """Latest value for every basis pair with data.""" + return await metrics_ops.run_async(self.client, metrics_ops.basis_all()) + + async def curve_structure(self, commodity: str) -> CurveStructure: + """Futures curve structure for a market.""" + return await metrics_ops.run_async(self.client, metrics_ops.curve_structure(commodity)) + + async def curve_structure_all(self) -> List[CurveStructure]: + """Curve structure for every market with a usable curve.""" + return await metrics_ops.run_async(self.client, metrics_ops.curve_structure_all()) + + async def margin(self, index: Optional[str] = None) -> RefineryMargin: + """Latest refinery margin (``usgc``, ``singapore`` or ``nwe``).""" + return await metrics_ops.run_async(self.client, metrics_ops.margin(index)) + + async def margin_historical( + self, + index: Optional[str] = None, + start_date: Optional[metrics_ops.DateInput] = None, + end_date: Optional[metrics_ops.DateInput] = None, + ) -> RefineryMarginHistory: + """Daily refinery margin history.""" + return await metrics_ops.run_async( + self.client, metrics_ops.margin_historical(index, start_date, end_date) + ) + + async def margin_all(self) -> List[RefineryMargin]: + """Latest margin for every index with data.""" + return await metrics_ops.run_async(self.client, metrics_ops.margin_all()) + + async def physical_premium(self, commodity: Optional[str] = None) -> PhysicalPremium: + """Latest physical vs futures premium (``BRENT`` or ``WTI``).""" + return await metrics_ops.run_async(self.client, metrics_ops.physical_premium(commodity)) + + async def physical_premium_historical( + self, + commodity: Optional[str] = None, + start_date: Optional[metrics_ops.DateInput] = None, + end_date: Optional[metrics_ops.DateInput] = None, + ) -> PhysicalPremiumHistory: + """Daily physical premium history.""" + return await metrics_ops.run_async( + self.client, metrics_ops.physical_premium_historical(commodity, start_date, end_date) + ) + + async def physical_premium_all(self) -> List[PhysicalPremium]: + """Latest premium for every commodity with data.""" + return await metrics_ops.run_async(self.client, metrics_ops.physical_premium_all()) + + +class AsyncIndicatorsResource: + """Async resource for ``/v1/indicators/*`` (#99). + + Mirrors :class:`oilpriceapi.resources.indicators.IndicatorsResource`. + """ + + def __init__(self, client: Any) -> None: + self.client = client + + async def fuel_switching(self, gas: Optional[str] = None, crude: Optional[str] = None) -> FuelSwitching: + """Gas-to-oil parity. See ``IndicatorsResource.fuel_switching``.""" + return await metrics_ops.run_async(self.client, metrics_ops.fuel_switching(gas, crude)) + + async def fuel_switching_historical( + self, + gas: Optional[str] = None, + crude: Optional[str] = None, + start_date: Optional[metrics_ops.DateInput] = None, + end_date: Optional[metrics_ops.DateInput] = None, + ) -> FuelSwitchingHistory: + """Daily gas-to-oil parity history.""" + return await metrics_ops.run_async( + self.client, metrics_ops.fuel_switching_historical(gas, crude, start_date, end_date) + ) + + async def price_context(self, code: str, related_spreads: bool = False) -> PriceContext: + """Latest price with historical context. See ``IndicatorsResource.price_context``.""" + return await metrics_ops.run_async( + self.client, metrics_ops.price_context(code, related_spreads) + ) + + async def storage_analytics(self, location: Optional[str] = None) -> StorageAnalytics: + """Storage analytics for ``CUSHING`` or ``SPR``.""" + return await metrics_ops.run_async(self.client, metrics_ops.storage_analytics(location)) + + async def storage_analytics_all(self) -> List[StorageAnalytics]: + """Storage analytics for every location with data.""" + return await metrics_ops.run_async(self.client, metrics_ops.storage_analytics_all()) + + async def annotations(self, code: str) -> MarketAnnotations: + """Notable-condition annotations for a commodity code.""" + return await metrics_ops.run_async(self.client, metrics_ops.annotations(code)) + + async def annotations_batch(self, codes: Sequence[str]) -> MarketAnnotationsBatch: + """Annotations for up to 20 codes. See ``IndicatorsResource.annotations_batch``.""" + return await metrics_ops.run_async(self.client, metrics_ops.annotations_batch(codes)) + + async def cftc_positioning(self, commodity: Optional[str] = None) -> CftcPositioning: + """Latest CFTC Commitments of Traders positioning.""" + return await metrics_ops.run_async(self.client, metrics_ops.cftc_positioning(commodity)) + + async def cftc_positioning_historical( + self, + commodity: Optional[str] = None, + start_date: Optional[metrics_ops.DateInput] = None, + end_date: Optional[metrics_ops.DateInput] = None, + ) -> CftcPositioningHistory: + """Weekly CFTC speculative net positioning history.""" + return await metrics_ops.run_async( + self.client, metrics_ops.cftc_positioning_historical(commodity, start_date, end_date) + ) + + async def cftc_positioning_all(self) -> List[CftcPositioning]: + """Latest positioning for every market with data.""" + return await metrics_ops.run_async(self.client, metrics_ops.cftc_positioning_all()) diff --git a/oilpriceapi/client.py b/oilpriceapi/client.py index 0000f8b..044a7e9 100644 --- a/oilpriceapi/client.py +++ b/oilpriceapi/client.py @@ -41,8 +41,10 @@ from .resources.fuel_surcharge import FuelSurchargeResource from .resources.futures import FuturesResource from .resources.historical import HistoricalResource +from .resources.indicators import IndicatorsResource from .resources.prices import PricesResource from .resources.rig_counts import RigCountsResource +from .resources.spreads import SpreadsResource from .resources.storage import StorageResource from .resources.subscriptions import SubscriptionsResource from .resources.webhooks import WebhooksResource @@ -211,6 +213,9 @@ def __init__( self.data_sources = DataSourcesResource(self) # Agent watch subscriptions + event polling (#3245 Phase 2). self.subscriptions = SubscriptionsResource(self) + # Server-calculated spreads and market indicators (#99). + self.spreads = SpreadsResource(self) + self.indicators = IndicatorsResource(self) # Public, no-auth demo endpoints (/v1/demo/*). self.demo = DemoResource(self) # LTL + parcel carrier fuel surcharges (#101). diff --git a/oilpriceapi/metrics_models.py b/oilpriceapi/metrics_models.py new file mode 100644 index 0000000..5a59a27 --- /dev/null +++ b/oilpriceapi/metrics_models.py @@ -0,0 +1,663 @@ +""" +Typed models for the calculated-metrics routes: ``/v1/spreads/*`` and +``/v1/indicators/*`` (#99). + +Every model is typed from the production wire shape (captured 2026-09-13) and +from the serializers in ``app/services/calculated_metrics/`` on the API. + +Conventions, applied throughout: + +* A key the server always emits is **required**. If the server can emit it as + ``null`` it is ``Optional[...]`` *without a default*, so a body that drops the + key fails validation instead of being silently read as ``None``. +* A key the server only emits conditionally (for example ``data_stale``, the + ``change_*`` deltas, or the inner fields of a block that is ``{}`` when there + is too little history) is ``Optional[...] = None``. ``None`` there means "the + server did not send it", never "false" or "zero". +* Values are kept exactly as sent: no rounding, unit conversion or clamping. + Timestamps parse to timezone-aware ``datetime``; calendar dates to ``date``. +* Unknown keys are preserved (``extra="allow"``) so a field the API adds later + is not dropped on the floor. +""" + +from datetime import date, datetime +from typing import Dict, List, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "BasisSpread", + "BasisSpreadHistory", + "BasisSpreadPoint", + "CftcCommercialPosition", + "CftcPositioning", + "CftcPositioningHistory", + "CftcPositioningPoint", + "CftcPositions", + "CftcSpeculativePosition", + "CrackSpread", + "CrackSpreadAll", + "CrackSpreadHistory", + "CrackSpreadPoint", + "CurveMonth", + "CurveStructure", + "CurveStructureSpreads", + "EnergyEquivalent", + "FuelSwitching", + "FuelSwitchingComponents", + "FuelSwitchingContext", + "FuelSwitchingHistory", + "FuelSwitchingPoint", + "GasoilCrackConversion", + "GasoilCrackLeg", + "GasoilCrackSpread", + "HistoryCoverage", + "HistoryPeriod", + "MarginCrudeInput", + "MarginProduct", + "MarketAnnotation", + "MarketAnnotations", + "MarketAnnotationsBatch", + "MetricChanges", + "OilParity", + "PhysicalPremium", + "PhysicalPremiumComponents", + "PhysicalPremiumHistory", + "PhysicalPremiumLeg", + "PhysicalPremiumPoint", + "PriceContext", + "PriceContextDetail", + "PricedLeg", + "RefineryMargin", + "RefineryMarginHistory", + "RefineryMarginPoint", + "RelatedSpread", + "StorageAnalytics", + "StorageAnomalies", + "StorageCurrent", + "StorageDrawRate", + "StorageRange", + "StorageSeasonal", +] + + +class _WireModel(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +# --------------------------------------------------------------------------- +# Shared building blocks +# --------------------------------------------------------------------------- + + +class MetricChanges(_WireModel): + """1-day / 1-week / 1-month deltas. + + The server only emits a pair when a prior value exists for that horizon, + so every field is optional and an absent horizon stays ``None``. + """ + + change_1d: Optional[float] = None + change_1d_pct: Optional[float] = None + change_1w: Optional[float] = None + change_1w_pct: Optional[float] = None + change_1m: Optional[float] = None + change_1m_pct: Optional[float] = None + + +class HistoryPeriod(_WireModel): + """The window the server *applied* (it echoes defaults and its 2-year cap).""" + + start: date + end: date + + +class HistoryCoverage(_WireModel): + """What a history response actually contains, as opposed to what was asked.""" + + from_: Optional[date] = Field(..., alias="from") + to: Optional[date] + observations: int + complete: bool + + +class PricedLeg(_WireModel): + """One priced input to a spread, e.g. ``{"code", "price", "unit"}``.""" + + code: str + price: float + unit: str + + +# --------------------------------------------------------------------------- +# /v1/spreads/crack* +# --------------------------------------------------------------------------- + + +class CrackSpread(_WireModel): + """``GET /v1/spreads/crack`` and each entry of ``/crack/all``. + + ``components`` is keyed ``crude`` + ``product`` for single-product cracks + and ``crude`` + ``gasoline`` + ``diesel`` for the 3-2-1 composite. + """ + + spread_type: str + crude_benchmark: str + value: float + unit: str + components: Dict[str, PricedLeg] + timestamp: datetime + changes: MetricChanges + data_stale: Optional[bool] = None + stale_warning: Optional[str] = None + + +class CrackSpreadAll(_WireModel): + """``GET /v1/spreads/crack/all``.""" + + crude_benchmark: str + spreads: List[CrackSpread] + + +class CrackSpreadPoint(_WireModel): + """One day of crack history. The 3-2-1 composite carries ``gasoline`` and + ``diesel``; single-product cracks carry ``product``.""" + + date: date + value: float + crude: float + product: Optional[float] = None + gasoline: Optional[float] = None + diesel: Optional[float] = None + + +class CrackSpreadHistory(_WireModel): + """``GET /v1/spreads/crack/historical``.""" + + spread_type: str + crude_benchmark: str + period: HistoryPeriod + coverage: HistoryCoverage + data_revised_at: Optional[datetime] + count: int + data: List[CrackSpreadPoint] + + +class GasoilCrackLeg(_WireModel): + """A futures leg of the European gasoil crack, with its contract month.""" + + code: str + contract_month: Optional[str] + updated_at: datetime + price: float + unit: str + settlement_date: Optional[date] = None + + +class GasoilCrackConversion(_WireModel): + """The tonne-to-barrel conversion the server applied.""" + + barrels_per_tonne: float + basis: str + gasoil_usd_per_bbl: float + + +class GasoilCrackSpread(_WireModel): + """``GET /v1/spreads/gasoil-crack`` (ICE Low Sulphur Gasoil vs ICE Brent).""" + + spread_type: str + name: str + value: float + unit: str + components: Dict[str, GasoilCrackLeg] + conversion: GasoilCrackConversion + timestamp: datetime + updated_at: datetime + data_stale: Optional[bool] = None + stale_warning: Optional[str] = None + + +# --------------------------------------------------------------------------- +# /v1/spreads/basis* +# --------------------------------------------------------------------------- + + +class BasisSpread(_WireModel): + """``GET /v1/spreads/basis`` and each entry of ``/basis/all``. + + ``components`` maps each leg's commodity code to its price. + ``negative_streak_days`` is only sent for pairs that track it (WAHA_HH). + """ + + pair: str + spread_name: str + value: float + unit: str + components: Dict[str, float] + signal: str + timestamp: datetime + percentile_1y: Optional[int] + changes: MetricChanges + negative_streak_days: Optional[int] = None + data_stale: Optional[bool] = None + stale_warning: Optional[str] = None + + +class BasisSpreadPoint(_WireModel): + """One day of basis history: ``value = code_a - code_b``.""" + + date: date + value: float + code_a: float + code_b: float + + +class BasisSpreadHistory(_WireModel): + """``GET /v1/spreads/basis/historical``.""" + + pair: str + period: HistoryPeriod + count: int + data: List[BasisSpreadPoint] + + +# --------------------------------------------------------------------------- +# /v1/spreads/curve-structure* +# --------------------------------------------------------------------------- + + +class CurveMonth(_WireModel): + price: float + contract: str + + +class CurveStructureSpreads(_WireModel): + """Front-month minus later-month spreads. The server omits a horizon it + could not compute, so ``m1_m3`` and ``m1_m12`` may be absent.""" + + m1_m6: float + m1_m3: Optional[float] = None + m1_m12: Optional[float] = None + + +class CurveStructure(_WireModel): + """``GET /v1/spreads/curve-structure`` and each entry of ``/all``.""" + + commodity: str + display_name: str + structure: str + severity: str + term_slope_pct: float + spreads: CurveStructureSpreads + front_month: CurveMonth + back_month_6: CurveMonth + curve_points: int + signal: str + timestamp: datetime + + +# --------------------------------------------------------------------------- +# /v1/spreads/margin* +# --------------------------------------------------------------------------- + + +class MarginCrudeInput(_WireModel): + code: str + price: float + + +class MarginProduct(_WireModel): + yield_pct: float + price: float + code: str + + +class RefineryMargin(_WireModel): + """``GET /v1/spreads/margin`` and each entry of ``/margin/all``. + + ``product_basket`` only contains products the server had a price for. + """ + + index: str + name: str + margin_usd_bbl: float + crude_input: MarginCrudeInput + product_basket: Dict[str, MarginProduct] + signal: str + percentile_1y: Optional[int] + changes: MetricChanges + timestamp: datetime + + +class RefineryMarginPoint(_WireModel): + date: date + margin: float + crude: float + revenue: float + + +class RefineryMarginHistory(_WireModel): + """``GET /v1/spreads/margin/historical``.""" + + index: str + period: HistoryPeriod + count: int + data: List[RefineryMarginPoint] + + +# --------------------------------------------------------------------------- +# /v1/spreads/physical-premium* +# --------------------------------------------------------------------------- + + +class PhysicalPremiumLeg(_WireModel): + code: str + price: float + contract: Optional[str] = None + + +class PhysicalPremiumComponents(_WireModel): + spot: PhysicalPremiumLeg + futures: PhysicalPremiumLeg + + +class PhysicalPremium(_WireModel): + """``GET /v1/spreads/physical-premium`` and each entry of ``/all``.""" + + commodity: str + name: str + premium: float + premium_pct: float + unit: str + components: PhysicalPremiumComponents + signal: str + elevated_streak_days: int + percentile_1y: Optional[int] + timestamp: datetime + data_stale: Optional[bool] = None + stale_warning: Optional[str] = None + + +class PhysicalPremiumPoint(_WireModel): + date: date + premium: float + premium_pct: float + spot: float + futures: float + + +class PhysicalPremiumHistory(_WireModel): + """``GET /v1/spreads/physical-premium/historical``.""" + + commodity: str + period: HistoryPeriod + count: int + data: List[PhysicalPremiumPoint] + + +# --------------------------------------------------------------------------- +# /v1/indicators/fuel-switching* +# --------------------------------------------------------------------------- + + +class OilParity(_WireModel): + ratio_pct: float + threshold_pct: float + signal: str + parity_price: float + current_gas: float + headroom_pct: float + + +class FuelSwitchingComponents(_WireModel): + gas: PricedLeg + crude: PricedLeg + + +class EnergyEquivalent(_WireModel): + crude_per_mmbtu: float + gas_premium_discount: float + + +class FuelSwitchingContext(_WireModel): + """Trailing-year context. The server sends ``{}`` with fewer than 10 + observations, so every field is optional.""" + + times_above_parity_last_year: Optional[int] = None + pct_above_parity: Optional[float] = None + avg_ratio_1y: Optional[float] = None + max_ratio_1y: Optional[float] = None + min_ratio_1y: Optional[float] = None + data_points: Optional[int] = None + + +class FuelSwitching(_WireModel): + """``GET /v1/indicators/fuel-switching``.""" + + oil_parity: OilParity + components: FuelSwitchingComponents + energy_equivalent: EnergyEquivalent + historical_context: FuelSwitchingContext + timestamp: datetime + + +class FuelSwitchingPoint(_WireModel): + date: date + ratio_pct: float + above_parity: bool + gas_price: float + crude_price: float + + +class FuelSwitchingHistory(_WireModel): + """``GET /v1/indicators/fuel-switching/historical``.""" + + gas_benchmark: str + crude_benchmark: str + period: HistoryPeriod + count: int + data: List[FuelSwitchingPoint] + + +# --------------------------------------------------------------------------- +# /v1/indicators/price-context +# --------------------------------------------------------------------------- + + +class PriceContextDetail(_WireModel): + """Where the latest price sits. Every metric other than ``anomaly`` is only + sent when the server had enough history to compute it.""" + + anomaly: bool + anomaly_reason: Optional[str] = None + change_1d: Optional[float] = None + change_1d_pct: Optional[float] = None + change_1w: Optional[float] = None + change_1w_pct: Optional[float] = None + change_1m: Optional[float] = None + change_1m_pct: Optional[float] = None + high_52w: Optional[float] = None + low_52w: Optional[float] = None + percentile_1y: Optional[int] = None + percentile_5y: Optional[int] = None + + +class RelatedSpread(_WireModel): + """A spread related to the requested code (``spreads=related``). + + ``value`` is numeric for basis/crack/parity entries and a structure label + (e.g. ``"backwardation"``) for the curve-structure entry, which carries + ``slope`` and no ``unit``. + """ + + name: str + value: Union[float, str] + signal: Optional[str] + unit: Optional[str] = None + slope: Optional[float] = None + + +class PriceContext(_WireModel): + """``GET /v1/indicators/price-context``.""" + + code: str + price: float + timestamp: datetime + context: PriceContextDetail + related_spreads: Optional[List[RelatedSpread]] = None + + +# --------------------------------------------------------------------------- +# /v1/indicators/storage-analytics* +# --------------------------------------------------------------------------- + + +class StorageCurrent(_WireModel): + volume_mmbbl: float + utilization_pct: Optional[float] + operational_capacity_mmbbl: float + data_date: datetime + timestamp: datetime + + +class StorageDrawRate(_WireModel): + """``{}`` when the latest report has no weekly change.""" + + weekly_mmbbl: Optional[float] = None + annualized_mmbbl: Optional[float] = None + type: Optional[str] = None + days_to_depletion: Optional[int] = None + + +class StorageSeasonal(_WireModel): + """``{}`` with fewer than three same-week observations in five years.""" + + five_year_avg_mmbbl: Optional[float] = None + five_year_min_mmbbl: Optional[float] = None + five_year_max_mmbbl: Optional[float] = None + deviation_from_avg_pct: Optional[float] = None + position: Optional[str] = None + + +class StorageAnomalies(_WireModel): + unusual_change: Optional[bool] = None + unusual_change_detail: Optional[str] = None + utilization_extreme: Optional[bool] = None + utilization_detail: Optional[str] = None + statistical_outlier: Optional[bool] = None + z_score: Optional[float] = None + + +class StorageRange(_WireModel): + """``{}`` when there is no data in the trailing 52 weeks.""" + + high_mmbbl: Optional[float] = None + low_mmbbl: Optional[float] = None + + +class StorageAnalytics(_WireModel): + """``GET /v1/indicators/storage-analytics`` and each entry of ``/all``.""" + + location: str + name: str + current: StorageCurrent + draw_rate: StorageDrawRate + seasonal: StorageSeasonal + anomalies: StorageAnomalies + range_52w: StorageRange + signal: Optional[str] + trading_implication: Optional[str] + + +# --------------------------------------------------------------------------- +# /v1/indicators/annotations* +# --------------------------------------------------------------------------- + + +class MarketAnnotation(_WireModel): + """One notable condition. Extra fields depend on ``type``: ``anomaly`` + (``z_score``, ``mean_90d``), ``velocity`` (``pct_change_5d``, ``z_score``), + ``streak`` (``direction``, ``streak_days``), ``record`` (``record_type``).""" + + type: str + severity: str + message: str + z_score: Optional[float] = None + mean_90d: Optional[float] = None + pct_change_5d: Optional[float] = None + direction: Optional[str] = None + streak_days: Optional[int] = None + record_type: Optional[str] = None + + +class MarketAnnotations(_WireModel): + """``GET /v1/indicators/annotations``.""" + + code: str + price: float + timestamp: datetime + annotation_count: int + annotations: List[MarketAnnotation] + + +class MarketAnnotationsBatch(_WireModel): + """``GET /v1/indicators/annotations/batch``. + + ``annotated`` omits codes the server has no data for and codes with no + annotations, so it can be shorter than ``total_codes``. + """ + + annotated: List[MarketAnnotations] + total_codes: int + codes_with_annotations: int + + +# --------------------------------------------------------------------------- +# /v1/indicators/cftc-positioning* +# --------------------------------------------------------------------------- + + +class CftcSpeculativePosition(_WireModel): + net: int + long: Optional[int] + short: Optional[int] + net_pct_of_oi: Optional[float] + + +class CftcCommercialPosition(_WireModel): + net: Optional[int] + + +class CftcPositions(_WireModel): + speculative: CftcSpeculativePosition + commercial: CftcCommercialPosition + open_interest: Optional[int] + + +class CftcPositioning(_WireModel): + """``GET /v1/indicators/cftc-positioning`` and each entry of ``/all``.""" + + commodity: str + name: str + report_date: date + positioning: CftcPositions + signal: str + percentile_1y: Optional[int] + week_change: Optional[int] + timestamp: datetime + + +class CftcPositioningPoint(_WireModel): + date: date + spec_net: int + open_interest: Optional[int] + spec_net_pct_oi: Optional[float] + + +class CftcPositioningHistory(_WireModel): + """``GET /v1/indicators/cftc-positioning/historical``.""" + + commodity: str + period: HistoryPeriod + count: int + data: List[CftcPositioningPoint] diff --git a/oilpriceapi/resources/_calculated_metrics.py b/oilpriceapi/resources/_calculated_metrics.py new file mode 100644 index 0000000..ee789cb --- /dev/null +++ b/oilpriceapi/resources/_calculated_metrics.py @@ -0,0 +1,426 @@ +"""Shared request building and response parsing for the calculated-metrics +routes (``/v1/spreads/*`` and ``/v1/indicators/*``, #99). + +The sync and async resources are thin wrappers over this module: each public +method builds a :class:`MetricsCall` here (path, query parameters, parser), +sends it with its own client, and hands the decoded body back to the parser. +That keeps validation, parameter names and envelope handling identical across +both clients by construction rather than by copy. + +Two rules are enforced here and nowhere else: + +* **Arguments are validated before any request is sent.** A blank selector or + an unparseable date raises ``ValidationError`` locally, with + ``status_code=None`` because nothing was sent. The API would otherwise + fall back to a default window, or answer an unknown selector on a history + route with an empty HTTP 200 -- neither is a result the caller asked for. +* **A malformed success body raises.** A 200 whose envelope, collection or + record does not match the typed model raises + ``OilPriceAPIError(code="MALFORMED_RESPONSE")`` carrying the raw body. Nothing + is defaulted to ``0``, ``""`` or "now". +""" + +import json +from datetime import date, datetime +from typing import Any, Callable, Dict, Generic, List, Optional, Sequence, Type, TypeVar, Union + +from pydantic import BaseModel +from pydantic import ValidationError as PydanticValidationError + +from ..exceptions import OilPriceAPIError, ValidationError +from ..metrics_models import ( + BasisSpread, + BasisSpreadHistory, + CftcPositioning, + CftcPositioningHistory, + CrackSpread, + CrackSpreadAll, + CrackSpreadHistory, + CurveStructure, + FuelSwitching, + FuelSwitchingHistory, + GasoilCrackSpread, + MarketAnnotations, + MarketAnnotationsBatch, + PhysicalPremium, + PhysicalPremiumHistory, + PriceContext, + RefineryMargin, + RefineryMarginHistory, + StorageAnalytics, +) +from ..resource_validators import format_date + +T = TypeVar("T") +M = TypeVar("M", bound=BaseModel) + +DateInput = Union[str, date, datetime] + +#: ``/v1/indicators/annotations/batch`` annotates only the first 20 codes and +#: silently drops the rest while still reporting ``total_codes``. +ANNOTATIONS_BATCH_MAX_CODES = 20 + + +class MetricsCall(Generic[T]): + """One GET request against a calculated-metrics route.""" + + __slots__ = ("path", "params", "subject", "parse") + + def __init__( + self, + path: str, + params: Dict[str, str], + subject: str, + parse: Callable[[Any], T], + ) -> None: + self.path = path + self.params = params + self.subject = subject + self.parse = parse + + +def run_sync(client: Any, call: "MetricsCall[T]") -> T: + """Send ``call`` with a sync client and parse the body.""" + try: + response = client.request(method="GET", path=call.path, params=call.params) + except json.JSONDecodeError as exc: + raise _malformed(call.subject, None, "the body is not valid JSON") from exc + return call.parse(response) + + +async def run_async(client: Any, call: "MetricsCall[T]") -> T: + """Send ``call`` with an async client and parse the body.""" + try: + response = await client.request(method="GET", path=call.path, params=call.params) + except json.JSONDecodeError as exc: + raise _malformed(call.subject, None, "the body is not valid JSON") from exc + return call.parse(response) + + +# --------------------------------------------------------------------------- +# Response parsing +# --------------------------------------------------------------------------- + + +def _malformed(subject: str, response: Any, detail: str) -> OilPriceAPIError: + return OilPriceAPIError( + "Malformed %s response: %s" % (subject, detail), + code="MALFORMED_RESPONSE", + raw_body=response, + ) + + +def _envelope_data(response: Any, subject: str) -> Dict[str, Any]: + if not isinstance(response, dict): + raise _malformed(subject, response, "expected a JSON object") + status = response.get("status") + if status is not None and status != "success": + raise _malformed(subject, response, "status is %r, not 'success'" % (status,)) + data = response.get("data") + if not isinstance(data, dict): + raise _malformed(subject, response, "expected a 'data' object") + return data + + +def _build(model: Type[M], payload: Dict[str, Any], response: Any, subject: str) -> M: + try: + return model.model_validate(payload) + except PydanticValidationError as exc: + first = exc.errors()[0] + location = ".".join(str(part) for part in first.get("loc", ())) or "record" + raise _malformed(subject, response, "%s: %s" % (location, first.get("msg"))) from exc + + +def _object_parser(model: Type[M], subject: str) -> Callable[[Any], M]: + def parse(response: Any) -> M: + return _build(model, _envelope_data(response, subject), response, subject) + + return parse + + +def _collection_parser(model: Type[M], key: str, subject: str) -> Callable[[Any], List[M]]: + def parse(response: Any) -> List[M]: + rows = _envelope_data(response, subject).get(key) + if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + raise _malformed(subject, response, "expected a '%s' list of records" % key) + return [_build(model, row, response, subject) for row in rows] + + return parse + + +def _object(path: str, params: Dict[str, str], model: Type[M], subject: str) -> "MetricsCall[M]": + return MetricsCall(path, params, subject, _object_parser(model, subject)) + + +def _collection(path: str, key: str, model: Type[M], subject: str) -> "MetricsCall[List[M]]": + return MetricsCall(path, {}, subject, _collection_parser(model, key, subject)) + + +# --------------------------------------------------------------------------- +# Argument validation +# --------------------------------------------------------------------------- + + +def _refuse(message: str, field: str, value: Any) -> ValidationError: + """Build a local refusal. + + Matches ``_url._reject``: a ``ValidationError`` (so the documented + ``except OilPriceAPIError`` catch-all sees it) with ``status_code=None``, + because no request was sent and there is no HTTP status to report (#123). + """ + return ValidationError(message=message, field=field, value=value, status_code=None) + + +def _text(name: str, value: Any, *, required: bool) -> Optional[str]: + if value is None and not required: + return None + if not isinstance(value, str) or not value.strip(): + raise _refuse("%s must be a non-empty string" % name, name, value) + return value.strip() + + +def _params(**values: Optional[str]) -> Dict[str, str]: + return {key: value for key, value in values.items() if value is not None} + + +def _date(name: str, value: Optional[DateInput]) -> Optional[str]: + if value is None: + return None + try: + return format_date(value) + except ValueError as exc: + # format_date is shared with older resources and raises ValueError; + # these new methods report the refusal in the SDK's own error type. + raise _refuse("%s: %s" % (name, exc), name, value) from exc + + +def _window(start_date: Optional[DateInput], end_date: Optional[DateInput]) -> Dict[str, str]: + start = _date("start_date", start_date) + end = _date("end_date", end_date) + if start is not None and end is not None and start > end: + raise _refuse( + "start_date (%s) must be on or before end_date (%s)" % (start, end), + "start_date", + start_date, + ) + return _params(start_date=start, end_date=end) + + +# --------------------------------------------------------------------------- +# Spreads +# --------------------------------------------------------------------------- + + +def crack(spread_type: Optional[str] = None, crude: Optional[str] = None) -> "MetricsCall[CrackSpread]": + params = _params( + type=_text("spread_type", spread_type, required=False), + crude=_text("crude", crude, required=False), + ) + return _object("/v1/spreads/crack", params, CrackSpread, "crack spread") + + +def crack_historical( + spread_type: Optional[str] = None, + crude: Optional[str] = None, + start_date: Optional[DateInput] = None, + end_date: Optional[DateInput] = None, +) -> "MetricsCall[CrackSpreadHistory]": + params = _params( + type=_text("spread_type", spread_type, required=False), + crude=_text("crude", crude, required=False), + ) + params.update(_window(start_date, end_date)) + return _object("/v1/spreads/crack/historical", params, CrackSpreadHistory, "crack spread history") + + +def crack_all(crude: Optional[str] = None) -> "MetricsCall[CrackSpreadAll]": + params = _params(crude=_text("crude", crude, required=False)) + return _object("/v1/spreads/crack/all", params, CrackSpreadAll, "crack spread list") + + +def gasoil_crack() -> "MetricsCall[GasoilCrackSpread]": + return _object("/v1/spreads/gasoil-crack", {}, GasoilCrackSpread, "gasoil crack") + + +def basis(pair: str) -> "MetricsCall[BasisSpread]": + params = _params(pair=_text("pair", pair, required=True)) + return _object("/v1/spreads/basis", params, BasisSpread, "basis spread") + + +def basis_historical( + pair: str, + start_date: Optional[DateInput] = None, + end_date: Optional[DateInput] = None, +) -> "MetricsCall[BasisSpreadHistory]": + params = _params(pair=_text("pair", pair, required=True)) + params.update(_window(start_date, end_date)) + return _object("/v1/spreads/basis/historical", params, BasisSpreadHistory, "basis spread history") + + +def basis_all() -> "MetricsCall[List[BasisSpread]]": + return _collection("/v1/spreads/basis/all", "spreads", BasisSpread, "basis spread list") + + +def curve_structure(commodity: str) -> "MetricsCall[CurveStructure]": + params = _params(commodity=_text("commodity", commodity, required=True)) + return _object("/v1/spreads/curve-structure", params, CurveStructure, "curve structure") + + +def curve_structure_all() -> "MetricsCall[List[CurveStructure]]": + return _collection( + "/v1/spreads/curve-structure/all", "commodities", CurveStructure, "curve structure list" + ) + + +def margin(index: Optional[str] = None) -> "MetricsCall[RefineryMargin]": + params = _params(index=_text("index", index, required=False)) + return _object("/v1/spreads/margin", params, RefineryMargin, "refinery margin") + + +def margin_historical( + index: Optional[str] = None, + start_date: Optional[DateInput] = None, + end_date: Optional[DateInput] = None, +) -> "MetricsCall[RefineryMarginHistory]": + params = _params(index=_text("index", index, required=False)) + params.update(_window(start_date, end_date)) + return _object( + "/v1/spreads/margin/historical", params, RefineryMarginHistory, "refinery margin history" + ) + + +def margin_all() -> "MetricsCall[List[RefineryMargin]]": + return _collection("/v1/spreads/margin/all", "margins", RefineryMargin, "refinery margin list") + + +def physical_premium(commodity: Optional[str] = None) -> "MetricsCall[PhysicalPremium]": + params = _params(commodity=_text("commodity", commodity, required=False)) + return _object("/v1/spreads/physical-premium", params, PhysicalPremium, "physical premium") + + +def physical_premium_historical( + commodity: Optional[str] = None, + start_date: Optional[DateInput] = None, + end_date: Optional[DateInput] = None, +) -> "MetricsCall[PhysicalPremiumHistory]": + params = _params(commodity=_text("commodity", commodity, required=False)) + params.update(_window(start_date, end_date)) + return _object( + "/v1/spreads/physical-premium/historical", + params, + PhysicalPremiumHistory, + "physical premium history", + ) + + +def physical_premium_all() -> "MetricsCall[List[PhysicalPremium]]": + return _collection( + "/v1/spreads/physical-premium/all", "premiums", PhysicalPremium, "physical premium list" + ) + + +# --------------------------------------------------------------------------- +# Indicators +# --------------------------------------------------------------------------- + + +def fuel_switching(gas: Optional[str] = None, crude: Optional[str] = None) -> "MetricsCall[FuelSwitching]": + params = _params( + gas=_text("gas", gas, required=False), + crude=_text("crude", crude, required=False), + ) + return _object("/v1/indicators/fuel-switching", params, FuelSwitching, "fuel switching") + + +def fuel_switching_historical( + gas: Optional[str] = None, + crude: Optional[str] = None, + start_date: Optional[DateInput] = None, + end_date: Optional[DateInput] = None, +) -> "MetricsCall[FuelSwitchingHistory]": + params = _params( + gas=_text("gas", gas, required=False), + crude=_text("crude", crude, required=False), + ) + params.update(_window(start_date, end_date)) + return _object( + "/v1/indicators/fuel-switching/historical", + params, + FuelSwitchingHistory, + "fuel switching history", + ) + + +def price_context(code: str, related_spreads: bool = False) -> "MetricsCall[PriceContext]": + params = _params(code=_text("code", code, required=True)) + if related_spreads: + params["spreads"] = "related" + return _object("/v1/indicators/price-context", params, PriceContext, "price context") + + +def storage_analytics(location: Optional[str] = None) -> "MetricsCall[StorageAnalytics]": + params = _params(location=_text("location", location, required=False)) + return _object("/v1/indicators/storage-analytics", params, StorageAnalytics, "storage analytics") + + +def storage_analytics_all() -> "MetricsCall[List[StorageAnalytics]]": + return _collection( + "/v1/indicators/storage-analytics/all", "locations", StorageAnalytics, "storage analytics list" + ) + + +def annotations(code: str) -> "MetricsCall[MarketAnnotations]": + params = _params(code=_text("code", code, required=True)) + return _object("/v1/indicators/annotations", params, MarketAnnotations, "market annotations") + + +def annotations_batch(codes: Sequence[str]) -> "MetricsCall[MarketAnnotationsBatch]": + if isinstance(codes, str) or not isinstance(codes, Sequence): + raise _refuse("codes must be a list of commodity codes", "codes", codes) + if not codes: + raise _refuse("codes must contain at least one commodity code", "codes", codes) + if len(codes) > ANNOTATIONS_BATCH_MAX_CODES: + raise _refuse( + "codes accepts at most %d commodity codes per call (got %d); the API " + "silently ignores the rest" % (ANNOTATIONS_BATCH_MAX_CODES, len(codes)), + "codes", + codes, + ) + cleaned: List[str] = [] + for code in codes: + if not isinstance(code, str) or not code.strip(): + raise _refuse("each code must be a non-empty string", "codes", code) + text = code.strip() + if "," in text: + raise _refuse("commodity codes may not contain ',' (got %r)" % text, "codes", text) + cleaned.append(text) + params = {"codes": ",".join(cleaned)} + return _object( + "/v1/indicators/annotations/batch", params, MarketAnnotationsBatch, "market annotations batch" + ) + + +def cftc_positioning(commodity: Optional[str] = None) -> "MetricsCall[CftcPositioning]": + params = _params(commodity=_text("commodity", commodity, required=False)) + return _object("/v1/indicators/cftc-positioning", params, CftcPositioning, "CFTC positioning") + + +def cftc_positioning_historical( + commodity: Optional[str] = None, + start_date: Optional[DateInput] = None, + end_date: Optional[DateInput] = None, +) -> "MetricsCall[CftcPositioningHistory]": + params = _params(commodity=_text("commodity", commodity, required=False)) + params.update(_window(start_date, end_date)) + return _object( + "/v1/indicators/cftc-positioning/historical", + params, + CftcPositioningHistory, + "CFTC positioning history", + ) + + +def cftc_positioning_all() -> "MetricsCall[List[CftcPositioning]]": + return _collection( + "/v1/indicators/cftc-positioning/all", "commodities", CftcPositioning, "CFTC positioning list" + ) diff --git a/oilpriceapi/resources/indicators.py b/oilpriceapi/resources/indicators.py new file mode 100644 index 0000000..92bdd7b --- /dev/null +++ b/oilpriceapi/resources/indicators.py @@ -0,0 +1,132 @@ +""" +Indicators Resource + +Typed access to the market indicator routes under ``/v1/indicators/*`` (#99): +gas-to-oil fuel-switching parity, enriched price context, storage analytics, +market annotations and CFTC Commitments of Traders positioning. + +Access requires a paid plan (Developer and above); other plans receive +``PermissionDeniedError`` with code ``PREMIUM_REQUIRED``. + +``/v1/indicators/congressional-trades`` is intentionally not exposed: it has +never returned data in production (HTTP 404 ``DATA_NOT_AVAILABLE``), so there +is no observed response shape to type. +""" + +from typing import Any, List, Optional, Sequence + +from ..metrics_models import ( + CftcPositioning, + CftcPositioningHistory, + FuelSwitching, + FuelSwitchingHistory, + MarketAnnotations, + MarketAnnotationsBatch, + PriceContext, + StorageAnalytics, +) +from . import _calculated_metrics as ops +from ._calculated_metrics import DateInput + + +class IndicatorsResource: + """Resource for ``/v1/indicators/*``.""" + + def __init__(self, client: Any) -> None: + """Initialize indicators resource. + + Args: + client: OilPriceAPI client instance + """ + self.client = client + + def fuel_switching(self, gas: Optional[str] = None, crude: Optional[str] = None) -> FuelSwitching: + """Get gas-to-oil parity (fuel-switching economics). + + Args: + gas: ``"NATURAL_GAS_USD"``, ``"DUTCH_TTF_NATURAL_GAS_USD"`` or + ``"NATURAL_GAS_WAHA"``; server default ``"NATURAL_GAS_USD"``. + crude: ``"BRENT_CRUDE_USD"``, ``"WTI_USD"`` or ``"BRENT_SPOT_USD"``; + server default ``"BRENT_CRUDE_USD"``. + + Example: + >>> parity = client.indicators.fuel_switching() + >>> print(parity.oil_parity.ratio_pct, parity.oil_parity.signal) + """ + return ops.run_sync(self.client, ops.fuel_switching(gas, crude)) + + def fuel_switching_historical( + self, + gas: Optional[str] = None, + crude: Optional[str] = None, + start_date: Optional[DateInput] = None, + end_date: Optional[DateInput] = None, + ) -> FuelSwitchingHistory: + """Get daily gas-to-oil parity history (server default: last 90 days).""" + return ops.run_sync( + self.client, ops.fuel_switching_historical(gas, crude, start_date, end_date) + ) + + def price_context(self, code: str, related_spreads: bool = False) -> PriceContext: + """Get the latest price with historical context for a commodity code. + + Args: + code: Commodity code, e.g. ``"BRENT_CRUDE_USD"``. + related_spreads: Also return the spreads related to ``code``. + + Returns: + PriceContext. Context metrics the server could not compute (not + enough history) are ``None``; ``context.anomaly`` is always set. + + Raises: + ValidationError: If ``code`` is blank (raised locally). + DataNotFoundError: No data for ``code``. + """ + return ops.run_sync(self.client, ops.price_context(code, related_spreads)) + + def storage_analytics(self, location: Optional[str] = None) -> StorageAnalytics: + """Get storage analytics for ``"CUSHING"`` (server default) or ``"SPR"``.""" + return ops.run_sync(self.client, ops.storage_analytics(location)) + + def storage_analytics_all(self) -> List[StorageAnalytics]: + """Get storage analytics for every location with data.""" + return ops.run_sync(self.client, ops.storage_analytics_all()) + + def annotations(self, code: str) -> MarketAnnotations: + """Get notable-condition annotations (anomaly, velocity, streak, + 52-week record) for a commodity code.""" + return ops.run_sync(self.client, ops.annotations(code)) + + def annotations_batch(self, codes: Sequence[str]) -> MarketAnnotationsBatch: + """Get annotations for up to 20 commodity codes in one request. + + The server leaves out codes it has no data for and codes with no + annotations. More than 20 codes raises ``ValidationError`` locally, because + the API silently annotates only the first 20. + """ + return ops.run_sync(self.client, ops.annotations_batch(codes)) + + def cftc_positioning(self, commodity: Optional[str] = None) -> CftcPositioning: + """Get the latest CFTC Commitments of Traders positioning. + + Args: + commodity: ``"WTI"`` (server default), ``"BRENT"``, + ``"NATURAL_GAS"``, ``"HEATING_OIL"`` or ``"GASOLINE"``. + Components the report does not publish for a market are ``None``. + """ + return ops.run_sync(self.client, ops.cftc_positioning(commodity)) + + def cftc_positioning_historical( + self, + commodity: Optional[str] = None, + start_date: Optional[DateInput] = None, + end_date: Optional[DateInput] = None, + ) -> CftcPositioningHistory: + """Get weekly CFTC speculative net positioning history.""" + return ops.run_sync( + self.client, ops.cftc_positioning_historical(commodity, start_date, end_date) + ) + + def cftc_positioning_all(self) -> List[CftcPositioning]: + """Get the latest positioning for every market with data.""" + return ops.run_sync(self.client, ops.cftc_positioning_all()) diff --git a/oilpriceapi/resources/spreads.py b/oilpriceapi/resources/spreads.py new file mode 100644 index 0000000..ead58f1 --- /dev/null +++ b/oilpriceapi/resources/spreads.py @@ -0,0 +1,220 @@ +""" +Spreads Resource + +Typed access to the calculated spread routes under ``/v1/spreads/*`` (#99): +crack spreads, the European gasoil crack, basis differentials, futures curve +structure, refinery margins and physical-vs-futures premiums. + +These are server-side calculations over OilPriceAPI price series, distinct +from :mod:`oilpriceapi.resources.analysis`, which computes locally. Access +requires a paid plan (Developer and above); other plans receive +``PermissionDeniedError`` with code ``PREMIUM_REQUIRED``. + +Every method validates its arguments before sending, returns a typed model +built from the response, and raises ``OilPriceAPIError`` with code +``MALFORMED_RESPONSE`` if a successful response does not match that model. +""" + +from typing import Any, List, Optional + +from ..metrics_models import ( + BasisSpread, + BasisSpreadHistory, + CrackSpread, + CrackSpreadAll, + CrackSpreadHistory, + CurveStructure, + GasoilCrackSpread, + PhysicalPremium, + PhysicalPremiumHistory, + RefineryMargin, + RefineryMarginHistory, +) +from . import _calculated_metrics as ops +from ._calculated_metrics import DateInput + + +class SpreadsResource: + """Resource for ``/v1/spreads/*``.""" + + def __init__(self, client: Any) -> None: + """Initialize spreads resource. + + Args: + client: OilPriceAPI client instance + """ + self.client = client + + # -- crack ---------------------------------------------------------------- + + def crack(self, spread_type: Optional[str] = None, crude: Optional[str] = None) -> CrackSpread: + """Get the latest crack spread. + + Args: + spread_type: ``"3-2-1"``, ``"jet"``, ``"diesel"`` or ``"gasoline"``. + Omit to use the server default (``"3-2-1"``). + crude: Crude benchmark code, e.g. ``"WTI_USD"``. Omit to use the + server default (``"BRENT_CRUDE_USD"``). + + Returns: + CrackSpread with ``value`` and ``unit`` (USD/bbl), the priced + ``components``, the oldest input ``timestamp`` and ``changes``. + + Raises: + ValidationError: If an argument is blank (raised locally, + ``status_code`` is ``None``). + DataNotFoundError: Unknown spread type, or no data for an input. + + Example: + >>> crack = client.spreads.crack(spread_type="diesel", crude="WTI_USD") + >>> print(crack.value, crack.unit, crack.timestamp) + """ + return ops.run_sync(self.client, ops.crack(spread_type, crude)) + + def crack_historical( + self, + spread_type: Optional[str] = None, + crude: Optional[str] = None, + start_date: Optional[DateInput] = None, + end_date: Optional[DateInput] = None, + ) -> CrackSpreadHistory: + """Get daily crack spread history. + + Args: + spread_type: Spread type; server default ``"3-2-1"``. + crude: Crude benchmark code; server default ``"BRENT_CRUDE_USD"``. + start_date: ``YYYY-MM-DD``, ``date`` or ``datetime``. Server default + is 30 days ago; the server caps the window at two years. + end_date: ``YYYY-MM-DD``, ``date`` or ``datetime``; default today. + + Returns: + CrackSpreadHistory. ``period`` is the window the server applied and + ``coverage`` describes what was actually returned -- compare them + before assuming the full window is present. ``data_revised_at`` + changes when the underlying inputs are restated. + + Raises: + ValidationError: Blank selector, invalid date, or start after end + (raised locally, ``status_code`` is ``None``). + """ + return ops.run_sync( + self.client, ops.crack_historical(spread_type, crude, start_date, end_date) + ) + + def crack_all(self, crude: Optional[str] = None) -> CrackSpreadAll: + """Get every crack spread type for one crude benchmark. + + Types without data are omitted by the server rather than returned empty. + """ + return ops.run_sync(self.client, ops.crack_all(crude)) + + def gasoil_crack(self) -> GasoilCrackSpread: + """Get the European gasoil crack (ICE Low Sulphur Gasoil vs ICE Brent). + + The gasoil leg is quoted in USD/tonne; ``conversion`` states the + barrels-per-tonne factor used to express the spread in USD/bbl, and each + leg names its ``contract_month``. + """ + return ops.run_sync(self.client, ops.gasoil_crack()) + + # -- basis ---------------------------------------------------------------- + + def basis(self, pair: str) -> BasisSpread: + """Get the latest basis spread for a pair. + + Args: + pair: Pair key, e.g. ``"BRENT_WTI"``, ``"WAHA_HH"``, ``"TTF_HH"``. + + Raises: + ValidationError: If ``pair`` is blank (raised locally). + DataNotFoundError: Unknown pair (the message lists valid pairs). + + Example: + >>> spread = client.spreads.basis("BRENT_WTI") + >>> spread.components + {'BRENT_CRUDE_USD': 104.32, 'WTI_USD': 99.99} + """ + return ops.run_sync(self.client, ops.basis(pair)) + + def basis_historical( + self, + pair: str, + start_date: Optional[DateInput] = None, + end_date: Optional[DateInput] = None, + ) -> BasisSpreadHistory: + """Get daily basis spread history for a pair. + + Note: the API answers an unknown ``pair`` on this route with an empty + 200 rather than a 404, so ``count == 0`` can mean a misspelled pair. + Use :meth:`basis_all` to list valid pairs. + """ + return ops.run_sync(self.client, ops.basis_historical(pair, start_date, end_date)) + + def basis_all(self) -> List[BasisSpread]: + """Get the latest value for every basis pair with data.""" + return ops.run_sync(self.client, ops.basis_all()) + + # -- curve structure ------------------------------------------------------ + + def curve_structure(self, commodity: str) -> CurveStructure: + """Get futures curve structure (backwardation/contango) for a market. + + Args: + commodity: ``"ICE_BRENT"``, ``"ICE_WTI"``, ``"ICE_GASOIL"``, + ``"NYMEX_NG"`` or ``"ICE_TTF"``. + """ + return ops.run_sync(self.client, ops.curve_structure(commodity)) + + def curve_structure_all(self) -> List[CurveStructure]: + """Get curve structure for every market with a usable curve.""" + return ops.run_sync(self.client, ops.curve_structure_all()) + + # -- refinery margin ------------------------------------------------------ + + def margin(self, index: Optional[str] = None) -> RefineryMargin: + """Get the latest refinery margin. + + Args: + index: ``"usgc"``, ``"singapore"`` or ``"nwe"``; server default + ``"usgc"``. + """ + return ops.run_sync(self.client, ops.margin(index)) + + def margin_historical( + self, + index: Optional[str] = None, + start_date: Optional[DateInput] = None, + end_date: Optional[DateInput] = None, + ) -> RefineryMarginHistory: + """Get daily refinery margin history (unknown ``index`` returns empty).""" + return ops.run_sync(self.client, ops.margin_historical(index, start_date, end_date)) + + def margin_all(self) -> List[RefineryMargin]: + """Get the latest margin for every index with data.""" + return ops.run_sync(self.client, ops.margin_all()) + + # -- physical premium ----------------------------------------------------- + + def physical_premium(self, commodity: Optional[str] = None) -> PhysicalPremium: + """Get the latest physical (spot) vs futures premium. + + Args: + commodity: ``"BRENT"`` or ``"WTI"``; server default ``"BRENT"``. + """ + return ops.run_sync(self.client, ops.physical_premium(commodity)) + + def physical_premium_historical( + self, + commodity: Optional[str] = None, + start_date: Optional[DateInput] = None, + end_date: Optional[DateInput] = None, + ) -> PhysicalPremiumHistory: + """Get daily physical premium history. An empty ``data`` list is a + valid result when the server has no overlapping spot/futures days.""" + return ops.run_sync( + self.client, ops.physical_premium_historical(commodity, start_date, end_date) + ) + + def physical_premium_all(self) -> List[PhysicalPremium]: + """Get the latest premium for every commodity with data.""" + return ops.run_sync(self.client, ops.physical_premium_all()) diff --git a/tests/integration/test_live_spreads_indicators.py b/tests/integration/test_live_spreads_indicators.py new file mode 100644 index 0000000..8b02ffb --- /dev/null +++ b/tests/integration/test_live_spreads_indicators.py @@ -0,0 +1,110 @@ +""" +Live smoke for the typed Spreads and Indicators resources (#99). + +Hits the REAL OilPriceAPI with the key in ``OILPRICEAPI_TEST_KEY`` and is +skipped without it. Read-only: every call is a GET. The calculated-metrics +routes require a paid plan, so the key must belong to an entitled account. + +Each call is spaced to respect the shared 1 request/second key, and a 429 is +reported as a skip rather than a failure, matching ``tests/integration``. +""" + +import os +import time + +import pytest + +from oilpriceapi import OilPriceAPI +from oilpriceapi.exceptions import RateLimitError +from oilpriceapi.metrics_models import ( + BasisSpread, + CftcPositioning, + CrackSpread, + CrackSpreadHistory, + CurveStructure, + FuelSwitching, + MarketAnnotations, + PhysicalPremium, + PriceContext, + RefineryMargin, + StorageAnalytics, +) + +TEST_KEY = os.environ.get("OILPRICEAPI_TEST_KEY") + +pytestmark = [ + pytest.mark.live, + pytest.mark.integration, + pytest.mark.skipif(not TEST_KEY, reason="OILPRICEAPI_TEST_KEY not set; skipping live metrics smoke"), +] + +SPACING_SECONDS = 1.1 + + +@pytest.fixture(scope="module") +def client(): + c = OilPriceAPI(api_key=TEST_KEY, max_retries=1, timeout=60) + yield c + c.close() + + +def _call(fn, *args, **kwargs): + time.sleep(SPACING_SECONDS) + try: + return fn(*args, **kwargs) + except RateLimitError: + pytest.skip("shared live key rate-limited") + + +def test_spreads_latest_and_all(client): + crack = _call(client.spreads.crack) + assert isinstance(crack, CrackSpread) + assert crack.unit and crack.timestamp.tzinfo is not None + + history = _call(client.spreads.crack_historical) + assert isinstance(history, CrackSpreadHistory) + assert history.count == len(history.data) + + assert _call(client.spreads.crack_all).spreads + assert _call(client.spreads.gasoil_crack).components["product"].unit + + basis_all = _call(client.spreads.basis_all) + assert basis_all and all(isinstance(b, BasisSpread) for b in basis_all) + pair = basis_all[0].pair + assert _call(client.spreads.basis, pair).pair == pair + assert _call(client.spreads.basis_historical, pair).pair == pair + + curves = _call(client.spreads.curve_structure_all) + assert curves + commodity = curves[0].commodity + assert isinstance(_call(client.spreads.curve_structure, commodity), CurveStructure) + + assert isinstance(_call(client.spreads.margin), RefineryMargin) + assert _call(client.spreads.margin_historical).index + assert _call(client.spreads.margin_all) + + assert isinstance(_call(client.spreads.physical_premium), PhysicalPremium) + assert _call(client.spreads.physical_premium_historical).commodity + assert _call(client.spreads.physical_premium_all) + + +def test_indicators(client): + assert isinstance(_call(client.indicators.fuel_switching), FuelSwitching) + assert _call(client.indicators.fuel_switching_historical).gas_benchmark + + context = _call(client.indicators.price_context, "BRENT_CRUDE_USD", related_spreads=True) + assert isinstance(context, PriceContext) + assert context.code == "BRENT_CRUDE_USD" + + assert isinstance(_call(client.indicators.storage_analytics), StorageAnalytics) + assert isinstance(_call(client.indicators.storage_analytics_all), list) + + notes = _call(client.indicators.annotations, "BRENT_CRUDE_USD") + assert isinstance(notes, MarketAnnotations) + assert notes.annotation_count == len(notes.annotations) + batch = _call(client.indicators.annotations_batch, ["BRENT_CRUDE_USD", "WTI_USD"]) + assert batch.total_codes == 2 + + assert isinstance(_call(client.indicators.cftc_positioning), CftcPositioning) + assert _call(client.indicators.cftc_positioning_historical).commodity + assert _call(client.indicators.cftc_positioning_all) diff --git a/tests/unit/fixtures/calculated_metrics/annotations.json b/tests/unit/fixtures/calculated_metrics/annotations.json new file mode 100644 index 0000000..f2cc6cb --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/annotations.json @@ -0,0 +1 @@ +{"status":"success","data":{"code":"BRENT_CRUDE_USD","price":104.32,"timestamp":"2026-09-13T19:56:18Z","annotation_count":3,"annotations":[{"type":"anomaly","severity":"significant","message":"BRENT_CRUDE_USD at 104.32 is 2.24 std dev above the 90-day mean (85.61)","z_score":2.24,"mean_90d":85.61},{"type":"velocity","severity":"significant","message":"BRENT_CRUDE_USD surge of 19.0% over 5 days — 2.96 std dev from normal","pct_change_5d":19.01,"z_score":2.96},{"type":"streak","severity":"notable","message":"BRENT_CRUDE_USD rising for 5 consecutive days","direction":"up","streak_days":5}]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/annotations_batch.json b/tests/unit/fixtures/calculated_metrics/annotations_batch.json new file mode 100644 index 0000000..4fb356f --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/annotations_batch.json @@ -0,0 +1 @@ +{"status":"success","data":{"annotated":[{"code":"BRENT_CRUDE_USD","price":104.32,"timestamp":"2026-09-13T19:56:18Z","annotation_count":3,"annotations":[{"type":"anomaly","severity":"significant","message":"BRENT_CRUDE_USD at 104.32 is 2.24 std dev above the 90-day mean (85.61)","z_score":2.24,"mean_90d":85.61},{"type":"velocity","severity":"significant","message":"BRENT_CRUDE_USD surge of 19.0% over 5 days — 2.96 std dev from normal","pct_change_5d":19.01,"z_score":2.96},{"type":"streak","severity":"notable","message":"BRENT_CRUDE_USD rising for 5 consecutive days","direction":"up","streak_days":5}]},{"code":"WTI_USD","price":99.99,"timestamp":"2026-09-13T18:31:12Z","annotation_count":3,"annotations":[{"type":"anomaly","severity":"significant","message":"WTI_USD at 99.99 is 2.64 std dev above the 90-day mean (80.6)","z_score":2.64,"mean_90d":80.6},{"type":"velocity","severity":"extreme","message":"WTI_USD surge of 22.3% over 5 days — 3.27 std dev from normal","pct_change_5d":22.32,"z_score":3.27},{"type":"streak","severity":"notable","message":"WTI_USD rising for 5 consecutive days","direction":"up","streak_days":5}]}],"total_codes":2,"codes_with_annotations":2}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/basis.json b/tests/unit/fixtures/calculated_metrics/basis.json new file mode 100644 index 0000000..a3c70a2 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/basis.json @@ -0,0 +1 @@ +{"status":"success","data":{"pair":"BRENT_WTI","spread_name":"Brent-WTI Spread","value":4.33,"unit":"USD/bbl","components":{"BRENT_CRUDE_USD":104.32,"WTI_USD":99.99},"signal":"normal","timestamp":"2026-09-13T18:31:12Z","percentile_1y":49,"changes":{"change_1d":-0.15,"change_1d_pct":-3.35,"change_1w":-0.73,"change_1w_pct":-14.43,"change_1m":-1.44,"change_1m_pct":-24.96}}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/basis_all.json b/tests/unit/fixtures/calculated_metrics/basis_all.json new file mode 100644 index 0000000..6de0f9a --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/basis_all.json @@ -0,0 +1 @@ +{"status":"success","data":{"spreads":[{"pair":"WAHA_HH","spread_name":"Waha-Henry Hub Basis","value":-0.94,"unit":"USD/MMBtu","components":{"NATURAL_GAS_WAHA":1.89,"NATURAL_GAS_USD":2.83},"signal":"normal","timestamp":"2026-09-11T20:30:31Z","percentile_1y":88,"changes":{"change_1d":0.0,"change_1d_pct":0.0,"change_1w":0.0,"change_1w_pct":0.0,"change_1m":-0.37,"change_1m_pct":-64.91},"negative_streak_days":61,"data_stale":true,"stale_warning":"Some price data is older than 24 hours — may reflect weekend/holiday staleness"},{"pair":"BRENT_WTI","spread_name":"Brent-WTI Spread","value":4.33,"unit":"USD/bbl","components":{"BRENT_CRUDE_USD":104.32,"WTI_USD":99.99},"signal":"normal","timestamp":"2026-09-13T18:31:12Z","percentile_1y":49,"changes":{"change_1d":-0.15,"change_1d_pct":-3.35,"change_1w":-0.73,"change_1w_pct":-14.43,"change_1m":-1.44,"change_1m_pct":-24.96}},{"pair":"BRENT_DUBAI","spread_name":"Brent-Dubai Spread","value":-10.59,"unit":"USD/bbl","components":{"BRENT_CRUDE_USD":104.32,"DUBAI_CRUDE_USD":114.91},"signal":"severe_bottleneck","timestamp":"2026-09-13T00:19:21Z","percentile_1y":11,"changes":{"change_1d":1.36,"change_1d_pct":11.38,"change_1w":-6.71,"change_1w_pct":-172.94,"change_1m":-13.03,"change_1m_pct":-534.02}},{"pair":"TTF_HH","spread_name":"TTF-Henry Hub Spread","value":89.4,"unit":"USD/MMBtu","components":{"DUTCH_TTF_NATURAL_GAS_USD":92.23,"NATURAL_GAS_USD":2.83},"signal":"extreme_wide","timestamp":"2026-09-13T19:38:30Z","percentile_1y":99,"changes":{"change_1d":0.26,"change_1d_pct":0.29,"change_1w":8.79,"change_1w_pct":10.9,"change_1m":22.29,"change_1m_pct":33.21}},{"pair":"BRENT_OMAN","spread_name":"Brent-Oman Spread","value":-17.57,"unit":"USD/bbl","components":{"BRENT_CRUDE_USD":104.32,"OMAN_CRUDE_USD":121.89},"signal":"severe_bottleneck","timestamp":"2026-09-11T09:30:21Z","percentile_1y":7,"changes":{"change_1d":-0.15,"change_1d_pct":-0.86,"change_1w":-12.05,"change_1w_pct":-218.3,"change_1m":-18.0,"change_1m_pct":-4186.05},"data_stale":true,"stale_warning":"Some price data is older than 24 hours — may reflect weekend/holiday staleness"}]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/basis_historical.json b/tests/unit/fixtures/calculated_metrics/basis_historical.json new file mode 100644 index 0000000..de9d2c0 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/basis_historical.json @@ -0,0 +1 @@ +{"status":"success","data":{"pair":"BRENT_WTI","period":{"start":"2026-09-01","end":"2026-09-13"},"count":9,"data":[{"date":"2026-09-11","value":4.79,"code_a":105.50626910999999,"code_b":100.71776786000001},{"date":"2026-09-10","value":4.99,"code_a":103.85720384,"code_b":98.86449612},{"date":"2026-09-09","value":5.11,"code_a":100.25115942000001,"code_b":95.13903226000001},{"date":"2026-09-08","value":4.68,"code_a":98.12858286000001,"code_b":93.452875},{"date":"2026-09-07","value":4.76,"code_a":97.07981818,"code_b":92.32241378999998},{"date":"2026-09-04","value":4.49,"code_a":95.50933628000001,"code_b":91.01531469},{"date":"2026-09-03","value":4.36,"code_a":95.69259312,"code_b":91.33737288},{"date":"2026-09-02","value":4.64,"code_a":95.25744718,"code_b":90.61566929},{"date":"2026-09-01","value":4.37,"code_a":92.62640074000001,"code_b":88.25451327}]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/cftc_positioning.json b/tests/unit/fixtures/calculated_metrics/cftc_positioning.json new file mode 100644 index 0000000..f3a040e --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/cftc_positioning.json @@ -0,0 +1 @@ +{"status":"success","data":{"commodity":"WTI","name":"WTI Crude Oil","report_date":"2026-09-11","positioning":{"speculative":{"net":136579,"long":350118,"short":213539,"net_pct_of_oi":7.04},"commercial":{"net":-166185},"open_interest":1939911},"signal":"neutral","percentile_1y":82,"week_change":6668,"timestamp":"2026-09-11T20:30:22Z"}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/cftc_positioning_all.json b/tests/unit/fixtures/calculated_metrics/cftc_positioning_all.json new file mode 100644 index 0000000..6c17c8a --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/cftc_positioning_all.json @@ -0,0 +1 @@ +{"status":"success","data":{"commodities":[{"commodity":"WTI","name":"WTI Crude Oil","report_date":"2026-09-11","positioning":{"speculative":{"net":136579,"long":350118,"short":213539,"net_pct_of_oi":7.04},"commercial":{"net":-166185},"open_interest":1939911},"signal":"neutral","percentile_1y":82,"week_change":6668,"timestamp":"2026-09-11T20:30:22Z"},{"commodity":"BRENT","name":"Brent Crude Oil (ICE)","report_date":"2026-09-11","positioning":{"speculative":{"net":-41836,"long":null,"short":null,"net_pct_of_oi":null},"commercial":{"net":null},"open_interest":null},"signal":"net_short","percentile_1y":0,"week_change":-2439,"timestamp":"2026-09-11T20:30:23Z"},{"commodity":"NATURAL_GAS","name":"Natural Gas","report_date":"2026-09-11","positioning":{"speculative":{"net":-219767,"long":null,"short":null,"net_pct_of_oi":null},"commercial":{"net":null},"open_interest":null},"signal":"net_short","percentile_1y":0,"week_change":-10856,"timestamp":"2026-09-11T20:30:23Z"},{"commodity":"HEATING_OIL","name":"NY Harbor ULSD (Heating Oil)","report_date":"2026-09-11","positioning":{"speculative":{"net":11708,"long":null,"short":null,"net_pct_of_oi":null},"commercial":{"net":null},"open_interest":null},"signal":"neutral","percentile_1y":50,"week_change":-2371,"timestamp":"2026-09-11T20:30:24Z"},{"commodity":"GASOLINE","name":"RBOB Gasoline","report_date":"2026-09-11","positioning":{"speculative":{"net":73192,"long":null,"short":null,"net_pct_of_oi":null},"commercial":{"net":null},"open_interest":null},"signal":"extreme_long","percentile_1y":90,"week_change":3567,"timestamp":"2026-09-11T20:30:24Z"}]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/cftc_positioning_historical.json b/tests/unit/fixtures/calculated_metrics/cftc_positioning_historical.json new file mode 100644 index 0000000..afe88c3 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/cftc_positioning_historical.json @@ -0,0 +1 @@ +{"status":"success","data":{"commodity":"WTI","period":{"start":"2026-06-01","end":"2026-09-13"},"count":10,"data":[{"date":"2026-09-11","spec_net":136579,"open_interest":1939911,"spec_net_pct_oi":0},{"date":"2026-09-04","spec_net":129911,"open_interest":1921085,"spec_net_pct_oi":0},{"date":"2026-08-28","spec_net":123449,"open_interest":1906740,"spec_net_pct_oi":0},{"date":"2026-08-21","spec_net":122090,"open_interest":1888960,"spec_net_pct_oi":0},{"date":"2026-08-14","spec_net":99196,"open_interest":1892429,"spec_net_pct_oi":0},{"date":"2026-08-07","spec_net":112443,"open_interest":1886816,"spec_net_pct_oi":0},{"date":"2026-07-31","spec_net":120108,"open_interest":1859795,"spec_net_pct_oi":0},{"date":"2026-07-24","spec_net":81689,"open_interest":1864487,"spec_net_pct_oi":0},{"date":"2026-07-18","spec_net":62683,"open_interest":1875496,"spec_net_pct_oi":0},{"date":"2026-07-10","spec_net":75749,"open_interest":1905761,"spec_net_pct_oi":0}]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/crack.json b/tests/unit/fixtures/calculated_metrics/crack.json new file mode 100644 index 0000000..ae37758 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/crack.json @@ -0,0 +1 @@ +{"status":"success","data":{"spread_type":"3-2-1","crude_benchmark":"BRENT_CRUDE_USD","value":58.5,"unit":"USD/bbl","components":{"crude":{"code":"BRENT_CRUDE_USD","price":104.32,"unit":"USD/bbl"},"gasoline":{"code":"GASOLINE_RBOB_USD","price":139.44,"unit":"USD/bbl"},"diesel":{"code":"HEATING_OIL_USD","price":209.58,"unit":"USD/bbl"}},"timestamp":"2026-09-13T19:50:24Z","changes":{"change_1d":0.43,"change_1d_pct":0.74,"change_1w":1.48,"change_1w_pct":2.6,"change_1m":-1.08,"change_1m_pct":-1.81}}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/crack_all.json b/tests/unit/fixtures/calculated_metrics/crack_all.json new file mode 100644 index 0000000..8d87e12 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/crack_all.json @@ -0,0 +1 @@ +{"status":"success","data":{"crude_benchmark":"BRENT_CRUDE_USD","spreads":[{"spread_type":"jet","crude_benchmark":"BRENT_CRUDE_USD","value":77.96,"unit":"USD/bbl","components":{"crude":{"code":"BRENT_CRUDE_USD","price":104.32,"unit":"USD/bbl"},"product":{"code":"JET_FUEL_USD","price":182.28,"unit":"USD/bbl"}},"timestamp":"2026-09-10T21:00:23Z","changes":{"change_1d":0.15,"change_1d_pct":0.19,"change_1w":-1.74,"change_1w_pct":-2.18,"change_1m":4.94,"change_1m_pct":6.77},"data_stale":true,"stale_warning":"Some price data is older than 24 hours — may reflect weekend/holiday staleness"},{"spread_type":"diesel","crude_benchmark":"BRENT_CRUDE_USD","value":105.26,"unit":"USD/bbl","components":{"crude":{"code":"BRENT_CRUDE_USD","price":104.32,"unit":"USD/bbl"},"product":{"code":"HEATING_OIL_USD","price":209.58,"unit":"USD/bbl"}},"timestamp":"2026-09-13T19:50:24Z","changes":{"change_1d":0.15,"change_1d_pct":0.14,"change_1w":10.44,"change_1w_pct":11.01,"change_1m":13.76,"change_1m_pct":15.04}},{"spread_type":"gasoline","crude_benchmark":"BRENT_CRUDE_USD","value":35.12,"unit":"USD/bbl","components":{"crude":{"code":"BRENT_CRUDE_USD","price":104.32,"unit":"USD/bbl"},"product":{"code":"GASOLINE_RBOB_USD","price":139.44,"unit":"USD/bbl"}},"timestamp":"2026-09-13T19:50:24Z","changes":{"change_1d":0.57,"change_1d_pct":1.65,"change_1w":-3.0,"change_1w_pct":-7.87,"change_1m":-8.5,"change_1m_pct":-19.49}},{"spread_type":"3-2-1","crude_benchmark":"BRENT_CRUDE_USD","value":58.5,"unit":"USD/bbl","components":{"crude":{"code":"BRENT_CRUDE_USD","price":104.32,"unit":"USD/bbl"},"gasoline":{"code":"GASOLINE_RBOB_USD","price":139.44,"unit":"USD/bbl"},"diesel":{"code":"HEATING_OIL_USD","price":209.58,"unit":"USD/bbl"}},"timestamp":"2026-09-13T19:50:24Z","changes":{"change_1d":0.43,"change_1d_pct":0.74,"change_1w":1.48,"change_1w_pct":2.6,"change_1m":-1.08,"change_1m_pct":-1.81}}]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/crack_historical.json b/tests/unit/fixtures/calculated_metrics/crack_historical.json new file mode 100644 index 0000000..240bdf2 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/crack_historical.json @@ -0,0 +1 @@ +{"status":"success","data":{"spread_type":"3-2-1","crude_benchmark":"BRENT_CRUDE_USD","period":{"start":"2026-09-01","end":"2026-09-12"},"coverage":{"from":"2026-09-01","to":"2026-09-11","observations":9,"complete":true},"data_revised_at":"2026-09-13T00:05:27.514Z","count":9,"data":[{"date":"2026-09-11","value":58.93,"crude":105.50626910999999,"gasoline":140.9,"diesel":211.51},{"date":"2026-09-10","value":57.41,"crude":103.85720384,"gasoline":138.75,"diesel":206.3},{"date":"2026-09-09","value":56.65,"crude":100.25115942000001,"gasoline":136.31,"diesel":198.08},{"date":"2026-09-08","value":58.06,"crude":98.12858286000001,"gasoline":136.82,"diesel":194.93},{"date":"2026-09-07","value":58.06,"crude":97.07981818,"gasoline":135.02,"diesel":195.38},{"date":"2026-09-04","value":55.97,"crude":95.50933628000001,"gasoline":131.93,"diesel":190.58},{"date":"2026-09-03","value":56.36,"crude":95.69259312,"gasoline":130.72,"diesel":194.73},{"date":"2026-09-02","value":58.09,"crude":95.25744718,"gasoline":131.79,"diesel":196.45},{"date":"2026-09-01","value":58.59,"crude":92.62640074000001,"gasoline":130.98,"diesel":191.68}]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/curve_structure.json b/tests/unit/fixtures/calculated_metrics/curve_structure.json new file mode 100644 index 0000000..4202b92 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/curve_structure.json @@ -0,0 +1 @@ +{"status":"success","data":{"commodity":"ICE_BRENT","display_name":"ICE Brent Crude","structure":"backwardation","severity":"extreme","term_slope_pct":-38.7,"spreads":{"m1_m3":9.09,"m1_m6":16.82,"m1_m12":25.26},"front_month":{"price":104.32,"contract":"Nov 2026"},"back_month_6":{"price":87.5,"contract":"Apr 2027"},"curve_points":16,"signal":"Physical scarcity — steep 38.7% backwardation signals acute supply stress","timestamp":"2026-09-13T19:56:18Z"}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/curve_structure_all.json b/tests/unit/fixtures/calculated_metrics/curve_structure_all.json new file mode 100644 index 0000000..5a944d5 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/curve_structure_all.json @@ -0,0 +1 @@ +{"status":"success","data":{"commodities":[{"commodity":"ICE_BRENT","display_name":"ICE Brent Crude","structure":"backwardation","severity":"extreme","term_slope_pct":-38.7,"spreads":{"m1_m3":9.09,"m1_m6":16.82,"m1_m12":25.26},"front_month":{"price":104.32,"contract":"Nov 2026"},"back_month_6":{"price":87.5,"contract":"Apr 2027"},"curve_points":16,"signal":"Physical scarcity — steep 38.7% backwardation signals acute supply stress","timestamp":"2026-09-13T19:56:18Z"},{"commodity":"ICE_WTI","display_name":"ICE WTI Crude","structure":"backwardation","severity":"extreme","term_slope_pct":-42.82,"spreads":{"m1_m3":8.62,"m1_m6":17.85,"m1_m12":26.39},"front_month":{"price":100.05,"contract":"Oct 2026"},"back_month_6":{"price":82.2,"contract":"Mar 2027"},"curve_points":17,"signal":"Physical scarcity — steep 42.8% backwardation signals acute supply stress","timestamp":"2026-09-13T19:56:17Z"},{"commodity":"ICE_GASOIL","display_name":"ICE Gas Oil","structure":"backwardation","severity":"extreme","term_slope_pct":-47.42,"spreads":{"m1_m3":160.0,"m1_m6":292.0,"m1_m12":469.25},"front_month":{"price":1478.0,"contract":"Oct 2026"},"back_month_6":{"price":1186.0,"contract":"Mar 2027"},"curve_points":15,"signal":"Physical scarcity — steep 47.4% backwardation signals acute supply stress","timestamp":"2026-09-13T19:42:36Z"},{"commodity":"NYMEX_NG","display_name":"NYMEX Natural Gas","structure":"contango","severity":"moderate","term_slope_pct":4.26,"spreads":{"m1_m3":-0.63,"m1_m6":-0.05,"m1_m12":-0.37},"front_month":{"price":2.82,"contract":"Oct 2026"},"back_month_6":{"price":2.87,"contract":"Mar 2027"},"curve_points":17,"signal":"Moderate contango — forward premium reflects storage/financing costs","timestamp":"2026-09-11T20:49:33Z"},{"commodity":"ICE_TTF","display_name":"ICE TTF Gas","structure":"backwardation","severity":"steep","term_slope_pct":-11.17,"spreads":{"m1_m3":0.57,"m1_m6":3.73,"m1_m12":27.85},"front_month":{"price":80.15,"contract":"Oct 2026"},"back_month_6":{"price":76.42,"contract":"Mar 2027"},"curve_points":15,"signal":"Physical scarcity — steep 11.2% backwardation signals acute supply stress","timestamp":"2026-09-13T17:23:35Z"}]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/error_400_missing_pair.json b/tests/unit/fixtures/calculated_metrics/error_400_missing_pair.json new file mode 100644 index 0000000..00d9ebe --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/error_400_missing_pair.json @@ -0,0 +1 @@ +{"error":{"code":"MISSING_PARAMETER","message":"Required parameter 'pair' missing. Valid: WAHA_HH, BRENT_WTI, BRENT_DUBAI, TTF_HH, BRENT_OMAN","status":400,"request_id":"ceee4875-8fa0-4a90-8f43-19982e9c1975","docs":"https://docs.oilpriceapi.com#MISSING_PARAMETER"}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/error_401.json b/tests/unit/fixtures/calculated_metrics/error_401.json new file mode 100644 index 0000000..0ffe8c0 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/error_401.json @@ -0,0 +1 @@ +{"error":{"code":"UNAUTHORIZED","message":"Missing or invalid API key. Include header: Authorization: Token YOUR_API_KEY","status":401,"request_id":"563a503a-98be-4408-84e0-d5e281d8802e","docs":"https://docs.oilpriceapi.com#UNAUTHORIZED","signup_url":"https://www.oilpriceapi.com/auth/signup","demo_endpoint":"/v1/demo/prices"}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/error_404_no_data.json b/tests/unit/fixtures/calculated_metrics/error_404_no_data.json new file mode 100644 index 0000000..88ce6c6 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/error_404_no_data.json @@ -0,0 +1 @@ +{"error":{"code":"DATA_NOT_AVAILABLE","message":"No congressional trading data available. Requires QUIVER_API_KEY configuration.","status":404,"request_id":"aed753c0-294f-4739-b834-ea76b93a7846","docs":"https://docs.oilpriceapi.com#DATA_NOT_AVAILABLE"}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/error_404_unknown_index.json b/tests/unit/fixtures/calculated_metrics/error_404_unknown_index.json new file mode 100644 index 0000000..88dbcc4 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/error_404_unknown_index.json @@ -0,0 +1 @@ +{"error":{"code":"DATA_NOT_AVAILABLE","message":"Unknown index: nope. Valid: usgc, singapore, nwe","status":404,"request_id":"f25bb4c4-0f30-4753-902c-d10dd04fc60c","docs":"https://docs.oilpriceapi.com#DATA_NOT_AVAILABLE"}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/fuel_switching.json b/tests/unit/fixtures/calculated_metrics/fuel_switching.json new file mode 100644 index 0000000..97edb32 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/fuel_switching.json @@ -0,0 +1 @@ +{"status":"success","data":{"oil_parity":{"ratio_pct":15.73,"threshold_pct":100.0,"signal":"gas_cheap_vs_oil","parity_price":17.99,"current_gas":2.83,"headroom_pct":84.3},"components":{"gas":{"code":"NATURAL_GAS_USD","price":2.83,"unit":"USD/MMBtu"},"crude":{"code":"BRENT_CRUDE_USD","price":104.32,"unit":"USD/bbl"}},"energy_equivalent":{"crude_per_mmbtu":17.99,"gas_premium_discount":-15.16},"historical_context":{"times_above_parity_last_year":0,"pct_above_parity":0.0,"avg_ratio_1y":26.06,"max_ratio_1y":56.78,"min_ratio_1y":13.03,"data_points":347},"timestamp":"2026-09-13T19:52:54Z"}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/fuel_switching_historical.json b/tests/unit/fixtures/calculated_metrics/fuel_switching_historical.json new file mode 100644 index 0000000..870f291 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/fuel_switching_historical.json @@ -0,0 +1 @@ +{"status":"success","data":{"gas_benchmark":"NATURAL_GAS_USD","crude_benchmark":"BRENT_CRUDE_USD","period":{"start":"2026-08-01","end":"2026-09-13"},"count":34,"data":[{"date":"2026-09-11","ratio_pct":15.51,"above_parity":false,"gas_price":2.8208421099999996,"crude_price":105.50626910999999},{"date":"2026-09-10","ratio_pct":15.66,"above_parity":false,"gas_price":2.80392982,"crude_price":103.85720384},{"date":"2026-09-09","ratio_pct":16.58,"above_parity":false,"gas_price":2.86527778,"crude_price":100.25115942000001},{"date":"2026-09-08","ratio_pct":17.38,"above_parity":false,"gas_price":2.9410507200000002,"crude_price":98.12858286000001},{"date":"2026-09-07","ratio_pct":17.66,"above_parity":false,"gas_price":2.95589595,"crude_price":97.07981818},{"date":"2026-09-04","ratio_pct":17.84,"above_parity":false,"gas_price":2.9373384,"crude_price":95.50933628000001},{"date":"2026-09-03","ratio_pct":17.96,"above_parity":false,"gas_price":2.96307692,"crude_price":95.69259312},{"date":"2026-09-02","ratio_pct":18.04,"above_parity":false,"gas_price":2.9634285699999996,"crude_price":95.25744718},{"date":"2026-09-01","ratio_pct":18.28,"above_parity":false,"gas_price":2.9197618999999997,"crude_price":92.62640074000001},{"date":"2026-08-31","ratio_pct":18.59,"above_parity":false,"gas_price":2.90097902,"crude_price":90.48644788},{"date":"2026-08-28","ratio_pct":18.86,"above_parity":false,"gas_price":2.89325581,"crude_price":88.95549505000001},{"date":"2026-08-27","ratio_pct":19.02,"above_parity":false,"gas_price":2.8879113899999997,"crude_price":88.06171247},{"date":"2026-08-26","ratio_pct":18.92,"above_parity":false,"gas_price":2.8328169,"crude_price":86.83975258000001},{"date":"2026-08-25","ratio_pct":17.68,"above_parity":false,"gas_price":2.74687773,"crude_price":90.13344778000001},{"date":"2026-08-24","ratio_pct":17.41,"above_parity":false,"gas_price":2.77257009,"crude_price":92.38254803},{"date":"2026-08-21","ratio_pct":17.13,"above_parity":false,"gas_price":2.76849206,"crude_price":93.75321839},{"date":"2026-08-20","ratio_pct":17.38,"above_parity":false,"gas_price":2.7719331699999996,"crude_price":92.50556560999999},{"date":"2026-08-19","ratio_pct":17.67,"above_parity":false,"gas_price":2.7927301599999996,"crude_price":91.64604061},{"date":"2026-08-18","ratio_pct":17.41,"above_parity":false,"gas_price":2.7359058800000002,"crude_price":91.12889951999999},{"date":"2026-08-17","ratio_pct":17.48,"above_parity":false,"gas_price":2.68914815,"crude_price":89.25023529},{"date":"2026-08-14","ratio_pct":18.2,"above_parity":false,"gas_price":2.7500713,"crude_price":87.65457013999999},{"date":"2026-08-13","ratio_pct":18.25,"above_parity":false,"gas_price":2.75777409,"crude_price":87.65564814999999},{"date":"2026-08-12","ratio_pct":18.05,"above_parity":false,"gas_price":2.78092166,"crude_price":89.35012048},{"date":"2026-08-11","ratio_pct":18.17,"above_parity":false,"gas_price":2.76309783,"crude_price":88.18094420999999},{"date":"2026-08-10","ratio_pct":18.83,"above_parity":false,"gas_price":2.75412987,"crude_price":84.82178038},{"date":"2026-08-09","ratio_pct":18.44,"above_parity":false,"gas_price":2.6708769,"crude_price":84.02230768999999},{"date":"2026-08-08","ratio_pct":18.74,"above_parity":false,"gas_price":2.66,"crude_price":82.34324323999999},{"date":"2026-08-07","ratio_pct":18.35,"above_parity":false,"gas_price":2.6352083299999998,"crude_price":83.27152055},{"date":"2026-08-06","ratio_pct":19.05,"above_parity":false,"gas_price":2.6550823500000003,"crude_price":80.83119313},{"date":"2026-08-05","ratio_pct":19.66,"above_parity":false,"gas_price":2.6878167100000003,"crude_price":79.30733573},{"date":"2026-08-04","ratio_pct":19.15,"above_parity":false,"gas_price":2.75090909,"crude_price":83.31080180000001},{"date":"2026-08-03","ratio_pct":19.21,"above_parity":false,"gas_price":2.7679878,"crude_price":83.5903643},{"date":"2026-08-02","ratio_pct":18.21,"above_parity":false,"gas_price":2.74886243,"crude_price":87.55757447},{"date":"2026-08-01","ratio_pct":17.66,"above_parity":false,"gas_price":2.75,"crude_price":90.335}]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/gasoil_crack.json b/tests/unit/fixtures/calculated_metrics/gasoil_crack.json new file mode 100644 index 0000000..62dd61d --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/gasoil_crack.json @@ -0,0 +1 @@ +{"status":"success","data":{"spread_type":"gasoil","name":"European gasoil crack (ICE Low Sulphur Gasoil vs ICE Brent)","value":92.19,"unit":"USD/bbl","components":{"product":{"code":"GASOIL_FUTURES_2026_09","contract_month":"2026-09","updated_at":"2026-09-11T07:07:31Z","price":1464.0,"unit":"USD/tonne"},"crude":{"code":"BRENT_FUTURES_2026_11","contract_month":"2026-11","updated_at":"2026-09-13T19:56:18Z","price":104.32,"unit":"USD/bbl"}},"conversion":{"barrels_per_tonne":7.45,"basis":"ICE/CME listed convention: 1,000 metric tonnes = 7,450 barrels","gasoil_usd_per_bbl":196.51},"timestamp":"2026-09-11T07:07:31Z","updated_at":"2026-09-13T19:56:18Z","data_stale":true,"stale_warning":"Some price data is older than 24 hours — may reflect weekend/holiday staleness"}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/margin.json b/tests/unit/fixtures/calculated_metrics/margin.json new file mode 100644 index 0000000..9b35e31 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/margin.json @@ -0,0 +1 @@ +{"status":"success","data":{"index":"usgc","name":"US Gulf Coast Cracking Margin","margin_usd_bbl":67.46,"crude_input":{"code":"BRENT_CRUDE_USD","price":104.32},"product_basket":{"gasoline":{"yield_pct":50,"price":139.44,"code":"GASOLINE_RBOB_USD"},"diesel":{"yield_pct":30,"price":209.58,"code":"HEATING_OIL_USD"},"jet_fuel":{"yield_pct":10,"price":182.28,"code":"JET_FUEL_USD"},"fuel_oil":{"yield_pct":10,"price":209.58,"code":"HEATING_OIL_USD"}},"signal":"extreme","percentile_1y":97,"changes":{"change_1d":0.36,"change_1d_pct":0.54,"change_1w":2.5,"change_1w_pct":3.85,"change_1m":1.75,"change_1m_pct":2.66},"timestamp":"2026-09-13T19:56:18Z"}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/margin_all.json b/tests/unit/fixtures/calculated_metrics/margin_all.json new file mode 100644 index 0000000..0d17c34 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/margin_all.json @@ -0,0 +1 @@ +{"status":"success","data":{"margins":[{"index":"usgc","name":"US Gulf Coast Cracking Margin","margin_usd_bbl":67.46,"crude_input":{"code":"BRENT_CRUDE_USD","price":104.32},"product_basket":{"gasoline":{"yield_pct":50,"price":139.44,"code":"GASOLINE_RBOB_USD"},"diesel":{"yield_pct":30,"price":209.58,"code":"HEATING_OIL_USD"},"jet_fuel":{"yield_pct":10,"price":182.28,"code":"JET_FUEL_USD"},"fuel_oil":{"yield_pct":10,"price":209.58,"code":"HEATING_OIL_USD"}},"signal":"extreme","percentile_1y":97,"changes":{"change_1d":0.36,"change_1d_pct":0.54,"change_1w":2.5,"change_1w_pct":3.85,"change_1m":1.75,"change_1m_pct":2.66},"timestamp":"2026-09-13T19:56:18Z"},{"index":"singapore","name":"Singapore Complex Margin","margin_usd_bbl":62.52,"crude_input":{"code":"DUBAI_CRUDE_USD","price":114.91},"product_basket":{"mogas":{"yield_pct":30,"price":139.44,"code":"GASOLINE_RBOB_USD"},"gasoil":{"yield_pct":35,"price":209.58,"code":"HEATING_OIL_USD"},"jet":{"yield_pct":15,"price":182.28,"code":"JET_FUEL_USD"},"naphtha":{"yield_pct":10,"price":139.44,"code":"GASOLINE_RBOB_USD"},"fuel_oil":{"yield_pct":10,"price":209.58,"code":"HEATING_OIL_USD"}},"signal":"extreme","percentile_1y":82,"changes":{"change_1d":1.68,"change_1d_pct":2.76,"change_1w":-3.47,"change_1w_pct":-5.26,"change_1m":-9.5,"change_1m_pct":-13.19},"timestamp":"2026-09-13T00:19:21Z"},{"index":"nwe","name":"NW Europe Cracking Margin","margin_usd_bbl":73.11,"crude_input":{"code":"BRENT_CRUDE_USD","price":104.32},"product_basket":{"gasoline":{"yield_pct":40,"price":139.44,"code":"GASOLINE_RBOB_USD"},"diesel":{"yield_pct":35,"price":209.58,"code":"HEATING_OIL_USD"},"jet_fuel":{"yield_pct":15,"price":182.28,"code":"JET_FUEL_USD"},"fuel_oil":{"yield_pct":10,"price":209.58,"code":"HEATING_OIL_USD"}},"signal":"extreme","percentile_1y":99,"changes":{"change_1d":0.32,"change_1d_pct":0.44,"change_1w":3.24,"change_1w_pct":4.64,"change_1m":3.53,"change_1m_pct":5.07},"timestamp":"2026-09-13T19:56:18Z"}]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/margin_historical.json b/tests/unit/fixtures/calculated_metrics/margin_historical.json new file mode 100644 index 0000000..74c99f8 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/margin_historical.json @@ -0,0 +1 @@ +{"status":"success","data":{"index":"usgc","period":{"start":"2026-09-01","end":"2026-09-13"},"count":9,"data":[{"date":"2026-09-11","margin":49.54,"crude":105.50626910999999,"revenue":155.05},{"date":"2026-09-10","margin":48.03,"crude":103.85720384,"revenue":151.89},{"date":"2026-09-09","margin":65.37,"crude":100.25115942000001,"revenue":165.62},{"date":"2026-09-08","margin":65.6,"crude":98.12858286000001,"revenue":163.73},{"date":"2026-09-07","margin":48.58,"crude":97.07981818,"revenue":145.66},{"date":"2026-09-04","margin":63.57,"crude":95.50933628000001,"revenue":159.08},{"date":"2026-09-03","margin":64.61,"crude":95.69259312,"revenue":160.3},{"date":"2026-09-02","margin":66.52,"crude":95.25744718,"revenue":161.78},{"date":"2026-09-01","margin":67.13,"crude":92.62640074000001,"revenue":159.76}]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/physical_premium.json b/tests/unit/fixtures/calculated_metrics/physical_premium.json new file mode 100644 index 0000000..795567c --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/physical_premium.json @@ -0,0 +1 @@ +{"status":"success","data":{"commodity":"BRENT","name":"Brent Physical Premium","premium":4.7,"premium_pct":4.48,"unit":"USD/bbl","components":{"spot":{"code":"BRENT_SPOT_USD","price":109.51},"futures":{"code":"BRENT_FUTURES_CONTINUOUS","price":104.81,"contract":"Continuous"}},"signal":"normal_backwardation","elevated_streak_days":0,"percentile_1y":null,"timestamp":"2026-09-10T21:00:18Z","data_stale":true,"stale_warning":"Some price data is older than 24 hours — may reflect weekend/holiday staleness"}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/physical_premium_all.json b/tests/unit/fixtures/calculated_metrics/physical_premium_all.json new file mode 100644 index 0000000..eafbe22 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/physical_premium_all.json @@ -0,0 +1 @@ +{"status":"success","data":{"premiums":[{"commodity":"BRENT","name":"Brent Physical Premium","premium":4.7,"premium_pct":4.48,"unit":"USD/bbl","components":{"spot":{"code":"BRENT_SPOT_USD","price":109.51},"futures":{"code":"BRENT_FUTURES_CONTINUOUS","price":104.81,"contract":"Continuous"}},"signal":"normal_backwardation","elevated_streak_days":0,"percentile_1y":null,"timestamp":"2026-09-10T21:00:18Z","data_stale":true,"stale_warning":"Some price data is older than 24 hours — may reflect weekend/holiday staleness"},{"commodity":"WTI","name":"WTI Physical Premium","premium":-3.35,"premium_pct":-3.33,"unit":"USD/bbl","components":{"spot":{"code":"WTI_SPOT_USD","price":97.26},"futures":{"code":"WTI_FUTURES_CONTINUOUS","price":100.61,"contract":"Continuous"}},"signal":"normal_contango","elevated_streak_days":0,"percentile_1y":null,"timestamp":"2026-09-10T21:00:17Z","data_stale":true,"stale_warning":"Some price data is older than 24 hours — may reflect weekend/holiday staleness"}]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/physical_premium_historical_empty.json b/tests/unit/fixtures/calculated_metrics/physical_premium_historical_empty.json new file mode 100644 index 0000000..4d354eb --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/physical_premium_historical_empty.json @@ -0,0 +1 @@ +{"status":"success","data":{"commodity":"BRENT","period":{"start":"2026-09-01","end":"2026-09-13"},"count":0,"data":[]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/price_context.json b/tests/unit/fixtures/calculated_metrics/price_context.json new file mode 100644 index 0000000..5f0e9a1 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/price_context.json @@ -0,0 +1 @@ +{"status":"success","data":{"code":"DIESEL_USD","price":4.95,"timestamp":"2026-09-11T00:17:46Z","context":{"change_1d":0.0,"change_1d_pct":0.0,"change_1w":0.41,"change_1w_pct":9.03,"change_1m":0.72,"change_1m_pct":17.02,"high_52w":5.16,"low_52w":1.98,"percentile_1y":100,"percentile_5y":100,"anomaly":true,"anomaly_reason":"Price is 2.9 std dev above the 90-day mean"}}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/price_context_related.json b/tests/unit/fixtures/calculated_metrics/price_context_related.json new file mode 100644 index 0000000..934df0f --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/price_context_related.json @@ -0,0 +1 @@ +{"status":"success","data":{"code":"BRENT_CRUDE_USD","price":104.32,"timestamp":"2026-09-13T19:56:18Z","context":{"change_1d":-0.15,"change_1d_pct":-0.14,"change_1w":8.04,"change_1w_pct":8.35,"change_1m":17.32,"change_1m_pct":19.91,"high_52w":126.39,"low_52w":58.8,"percentile_1y":90,"percentile_5y":91,"anomaly":true,"anomaly_reason":"Price is 2.1 std dev above the 90-day mean"},"related_spreads":[{"name":"Brent-WTI","value":4.33,"unit":"USD/bbl","signal":"normal"},{"name":"Brent-Dubai","value":-10.59,"unit":"USD/bbl","signal":"severe_bottleneck"},{"name":"3-2-1 Crack","value":58.5,"unit":"USD/bbl","signal":null},{"name":"Curve Structure","value":"backwardation","signal":"extreme","slope":-38.7}]}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/storage_analytics.json b/tests/unit/fixtures/calculated_metrics/storage_analytics.json new file mode 100644 index 0000000..f9ca3db --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/storage_analytics.json @@ -0,0 +1 @@ +{"status":"success","data":{"location":"CUSHING","name":"Cushing, OK Crude Storage","current":{"volume_mmbbl":21.82,"utilization_pct":35.8,"operational_capacity_mmbbl":61.0,"data_date":"2026-09-04T00:00:00.000Z","timestamp":"2026-09-10T19:08:11Z"},"draw_rate":{"weekly_mmbbl":-0.68,"annualized_mmbbl":-35.36,"type":"draw","days_to_depletion":225},"seasonal":{},"anomalies":{"unusual_change":false,"utilization_extreme":false},"range_52w":{},"signal":"low","trading_implication":"Contango market - consider storage plays"}} \ No newline at end of file diff --git a/tests/unit/fixtures/calculated_metrics/storage_analytics_all.json b/tests/unit/fixtures/calculated_metrics/storage_analytics_all.json new file mode 100644 index 0000000..8371d08 --- /dev/null +++ b/tests/unit/fixtures/calculated_metrics/storage_analytics_all.json @@ -0,0 +1 @@ +{"status":"success","data":{"locations":[{"location":"CUSHING","name":"Cushing, OK Crude Storage","current":{"volume_mmbbl":21.82,"utilization_pct":35.8,"operational_capacity_mmbbl":61.0,"data_date":"2026-09-04T00:00:00.000Z","timestamp":"2026-09-10T19:08:11Z"},"draw_rate":{"weekly_mmbbl":-0.68,"annualized_mmbbl":-35.36,"type":"draw","days_to_depletion":225},"seasonal":{},"anomalies":{"unusual_change":false,"utilization_extreme":false},"range_52w":{},"signal":"low","trading_implication":"Contango market - consider storage plays"}]}} \ No newline at end of file diff --git a/tests/unit/test_spreads_indicators_resource.py b/tests/unit/test_spreads_indicators_resource.py new file mode 100644 index 0000000..5e7fb00 --- /dev/null +++ b/tests/unit/test_spreads_indicators_resource.py @@ -0,0 +1,835 @@ +"""Typed Spreads and market Indicators resources (#99). + +Every test drives the REAL sync or async client through a mocked transport +(``httpx.Client.request`` / ``httpx.AsyncClient.request``, this repo's +convention), so envelope unwrapping, error mapping, retry and model +validation all run exactly as they do against production. + +Success fixtures in ``fixtures/calculated_metrics/`` are verbatim production +bodies captured on 2026-09-13 with a paid test account. Nothing in them is +invented; tests that need a malformed body derive it from a real one by +removing or breaking a single field. +""" + +import copy +import json +from datetime import date, datetime, timezone +from pathlib import Path +from unittest.mock import Mock, patch + +import httpx +import pytest + +from oilpriceapi import AsyncOilPriceAPI, OilPriceAPI +from oilpriceapi.exceptions import ( + AuthenticationError, + BadRequestError, + DataNotFoundError, + OilPriceAPIError, + PaymentRequiredError, + PermissionDeniedError, + RateLimitError, + ValidationError, +) +from oilpriceapi.exceptions import TimeoutError as OPATimeoutError +from oilpriceapi.metrics_models import ( + BasisSpread, + BasisSpreadHistory, + CftcPositioning, + CftcPositioningHistory, + CrackSpread, + CrackSpreadAll, + CrackSpreadHistory, + CurveStructure, + FuelSwitching, + FuelSwitchingHistory, + GasoilCrackSpread, + MarketAnnotations, + MarketAnnotationsBatch, + PhysicalPremium, + PhysicalPremiumHistory, + PriceContext, + RefineryMargin, + RefineryMarginHistory, + StorageAnalytics, +) + +FIXTURES = Path(__file__).parent / "fixtures" / "calculated_metrics" + +# Not a credential: a fixture string, every request here is mocked. +FIXTURE_KEY = "-".join(["fixture", "not", "a", "real", "key"]) + +UTC = timezone.utc + + +def load(name): + return json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8")) + + +def _response(status, payload=None, *, body=None, headers=None): + response = Mock() + response.status_code = status + response.headers = headers or {} + if body is None: + text = json.dumps(payload) + response.json.return_value = payload + else: + text = body + response.json.side_effect = json.JSONDecodeError("Expecting value", body, 0) + response.text = text + response.content = text.encode() + return response + + +def _sync(**kwargs): + kwargs.setdefault("max_retries", 1) + return OilPriceAPI(api_key=FIXTURE_KEY, **kwargs) + + +def _async(**kwargs): + kwargs.setdefault("max_retries", 1) + return AsyncOilPriceAPI(api_key=FIXTURE_KEY, **kwargs) + + +def _sent(mock_request): + """Return (path, params) of the single request the client sent.""" + assert mock_request.call_count == 1 + kwargs = mock_request.call_args.kwargs + url = httpx.URL(kwargs["url"]) + return url.path, kwargs.get("params") + + +# --------------------------------------------------------------------------- +# Field-level assertions against the captured production bodies +# --------------------------------------------------------------------------- + + +def check_crack(result): + assert isinstance(result, CrackSpread) + assert result.spread_type == "3-2-1" + assert result.crude_benchmark == "BRENT_CRUDE_USD" + assert result.value == 58.5 + assert result.unit == "USD/bbl" + assert result.components["crude"].code == "BRENT_CRUDE_USD" + assert result.components["gasoline"].price == 139.44 + assert result.components["diesel"].unit == "USD/bbl" + assert result.timestamp == datetime(2026, 9, 13, 19, 50, 24, tzinfo=UTC) + assert result.changes.change_1m_pct == -1.81 + # The composite body carries no stale flag; absence is not turned into False. + assert result.data_stale is None + + +def check_crack_historical(result): + assert isinstance(result, CrackSpreadHistory) + assert result.period.start == date(2026, 9, 1) + assert result.period.end == date(2026, 9, 12) + assert result.coverage.from_ == date(2026, 9, 1) + assert result.coverage.to == date(2026, 9, 11) + assert result.coverage.observations == 9 + assert result.coverage.complete is True + assert result.data_revised_at == datetime(2026, 9, 13, 0, 5, 27, 514000, tzinfo=UTC) + assert result.count == 9 == len(result.data) + first = result.data[0] + assert first.date == date(2026, 9, 11) + assert first.value == 58.93 + # Full precision exactly as sent, not rounded by the SDK. + assert first.crude == 105.50626910999999 + assert first.gasoline == 140.9 and first.diesel == 211.51 + assert first.product is None + + +def check_crack_all(result): + assert isinstance(result, CrackSpreadAll) + assert result.crude_benchmark == "BRENT_CRUDE_USD" + assert [s.spread_type for s in result.spreads] == ["jet", "diesel", "gasoline", "3-2-1"] + jet = result.spreads[0] + assert jet.components["product"].code == "JET_FUEL_USD" + assert jet.data_stale is True + assert jet.stale_warning.startswith("Some price data is older than 24 hours") + + +def check_gasoil_crack(result): + assert isinstance(result, GasoilCrackSpread) + assert result.value == 92.19 + product = result.components["product"] + assert product.unit == "USD/tonne" + assert product.contract_month == "2026-09" + assert product.updated_at == datetime(2026, 9, 11, 7, 7, 31, tzinfo=UTC) + assert product.settlement_date is None + assert result.conversion.barrels_per_tonne == 7.45 + assert result.timestamp == datetime(2026, 9, 11, 7, 7, 31, tzinfo=UTC) + assert result.updated_at == datetime(2026, 9, 13, 19, 56, 18, tzinfo=UTC) + assert result.data_stale is True + + +def check_basis(result): + assert isinstance(result, BasisSpread) + assert result.pair == "BRENT_WTI" + assert result.components == {"BRENT_CRUDE_USD": 104.32, "WTI_USD": 99.99} + assert result.percentile_1y == 49 + assert result.negative_streak_days is None + + +def check_basis_historical(result): + assert isinstance(result, BasisSpreadHistory) + assert result.pair == "BRENT_WTI" + assert result.count == 9 + assert result.data[0].code_a == 105.50626910999999 + assert result.data[0].code_b == 100.71776786000001 + + +def check_basis_all(result): + assert [s.pair for s in result] == ["WAHA_HH", "BRENT_WTI", "BRENT_DUBAI", "TTF_HH", "BRENT_OMAN"] + assert all(isinstance(s, BasisSpread) for s in result) + assert result[0].negative_streak_days == 61 + assert result[0].data_stale is True + + +def check_curve(result): + assert isinstance(result, CurveStructure) + assert result.commodity == "ICE_BRENT" + assert result.structure == "backwardation" + assert result.spreads.m1_m12 == 25.26 + assert result.front_month.contract == "Nov 2026" + assert result.back_month_6.price == 87.5 + assert result.curve_points == 16 + + +def check_curve_all(result): + assert [c.commodity for c in result] == ["ICE_BRENT", "ICE_WTI", "ICE_GASOIL", "NYMEX_NG", "ICE_TTF"] + + +def check_margin(result): + assert isinstance(result, RefineryMargin) + assert result.index == "usgc" + assert result.margin_usd_bbl == 67.46 + assert result.crude_input.code == "BRENT_CRUDE_USD" + assert result.product_basket["jet_fuel"].yield_pct == 10 + assert result.percentile_1y == 97 + + +def check_margin_historical(result): + assert isinstance(result, RefineryMarginHistory) + assert result.index == "usgc" + assert result.data[0].margin == 49.54 + assert result.data[0].revenue == 155.05 + + +def check_margin_all(result): + assert [m.index for m in result] == ["usgc", "singapore", "nwe"] + assert set(result[1].product_basket) == {"mogas", "gasoil", "jet", "naphtha", "fuel_oil"} + + +def check_premium(result): + assert isinstance(result, PhysicalPremium) + assert result.premium == 4.7 + assert result.components.futures.contract == "Continuous" + # null is preserved as None, not replaced with 0. + assert result.percentile_1y is None + assert result.elevated_streak_days == 0 + + +def check_premium_historical_empty(result): + assert isinstance(result, PhysicalPremiumHistory) + assert result.commodity == "BRENT" + assert result.count == 0 + assert result.data == [] + + +def check_premium_all(result): + assert [p.commodity for p in result] == ["BRENT", "WTI"] + + +def check_fuel_switching(result): + assert isinstance(result, FuelSwitching) + assert result.oil_parity.ratio_pct == 15.73 + assert result.oil_parity.signal == "gas_cheap_vs_oil" + assert result.components.gas.unit == "USD/MMBtu" + assert result.energy_equivalent.gas_premium_discount == -15.16 + assert result.historical_context.data_points == 347 + + +def check_fuel_switching_historical(result): + assert isinstance(result, FuelSwitchingHistory) + assert result.count == 34 == len(result.data) + assert result.data[0].above_parity is False + assert result.data[0].gas_price == 2.8208421099999996 + + +def check_price_context(result): + assert isinstance(result, PriceContext) + assert result.code == "DIESEL_USD" + assert result.context.percentile_1y == 100 + assert result.context.anomaly is True + assert result.related_spreads is None + + +def check_price_context_related(result): + assert isinstance(result, PriceContext) + names = [s.name for s in result.related_spreads] + assert names == ["Brent-WTI", "Brent-Dubai", "3-2-1 Crack", "Curve Structure"] + crack = result.related_spreads[2] + assert crack.value == 58.5 and crack.signal is None + curve = result.related_spreads[3] + assert curve.value == "backwardation" + assert curve.unit is None + assert curve.slope == -38.7 + + +def check_storage(result): + assert isinstance(result, StorageAnalytics) + assert result.location == "CUSHING" + assert result.current.volume_mmbbl == 21.82 + assert result.current.data_date == datetime(2026, 9, 4, tzinfo=UTC) + assert result.draw_rate.days_to_depletion == 225 + # The server sends {} when there is too little history; it stays empty. + assert result.seasonal.five_year_avg_mmbbl is None + assert result.range_52w.high_mmbbl is None + + +def check_storage_all(result): + assert [s.location for s in result] == ["CUSHING"] + + +def check_annotations(result): + assert isinstance(result, MarketAnnotations) + assert result.annotation_count == 3 + kinds = [a.type for a in result.annotations] + assert kinds == ["anomaly", "velocity", "streak"] + assert result.annotations[0].z_score == 2.24 + assert result.annotations[2].streak_days == 5 + + +def check_annotations_batch(result): + assert isinstance(result, MarketAnnotationsBatch) + assert result.total_codes == 2 + assert result.codes_with_annotations == 2 + assert [a.code for a in result.annotated] == ["BRENT_CRUDE_USD", "WTI_USD"] + + +def check_cftc(result): + assert isinstance(result, CftcPositioning) + assert result.report_date == date(2026, 9, 11) + assert result.positioning.speculative.net == 136579 + assert result.positioning.speculative.net_pct_of_oi == 7.04 + assert result.positioning.open_interest == 1939911 + + +def check_cftc_historical(result): + assert isinstance(result, CftcPositioningHistory) + assert result.count == 10 + assert result.data[0].spec_net == 136579 + # Preserved exactly as the API sends it (the 0 itself is an API defect). + assert result.data[0].spec_net_pct_oi == 0 + + +def check_cftc_all(result): + brent = result[1] + assert brent.commodity == "BRENT" + assert brent.positioning.speculative.long is None + assert brent.positioning.commercial.net is None + assert brent.positioning.open_interest is None + + +# (namespace, method, args, kwargs, path, params, fixture, check) +CASES = [ + ("spreads", "crack", (), {}, "/v1/spreads/crack", {}, "crack", check_crack), + ( + "spreads", "crack", (), {"spread_type": "diesel", "crude": "WTI_USD"}, + "/v1/spreads/crack", {"type": "diesel", "crude": "WTI_USD"}, "crack", check_crack, + ), + ( + "spreads", "crack_historical", (), + {"start_date": "2026-09-01", "end_date": date(2026, 9, 12)}, + "/v1/spreads/crack/historical", {"start_date": "2026-09-01", "end_date": "2026-09-12"}, + "crack_historical", check_crack_historical, + ), + ("spreads", "crack_all", (), {}, "/v1/spreads/crack/all", {}, "crack_all", check_crack_all), + ( + "spreads", "gasoil_crack", (), {}, "/v1/spreads/gasoil-crack", {}, + "gasoil_crack", check_gasoil_crack, + ), + ( + "spreads", "basis", ("BRENT_WTI",), {}, "/v1/spreads/basis", {"pair": "BRENT_WTI"}, + "basis", check_basis, + ), + ( + "spreads", "basis_historical", ("BRENT_WTI",), {"start_date": "2026-09-01"}, + "/v1/spreads/basis/historical", {"pair": "BRENT_WTI", "start_date": "2026-09-01"}, + "basis_historical", check_basis_historical, + ), + ("spreads", "basis_all", (), {}, "/v1/spreads/basis/all", {}, "basis_all", check_basis_all), + ( + "spreads", "curve_structure", ("ICE_BRENT",), {}, "/v1/spreads/curve-structure", + {"commodity": "ICE_BRENT"}, "curve_structure", check_curve, + ), + ( + "spreads", "curve_structure_all", (), {}, "/v1/spreads/curve-structure/all", {}, + "curve_structure_all", check_curve_all, + ), + ("spreads", "margin", (), {}, "/v1/spreads/margin", {}, "margin", check_margin), + ( + "spreads", "margin_historical", (), {"index": "usgc", "start_date": "2026-09-01"}, + "/v1/spreads/margin/historical", {"index": "usgc", "start_date": "2026-09-01"}, + "margin_historical", check_margin_historical, + ), + ("spreads", "margin_all", (), {}, "/v1/spreads/margin/all", {}, "margin_all", check_margin_all), + ( + "spreads", "physical_premium", (), {"commodity": "BRENT"}, + "/v1/spreads/physical-premium", {"commodity": "BRENT"}, + "physical_premium", check_premium, + ), + ( + "spreads", "physical_premium_historical", (), {"start_date": "2026-09-01"}, + "/v1/spreads/physical-premium/historical", {"start_date": "2026-09-01"}, + "physical_premium_historical_empty", check_premium_historical_empty, + ), + ( + "spreads", "physical_premium_all", (), {}, "/v1/spreads/physical-premium/all", {}, + "physical_premium_all", check_premium_all, + ), + ( + "indicators", "fuel_switching", (), {}, "/v1/indicators/fuel-switching", {}, + "fuel_switching", check_fuel_switching, + ), + ( + "indicators", "fuel_switching_historical", (), + {"gas": "NATURAL_GAS_USD", "start_date": "2026-08-01"}, + "/v1/indicators/fuel-switching/historical", + {"gas": "NATURAL_GAS_USD", "start_date": "2026-08-01"}, + "fuel_switching_historical", check_fuel_switching_historical, + ), + ( + "indicators", "price_context", ("DIESEL_USD",), {}, "/v1/indicators/price-context", + {"code": "DIESEL_USD"}, "price_context", check_price_context, + ), + ( + "indicators", "price_context", ("BRENT_CRUDE_USD",), {"related_spreads": True}, + "/v1/indicators/price-context", {"code": "BRENT_CRUDE_USD", "spreads": "related"}, + "price_context_related", check_price_context_related, + ), + ( + "indicators", "storage_analytics", (), {"location": "CUSHING"}, + "/v1/indicators/storage-analytics", {"location": "CUSHING"}, + "storage_analytics", check_storage, + ), + ( + "indicators", "storage_analytics_all", (), {}, "/v1/indicators/storage-analytics/all", {}, + "storage_analytics_all", check_storage_all, + ), + ( + "indicators", "annotations", ("BRENT_CRUDE_USD",), {}, "/v1/indicators/annotations", + {"code": "BRENT_CRUDE_USD"}, "annotations", check_annotations, + ), + ( + "indicators", "annotations_batch", (["BRENT_CRUDE_USD", "WTI_USD"],), {}, + "/v1/indicators/annotations/batch", {"codes": "BRENT_CRUDE_USD,WTI_USD"}, + "annotations_batch", check_annotations_batch, + ), + ( + "indicators", "cftc_positioning", (), {}, "/v1/indicators/cftc-positioning", {}, + "cftc_positioning", check_cftc, + ), + ( + "indicators", "cftc_positioning_historical", (), {"commodity": "WTI", "start_date": "2026-06-01"}, + "/v1/indicators/cftc-positioning/historical", {"commodity": "WTI", "start_date": "2026-06-01"}, + "cftc_positioning_historical", check_cftc_historical, + ), + ( + "indicators", "cftc_positioning_all", (), {}, "/v1/indicators/cftc-positioning/all", {}, + "cftc_positioning_all", check_cftc_all, + ), +] + +CASE_IDS = [f"{c[0]}.{c[1]}-{c[6]}" for c in CASES] + + +@pytest.mark.parametrize("namespace,method,args,kwargs,path,params,fixture,check", CASES, ids=CASE_IDS) +@patch("httpx.Client.request") +def test_sync_success_is_typed_from_the_wire( + mock_request, namespace, method, args, kwargs, path, params, fixture, check +): + mock_request.return_value = _response(200, load(fixture)) + result = getattr(getattr(_sync(), namespace), method)(*args, **kwargs) + sent_path, sent_params = _sent(mock_request) + assert sent_path == path + assert (sent_params or {}) == params + assert mock_request.call_args.kwargs["method"] == "GET" + check(result) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("namespace,method,args,kwargs,path,params,fixture,check", CASES, ids=CASE_IDS) +@patch("httpx.AsyncClient.request") +async def test_async_success_matches_sync( + mock_request, namespace, method, args, kwargs, path, params, fixture, check +): + mock_request.return_value = _response(200, load(fixture)) + result = await getattr(getattr(_async(), namespace), method)(*args, **kwargs) + sent_path, sent_params = _sent(mock_request) + assert sent_path == path + assert (sent_params or {}) == params + check(result) + + +# --------------------------------------------------------------------------- +# HTTP errors map to the SDK's typed exceptions with recovery metadata intact +# --------------------------------------------------------------------------- + +PREMIUM_REQUIRED = { + # Shape of V1::SpreadsController#check_analytics_access -> render_standard_error. + "error": { + "code": "PREMIUM_REQUIRED", + "message": "Calculated metrics require a paid plan (Developer and above). " + "Upgrade at https://oilpriceapi.com/pricing", + "status": 403, + "request_id": "fixture-request-id", + "docs": "https://docs.oilpriceapi.com#PREMIUM_REQUIRED", + } +} + +ERROR_CASES = [ + (401, "error_401", {}, AuthenticationError, "UNAUTHORIZED"), + ( + 402, + {"error": {"code": "PAYMENT_REQUIRED", "message": "Upgrade required", "required_plan": "developer", + "upgrade_url": "https://www.oilpriceapi.com/pricing"}}, + {}, + PaymentRequiredError, + "PAYMENT_REQUIRED", + ), + (403, PREMIUM_REQUIRED, {}, PermissionDeniedError, "PREMIUM_REQUIRED"), + (404, "error_404_unknown_index", {}, DataNotFoundError, "DATA_NOT_AVAILABLE"), + (400, "error_400_missing_pair", {}, BadRequestError, "MISSING_PARAMETER"), + ( + 429, + {"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests"}}, + {"Retry-After": "7", "X-RateLimit-Limit": "1", "X-RateLimit-Remaining": "0"}, + RateLimitError, + "RATE_LIMIT_EXCEEDED", + ), +] + + +def _error_body(spec): + return load(spec) if isinstance(spec, str) else spec + + +@pytest.mark.parametrize("status,body,headers,exc_type,code", ERROR_CASES, ids=[str(c[0]) for c in ERROR_CASES]) +@patch("httpx.Client.request") +def test_sync_http_errors_are_typed(mock_request, status, body, headers, exc_type, code): + mock_request.return_value = _response(status, _error_body(body), headers=headers) + with pytest.raises(exc_type) as info: + _sync().spreads.margin(index="nope") + assert info.value.status_code == status + assert info.value.code == code + if status == 404: + assert "Valid: usgc, singapore, nwe" in str(info.value) + if status == 402: + assert info.value.required_plan == "developer" + assert info.value.remediation_url == "https://www.oilpriceapi.com/pricing" + if status == 403: + assert info.value.request_id == "fixture-request-id" + if status == 429: + assert info.value.retry_after == 7 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status,body,headers,exc_type,code", ERROR_CASES, ids=[str(c[0]) for c in ERROR_CASES]) +@patch("httpx.AsyncClient.request") +async def test_async_http_errors_are_typed(mock_request, status, body, headers, exc_type, code): + mock_request.return_value = _response(status, _error_body(body), headers=headers) + with pytest.raises(exc_type) as info: + await _async().indicators.cftc_positioning(commodity="NOPE") + assert info.value.status_code == status + assert info.value.code == code + + +@patch("httpx.Client.request") +def test_no_data_404_is_data_not_found(mock_request): + mock_request.return_value = _response(404, load("error_404_no_data")) + with pytest.raises(DataNotFoundError) as info: + _sync().spreads.gasoil_crack() + assert info.value.code == "DATA_NOT_AVAILABLE" + + +# --------------------------------------------------------------------------- +# A malformed 200 raises; nothing is defaulted +# --------------------------------------------------------------------------- + + +def _without(fixture, *path): + body = copy.deepcopy(load(fixture)) + node = body + for key in path[:-1]: + node = node[key] + del node[path[-1]] + return body + + +def _replace(fixture, value, *path): + body = copy.deepcopy(load(fixture)) + node = body + for key in path[:-1]: + node = node[key] + node[path[-1]] = value + return body + + +MALFORMED = [ + ("missing value", "spreads", "crack", (), _without("crack", "data", "value")), + ("missing unit", "spreads", "crack", (), _without("crack", "data", "unit")), + ("missing timestamp", "spreads", "basis", ("BRENT_WTI",), _without("basis", "data", "timestamp")), + ("non-numeric value", "spreads", "crack", (), _replace("crack", "n/a", "data", "value")), + ("unparseable timestamp", "spreads", "crack", (), _replace("crack", "yesterday", "data", "timestamp")), + ("data is a list", "spreads", "crack", (), {"status": "success", "data": []}), + ("no data envelope", "spreads", "crack", (), {"status": "success"}), + ("status not success", "spreads", "crack", (), _replace("crack", "fail", "status")), + ("collection key missing", "spreads", "basis_all", (), {"status": "success", "data": {}}), + ( + "collection not a list", "spreads", "margin_all", (), + {"status": "success", "data": {"margins": {"index": "usgc"}}}, + ), + ( + "collection row missing field", "indicators", "cftc_positioning_all", (), + _without("cftc_positioning_all", "data", "commodities", 0, "report_date"), + ), + ( + "history row missing date", "spreads", "crack_historical", (), + _without("crack_historical", "data", "data", 0, "date"), + ), + ( + "history missing period", "spreads", "crack_historical", (), + _without("crack_historical", "data", "period"), + ), + ( + "nullable key absent", "spreads", "physical_premium", (), + _without("physical_premium", "data", "percentile_1y"), + ), + ( + "annotation missing message", "indicators", "annotations", ("BRENT_CRUDE_USD",), + _without("annotations", "data", "annotations", 0, "message"), + ), +] + + +@pytest.mark.parametrize("label,namespace,method,args,body", MALFORMED, ids=[m[0] for m in MALFORMED]) +@patch("httpx.Client.request") +def test_sync_malformed_success_raises(mock_request, label, namespace, method, args, body): + mock_request.return_value = _response(200, body) + with pytest.raises(OilPriceAPIError) as info: + getattr(getattr(_sync(), namespace), method)(*args) + assert info.value.code == "MALFORMED_RESPONSE" + assert info.value.raw_body == body + + +@pytest.mark.asyncio +@pytest.mark.parametrize("label,namespace,method,args,body", MALFORMED, ids=[m[0] for m in MALFORMED]) +@patch("httpx.AsyncClient.request") +async def test_async_malformed_success_raises(mock_request, label, namespace, method, args, body): + mock_request.return_value = _response(200, body) + with pytest.raises(OilPriceAPIError) as info: + await getattr(getattr(_async(), namespace), method)(*args) + assert info.value.code == "MALFORMED_RESPONSE" + + +@patch("httpx.Client.request") +def test_sync_non_json_200_is_malformed(mock_request): + mock_request.return_value = _response(200, body="gateway") + with pytest.raises(OilPriceAPIError) as info: + _sync().spreads.crack() + assert info.value.code == "MALFORMED_RESPONSE" + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request") +async def test_async_non_json_200_is_malformed(mock_request): + mock_request.return_value = _response(200, body="gateway") + with pytest.raises(OilPriceAPIError) as info: + await _async().indicators.fuel_switching() + assert info.value.code == "MALFORMED_RESPONSE" + + +@patch("httpx.Client.request") +def test_empty_200_body_is_malformed_not_empty_success(mock_request): + mock_request.return_value = _response(200, body="") + with pytest.raises(OilPriceAPIError) as info: + _sync().spreads.basis_all() + assert info.value.code == "MALFORMED_RESPONSE" + + +# --------------------------------------------------------------------------- +# No-data: an empty historical window is an empty result, not an error +# --------------------------------------------------------------------------- + + +@patch("httpx.Client.request") +def test_empty_history_is_empty_not_error(mock_request): + mock_request.return_value = _response(200, load("physical_premium_historical_empty")) + result = _sync().spreads.physical_premium_historical(start_date="2026-09-01") + assert result.count == 0 + assert result.data == [] + + +@patch("httpx.Client.request") +def test_empty_collection_is_empty_list(mock_request): + mock_request.return_value = _response(200, {"status": "success", "data": {"locations": []}}) + assert _sync().indicators.storage_analytics_all() == [] + + +@patch("httpx.Client.request") +def test_crack_history_with_no_observations_keeps_null_coverage(mock_request): + body = { + # Verbatim production body for /v1/spreads/crack/historical?type=nope. + "status": "success", + "data": { + "spread_type": "nope", + "crude_benchmark": "BRENT_CRUDE_USD", + "period": {"start": "2026-08-14", "end": "2026-09-13"}, + "coverage": {"from": None, "to": None, "observations": 0, "complete": False}, + "data_revised_at": "2026-09-13T19:56:18.781Z", + "count": 0, + "data": [], + }, + } + mock_request.return_value = _response(200, body) + result = _sync().spreads.crack_historical(spread_type="nope") + assert result.coverage.from_ is None and result.coverage.to is None + assert result.coverage.complete is False + assert result.data == [] + + +# --------------------------------------------------------------------------- +# Timeouts: typed, and a replay-safe GET recovers on retry +# --------------------------------------------------------------------------- + + +@patch("httpx.Client.request") +def test_sync_timeout_is_typed(mock_request): + mock_request.side_effect = httpx.ReadTimeout("timed out") + with pytest.raises(OPATimeoutError): + _sync().spreads.crack() + + +@pytest.mark.asyncio +@patch("httpx.AsyncClient.request") +async def test_async_timeout_is_typed(mock_request): + mock_request.side_effect = httpx.ReadTimeout("timed out") + with pytest.raises(OPATimeoutError): + await _async().indicators.price_context("BRENT_CRUDE_USD") + + +@patch("oilpriceapi.client.time.sleep") +@patch("httpx.Client.request") +def test_sync_timeout_recovers_on_retry(mock_request, _sleep): + mock_request.side_effect = [httpx.ReadTimeout("timed out"), _response(200, load("crack"))] + result = _sync(max_retries=2).spreads.crack() + assert mock_request.call_count == 2 + check_crack(result) + + +@pytest.mark.asyncio +@patch("oilpriceapi.async_client.asyncio.sleep") +@patch("httpx.AsyncClient.request") +async def test_async_timeout_recovers_on_retry(mock_request, _sleep): + mock_request.side_effect = [httpx.ReadTimeout("timed out"), _response(200, load("margin"))] + result = await _async(max_retries=2).spreads.margin() + assert mock_request.call_count == 2 + check_margin(result) + + +# --------------------------------------------------------------------------- +# Local validation happens before any request is sent +# --------------------------------------------------------------------------- + +INVALID_CALLS = [ + ("basis empty pair", "spreads", "basis", ("",), {}, "pair"), + ("basis blank pair", "spreads", "basis", (" ",), {}, "pair"), + ("basis non-str pair", "spreads", "basis", (None,), {}, "pair"), + ("basis_historical empty pair", "spreads", "basis_historical", ("",), {}, "pair"), + ("curve empty commodity", "spreads", "curve_structure", ("",), {}, "commodity"), + ("crack blank type", "spreads", "crack", (), {"spread_type": " "}, "spread_type"), + ( + "crack bad start_date", "spreads", "crack_historical", (), {"start_date": "09/01/2026"}, + "start_date", + ), + ( + "margin impossible end_date", "spreads", "margin_historical", (), {"end_date": "2026-02-30"}, + "end_date", + ), + ( + "start after end", "spreads", "basis_historical", ("BRENT_WTI",), + {"start_date": "2026-09-10", "end_date": "2026-09-01"}, "start_date", + ), + ("price_context empty code", "indicators", "price_context", ("",), {}, "code"), + ("annotations empty code", "indicators", "annotations", ("",), {}, "code"), + ("annotations_batch empty list", "indicators", "annotations_batch", ([],), {}, "codes"), + ( + "annotations_batch blank code", "indicators", "annotations_batch", + (["BRENT_CRUDE_USD", ""],), {}, "codes", + ), + ("annotations_batch comma in code", "indicators", "annotations_batch", (["A,B"],), {}, "codes"), + ( + "annotations_batch bare string", "indicators", "annotations_batch", + ("BRENT_CRUDE_USD",), {}, "codes", + ), + ( + "annotations_batch over server cap", "indicators", "annotations_batch", + ([f"CODE_{i}" for i in range(21)],), {}, "codes", + ), + ( + "cftc bad date type", "indicators", "cftc_positioning_historical", (), + {"start_date": 20260901}, "start_date", + ), +] + + +def _assert_local_refusal(error, field): + """A local refusal is the SDK's ValidationError, catchable as OilPriceAPIError, + and carries no HTTP status because no request was sent (#123, #134).""" + assert type(error) is ValidationError + assert isinstance(error, OilPriceAPIError) + assert error.status_code is None + assert error.field == field + assert str(error) + + +@pytest.mark.parametrize( + "label,namespace,method,args,kwargs,field", INVALID_CALLS, ids=[c[0] for c in INVALID_CALLS] +) +@patch("httpx.Client.request") +def test_sync_invalid_arguments_never_reach_the_network( + mock_request, label, namespace, method, args, kwargs, field +): + with pytest.raises(OilPriceAPIError) as info: + getattr(getattr(_sync(), namespace), method)(*args, **kwargs) + _assert_local_refusal(info.value, field) + mock_request.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "label,namespace,method,args,kwargs,field", INVALID_CALLS, ids=[c[0] for c in INVALID_CALLS] +) +@patch("httpx.AsyncClient.request") +async def test_async_invalid_arguments_never_reach_the_network( + mock_request, label, namespace, method, args, kwargs, field +): + with pytest.raises(OilPriceAPIError) as info: + await getattr(getattr(_async(), namespace), method)(*args, **kwargs) + _assert_local_refusal(info.value, field) + mock_request.assert_not_called() + + +def test_local_refusal_keeps_the_offending_value(): + with pytest.raises(ValidationError) as info: + _sync().spreads.basis_historical("BRENT_WTI", start_date="2026-09-10", end_date="2026-09-01") + assert info.value.value == "2026-09-10" + with pytest.raises(ValidationError) as info: + _sync().indicators.annotations_batch(["A,B"]) + assert info.value.value == "A,B" + + +def test_congressional_trades_is_not_exposed(): + """The route has never produced data in production; no typed method ships for it.""" + assert not hasattr(_sync().indicators, "congressional_trades")