Skip to content

Offside: a cohort's cost basis marked to market, per contract - #28

Merged
mspinola merged 1 commit into
mainfrom
claude/offside-cost-basis
Aug 24, 2026
Merged

Offside: a cohort's cost basis marked to market, per contract#28
mspinola merged 1 commit into
mainfrom
claude/offside-cost-basis

Conversation

@mspinola

Copy link
Copy Markdown
Owner

What this adds

cotmetrics.offside: a cohort's average cost basis, marked to market, in units of the market's own trailing weekly volatility.

adding      B_t = (B_{t-1}|N_{t-1}| + A_t (|N_t| - |N_{t-1}|)) / |N_t|
reducing    B_t = B_{t-1}                     closed at market, basis unchanged
flat/flip   B_t = A_t                         fresh basis

offside_t = sign(N_t) x log(P_t / B_t) / sigma_t       negative = underwater

Every other level metric in this package is a statement about position SIZE. The range index and z-score say how unusual the size is against its own history; exposure says how large it is in dollars or dollar risk. None of them says whether the people holding it are winning or losing, which is a first-moment claim and needs a price to compare against.

Two design points worth reviewing

sign(N), not N. The reading is per CONTRACT, so a 400-lot cohort and a 400,000-lot cohort read alike when both sit the same distance under. That is the opposite choice from exposure, which multiplies by size and by sigma to answer how much selling a vol move must force. The two are complements; their product would be a dollar loss, a third quantity neither module returns.

It lives beside exposure, not in CotIndexer. Three reasons converge: it needs an explicit propadj tier, which tests/test_price_tier.py forbids inside that module by design; it is lookback-invariant, where everything in process_lookback is window-scoped; and per-cohort columns in a flat {symbol}.parquet would be three more that nothing else reads. Consequence: no new parquet column, so METRICS_CACHE_VERSION is untouched and existing caches stay valid.

The price tier is a guard, not a preference

backadj looks right (a cost basis is a difference in points) and is unusable: additive back-adjustment drives 14 of 45 markets through zero (HO 90.6% of bars, RB 77.1%, OJ 73.6%), and log(P/B) is undefined either side of that while still returning a plausible number. propadj is non-positive on exactly one print across the universe (CL 2020-04-20), which is masked. Same reasoning exposure.sigma_series uses, and _basis_close refuses any other tier by name.

Verification

Reproduces the published figures from npf/docs/analysis/2026-08-23-offside-positioning-measure.md exactly, through an independent implementation:

published this module
CL 2015-01-27 basis 99.47 99.467
CL 2015-01-27 price 52.55 52.547
CL 2015-01-27 offside -15.2 -15.153
net contracts 274,271 274,271

18 new store-free tests (the recurrence branch by branch, sign symmetry, size-invariance, vol-scaling, and every guard). Full suite 379 passed, ruff clean.

What the docstring carries, and why it is there rather than in a doc

Three measured results that a reader of this module would otherwise get wrong:

  • Extremity is close to the opposite of offside. Large Specs at a range-index extreme are underwater 14.2% of the time against 42.7% when neutral, overlapping on 6.7% of crowded weeks where independence gives 10%. Positioning becomes extreme because a cohort added into a move that was working, so extremity is built by winners.
  • Commercials sit underwater 65.6% of the time by design. That is the hedge working against an unseen physical leg, so is_hedge_leg exists to label rather than rank them.
  • This is a display, not a timing signal. The pre-registered test (npf #214) asked whether deep offside predicts capitulation beyond the adverse move it is built from and returned adverse-move proxy: within equally severe adverse moves, deep-offside cohorts capitulated no more often, and Small Traders significantly less. Anything proposing to trade it needs its own pre-registration, and the evidence leans against the intuitive direction.

Version 0.6.0 to 0.7.0. Consumed by cot-analyzer's Offside column (mspinola/cot-analyzer#59), which pins >=0.7.0.

🤖 Generated with Claude Code

Every other level metric here is a statement about position SIZE. The range index
and z-score say how unusual the size is; exposure says how large it is in dollars
or dollar risk. None says whether the holders are winning or losing, which is a
first-moment claim and needs a price to compare against. This adds the comparison:
average-cost accounting on the weekly net position, marked at the report Tuesday,
in units of the market's own trailing weekly volatility.

sign(N), not N. The reading is per CONTRACT, so a 400-lot cohort and a 400,000-lot
cohort read alike when both sit the same distance under. That is the opposite
choice from exposure, which multiplies by size and by sigma; these are complements
and the product would be a third quantity (a dollar loss) that neither returns.

Lives beside exposure rather than in CotIndexer for three reasons: it needs an
explicit propadj tier, which tests/test_price_tier.py forbids in that module by
design; it is lookback-invariant; and per-cohort columns in a flat {symbol}.parquet
would be three more that nothing else reads. No new parquet column, so
METRICS_CACHE_VERSION is untouched.

propadj is a guard, not a preference: additive back-adjustment drives 14 of 45
markets through zero (HO 90.6% of bars, RB 77.1%), and log(P/B) is undefined either
side of that while still returning a number. Reproduces the published CL January
2015 figures exactly (basis 99.47, price 52.55, offside -15.2 sigma on 274,271 long
contracts, while the range index read 46.6 and saw nothing).

Carries the measured limits in the docstring rather than in a doc that can drift:
extremity is built by winners and is close to the opposite of offside (14.2%
underwater at an index extreme against 42.7% when neutral); Commercials sit
underwater 65.6% of the time by design, so is_hedge_leg exists to label rather than
rank them; and the pre-registered capitulation test returned adverse-move proxy, so
this is a display and not a timing signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mspinola
mspinola merged commit 0a01024 into main Aug 24, 2026
4 checks passed
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