Repository navigation
[P1][bug] normalize_futures_slug silently rewrites 18 live catalog codes onto a DIFFERENT instrument #112
Description
Activity
- addedbugSomething isn't workingSomething isn't workingtechnical-debtTechnical debt that should be addressedTechnical debt that should be addressedpriority: criticalMust be fixed immediatelyMust be fixed immediately
on Sep 13, 2026 Verified fixed by
6e1a66c— not closing, leaving that to KarlRe-verified by execution on 2026-09-13 against a fresh clone of
main(fcc4edf), CPython 3.14.7. The resolver was loaded twice in one process — once from31b8d80(the commit before the fix) and once frommain— and all 18 codes from the issue table were run through both.code parent 31b8d806e1a66c(main)LNG_NW_EUROPE_EURlng-jkmValueErrorLNG_SOUTH_EUROPE_EURlng-jkmValueErrorLNG_EU_AVERAGE_EURlng-jkmValueErrorWTI_MIDLAND_USDwtiValueErrorWTI_SPOT_CUSHING_USDwtiValueErrorWTI_SPOT_USDwtiValueErrorWTI_USDwtiValueErrorBRENT_SPOT_EUROPE_USDbrentValueErrorBRENT_SPOT_USDbrentValueErrorBRENT_CRUDE_USDbrentValueErrorGASOIL_USDgasoilValueErrorJKM_LNG_USDlng-jkmValueErrorBRENT_FUTURESbrentValueErrorWTI_FUTURESwtiValueErrorTTF_FUTURESttf-gasValueErrorJKM_FUTURESlng-jkmValueErrorBRENT_FUTURES_CONTINUOUSbrentValueErrorWTI_FUTURES_CONTINUOUSwtiValueErrorsilently rewritten before: 18 silently rewritten after: 0Canonical inputs are unaffected —
ice-brent,ice-wti,CL,BZ,CL1!,continuous/brentall still resolve. The fix took the recommendation in this issue: the prefix fallback now only fires when the discarded tail is a month/order marker, so an unrecognised code raises rather than guessing at a neighbouring contract.Two things from this issue are still open.
-
The raise is a bare builtin
ValueError, outsideOilPriceAPIError. Turning 18 codes from a wrong answer into a refusal is only a win if the refusal is catchable through the documented base class —except OilPriceAPIError: fall_back()currently does not catch it, so this went from "silently wrong" to "uncaught crash". Filed as [P2][bug] normalize_futures_slug raises bare ValueError, outside OilPriceAPIError - #111 made that path far more common #122 and fixed in fix(url,retry,futures): un-break today's three regressions (#119, #118, #122) #125 (FuturesContractError(ValidationError, ValueError)— catchable, and still aValueErrorfor existing callers). -
The
prices.pyresidual noted at the bottom of this issue is NOT fixed.oilpriceapi/resources/prices.py:41onmaintoday:commodity=cast(str, price_data.get("code", fallback_code)),
When the API response omits
code, the SDK still stamps the requested code onto the response object, manufacturing the appearance of a match and defeating any consumer-side "did I get back what I asked for?" check.prices.py:207does the right thing (price_data.get("code", "")). Unfixed as offcc4edf. -
Low, same file:
_futures_slug.pystill strips trailing digits unconditionally after the new month-suffix guard declines to split —WTI2026 -> wti,BRENT1 -> brent,LNG1 -> lng-jkm,CL0 -> wti. Verified today. Same "guess rather than refuse" shape, no live catalog code matches it, and removing it risks the TradingViewCL1!handling, so it wants its own change and its own catalog re-run.
Suggested disposition: close this one on the strength of the table above, and let #122 / #125 carry item 1. Items 2 and 3 need somewhere to live — happy to file them if you want them tracked separately.
-
Follow-up to my verification comment above: the two residuals noted in this issue are now filed separately, both re-confirmed by execution against
mainat9823588(post-#124/#125/#126):- [P1][bug] prices.py:41 fabricates the requested code onto a response that omitted it, defeating the consumer-side wrong-commodity check #127 —
prices.py:41/async_client.py:439fabricate the requested code onto a response that omittedcode. Filed P1: it does not merely fail to guard against the wrong-commodity class this issue is about, it defeats the consumer-sideprice.commodity == requestedcheck by making it pass on exactly the responses where it needed to fail. - [P2][bug] normalize_futures_slug still strips trailing digits, so WTI2026 silently resolves to the generic wti contract #128 —
_futures_slug.py:142still strips trailing digits unconditionally, soWTI2026 -> wtiandBRENT2027 -> brent: a dated contract answered with the generic front-month slug. Filed P2 — no current live catalog code matches that shape, so exposure is a caller spelling a dated contract the way an exchange does.
The main defect this issue reports — 18 live catalog codes rewritten onto a different instrument — remains verified fixed by
6e1a66cper the table above, and the uncatchable-refusal follow-on (#122) shipped in #125. Still leaving this one open for Karl to close.- [P1][bug] prices.py:41 fabricates the requested code onto a response that omitted it, defeating the consumer-side wrong-commodity check #127 —
- added a commit that references this issue
on Sep 13, 2026 Closing — verified fixed and shipped.
Fixed by
6e1a66c(#111), verified by loading the resolver from31b8d80and frommainin one process: 18 codes rewritten before, 0 after, canonical inputs unaffected.Shipped to users in v1.14.0 (PyPI, 2026-09-13) — not just merged.
The two residuals noted in this thread are tracked separately and are not closed by this:
- [P1][bug] prices.py:41 fabricates the requested code onto a response that omitted it, defeating the consumer-side wrong-commodity check #127 —
prices.py:41stamping the requested code onto a response that omitted it. Fixed in fix(prices): stop stamping the requested code onto a response that omitted it (#127) #129 and also shipped in 1.14.0. Closed. - [P2][bug] normalize_futures_slug still strips trailing digits, so WTI2026 silently resolves to the generic wti contract #128 —
_futures_slug.pystill strips trailing digits unconditionally, soWTI2026 -> wti. Still open, the other half of this same function.
- [P1][bug] prices.py:41 fabricates the requested code onto a response that omitted it, defeating the consumer-side wrong-commodity check #127 —
Summary
normalize_futures_slug(oilpriceapi/resources/_futures_slug.py) resolves an unrecognised code by splitting on the first separator and stripping trailing digits, then looking the remaining leading token up inCONTRACT_CODE_TO_SLUG. The geography and currency tokens — the two fields that distinguish these instruments from one another — are discarded before the lookup.The result is that 18 real catalog codes are silently rewritten onto a different instrument and the call succeeds, returning the wrong commodity as if it were correct.
Confirmed-live 2026-09-13.
Evidence
Measured 2026-09-13 by executing
normalize_futures_slugagainst all 604 codes returned by the live catalog (GET https://api.oilpriceapi.com/v1/commodities, authenticated, 2026-09-13):586/604 raise a loud
ValueError— that part is fine. The 18 that do not:LNG_NW_EUROPE_EURlng-jkmLNG_SOUTH_EUROPE_EURlng-jkmLNG_EU_AVERAGE_EURlng-jkmWTI_MIDLAND_USDice-wtiWTI_SPOT_CUSHING_USDice-wtiWTI_SPOT_USDice-wtiWTI_USDice-wtiBRENT_SPOT_EUROPE_USDice-brentBRENT_SPOT_USDice-brentBRENT_CRUDE_USDice-brentGASOIL_USDice-gasoilJKM_LNG_USDlng-jkmBRENT_FUTURESice-brentWTI_FUTURESice-wtiTTF_FUTURESttf-gasJKM_FUTURESlng-jkmBRENT_FUTURES_CONTINUOUSice-brentWTI_FUTURES_CONTINUOUSice-wtiThe three LNG rows are the worst: a different delivery region and a different currency.
WTI_MIDLAND_USD → ice-wtiis a different physical delivery point. Neither is a typo correction — both are real, currently-listed catalog codes being answered with a different contract.Repro (no network needed for the resolver itself):
Reachability
_futures_slug.py:78-92is the fallback path. It is reached from every futures method —oilpriceapi/resources/futures.pylines 51, 87, 122, 152, 205, 250 — sofutures.get,curve,historical,ohlc,intradayandspread_historyall inherit it.Same bug class as mcp-server#90
OilpriceAPI/mcp-server#90("stop substituting a different commodity for a real code", merged, released as v3.3.0 today) is the same defect in a different resolver: a local mapping rewriting a real code onto a different commodity. The fix there should be applied here.Recommendation
Remove the prefix-fallback entirely.
normalize_futures_slugshould accept the canonical slugs, the continuous slugs, and an exact entry inCONTRACT_CODE_TO_SLUG, and raiseValueErrorfor everything else — including the 18 above. A loud failure is correct; the caller asked for an instrument we do not serve on the futures route, and guessing at a neighbouring contract is not a better answer.Where an alias is genuinely wanted (
BRENT_FUTURES→ice-brent), add it as an explicit full-string key, not as a prefix guess.Related, same repo — decide whether to fold in or link
oilpriceapi/resources/prices.py:50:When the API response omits
code, the SDK stamps the requested code onto the response object. That manufactures the appearance of a match and would defeat any consumer-side "did I get back what I asked for?" check — including a check someone might add as a mitigation for the slug bug above.prices.py:159does the right thing (price_data.get("code", "")). Recommendprices.py:50be made to match, or that the key be left absent rather than fabricated.