feat(spreads,indicators): typed sync+async Spreads and Indicators resources (#99) - #146
Merged
Merged
Conversation
…and /v1/indicators (#99) Adds client.spreads (15 methods) and client.indicators (10 methods) on both clients, typed from production responses captured 2026-09-13. Request building, argument validation and envelope parsing live once in resources/_calculated_metrics.py so sync and async cannot drift. A key the server always emits is required; a malformed 200 raises OilPriceAPIError(code="MALFORMED_RESPONSE") with the raw body. Blank selectors, invalid dates, start>end and >20 batch codes are refused before any request. congressional-trades is not exposed (never returned data). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…t ValueError (#99) A builtin ValueError escaped the documented `except OilPriceAPIError` catch-all (#123). Local refusals now go through one helper matching _url._reject: ValidationError(message, field, value, status_code=None), since no request was sent (#134). format_date's ValueError is re-raised as ValidationError with the right field. All methods are new, so no dual-base subclass is needed. Tests assert the exact type, status_code None and field; proven red by temporarily restoring ValueError (35 failed). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
…/99-spreads-indicators Conflicts resolved by keeping every side: - CHANGELOG.md: #99 spreads/indicators entry, then the #100 and #101 Added entries and the #100 Fixed entries, all under [Unreleased]. - oilpriceapi/async_client.py: spreads, indicators and fuel_surcharge all registered (subscriptions untouched). - oilpriceapi/async_resources.py: metrics_models import plus main's multi-line models import; AsyncFuelSurchargeResource kept whole, followed by AsyncSpreadsResource and AsyncIndicatorsResource. Verified on the merged tree by instantiating both clients: spreads, indicators, fuel_surcharge and subscriptions and all their methods exist (88/88). Full suite 1859 passed / 3 failed (known live demo 429s) / 68 skipped; ruff, mypy and storefront validator clean. The .gitignore fixture exception and all 30 fixtures survive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #99.
What
client.spreadsandclient.indicatorsonOilPriceAPIandAsyncOilPriceAPI, with typed pydantic models in the newoilpriceapi/metrics_models.py.spreads, 15 methods:crack,crack_historical,crack_all,gasoil_crackbasis,basis_historical,basis_allcurve_structure,curve_structure_allmargin,margin_historical,margin_allphysical_premium,physical_premium_historical,physical_premium_allindicators, 10 methods:fuel_switching,fuel_switching_historicalprice_context(code, related_spreads=False)storage_analytics,storage_analytics_allannotations,annotations_batchcftc_positioning,cftc_positioning_historical,cftc_positioning_alloilpriceapi/resources/_calculated_metrics.pyis the single place that builds paths and params, validates arguments, and parses envelopes. The sync resources (resources/spreads.py,resources/indicators.py) and the async resources (async_resources.py) are thin wrappers over it, so the two clients cannot drift.examples/spreads_indicators.py, and CHANGELOG under[Unreleased]. No version bump or tag..gitignorehas a blanket*.json, so this PR adds one exception,!tests/unit/fixtures/**/*.json, to commit the captured production bodies.Premise verification
Checked against oilpriceapi-api origin/main
02196c516:config/routes.rbscope 'spreads'/scope 'indicators'map toV1::SpreadsController. All 28 routes exist. Params come from the controller:type,crude,pair,commodity,index,gas,location,code,codes,spreads=related,start_date,end_date.app/services/calculated_metrics/*.rbto separate always-present keys from conditional ones (stale_flag,changes,.compact,{}blocks).tests/unit/fixtures/calculated_metrics/are verbatim captures.PREMIUM_REQUIRED, not 402.congressional-tradesis excluded because it has never returned data (api#8478).Wire shapes typed against (production, 2026-09-13)
{"status":"success","data":{...}}. Collections are nested under a named key:spreads,commodities,margins,premiums,locations.{"error":{"code","message","status","request_id","docs"}}.MISSING_PARAMETERUNAUTHORIZEDDATA_NOT_AVAILABLE, with the valid values listed in the message{spread_type, crude_benchmark, value, unit, components:{crude|product|gasoline|diesel:{code,price,unit}}, timestamp, changes:{change_1d..}}data_stale/stale_warningonly when stale, and only on single-product types (api#8477).{spread_type, crude_benchmark, period:{start,end}, coverage:{from,to,observations,complete}, data_revised_at, count, data:[{date,value,crude,gasoline,diesel}]}.{spread_type, name, value, unit, components:{product|crude:{code,contract_month,updated_at,price,unit}}, conversion:{barrels_per_tonne,basis,gasoil_usd_per_bbl}, timestamp, updated_at, data_stale?}.{pair, spread_name, value, unit, components:{CODE:price}, signal, timestamp, percentile_1y, changes, negative_streak_days? (WAHA_HH only)}. History rows are{date,value,code_a,code_b}.{commodity, display_name, structure, severity, term_slope_pct, spreads:{m1_m3?,m1_m6,m1_m12?}, front_month:{price,contract}, back_month_6, curve_points, signal, timestamp}.{index, name, margin_usd_bbl, crude_input:{code,price}, product_basket:{name:{yield_pct,price,code}}, signal, percentile_1y, changes, timestamp}. History rows are{date,margin,crude,revenue}.{commodity, name, premium, premium_pct, unit, components:{spot,futures:{code,price,contract}}, signal, elevated_streak_days, percentile_1y (null observed), timestamp}.{oil_parity:{ratio_pct,threshold_pct,signal,parity_price,current_gas,headroom_pct}, components:{gas,crude}, energy_equivalent, historical_context ({} under 10 points), timestamp}.{code, price, timestamp, context:{anomaly, anomaly_reason?, change_*?, high_52w?, low_52w?, percentile_1y?, percentile_5y?}, related_spreads?}related_spreadsentries are{name,value(float|str),signal,unit?,slope?}.{location, name, current:{volume_mmbbl,utilization_pct,operational_capacity_mmbbl,data_date,timestamp}, draw_rate, seasonal ({} observed), anomalies, range_52w ({} observed), signal, trading_implication}.{code, price, timestamp, annotation_count, annotations:[{type,severity,message,...}]}. The batch form is{annotated:[...], total_codes, codes_with_annotations}.{commodity, name, report_date, positioning:{speculative:{net,long,short,net_pct_of_oi}, commercial:{net}, open_interest}, signal, percentile_1y, week_change, timestamp}{date,spec_net,open_interest,spec_net_pct_oi}.spec_net_pct_oiis always 0 on the wire (api#8476) and is preserved as sent.Rules the models enforce
Optional[...]with no default, so a body that drops it fails.Optional[...] = None.Nonemeans "not sent", never false or zero.datetime; calendar dates becomedate.OilPriceAPIError(code="MALFORMED_RESPONSE", raw_body=...). That covers a bad envelope,status != "success", a missing or mistyped field, a non-list collection, and non-JSON or empty bodies.ValueError):YYYY-MM-DDform or not real calendar datesstart_dateafterend_dateannotations_batchReview fix: local refusals raise
ValidationError, notValueErrorThe first push had 6 raw
raise ValueError(for local input refusals. A builtinValueErrorslips past the documentedexcept OilPriceAPIErrorcatch-all, which is the defect class #123 fixed. Every refusal now goes through one helper that matches_url._reject:status_code=None, because no request was sent (fix(url,retry,errors): typed errors, a real 60s cap, no status on a local refusal (#123) #134).fieldis the argument name:pair,commodity,spread_type,code,codes,start_dateorend_date.valueis the input that was rejected.format_date, which raisesValueErrorfor older resources. These new methods catch that and re-raise it asValidationErrorwith the rightfield.except ValueErrorcaller to keep working.git diff origin/main -- oilpriceapi/ | grep -c '^+.*raise ValueError'gives0.The tests now check the exact type (
type(error) is ValidationError), that it is anOilPriceAPIError, thatstatus_code is None, and the expectedfield. That covers 17 cases each for sync and async, plus one test that the offendingvalueis kept. Every case still asserts the transport was never called.Red run with the old assertions still in place (the implementation was still raising
ValueError):Red-capable proof after the fix. The helper was temporarily changed back to
return ValueError(message), the tests were run, and the file was restored:Green after the fix: the module passes 143 tests. The full suite is 1372 passed / 3 failed / 65 skipped; the 3 failures are the same live
test_demo_contract.py429s. ruff, mypy (53 files) andvalidate_storefront_claims.pyall pass.TDD evidence
Red (tests written first, before any implementation)
Green (new test module)
The 142 tests drive the real sync and async clients through mocked
httpx.Client.request/httpx.AsyncClient.request, asserting the path and params sent and field values from the captured bodies:code,request_id,required_plan,remediation_urlandretry_afterMutation check (the suite fails when the guarantees are removed)
Full suite, lint, types
origin/main4bd2900was 1229 passed / 3 failed / 63 skipped.tests/integration/test_demo_contract.py429s, unchanged.Live smoke: run, passed
tests/integration/test_live_spreads_indicators.pyran against production on 2026-09-13 with the paid test key. It is read-only (GET only), spaces calls 1.1s apart, and treats a 429 as a skip.Defects found and filed
spec_net_pct_oiis always 0 (integer division onvalue_units).data_stale.congressional-tradesis routed, has never returned data, and its 404 exposesQUIVER_API_KEY.price-context, with request_ids.Merge notes
This PR will conflict trivially with #143 (#100) and #144 (#101) in
CHANGELOG.md,README.md,docs/reference/resources.md,async_resources.pyand both clients. All of those conflicts are additive.🤖 Generated with Claude Code
https://claude.ai/code/session_015ao5paex73xXvuM424Libo