diff --git a/oilpriceapi/resources/_futures_slug.py b/oilpriceapi/resources/_futures_slug.py index fca74cb..4ed558f 100644 --- a/oilpriceapi/resources/_futures_slug.py +++ b/oilpriceapi/resources/_futures_slug.py @@ -70,6 +70,17 @@ } +def _is_month_or_order_suffix(tail: str) -> bool: + """True when ``tail`` is a month/order marker rather than meaningful text. + + A contract code carries an order or expiry after the symbol ("CL.1", + "CL1!", "BZ-2025-12"). A commodity code carries geography and currency + ("LNG_NW_EUROPE_EUR"). Only the former may be discarded. + """ + stripped = tail.strip().strip("!").replace("-", "").replace("_", "").replace(".", "") + return stripped.isdigit() or stripped == "" + + def normalize_futures_slug(contract: str) -> str: """Resolve a futures ``contract`` argument to the API's canonical slug. @@ -104,11 +115,27 @@ def normalize_futures_slug(contract: str) -> str: if exact_code_slug is not None: return exact_code_slug - # Contract code form: take the leading symbol before any month/order - # suffix such as ".1", "1!", "-2025-12", "_2025_12". + # Contract code form: take the leading symbol before a month/order suffix + # such as ".1", "1!", "-2025-12", "_2025_12". + # + # The discarded tail MUST look like a month/order marker. Splitting + # unconditionally truncated spot commodity codes into contract codes and + # returned a different instrument: measured against all 604 live catalog + # codes on 2026-09-13, 18 were silently rewritten -- + # + # LNG_NW_EUROPE_EUR -> lng-jkm (NW Europe LNG in EUR -> JKM in USD) + # WTI_MIDLAND_USD -> wti (Permian basis grade -> Cushing WTI) + # + # -- discarding exactly the geography and currency that distinguish them. + # Same class as the mcp-server substitution fixed in its v3.3.0. An + # unrecognised input must raise, never guess: a refusal is recoverable, a + # wrong instrument's curve is not. for sep in (".", "!", "-", "_", " "): if sep in symbol: - symbol = symbol.split(sep, 1)[0] + head, tail = symbol.split(sep, 1) + if not _is_month_or_order_suffix(tail): + break + symbol = head # Strip a trailing contract-order number (e.g. TradingView "CL1!" -> "CL1"). symbol = symbol.rstrip("0123456789").strip() diff --git a/tests/unit/test_futures_slug.py b/tests/unit/test_futures_slug.py index aa2020e..a37b750 100644 --- a/tests/unit/test_futures_slug.py +++ b/tests/unit/test_futures_slug.py @@ -76,3 +76,45 @@ def test_invalid_raises_value_error(self, bad): def test_mapping_targets_are_valid_slugs(self): for slug in CONTRACT_CODE_TO_SLUG.values(): assert slug in VALID_SLUGS + +class TestCommodityCodesAreNeverRewritten: + """A spot commodity code must never be truncated into a futures contract. + + ``normalize_futures_slug`` split on the first separator and kept only the + leading token, which discards the geography and the currency -- the two + fields that distinguish these instruments. Executed against all 604 live + catalog codes on 2026-09-13, 18 were silently rewritten onto a DIFFERENT + contract and the caller received the wrong instrument's curve. + + Same class as the mcp-server substitution released as v3.3.0 today. A + refusal is recoverable; a wrong number is not. + """ + + # code -> the contract it was wrongly rewritten to + SILENT_REWRITES = { + "LNG_NW_EUROPE_EUR": "lng-jkm", # NW Europe LNG in EUR -> Japan/Korea Marker in USD + "LNG_SOUTH_EUROPE_EUR": "lng-jkm", + "LNG_EU_AVERAGE_EUR": "lng-jkm", + "WTI_MIDLAND_USD": "wti", # Permian basis grade -> Cushing WTI + "WTI_SPOT_CUSHING_USD": "wti", # spot -> futures curve + "BRENT_SPOT_EUROPE_USD": "brent", + } + + @pytest.mark.parametrize("code", sorted(SILENT_REWRITES)) + def test_commodity_code_raises_rather_than_guessing(self, code): + with pytest.raises(ValueError): + normalize_futures_slug(code) + + @pytest.mark.parametrize( + "contract,expected", + [ + # The legitimate reason the suffix-stripping exists: month and + # order markers on a real contract code. These must keep working. + ("CL.1", "wti"), + ("CL1!", "wti"), + ("BZ", "brent"), + ("NG", "natural-gas"), + ], + ) + def test_month_and_order_suffixes_still_resolve(self, contract, expected): + assert normalize_futures_slug(contract) == expected