Skip to content

Correct the Open Interest label: whole-market, not front-month - #27

Merged
mspinola merged 2 commits into
mainfrom
claude/frosty-dirac-74e044
Aug 25, 2026
Merged

mspinola merged 2 commits into
mainfrom
claude/frosty-dirac-74e044

Conversation

@mspinola

Copy link
Copy Markdown
Owner

README.md's "What a futures bar carries" table described the stored Open Interest column as front-month open interest. It is whole-market, and the label has now cost real time: it was one of two written notes that nearly led to the conclusion that the data a between-reports positioning estimator needs was missing from this store, when it is present on all 41 npf-universe markets back to a median start of 1979.

Documentation only. No code, no stored data, no behaviour change.

The evidence

Norgate collects open interest from the exchange; the CFTC collects its own from clearing members. Two vendors, two collection paths, matched on the COT report Tuesday. They agree, three times over:

measurement result
cotdata/docs/design/reading-the-store.md §4 exact agreement on 25 of 26 markets, palladium at 0.998, median ratio 1.000
npf (private), full 41-market universe at the current vintage, scripts/nowcast_data_check.py and docs/npf/nowcast_data_check.md per-market median ratio 0.9999 to 1.0000 on 41 of 41, median within-market IQR 0.0000, median 1,872 pairs per market. PA exact at this vintage too
spot check for this PR, 14 markets, ~24,000 pairs median ratio 1.0000 on 14 of 14, zero IQR on 12 of them (6E loosest at 0.0045, PA at 0.0001)

Two independent collection paths cannot agree to four decimals on anything but the whole market. A front-month series would sit far below 1.0.

The README now carries a runnable reproducer for CL, verified to print 1926 1.0.

The same mislabel on the neighbouring row

The Volume row said "front-month volume", which is the identical naming trap the get_bars docstring already warns about from the other end: volume="front" spans the whole curve, while reconstructed sounds fuller and is FirstVolume + SecondVolume, exactly two expiries. Measured on the current store (median reconstructed / front over the last 1,500 bars):

NG CL HE ZC ZS GC SI ES
0.55 0.59 0.67 0.77 0.76 0.97 0.97 1.00

Corrected in the table, in the Volume_Reconstructed fall-back row, and in two code comments that stated the direction backwards (bars.py::_reconstructed_volume and providers/norgate.py::_reconstruct_volume both called the wider series "front-month").

Scoping

The whole-market reading is a fact about the Norgate producer, which is what writes this store. The databento producer reads OI from the .n.0 continuous contract's own statistics (stat_type 9), so its Open Interest is that contract's rather than the market's. Stated explicitly so the correction is not carried across vendors into a new error.

Checked and unchanged

docs/design.md carries no occurrence of the mislabel. Its only mention of open interest (line 235, the full-history-rewrite argument) is correct as written.

Verification

  • ruff check src tests (ruff 0.15.22): clean
  • pytest tests/ -q with MARKETDATA_STORE set: 242 passed, 12 skipped

Bottom line, in plain language: the store has always held whole-market open interest on every futures market. Only the label was wrong, and it was wrong in the one direction that makes a reader conclude the data is unusable. Three separate measurements, one of them run fresh for this PR, put the agreement with the CFTC's own total at four decimal places. This is a genuine and settled correction, not a marginal call.

🤖 Generated with Claude Code

mspinola and others added 2 commits August 25, 2026 11:38
README's futures-bar table called the stored `Open Interest` column
"front-month open interest". It is whole-market, and the mislabel has now
cost time twice: it was one of two written notes that nearly led to the
conclusion that the data a between-reports positioning estimator needs was
missing from the store, when it is present on all 41 npf-universe markets
back to a median of 1979.

Three independent measurements say whole-market:

- cotdata/docs/design/reading-the-store.md section 4: exact agreement with
  CFTC total-market OI on 25 of 26 markets, palladium at 0.998, median
  ratio 1.000.
- npf (private) re-measured across the full 41-market universe at the
  current vintage: per-market median ratio 0.9999 to 1.0000 on 41 of 41,
  median within-market IQR 0.0000, median 1,872 pairs per market. PA is
  exact at this vintage too.
- Spot-checked again here on 14 markets (~24,000 pairs): median ratio
  1.0000 on 14 of 14, zero IQR on 12 of them. The README now carries a
  runnable reproducer for CL, verified to print `1926 1.0`.

The same mislabel sat on the neighbouring `Volume` row, which is the
identical naming trap the `get_bars` docstring already warns about from the
other end: `volume="front"` spans the whole curve while `reconstructed`
sums exactly two expiries. Measured on the current store, the median
`reconstructed / front` ratio is 0.55 in NG, 0.59 in CL, 0.67 in HE and
0.77 in ZC, so the fuller-sounding parameter is the narrower series.
Corrected in the table, in the `Volume_Reconstructed` fall-back row, and in
two code comments that stated the direction backwards.

Scoped the claim to the Norgate producer, which is what writes this store.
The databento producer reads OI from the `.n.0` continuous contract's own
statistics (stat_type 9), so its column is that contract's rather than the
market's, and the CFTC agreement should not be carried across vendors.

docs/design.md was checked and carries no occurrence of the mislabel.

Documentation only: no code, no stored data, no behaviour change.
`ruff check src tests` clean, 242 passed / 12 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test_live_store_agrees_with_the_last_declared_regime` already meant to skip
when there is no store to check, and said so in its docstring. It could not.
`store.read_metadata()` resolves the store root, which raises by design when
`MARKETDATA_STORE` is unset, so the guard never got to run: in a plain shell
the two parametrized cases failed with a RuntimeError two frames down that
reads like a real breakage on an untouched tree.

CI hid this. `python-test.yml` sets MARKETDATA_STORE to an empty directory,
so `read_metadata` returns an empty frame and the written guard fires on its
second arm. The unset case is invisible to CI by construction, which is why
a green run never surfaced it.

The guard now checks the variable first and skips naming it, then falls
through to the existing empty-specs arm. This is the same shape as the npf
skip guards that keyed on COTDATA_STORE alone after the price read moved to
MARKETDATA_STORE, and produced 20 tracebacks where 26 skips were wanted.

Added a test that the guard fires, on this file's own principle that a guard
which has never fired is indistinguishable from one that is not wired in. It
asserts on the skip REASON rather than merely that a skip happened, since the
old RuntimeError also named the variable and still read as a breakage. A blank
value is covered alongside an unset one, because `config.store_root` strips
before checking and the two are the same mistake. Verified by deleting the
guard: the new test fails.

Measured in three environments, `ruff check src tests` clean in all:

  no MARKETDATA_STORE          243 passed, 14 skipped  (was 2 failed)
  empty store, as CI sets it   243 passed, 14 skipped
  live store                   245 passed, 12 skipped  (tripwire runs)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mspinola

Copy link
Copy Markdown
Owner Author

Pushed bb13487, adding the skip guard for the two live-store tests noted when this PR was opened. Unrelated to the OI correction, but it was found by running this suite and it is the same class of defect: a guard written but not reachable.

test_live_store_agrees_with_the_last_declared_regime already meant to skip when there is no store, and its docstring said so. It could not: store.read_metadata() resolves the store root, which raises by design when MARKETDATA_STORE is unset, so in a plain shell the two parametrized cases failed with a RuntimeError two frames down rather than skipping.

CI hid it. python-test.yml sets MARKETDATA_STORE to an empty directory, so read_metadata returns an empty frame and the written guard fires on its second arm. The unset case is invisible to CI by construction, which is why a green run never surfaced it. Same shape as the npf skip guards that keyed on COTDATA_STORE alone after the price read moved to MARKETDATA_STORE.

The guard now checks the variable first and skips naming it, then falls through to the existing empty-specs arm. Added a test that the guard fires, on this file's own stated principle that a guard which has never fired is indistinguishable from one that is not wired in. It asserts on the skip reason, not merely that a skip happened, because the old RuntimeError also named the variable and still read as a breakage. Blank values are covered alongside unset, since config.store_root strips before checking. Verified by deleting the guard: the new test fails.

Measured in three environments, ruff check src tests clean in all:

environment result
no MARKETDATA_STORE 243 passed, 14 skipped (was 2 failed)
empty store, as CI sets it 243 passed, 14 skipped
live store 245 passed, 12 skipped (tripwire actually runs)

@mspinola
mspinola merged commit 38e8c3f into main Aug 25, 2026
5 checks passed
@mspinola
mspinola deleted the claude/frosty-dirac-74e044 branch August 25, 2026 16:05
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