Effective-dated contract multipliers (additive contract_regimes table) - #23
Merged
Conversation
contract_specs answers "what is this contract today": one row per symbol, no effective date. That is right for its question and wrong for any consumer that multiplies a HISTORICAL position or trade by it. ICE halved the Russell multiplier from $100 to $50 on 2016-12-05 and converted each open lot into two, so applying today's $50 to the whole series understates 59% of the Russell's priced weeks by exactly 2x. Audited in cotmetrics#22. Adds contract_regimes.yaml plus marketdata.point_value_asof / tick_value_asof. Two symbols declared: RTY, the confirmed defect, and LBR for completeness (cotdata already bridges the lumber replacement through its own hist_codes scale, and the YAML says so, because applying both would convert twice). Three decisions, each ruling out the obvious alternative: Separate table, not a Valid_From column on contract_specs. The column would be harmless; the ROWS are not. Consumers index that table by Symbol and npf's costs.py does specs.loc[sym], which silently returns a DataFrame instead of a Series once a symbol has two rows. Verified unchanged: 49 rows, 11 columns, no duplicate Symbols, specs.loc["RTY"] still a Series, cotmetrics.point_values() still 49 entries. Purely additive, so no deprecation path despite PyPI. Packaged file, not a store table. Norgate and databento publish only the current spec, so a store artifact would be a producer writing a hand-entered constant, then needing a mirror and a producer run to change. registry.yaml is the precedent and the reason is written at the top of it. Undeclared symbols fall back to their current spec, so a caller writes one code path for all 47 markets. Where a multiplier was never established the answer is NaN, not a guess: LBR's pre-1995 sizes are unknown, so dates before its first regime resolve to NaN. A gap is visible downstream; a guess is not. Every row needs a source citation, enforced by the parser. The last regime restates the CURRENT multiplier purely so it can be compared against the vendor-refreshed contract_specs; a live-store test makes that comparison and skips when there is no store. Without it the file has one silent failure mode and it is the bad one: an exchange changes a multiplier again, the vendor picks it up, and this file keeps back-dating the superseded value while every lookup still returns a plausible number. Lookup uses searchsorted rather than merge_asof. The first implementation raised "cannot reindex on an axis with duplicate labels" on repeated dates, which is exactly what a trade log is, and rejected tz-aware input with a pandas error. Both are regression-tested. 231 passed, ruff clean, wheel carries the YAML, consumer dep floors still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Implements the recommendation from cotmetrics#22.
contract_specsanswers "what is this contract today": one row per symbol, no effective date. That is right for its question and wrong for any consumer that multiplies a historical position or trade by it. ICE halved the Russell multiplier from $100 to $50 effective trade date 2016-12-05 and converted each open lot into two, so applying today's $50 to the whole series understates 59% of the Russell's priced weeks by exactly 2x.What this adds
src/marketdata/contract_regimes.yaml— the data, two symbols, four rowssrc/marketdata/regimes.py—read_contract_regimes,point_value_asof,tick_value_asof,declared_symbolstests/test_contract_regimes.py— 26 testsContract regimessection indocs/design.md, plus a new entry underKnown holesThree decisions, each ruling out the obvious alternative
Separate table, not a
Valid_Fromcolumn oncontract_specs. The column would be harmless; the rows are not. Consumers index that table bySymboland npf'scosts.pydoesspecs.loc[sym], which silently returns a DataFrame instead of a Series once a symbol has two rows — a wrong answer rather than an error, in the repo whose numbers feed a gate verdict.Verified untouched: 49 rows, 11 columns, 0 duplicate Symbols,
specs.loc["RTY"]still a Series,cotmetrics.exposure.point_values()still returns 49 entries. Purely additive, so no deprecation path is needed despite the package being public and on PyPI.Packaged file, not a store table. This deviates from the audit doc, which proposed
metadata/contract_regimes.parquet. Every other table undermetadata/is written by a producer from a vendor, and this one cannot be: Norgate and databento both publish only the current specification, so a store artifact would be a producer writing a hand-entered constant, and would then need mirroring to every replica and a producer run to change. A packaged file travels with the version, is byte-identical on the Windows producer and every consumer, and resolves with no store configured at all.registry.yamlis the precedent, for the reason written at the top of it. Confirmed the wheel carries it.Undeclared symbols fall back to their current spec, so a caller writes one code path for all 47 markets and only the two declared ones behave differently. The alternative would push a branch into every consumer to express "nothing re-denominated this", which is the common case.
Honesty about what is not known
Where a multiplier was never established the answer is NaN, not a guess. LBR's pre-1995 sizes are unknown (the CFTC names hint at a resize:
RANDOM LENGTH LUMBER-NEW,RANDOM LENGTH LUMBER-80/110000), so its first regime is bounded and earlier dates resolve to NaN. RTY's first regime is unbounded, because what is established is that $100 was in force immediately before the change, not the contract's listing date. Same fortick_value: nullon the old lumber contract. A gap is visible downstream; a guess is not.Every row requires a
sourcea reader can check without trusting the file, and the parser rejects a row without one.The tripwire
The file restates each declared symbol's current multiplier as its last regime, purely so that value can be compared against the vendor-refreshed
contract_specs. A live-store test makes that comparison and skips when there is no store (CI has none). A fixture test exercises the same comparison in both directions, because a guard that has never fired is indistinguishable from one that is not wired in.Without it the file has exactly one silent failure mode and it is the bad one: an exchange changes a multiplier again, the vendor picks it up, and this file keeps back-dating the superseded value over the new history while every lookup still returns a plausible number.
Two bugs I found in my own first implementation
Both now regression-tested. The lookup originally used
merge_asofand had to reindex back onto the caller's dates, which:cannot reindex on an axis with duplicate labelson repeated dates — which is exactly what a trade log is, i.e. the npf use case would have hit it immediatelyTypeErrorReplaced with
searchsorted, which preserves input order, tolerates duplicates, and needs no sort of the caller's dates.valid_fromis an exchange-local calendar date, so tz is dropped rather than rejected, matching how the package normalizes a bar index elsewhere.Checks
pytest tests/ -q: 231 passed, 12 skipped (network), up from 205~/code/marketdata_storeand to SKIP on an empty storeruff check src tests: cleanmarketdata/contract_regimes.yamlandregimes.pyboth packagedcheck_dep_floors.pyin npf, cotmetrics, cot-analyzer, livebook: all pass (they pin>=0.1.0)Not in this PR
Nothing consumes the new API yet, deliberately — this is the additive half. The follow-ups, in order of payoff:
exposure.point_values()-> per-date, which is what actually fixes the 740 understated Russell weeks and unblocks promoting the dollar-risk percentilevalidation/costs.py-> resolve per trade date (same defect, roughly a tenth the severity: the slippage term cancels because both tick and point value halve, so only the fixed commission term is wrong)Recap in plain language
marketdata can now answer "what was this contract worth on that date" instead of only "what is it worth today". Two markets need it, and only the Russell is actually broken today. Nothing existing changed shape, so no consumer has to do anything until it wants the new answer, and the package's public surface only grew.
The part worth keeping an eye on is the tripwire: the file deliberately duplicates each contract's current multiplier so a test can catch it going stale, because the failure mode of a hand-maintained table like this is not an error, it is a plausible wrong number.
🤖 Generated with Claude Code