Skip to content

Commit 6e1a66c

Browse files
karlwaldmanclaude
andauthored
fix(futures): stop truncating a commodity code into a different contract (#111)
`normalize_futures_slug` split on the first separator and kept the leading token, discarding the geography and the currency -- the two fields that distinguish these instruments. A caller asking for one thing received another contract's curve, with no error. Measured against all 604 live catalog codes on 2026-09-13: 586 raised (correct), 18 silently rewrote. 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 Reachable from every method in resources/futures.py (lines 340-411). Same class as the mcp-server substitution shipped as v3.3.0 today. The suffix stripping exists for a real reason -- month and order markers on contract codes ("CL.1", "CL1!", "BZ-2025-12") -- so it is kept, but the discarded tail must now actually look like a month/order marker. Alphabetic tails carrying geography and currency are no longer thrown away, and an unrecognised input raises ValueError as it already did for the other 586. After the fix all 604 live codes raise and none are rewritten; the four contract-code forms still resolve. Red before: 6 failed, 34 passed. Green after: 40 passed. Wider unit suite unchanged at 109 pre-existing failures, verified identical on clean origin/main. Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 31b8d80 commit 6e1a66c

2 files changed

Lines changed: 72 additions & 3 deletions

File tree

‎oilpriceapi/resources/_futures_slug.py‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@
7070
}
7171

7272

73+
def _is_month_or_order_suffix(tail: str) -> bool:
74+
"""True when ``tail`` is a month/order marker rather than meaningful text.
75+
76+
A contract code carries an order or expiry after the symbol ("CL.1",
77+
"CL1!", "BZ-2025-12"). A commodity code carries geography and currency
78+
("LNG_NW_EUROPE_EUR"). Only the former may be discarded.
79+
"""
80+
stripped = tail.strip().strip("!").replace("-", "").replace("_", "").replace(".", "")
81+
return stripped.isdigit() or stripped == ""
82+
83+
7384
def normalize_futures_slug(contract: str) -> str:
7485
"""Resolve a futures ``contract`` argument to the API's canonical slug.
7586
@@ -104,11 +115,27 @@ def normalize_futures_slug(contract: str) -> str:
104115
if exact_code_slug is not None:
105116
return exact_code_slug
106117

107-
# Contract code form: take the leading symbol before any month/order
108-
# suffix such as ".1", "1!", "-2025-12", "_2025_12".
118+
# Contract code form: take the leading symbol before a month/order suffix
119+
# such as ".1", "1!", "-2025-12", "_2025_12".
120+
#
121+
# The discarded tail MUST look like a month/order marker. Splitting
122+
# unconditionally truncated spot commodity codes into contract codes and
123+
# returned a different instrument: measured against all 604 live catalog
124+
# codes on 2026-09-13, 18 were silently rewritten --
125+
#
126+
# LNG_NW_EUROPE_EUR -> lng-jkm (NW Europe LNG in EUR -> JKM in USD)
127+
# WTI_MIDLAND_USD -> wti (Permian basis grade -> Cushing WTI)
128+
#
129+
# -- discarding exactly the geography and currency that distinguish them.
130+
# Same class as the mcp-server substitution fixed in its v3.3.0. An
131+
# unrecognised input must raise, never guess: a refusal is recoverable, a
132+
# wrong instrument's curve is not.
109133
for sep in (".", "!", "-", "_", " "):
110134
if sep in symbol:
111-
symbol = symbol.split(sep, 1)[0]
135+
head, tail = symbol.split(sep, 1)
136+
if not _is_month_or_order_suffix(tail):
137+
break
138+
symbol = head
112139
# Strip a trailing contract-order number (e.g. TradingView "CL1!" -> "CL1").
113140
symbol = symbol.rstrip("0123456789").strip()
114141

‎tests/unit/test_futures_slug.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,45 @@ def test_invalid_raises_value_error(self, bad):
7676
def test_mapping_targets_are_valid_slugs(self):
7777
for slug in CONTRACT_CODE_TO_SLUG.values():
7878
assert slug in VALID_SLUGS
79+
80+
class TestCommodityCodesAreNeverRewritten:
81+
"""A spot commodity code must never be truncated into a futures contract.
82+
83+
``normalize_futures_slug`` split on the first separator and kept only the
84+
leading token, which discards the geography and the currency -- the two
85+
fields that distinguish these instruments. Executed against all 604 live
86+
catalog codes on 2026-09-13, 18 were silently rewritten onto a DIFFERENT
87+
contract and the caller received the wrong instrument's curve.
88+
89+
Same class as the mcp-server substitution released as v3.3.0 today. A
90+
refusal is recoverable; a wrong number is not.
91+
"""
92+
93+
# code -> the contract it was wrongly rewritten to
94+
SILENT_REWRITES = {
95+
"LNG_NW_EUROPE_EUR": "lng-jkm", # NW Europe LNG in EUR -> Japan/Korea Marker in USD
96+
"LNG_SOUTH_EUROPE_EUR": "lng-jkm",
97+
"LNG_EU_AVERAGE_EUR": "lng-jkm",
98+
"WTI_MIDLAND_USD": "wti", # Permian basis grade -> Cushing WTI
99+
"WTI_SPOT_CUSHING_USD": "wti", # spot -> futures curve
100+
"BRENT_SPOT_EUROPE_USD": "brent",
101+
}
102+
103+
@pytest.mark.parametrize("code", sorted(SILENT_REWRITES))
104+
def test_commodity_code_raises_rather_than_guessing(self, code):
105+
with pytest.raises(ValueError):
106+
normalize_futures_slug(code)
107+
108+
@pytest.mark.parametrize(
109+
"contract,expected",
110+
[
111+
# The legitimate reason the suffix-stripping exists: month and
112+
# order markers on a real contract code. These must keep working.
113+
("CL.1", "wti"),
114+
("CL1!", "wti"),
115+
("BZ", "brent"),
116+
("NG", "natural-gas"),
117+
],
118+
)
119+
def test_month_and_order_suffixes_still_resolve(self, contract, expected):
120+
assert normalize_futures_slug(contract) == expected

0 commit comments

Comments
 (0)