Skip to content

Effective-dated contract multipliers (additive contract_regimes table) - #23

Merged
mspinola merged 1 commit into
mainfrom
claude/contract-regimes
Aug 23, 2026
Merged

Effective-dated contract multipliers (additive contract_regimes table)#23
mspinola merged 1 commit into
mainfrom
claude/contract-regimes

Conversation

@mspinola

Copy link
Copy Markdown
Owner

Implements the recommendation from cotmetrics#22.

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 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 rows
  • src/marketdata/regimes.pyread_contract_regimes, point_value_asof, tick_value_asof, declared_symbols
  • tests/test_contract_regimes.py — 26 tests
  • a Contract regimes section in docs/design.md, plus a new entry under Known holes
  • version 0.1.0 -> 0.2.0 (additive, so a minor bump)
>>> marketdata.point_value_asof("RTY", ["2016-12-04", "2016-12-05"])
2016-12-04    100.0
2016-12-05     50.0

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 — 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 under metadata/ 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.yaml is 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 for tick_value: null on the old lumber contract. A gap is visible downstream; a guess is not.

Every row requires a source a 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_asof and had to reindex back onto the caller's dates, which:

  • raised cannot reindex on an axis with duplicate labels on repeated dates — which is exactly what a trade log is, i.e. the npf use case would have hit it immediately
  • rejected tz-aware input with a raw pandas TypeError

Replaced with searchsorted, which preserves input order, tolerates duplicates, and needs no sort of the caller's dates. valid_from is 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
  • live tripwire confirmed to RUN against ~/code/marketdata_store and to SKIP on an empty store
  • ruff check src tests: clean
  • wheel built: marketdata/contract_regimes.yaml and regimes.py both packaged
  • check_dep_floors.py in 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:

  1. cotmetrics exposure.point_values() -> per-date, which is what actually fixes the 740 understated Russell weeks and unblocks promoting the dollar-risk percentile
  2. npf validation/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

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>
@mspinola
mspinola merged commit 8dbabb1 into main Aug 23, 2026
5 checks passed
@mspinola
mspinola deleted the claude/contract-regimes branch August 23, 2026 00:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant