From a38fbdb5b3ba77f638424073293ed2489605420a Mon Sep 17 00:00:00 2001 From: Matt Spinola Date: Mon, 24 Aug 2026 16:44:21 -0400 Subject: [PATCH 1/3] Strip: the same positioning, measured in dollars at risk The printed positioning reports this page was modelled on draw each position twice, once in contracts and once in dollars. Holding the market, the leg, the window and the statistic constant and changing only the unit, the two are not the same series: dollar risk correlates 0.917 with the contract reading at the median, parts from it by 30 index points at the 95th percentile, and lands in a different one of the model's three bands on 11.7% of weeks. On 2026-08-18, 13 of the 45 priceable markets disagree. Silver is the case it was built from. Commercials sat at the very bottom of their 24-week contract range and at 96 on dollars at risk, because daily volatility had fallen from 6.7% to 2.7% across the window, so a record short carried a third of the money. Natural Gas runs the other way, 99 against 9. Dollar NOTIONAL is measured and deliberately not drawn (0.979, 14.5 points, 5.4%). Over a rolling window it is contracts times a slowly-moving price, so a second mark for it would sit on top of the first. It is one line in the hover, because it is the reading those reports plot. One reference mark per row, chosen by a new COMPARE control, rather than a fifth mark on a 22px row: the six-weeks-ago ring and the dollar diamond ask the same shape of question and at that height they are one smudge together. The mark takes the row's own colour, because every palette slot is already a verdict here, and it keeps its connector, because the gap is the subject and the ink is self-limiting (a market whose lenses agree draws a line of zero length). Disagreement is counted on three bands rather than on a gap in points, since Silver's 0-against-96 is both ends of the axis at once and a boolean scores it as agreement. Off by default: it is the only thing this page draws that needs the price store. Measurements and reproducer in docs/analysis/2026-08-24-contracts-against-dollars.md. Co-Authored-By: Claude Opus 5 --- .../2026-08-24-contracts-against-dollars.json | 430 ++++++++++++++++++ .../2026-08-24-contracts-against-dollars.md | 148 ++++++ docs/design/positioning-report-view-ideas.md | 95 ++++ scripts/measure_dollar_wedge.py | 252 ++++++++++ src/components/strip_traces.py | 228 +++++++++- src/pages/analytics/strip.py | 166 ++++++- tests/test_strip_copy.py | 41 ++ tests/test_strip_traces.py | 188 +++++++- 8 files changed, 1523 insertions(+), 25 deletions(-) create mode 100644 docs/analysis/2026-08-24-contracts-against-dollars.json create mode 100644 docs/analysis/2026-08-24-contracts-against-dollars.md create mode 100644 scripts/measure_dollar_wedge.py diff --git a/docs/analysis/2026-08-24-contracts-against-dollars.json b/docs/analysis/2026-08-24-contracts-against-dollars.json new file mode 100644 index 0000000..ee0863c --- /dev/null +++ b/docs/analysis/2026-08-24-contracts-against-dollars.json @@ -0,0 +1,430 @@ +{ + "lookback": "Custom", + "universe": 47, + "priced": 45, + "unpriced": { + "MSCI Emerging Mkts": "no contract multiplier or no bars", + "MSCI EAFE": "no contract multiplier or no bars" + }, + "windows": { + "30-Year Note": 97, + "10-Year Note": 97, + "5-Year Note": 97, + "2-Year Note": 97, + "DOW": 28, + "Nasdaq": 28, + "Russell": 28, + "S&P 500": 28, + "S&P MidCap 400": 52, + "Nikkei 225": 52, + "Feeder Cattle": 13, + "Lean Hogs": 13, + "Live Cattle": 13, + "Cotton": 40, + "Cocoa": 10, + "Coffee": 8, + "Lumber": 140, + "Orange Juice": 8, + "Sugar": 128, + "Copper": 88, + "Gold": 26, + "Palladium": 216, + "Platinum": 16, + "Silver": 24, + "Bitcoin": 84, + "Ethereum": 92, + "Australian Dollar": 80, + "British Pound": 30, + "Canadian Dollar": 30, + "Swiss Franc": 30, + "Euro": 90, + "Japanese Yen": 30, + "Mexican Peso": 82, + "New Zealand": 82, + "US Dollar": 90, + "Crude Oil": 42, + "Natural Gas": 70, + "Gasoline": 42, + "Heating Oil": 42, + "Corn": 86, + "KC HRW Wheat": 52, + "Soybean": 126, + "Soybean Meal": 30, + "Soybean Oil": 34, + "Wheat": 10 + }, + "oi_share_identity": { + "markets": 45, + "max_abs_difference": 2.220446049250313e-16 + }, + "pairs": { + "raw_vs_risk": { + "markets": 44, + "bands": [ + 5, + 95 + ], + "corr": { + "median": 0.9168887407666972, + "p10": 0.7516074612160087, + "p90": 0.9484960620175786 + }, + "gap_p95": { + "median": 30.35689453108722, + "p10": 21.98899725297845, + "p90": 47.13864571731827 + }, + "band_disagree": { + "median": 0.11659427284427284, + "p10": 0.08999434819506971, + "p90": 0.1743120463740047 + }, + "gap_autocorr": { + "median": 0.9183795674964408, + "p10": 0.7474975170878647, + "p90": 0.9531940675042178 + }, + "gap_vs_vol": { + "median": 0.25345077355104106, + "p10": -0.3157527981271898, + "p90": 0.5234596308094619 + }, + "gap_vs_vol_positive": 33, + "gap_vs_vol_negative": 11, + "model": "raw_pf" + }, + "raw_vs_notional": { + "markets": 44, + "bands": [ + 5, + 95 + ], + "corr": { + "median": 0.979059460464704, + "p10": 0.9317871211983836, + "p90": 0.9966965328499211 + }, + "gap_p95": { + "median": 14.533675042913952, + "p10": 5.3902186186113, + "p90": 28.821683730490555 + }, + "band_disagree": { + "median": 0.053978627568358785, + "p10": 0.018835746759053206, + "p90": 0.10727119177963158 + }, + "gap_autocorr": { + "median": 0.8323425769890425, + "p10": 0.4549747347116085, + "p90": 0.9334966607989217 + }, + "gap_vs_vol": { + "median": 0.055496787632892314, + "p10": -0.08768261886891124, + "p90": 0.17156175261216633 + }, + "gap_vs_vol_positive": 31, + "gap_vs_vol_negative": 13, + "model": "raw_pf" + }, + "norm_vs_risk": { + "markets": 44, + "bands": [ + 20, + 80 + ], + "corr": { + "median": 0.895536490988696, + "p10": 0.7575535827113733, + "p90": 0.932763166107716 + }, + "gap_p95": { + "median": 33.899499630214635, + "p10": 25.405501675098034, + "p90": 49.668555553460465 + }, + "band_disagree": { + "median": 0.20696734079209247, + "p10": 0.15632332050680267, + "p90": 0.2862719368606502 + }, + "gap_autocorr": { + "median": 0.8874766450996081, + "p10": 0.7184805130025518, + "p90": 0.9416704777658953 + }, + "gap_vs_vol": { + "median": 0.1881894016842529, + "p10": -0.28349808465379545, + "p90": 0.45475284613598377 + }, + "gap_vs_vol_positive": 34, + "gap_vs_vol_negative": 10, + "model": "npf" + } + }, + "latest": { + "raw_vs_risk": { + "markets": 45, + "disagree": 13, + "wide_gaps": 12, + "widest": [ + { + "market": "Silver", + "date": "2026-08-18", + "raw": 0.0, + "risk": 96.26432021737897, + "gap": 96.26432021737897, + "disagrees": true + }, + { + "market": "Natural Gas", + "date": "2026-08-18", + "raw": 99.0, + "risk": 8.928751648833504, + "gap": 90.0712483511665, + "disagrees": true + }, + { + "market": "Gold", + "date": "2026-08-18", + "raw": 0.0, + "risk": 62.801135341456714, + "gap": 62.801135341456714, + "disagrees": true + }, + { + "market": "Lean Hogs", + "date": "2026-08-18", + "raw": 100.0, + "risk": 60.879039924554036, + "gap": 39.120960075445964, + "disagrees": true + }, + { + "market": "Ethereum", + "date": "2026-08-18", + "raw": 13.0, + "risk": 51.93282209028288, + "gap": 38.93282209028288, + "disagrees": false + }, + { + "market": "Gasoline", + "date": "2026-08-18", + "raw": 76.0, + "risk": 38.94114561047047, + "gap": 37.05885438952953, + "disagrees": false + }, + { + "market": "Heating Oil", + "date": "2026-08-18", + "raw": 67.0, + "risk": 31.874653482507398, + "gap": 35.1253465174926, + "disagrees": false + }, + { + "market": "Swiss Franc", + "date": "2026-08-18", + "raw": 35.0, + "risk": 0.0, + "gap": 35.0, + "disagrees": true + }, + { + "market": "10-Year Note", + "date": "2026-08-18", + "raw": 84.0, + "risk": 52.77871902778203, + "gap": 31.22128097221797, + "disagrees": false + }, + { + "market": "Soybean Meal", + "date": "2026-08-18", + "raw": 22.0, + "risk": 50.899360317425476, + "gap": 28.899360317425476, + "disagrees": false + } + ] + }, + "raw_vs_notional": { + "markets": 45, + "disagree": 5, + "wide_gaps": 6, + "widest": [ + { + "market": "Heating Oil", + "date": "2026-08-18", + "raw": 67.0, + "notional": 5.6696261912663415, + "gap": 61.33037380873366, + "disagrees": false + }, + { + "market": "Natural Gas", + "date": "2026-08-18", + "raw": 99.0, + "notional": 38.06108179626795, + "gap": 60.93891820373205, + "disagrees": true + }, + { + "market": "Silver", + "date": "2026-08-18", + "raw": 0.0, + "notional": 57.706162588331246, + "gap": 57.706162588331246, + "disagrees": true + }, + { + "market": "Gasoline", + "date": "2026-08-18", + "raw": 76.0, + "notional": 37.20786983991636, + "gap": 38.79213016008364, + "disagrees": false + }, + { + "market": "Ethereum", + "date": "2026-08-18", + "raw": 13.0, + "notional": 40.43446906823555, + "gap": 27.43446906823555, + "disagrees": false + }, + { + "market": "Lean Hogs", + "date": "2026-08-18", + "raw": 100.0, + "notional": 75.45914090401189, + "gap": 24.54085909598811, + "disagrees": true + }, + { + "market": "Palladium", + "date": "2026-08-18", + "raw": 40.0, + "notional": 53.80816704684507, + "gap": 13.808167046845071, + "disagrees": false + }, + { + "market": "Sugar", + "date": "2026-08-18", + "raw": 8.0, + "notional": 19.71073342668911, + "gap": 11.71073342668911, + "disagrees": false + }, + { + "market": "S&P MidCap 400", + "date": "2026-08-18", + "raw": 36.0, + "notional": 26.214734835203966, + "gap": 9.785265164796034, + "disagrees": false + }, + { + "market": "Soybean Oil", + "date": "2026-08-18", + "raw": 27.0, + "notional": 34.630723133490974, + "gap": 7.630723133490974, + "disagrees": false + } + ] + }, + "norm_vs_risk": { + "markets": 45, + "disagree": 11, + "wide_gaps": 10, + "widest": [ + { + "market": "Natural Gas", + "date": "2026-08-18", + "norm": 91.0, + "risk": 8.928751648833504, + "gap": 82.0712483511665, + "disagrees": true + }, + { + "market": "Gold", + "date": "2026-08-18", + "norm": 0.0, + "risk": 62.801135341456714, + "gap": 62.801135341456714, + "disagrees": true + }, + { + "market": "Silver", + "date": "2026-08-18", + "norm": 57.0, + "risk": 96.26432021737897, + "gap": 39.264320217378966, + "disagrees": true + }, + { + "market": "Lean Hogs", + "date": "2026-08-18", + "norm": 100.0, + "risk": 60.879039924554036, + "gap": 39.120960075445964, + "disagrees": true + }, + { + "market": "Ethereum", + "date": "2026-08-18", + "norm": 19.0, + "risk": 51.93282209028288, + "gap": 32.93282209028288, + "disagrees": true + }, + { + "market": "Soybean Meal", + "date": "2026-08-18", + "norm": 20.0, + "risk": 50.899360317425476, + "gap": 30.899360317425476, + "disagrees": true + }, + { + "market": "Nasdaq", + "date": "2026-08-18", + "norm": 42.0, + "risk": 12.850031092196925, + "gap": 29.149968907803075, + "disagrees": true + }, + { + "market": "Gasoline", + "date": "2026-08-18", + "norm": 64.0, + "risk": 38.94114561047047, + "gap": 25.05885438952953, + "disagrees": false + }, + { + "market": "10-Year Note", + "date": "2026-08-18", + "norm": 77.0, + "risk": 52.77871902778203, + "gap": 24.22128097221797, + "disagrees": false + }, + { + "market": "Lumber", + "date": "2026-08-18", + "norm": 95.0, + "risk": 72.25126693217977, + "gap": 22.748733067820226, + "disagrees": true + } + ] + } + } +} \ No newline at end of file diff --git a/docs/analysis/2026-08-24-contracts-against-dollars.md b/docs/analysis/2026-08-24-contracts-against-dollars.md new file mode 100644 index 0000000..7c27894 --- /dev/null +++ b/docs/analysis/2026-08-24-contracts-against-dollars.md @@ -0,0 +1,148 @@ +# Contracts against dollars, market by market + +**Point-in-time analysis, 2026-08-24. Not amended after this date** (see the doc lifecycle note +in the workspace CLAUDE.md: `analysis/` is a record of when a thing was learned). + +Answers the question raised by a third-party positioning report that draws the same speculative +position twice, once in net contracts and once in US dollar notional, each against its own +history. The question for this app is narrower than the report's: **the Crowding Strip already +draws a 0-100 positioning index per market, so is a dollar version of that index a different +series, or the same one wearing bigger numbers?** + +It is a different series, but only on one of the two dollar rungs. **Dollar RISK parts from the +contract reading by a median 30 index points at the 95th percentile and changes the model's own +answer on 11.7% of weeks. Dollar NOTIONAL does not: over a rolling window it is very nearly the +contract count again.** That split is what decided which reading the strip draws and which one it +keeps to the hover. + +## Reproducer + +```bash +.venv/bin/python scripts/measure_dollar_wedge.py --out docs/analysis/2026-08-24-contracts-against-dollars.json +``` + +Run from the `cot-analyzer` repo root with the environment `run-local.sh` sets. Nothing here is +sampled, so there is no seed and a rerun against the same store reproduces every figure. Full +output is in the JSON beside this file. + +**Data reference.** `COTDATA_STORE` at `~/code/cotdata_store`, `schema_version` 2, all four +report families at `newest_data` 2026-08-18, legacy 53 entries / 81,870 rows. +`MARKETDATA_STORE` at `~/code/marketdata_store`: 98 Norgate futures entries all at `last_date` +2026-08-21, 15 equities, and a 49-row `contract_specs` table written 2026-08-22. +`COTMETRICS_PARAMS` pointed at the private `cotmetrics-config/params.yaml`, so the universe is +47 markets including `heldout`, of which **45 can be priced**. The two that cannot are MSCI EAFE +and MSCI Emerging Mkts, which have no contract multiplier in the specs table: they are ICE MSCI +futures that Norgate carries no continuous series for. + +## What was measured + +One statistic, four series, everything else held constant. For each market the reading is +`indicators.calculate_range_index` over **that market's own tuned lookback**, which is the window +the page draws under its default Custom setting, applied to: + +| series | what it is | who draws it | +|---|---|---| +| `raw` | Commercial net contracts | the Raw PF index | +| `norm` | Commercial net over open interest | the NPF index | +| `notional` | contracts x point value x price | nothing; hover only | +| `risk` | notional x daily volatility | the strip's dollar mark | + +`cotmetrics.exposure` supplies the last two, on the tiers it insists on: `unadj` for price levels +and `propadj` for the volatility factor. The leg is Commercials throughout, matching the mark the +strip already draws. The Legacy legs sum to zero, so the speculator mirror is exact and the +choice of leg is presentation rather than measurement. + +## Result 1: dollar risk is a different series, dollar notional is not + +Per market, over every week both readings exist (44 markets clear the 104-week minimum): + +| pair | correlation | p95 \|gap\|, index points | disagree on the band | +|---|---|---|---| +| contracts vs **risk** (Raw PF, 5/95) | **0.917** | **30.4** | **11.7%** | +| contracts vs notional (Raw PF, 5/95) | 0.979 | 14.5 | 5.4% | +| share of OI vs **risk** (NPF, 20/80) | 0.896 | 33.9 | **20.7%** | + +All three figures are medians across markets. "Disagree on the band" means the two readings do +not land in the same one of the model's three bands (below its low gate, between, above its high +gate), which is the only difference between two lenses that changes an answer on this page. + +The middle row is the useful negative. Notional is contracts times a slowly-moving price, so over +a 26 to 52 week window it is close to a monotone transform of the contract count and a second +mark for it would sit on top of the first. Risk multiplies by volatility, which moves on its own +schedule and is the term no positioning index can carry. + +The third row is the largest of the three and is worth reading carefully: it compares two +different NORMALIZERS as well as two units, because NPF's index is already a share of open +interest. See Result 5 for why there is no dollar version of that share to compare against +instead. + +## Result 2: it is not just "volatility is high" + +The obvious deflation of Result 1 is that `risk = contracts x point value x price x sigma`, so the +gap could be nothing but a volatility chart wearing a positioning label. Measured against each +market's own daily volatility, as a range index over the same window: + +- correlation of the gap with the volatility index: **0.25 at the median**, +- and it **flips sign across markets**: 33 of 44 positive, 11 negative. + +The flip is the mechanism rather than noise. Volatility acts on a position that has a side, so the +same volatility collapse widens the gap upward on a short and downward on a long. A single +volatility overlay cannot reproduce this, because it does not know the sign of the position it is +scaling. + +## Result 3: the gap is a state, not a jitter + +Lag-1 autocorrelation of the gap is **0.918** at the median (p10 0.75, p90 0.95). A market whose +two lenses disagree this week disagreed last week. That is what makes it worth a mark on a weekly +board rather than a warning that fires and clears. + +## Result 4: the live week, 2026-08-18 + +Of the 45 priceable markets, **13 disagree about the band** on the Raw PF reading and 11 on the +NPF reading. The three worth naming: + +| market | window | contracts | dollars at risk | what changed | +|---|---|---|---|---| +| **Silver** | 24w | **0** | **96** | daily volatility fell from 6.7% to 2.7% across the window, so a record short (-44,792 lots) carries -$385m against the window's -$1,235m | +| **Natural Gas** | 70w | **99** | **9** | volatility fell from 7.9% to 2.5%; the largest contract position in the window is a fifth of the money the window has seen | +| **Gold** | 26w | **0** | **63** | Commercials are at their most short in contracts and in notional, and mid-range in risk, because volatility sits near the bottom of its own window | + +Silver is the case the whole comparison was built from, and it is the one a boolean test misses: +it sits at the bottom of the contract range and near the top of the dollar range, which is both +ends of the axis at once. Counting "is it through a gate" scores that as agreement, which is why +`strip_traces.band_of` returns three bands rather than two. + +## Result 5: a share of open interest is unit-free, exactly + +Across all 45 priceable markets and every week in the store, the position's share of open interest +computed in contracts and computed in dollars at risk differ by at most **2.2e-16**. + +This is algebra rather than a finding (the point value, the price and the volatility all cancel +between numerator and denominator), and it decides something concrete: **there is no dollar +version of the NPF basis to draw.** The dollar lens is inherently a LEVEL lens, so under NPF the +mark compares two different normalizations, and the caption has to say the window and the unit +rather than pretend only one thing changed. + +## What this is not + +Description, not a signal. Whether the gap predicts anything is a question for the ladder in +`npf`, judged by someone other than whoever proposed it, and the prior is not neutral: `crowdmon` +tested a close cousin of this (damage = crowding x illiquidity x fragility) across four +pre-registered tests and got no positive result. Nothing here licenses trading the wedge. + +It is also not a claim about the printed report that prompted it. That report ranks a position +against its ENTIRE history rather than against a rolling window, which is a different statistic; +this app's expanding-percentile version of that question already lives on `/exposure`. + +## Bottom line + +In plain language: measuring the same positions in money instead of in contracts genuinely changes +the picture, and it changes it most where a reader would care, at the extremes. About one week in +nine, the two readings put a market in different bands, and this week thirteen of forty-five +markets are in that state, with silver the extreme case: the crowd's largest short of the year in +contracts is an ordinary-sized bet in dollars because volatility has halved. The effect is a +persistent state rather than weekly noise, and it is not simply a volatility chart, since the same +volatility move pushes the gap in opposite directions depending on which way the position leans. +The dollar-notional version of the same idea, which is what the source report plots, adds almost +nothing over a rolling window, so it is kept in the hover rather than drawn. None of this is +evidence that the gap predicts returns; it is a better description of what the position is. diff --git a/docs/design/positioning-report-view-ideas.md b/docs/design/positioning-report-view-ideas.md index d9d85c4..5aa9467 100644 --- a/docs/design/positioning-report-view-ideas.md +++ b/docs/design/positioning-report-view-ideas.md @@ -880,3 +880,98 @@ the week it matters. question for the ladder in npf, judged by someone other than whoever proposed it. The prior is not neutral: `crowdmon` tested a close cousin, damage = crowding x illiquidity x fragility, across four pre-registered tests and got no positive result. + + +## After the eleventh review: contracts against dollars, on the whole board + +The tenth review put the contracts-against-dollars wedge on `/exposure`, for one market at a +time. This puts the same comparison on the strip, where it is forty-odd markets at once, and the +measurements behind it are in +[`docs/analysis/2026-08-24-contracts-against-dollars.md`](../analysis/2026-08-24-contracts-against-dollars.md) +with a reproducer script beside it. + +The prompt was another printed report, this time gold and silver drawn twice each: net position +in contracts, then the same position in US dollar notional, each with a percentile against its +entire history. Its own numbers are the argument for looking. Silver's speculators were at the +52nd percentile in contracts and the 97th in dollars in the same week. + +**What the strip draws is the risk rung, not the notional one, and that is measured rather than +preferred.** Holding the market, the leg, the window and the statistic constant and changing only +the unit: dollar risk correlates 0.917 with the contract reading at the median, parts from it by +30 index points at the 95th percentile, and lands in a different one of the model's three bands +on 11.7% of weeks. Dollar notional scores 0.979, 14.5 points and 5.4% on the same test. Over a +rolling window notional is contracts times a slowly-moving price, so it is close to a monotone +transform of the count, and a second mark for it would sit on top of the first on most rows. It +is carried in the hover instead, one line, because it is the reading the printed reports plot and +a reader comparing the two boards should be able to see both numbers. + +Worth being explicit that this is not a rebuttal of the source. The report ranks against the +ENTIRE history, where notional carries the price level and therefore says a great deal; the strip +ranks against a rolling window, which renormalizes exactly that away. The all-history version of +the question already has a home on `/exposure`, and its expanding percentile is what answers it. + +**One reference mark per row, chosen by a control, rather than a fifth mark.** A row already +carries the head, its stem and a tick per gated leg. The two comparisons worth putting beside them +are the same shape of question, "this leg measured differently", and they compete for the same few +pixels: where the index stood six weeks ago (differently in TIME) and where it sits in dollars at +risk (differently in UNIT). Drawing both was tried first. At ROW_PX a hollow ring and a hollow +diamond a few points apart are one smudge, and on a quiet row they are the same colour as well, so +the row stops saying which is which. The COMPARE control picks one, and a third setting turns both +off, which the page could not do before. + +**The dollar mark takes the row's colour and a different shape, because the palette has no free +slot.** Every slot is spoken for twice over: `grid_colors` builds the bull verdict from slot 3, +which is also Price, and the bear verdict from slot 0, which is also Commercials. So a +"this is the dollar reading" colour would collide with a verdict on the one figure whose whole +colour vocabulary is verdicts. Shape carries which comparison it is; colour goes on saying what it +says everywhere else on the row. + +**The connector stays, which is the opposite call from the prior mark.** That mark deliberately has +no line back to the head, on the argument that 42 connectors are a lot of ink for a move of a few +points. Here the GAP is the subject rather than a by-product, and the ink is self-limiting in a way +the momentum version's would not be: a market where money and contracts agree draws its diamond +around its own head and a line of zero length, so the rows that spend ink are exactly the rows +worth reading. Where the dollar reading falls between neutral and the head the connector lies along +the stem and is invisible, which is the same information (the diamond is ON the stem, and the +distance to the head is the gap) at no cost. + +**Disagreement is counted on the bands, and on three of them.** The caption says how many drawn +markets the two lenses put in different bands, not how many differ by some number of points: +contracts at 98 against dollars at 90 is a wide gap the model answers the same way twice, while 96 +against 94 under NPF straddles the line. Three bands rather than a boolean "through a gate", +because Silver on 2026-08-18 sat at 0 on contracts and 96 on dollars, which is both ends of the +axis at once, and a boolean scores the sharpest disagreement on the board as agreement. + +**Two counts, for the reason the caption already counts hidden and index-less markets.** A row with +no diamond and a row whose two marks coincide look identical, and only one of them is a +measurement. MSCI EAFE and MSCI Emerging Mkts have no contract multiplier, so they can never carry +one; they are `heldout` today and therefore off the live board, which is why the sentence usually +does not appear. + +**Off by default.** It is the only thing this page draws that needs the price store, so it is the +only thing on it that can fail for reasons that have nothing to do with COT. The join costs about +four seconds cold for the whole universe and about a third of a second warm, since `exposure` +caches the daily price and volatility series per symbol and the strip caches the per-market +readings keyed by the store's newest date, exactly as the Heatmap's two joined columns do. + +**Where the arithmetic lives.** Both halves are cotmetrics functions (`exposure.market_exposure` +for the dollar series, `indicators.calculate_range_index` for the index) and this repo only +composes them, the same shape as the Heatmap's `_spec_risk`, which pairs `market_exposure` with +`expanding_pct_rank`. The composition is a range index of a dollar series and nothing else in the +app wants one yet. The moment a second surface does, it moves to `cotmetrics.exposure` beside +`windowed_pct_rank` rather than being copied. + +**One thing that fell out of the arithmetic and constrains what can ever be drawn here.** A +position's share of open interest is the same number in contracts and in dollars, to 2.2e-16 +across the universe, because the point value, the price and the volatility cancel between the +position and the market it sits in. So there is no dollar version of the NPF basis: the dollar +lens is inherently a LEVEL lens, and under NPF the two marks differ in normalizer as well as in +unit. That is the one place this comparison is not a controlled one, it is stated in the caption +rather than hidden, and it is also why the NPF disagreement rate (20.7% of weeks) is the largest +of the three measured. + +Still not built, from the list this document opened with: the window min/max/current tooltip, time +in state, and the horizon ladder. The percentile tick from the whiskers section is now cheap, +since `cotmetrics.exposure` grew `windowed_pct_rank` and `expanding_pct_rank` for the exposure +page, but it is a second statistic on one axis and would need the same care about labelling that +this comparison needed about units. diff --git a/scripts/measure_dollar_wedge.py b/scripts/measure_dollar_wedge.py new file mode 100644 index 0000000..03bc261 --- /dev/null +++ b/scripts/measure_dollar_wedge.py @@ -0,0 +1,252 @@ +"""Measure what the dollar lens adds to the Crowding Strip's positioning index. + +The strip draws one 0-100 range index per market: where this market's Commercial net +position sits inside its own tuned lookback window, in CONTRACTS (Raw PF) or as a SHARE +OF OPEN INTEREST (NPF). The printed positioning reports this page was modelled on draw +the same position a second time in US DOLLARS, and the question this script answers is +whether that second reading is a different series or the same one wearing bigger +numbers. + +It holds everything constant except the unit. Same market, same leg, same window, same +statistic (`indicators.calculate_range_index`), computed on four series: + + raw net contracts, which is what the Raw PF index reads + norm net contracts over open interest, which is what the NPF index reads + notional contracts x point value x price, the middle rung of cotmetrics.exposure + risk notional x daily volatility, the rung that is comparable across markets + +Four things are reported, and the last two are the ones that decide whether a mark is +worth drawing rather than merely computable: + + 1. how far the two readings part (correlation, and the 95th percentile of |gap|) + 2. how often they disagree about the market being through the MODEL's own gate band, + which is the only difference between two lenses that changes an answer here + 3. whether the gap is a state or a jitter (lag-1 autocorrelation) + 4. whether the gap is just "volatility is high", tested against a range index of the + market's own daily volatility over the same window + +It also checks the one identity that decides where the dollar lens can be drawn at all: +a position's SHARE of open interest is the same number in contracts and in dollars, +because the point value, the price and the volatility all cancel between numerator and +denominator. If that holds to floating point, there is no dollar version of the NPF +basis to draw, and the dollar reading is necessarily a level reading. + +Deterministic: nothing here is sampled, so a rerun against the same store reproduces +every figure exactly. + +Usage (from the repo root, with the store env set as run-local.sh sets it): + + .venv/bin/python scripts/measure_dollar_wedge.py --out docs/analysis/.json +""" + +from __future__ import annotations + +import argparse +import json +import sys + +import numpy as np +import pandas as pd +from cotmetrics import exposure, indicators, models +from cotmetrics.indexer import get_indexer + +#: A gap this wide is worth reporting as a headline count. Display threshold, not a +#: statistic: it is roughly the pooled 95th percentile, so "a fifth of the axis" and +#: "unusual" happen to name the same rows. +WIDE_GAP = 20 + +#: Under this many weeks of overlap a per-market correlation is not worth quoting. +MIN_WEEKS = 104 + + +def market_panel(name, lookback): + """One market's four readings on one index, plus the volatility index behind them. + + Returns `(frame, weeks)` or `(None, reason)`. The window is the market's own tuned + lookback under "Custom", which is what the page draws, so the dollar reading is + measured over exactly the window the contract reading was. + """ + indexer = get_indexer() + instrument = indexer.get_instrument_from_name(name) + if instrument is None: + return None, "no instrument" + if lookback in ("26", "52"): + window = int(lookback) + else: + window = int(instrument.custom_lookback) + + weekly = indexer.get_symbols_data(name, lookback) + if weekly.empty: + return None, "no weekly frame" + try: + ex = exposure.market_exposure(name, leg=exposure.LEG_COMM, lookback=lookback, + frame=weekly, symbol=instrument.symbol) + except Exception as e: # noqa: BLE001 - one unpriceable market is not a failure + return None, f"{type(e).__name__}: {e}" + if not ex["risk_usd"].notna().any(): + return None, "no contract multiplier or no bars" + + prefix = f"Comm {lookback} " if lookback in ("26", "52") else "Comm Custom " + panel = pd.DataFrame({ + "raw": pd.to_numeric(weekly[prefix + "Idx"], errors="coerce").to_numpy(), + "norm": pd.to_numeric(weekly[prefix + "Idx Norm"], errors="coerce").to_numpy(), + "notional": indicators.calculate_range_index(ex["notional_usd"], window).to_numpy(), + "risk": indicators.calculate_range_index(ex["risk_usd"], window).to_numpy(), + "vol": indicators.calculate_range_index(ex["sigma_daily"], window).to_numpy(), + }, index=pd.to_datetime(weekly.index)) + panel["risk_usd"] = ex["risk_usd"].to_numpy() + panel["sigma_daily"] = ex["sigma_daily"].to_numpy() + panel["net_contracts"] = ex["net_contracts"].to_numpy() + return panel, window + + +def band(values, low, high): + """Which of the model's three bands each reading sits in: -1, 0 or +1. + + Three, not two. A boolean "is it through a gate" calls the sharpest disagreement on + the board a match, because a market at 0 on one lens and 96 on the other is at an + extreme under both. + """ + return np.where(values >= high, 1, np.where(values <= low, -1, 0)) + + +def compare(panels, base, other, low, high): + """Pooled per-market statistics for one pair of lenses at one model's bands.""" + rows = [] + for name, panel in panels.items(): + d = panel[[base, other, "vol"]].dropna() + if len(d) < MIN_WEEKS: + continue + gap = d[base] - d[other] + rows.append({ + "market": name, + "weeks": int(len(d)), + "corr": float(d[base].corr(d[other])), + "gap_p95": float(np.percentile(gap.abs(), 95)), + "band_disagree": float((band(d[base], low, high) + != band(d[other], low, high)).mean()), + "gap_autocorr": float(gap.autocorr(1)), + "gap_vs_vol": float(gap.corr(d["vol"])), + }) + frame = pd.DataFrame(rows) + summary = { + "markets": int(len(frame)), + "bands": [low, high], + } + for column in ("corr", "gap_p95", "band_disagree", "gap_autocorr", "gap_vs_vol"): + summary[column] = { + "median": float(frame[column].median()), + "p10": float(frame[column].quantile(0.10)), + "p90": float(frame[column].quantile(0.90)), + } + summary["gap_vs_vol_positive"] = int((frame["gap_vs_vol"] > 0).sum()) + summary["gap_vs_vol_negative"] = int((frame["gap_vs_vol"] < 0).sum()) + return summary, frame + + +def latest_week(panels, base, other, low, high): + """What the board would show on the newest week each market has.""" + rows = [] + for name, panel in panels.items(): + d = panel[[base, other]].dropna() + if d.empty: + continue + last = d.iloc[-1] + rows.append({ + "market": name, + "date": str(d.index[-1].date()), + base: float(last[base]), + other: float(last[other]), + "gap": float(abs(last[base] - last[other])), + "disagrees": bool(band(np.array([last[base]]), low, high)[0] + != band(np.array([last[other]]), low, high)[0]), + }) + frame = pd.DataFrame(rows).sort_values("gap", ascending=False) + return { + "markets": int(len(frame)), + "disagree": int(frame["disagrees"].sum()), + "wide_gaps": int((frame["gap"] >= WIDE_GAP).sum()), + "widest": frame.head(10).to_dict("records"), + }, frame + + +def oi_share_identity(names): + """The share of open interest is the same number in contracts and in dollars. + + Point value, price and volatility all cancel between a position and the market it + sits in, so this is algebra rather than an empirical result; it is measured because + it is the reason the dollar lens has no OI-normalised form to draw. + """ + worst, checked = 0.0, 0 + indexer = get_indexer() + for name in names: + instrument = indexer.get_instrument_from_name(name) + weekly = indexer.get_symbols_data(name, "Custom") + try: + ex = exposure.market_exposure(name, leg=exposure.LEG_COMM, + lookback="Custom", frame=weekly, + symbol=instrument.symbol) + except Exception: # noqa: BLE001 + continue + if "oi_risk_usd" not in ex: + continue + oi = pd.to_numeric(weekly["Open_Interest_All"], errors="coerce").to_numpy() + in_contracts = ex["net_contracts"].to_numpy() / np.where(oi > 0, oi, np.nan) + in_dollars = (ex["risk_usd"] / ex["oi_risk_usd"]).to_numpy() + difference = np.nanmax(np.abs(in_contracts - in_dollars)) + if difference == difference: + worst = max(worst, float(difference)) + checked += 1 + return {"markets": checked, "max_abs_difference": worst} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--lookback", default="Custom", + choices=("26", "52", "Custom"), + help="the index window, as the page's control names it") + parser.add_argument("--out", help="write the full result to this JSON path") + args = parser.parse_args(argv) + + indexer = get_indexer() + names = [indexer.get_instrument_from_symbol(s).name + for s in indexer.get_instrument_names()] + + panels, unpriced, windows = {}, {}, {} + for name in names: + panel, detail = market_panel(name, args.lookback) + if panel is None: + unpriced[name] = detail + else: + panels[name] = panel + windows[name] = detail + + result = { + "lookback": args.lookback, + "universe": len(names), + "priced": len(panels), + "unpriced": unpriced, + "windows": windows, + "oi_share_identity": oi_share_identity(names), + "pairs": {}, + "latest": {}, + } + for label, base, model in (("raw_vs_risk", "raw", models.RAW_PF), + ("raw_vs_notional", "raw", models.RAW_PF), + ("norm_vs_risk", "norm", models.NPF)): + other = "notional" if label.endswith("notional") else "risk" + summary, _ = compare(panels, base, other, model.low, model.high) + summary["model"] = model.key + result["pairs"][label] = summary + latest, _ = latest_week(panels, base, other, model.low, model.high) + result["latest"][label] = latest + + print(json.dumps(result, indent=2, default=str)) + if args.out: + with open(args.out, "w") as fh: + json.dump(result, fh, indent=2, default=str) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/components/strip_traces.py b/src/components/strip_traces.py index 2392120..e578375 100644 --- a/src/components/strip_traces.py +++ b/src/components/strip_traces.py @@ -34,6 +34,18 @@ lollipop's POSITION is the level and its COLOUR is the verdict, and the two disagreeing is the interesting case rather than a defect. +**One optional comparison per row, in dollars.** Those reports draw the same position +twice, once in contracts and once in US dollars, and the two disagree far more than a +reader expects: on this universe the same 52-week range index computed on dollar risk +(contracts x point value x price x daily volatility) correlates 0.92 with the contract +version at the median, parts from it by 30 index points at the 95th percentile, and +disagrees about whether the market is through the model's own gate band on 12% of +weeks. Silver on 2026-08-18 is the case that makes it concrete: Commercials sat at the +very bottom of their 24-week contract range and at 96 on dollars at risk, because +silver's daily volatility had fallen from 6.7% to 2.7% over the same window, so the +larger short carried a third of the money. The mark is off by default and shares its +place on the row with the six-weeks-ago mark, for the reasons at COMPARE_PRIOR. + Everything in here is a pure function over a `get_matrix_data` frame plus a colour set, so the layout is testable without a store, a palette file or a browser. """ @@ -247,6 +259,46 @@ SIDE_BULL = "bull" SIDE_BEAR = "bear" +# The reference mark: ONE second position per row, and which one is a choice. +# +# A row already carries the head, its stem and a tick per gated leg. The two things +# worth putting beside them answer the same shape of question, "the same leg measured +# differently", and they compete for the same few pixels: where this index stood +# MOMENTUM_PERIOD weeks ago (differently in TIME), and where it sits when the position +# is measured in dollars at risk rather than in contracts (differently in UNIT). +# +# Drawing both was tried and is the reason this is a selector. At ROW_PX a hollow ring +# and a hollow diamond a few points apart are one smudge, and on a quiet row they are +# the same colour as well, so the row stops saying which is which. One at a time keeps +# every mark on the row nameable, and the legend and caption then only have to explain +# the comparison actually on screen. +# +# COMPARE_DOLLARS is the newer one and it is off by default, because it is the only +# thing this page draws that needs the price store: a market with no contract +# multiplier or no bars has no dollar reading at all, and the caption has to say so. +COMPARE_PRIOR = "prior" +COMPARE_DOLLARS = "dollars" +COMPARE_NONE = "none" + +# The dollar mark and the line back to the head. +# +# Shape carries which comparison this is (a hollow diamond, against the prior mark's +# hollow ring), and colour stays what it is everywhere else on this row: the ROW's +# tier, verdict or quiet. Spending colour on "this one is the dollar reading" would put +# a third meaning on the one channel that already means verdict here, and the palette +# has no free slot for it anyway: slot 3 is Price AND the bull colour, slot 0 is +# Commercials AND the bear colour. +# +# The connector is what makes the pair read as one fact rather than as two marks. It is +# the opposite call from the prior mark, which deliberately has no connector, and the +# reason is that here the GAP is the subject: contracts and money disagreeing about the +# same week is the whole reason to switch this on. It also costs nothing on the rows +# with nothing to say, since a market where the two agree draws a line of zero length. +DOLLAR_SYMBOL = "diamond-open" +DOLLAR_SIZE = 8 +WEDGE_ALPHA = 0.45 +WEDGE_WIDTH = 1 + # How the Commercial index is drawn: ONE form, a lollipop — a thin stem from the # neutral midpoint with a full-strength head at the value. # @@ -259,6 +311,24 @@ # carry the hover. +@dataclass(frozen=True) +class DollarRead: + """The same market, the same leg and the same window, measured in MONEY. + + `index` is the range index the row's head already carries, recomputed on dollar + risk (contracts x point value x price x daily volatility) rather than on contracts + or on share of open interest. The rest is hover material: the level behind the + index, the volatility that scaled it, the window it was measured over, and the + notional index, which is drawn nowhere and is carried because it is the reading the + printed reports use (see COMPARE_DOLLARS). + """ + index: float + risk_usd: float = None + notional_index: float = None + sigma_daily: float = None + weeks: int = None + + @dataclass(frozen=True) class StripRow: """One line of the strip: an asset-class header, a market, or a blank spacer.""" @@ -270,6 +340,7 @@ class StripRow: state: str = const.SETUP_NONE is_equity: bool = False prior: float = None # where the index stood MOMENTUM_PERIOD weeks ago + dollar: DollarRead = None # the same reading in dollars at risk, or None def _num(value): @@ -313,7 +384,49 @@ def keeps(row, show, side): return True -def build_rows(df, model, sort_by_index=True, show=SHOW_ALL, side=SIDE_BOTH): +def band_of(value, model): + """Which of the model's three bands a 0-100 reading sits in: -1, 0 or +1. + + Three, not two. Asking only "is it through a gate" scores the sharpest + disagreement on the board as agreement: Silver on 2026-08-18 sat at 0 on contracts + and 96 on dollars at risk, which is both ends of the axis at once, and a boolean + calls that a match because both are extremes. + + The bands are also why this is asked here rather than on a gap in points. Contracts + at 98 against dollars at 90 is a wide gap the model answers the same way twice, + while 96 against 94 under NPF straddles the line and changes the answer. + """ + if value is None: + return None + if value >= model.high: + return 1 + if value <= model.low: + return -1 + return 0 + + +def dollar_split(rows, model): + """`(disagree, missing)` over the drawn market rows. + + `disagree` counts rows where the contract reading and the dollar reading are not + on the same side of the model's gate bands, which is the one difference between + the two lenses that changes an answer. `missing` counts markets with no dollar + reading at all: no contract multiplier in the specs table, or no bars to price. + + Both are for the caption, and both exist for the same reason the skipped-market + count does. A board where six rows quietly have no second mark looks exactly like + a board where six markets agree. + """ + markets = [r for r in rows if r.kind == "market"] + missing = sum(1 for r in markets if r.dollar is None or r.dollar.index is None) + disagree = sum(1 for r in markets + if r.dollar is not None and r.dollar.index is not None + and band_of(r.comm, model) != band_of(r.dollar.index, model)) + return disagree, missing + + +def build_rows(df, model, sort_by_index=True, show=SHOW_ALL, side=SIDE_BOTH, + dollars=None): """`(rows, skipped)` for one Signal Matrix frame. Markets with no index at the selected week cannot be placed on the axis, so they @@ -323,6 +436,10 @@ def build_rows(df, model, sort_by_index=True, show=SHOW_ALL, side=SIDE_BOTH): Filtered-out markets are a different thing and are NOT counted here. The caller knows the filter it asked for, and a class left empty by one loses its header rather than sitting there as a heading over nothing. + + `dollars` is an optional `{asset: DollarRead}` table, joined onto the rows here so + the figure stays pure over rows. It is the caller's job to have it match the week + being drawn; this only looks a name up. """ cols = LEG_COLUMNS[model.key] state_col = SETUP_COLUMN[model.key] @@ -353,7 +470,12 @@ def build_rows(df, model, sort_by_index=True, show=SHOW_ALL, side=SIDE_BOTH): # point difference: a market that ran from 2 to 98 would put # its prior mark off the axis otherwise. prior=None if move is None - else min(100.0, max(0.0, comm - move))) + else min(100.0, max(0.0, comm - move)), + # Keyed by the display name, the same key the frame and the + # caller's dollar table both use. A market absent from that + # table is one that cannot be priced, which is a state the + # row has to carry rather than a lookup that may fail. + dollar=(dollars or {}).get(asset)) if not keeps(market, show, side): continue by_class.setdefault(market.asset_class, []).append(market) @@ -538,7 +660,41 @@ def _tick_label(row, colors): return f'{row.label}' -def _hover(row, model): +def _money(value): + """A dollar figure at the scale a reader can hold: bn, m, or k.""" + if value is None or value != value: + return "n/a" + sign = "-" if value < 0 else "" + v = abs(value) + for unit, size in (("bn", 1e9), ("m", 1e6), ("k", 1e3)): + if v >= size: + return f"{sign}${v / size:,.1f}{unit}" + return f"{sign}${v:,.0f}" + + +def _dollar_hover(row): + """The dollar block of a row's hover, or "" when there is nothing to say. + + This is where the notional index lives. It is not drawn, because over a rolling + window it is very nearly the contract count again (the two agree to a median + correlation of 0.98, against 0.92 for dollar risk), so a second mark for it would + sit on top of the first on most rows and say nothing. In the hover it costs one + line and it is the reading the printed reports plot, so a reader comparing this + board against one of those can see both numbers rather than wonder which we drew. + """ + d = row.dollar + if d is None or d.index is None: + return "
No dollar reading: no contract multiplier or no bars." + window = f" ({d.weeks}w)" if d.weeks else "" + notional = ("" if d.notional_index is None + else f"
Same, on notional: {d.notional_index:.0f}") + sigma = ("" if d.sigma_daily is None + else f"
Daily vol: {d.sigma_daily * 100:.1f}%") + return (f"

In dollars at risk{window}: {d.index:.0f}" + f"
Level: {_money(d.risk_usd)}{notional}{sigma}") + + +def _hover(row, model, compare=COMPARE_PRIOR): legs = "".join( f"
{LEG_LABELS[leg]}: {value:.0f}" f"{' (through its gate)' if gate else ''}" @@ -546,8 +702,11 @@ def _hover(row, model): verdict = STATE_LABELS.get(row.state) tail = f"

{model.title}: {verdict}" if verdict else f"

{model.title}: no setup" equity = "
Equity index: gated on Commercials alone" if row.is_equity else "" + # Only when the mark is on screen. A hover naming a dollar reading with no mark + # beside it is a claim about a comparison the reader cannot see. + money = _dollar_hover(row) if compare == COMPARE_DOLLARS else "" return (f"{row.label}
{row.asset_class}" - f"
{LEG_LABELS['comm']}: {row.comm:.0f}{legs}{tail}{equity}") + f"
{LEG_LABELS['comm']}: {row.comm:.0f}{legs}{money}{tail}{equity}") def _ticks(model): @@ -566,9 +725,10 @@ def _tick_text(model): GLYPH_MARK = "mark" # the lollipop head GLYPH_TICK = "tick" # the line-ns symbol: the speculator legs GLYPH_CIRCLE = "circle" # the hollow prior-position circle +GLYPH_DIAMOND = "diamond" # the hollow dollar mark -def legend_items(model, colors, palette): +def legend_items(model, colors, palette, compare=COMPARE_PRIOR): """The legend, as data: `[(group title, [(label, colour, glyph), ...]), ...]`. This used to be empty traces inside the first figure, the idiom plot_traces uses. @@ -585,6 +745,13 @@ def legend_items(model, colors, palette): quiet row is not colourless: it is the Commercial series colour, and a reader has to be told that red-ish head does not mean bearish. """ + # The reference key names whichever comparison is on, and nothing when neither is. + # A key for a mark the figure is not drawing is worse than no key at all: it is the + # one place a reader goes to find out what they are looking at. + reference = { + COMPARE_PRIOR: [(f"{const.MOMENTUM_PERIOD}w ago", colors.dim, GLYPH_CIRCLE)], + COMPARE_DOLLARS: [("Same, in $ at risk", colors.dim, GLYPH_DIAMOND)], + }.get(compare, []) # The neutral keys are as dim as the marks they stand for: a full-strength # "No setup" swatch would promise a colour the plot never draws. return [ @@ -592,15 +759,15 @@ def legend_items(model, colors, palette): [("Bull setup", colors.bull, GLYPH_MARK), ("Bear setup", colors.bear, GLYPH_MARK), ("Near", colors.bull_near, GLYPH_MARK), - ("No setup", colors.dim, GLYPH_MARK), - (f"{const.MOMENTUM_PERIOD}w ago", colors.dim, GLYPH_CIRCLE)]), + ("No setup", colors.dim, GLYPH_MARK)] + reference), ("Ticks: the legs this gate also reads", [(LEG_LABELS[leg], palette[LEG_PALETTE_SLOT[leg]], GLYPH_TICK) for leg in model.spec_legs if leg in LEG_LABELS]), ] -def build_figure(rows, model, colors, palette, background=vc.BACKGROUND_COLOR): +def build_figure(rows, model, colors, palette, background=vc.BACKGROUND_COLOR, + compare=COMPARE_PRIOR): """The strip, as one figure. `rows` comes from build_rows. Nothing here reads a store, so a caller can hand it @@ -709,7 +876,7 @@ def build_figure(rows, model, colors, palette, background=vc.BACKGROUND_COLOR): size=[HEAD_SIZE if _verdict_colour(r, colors) else QUIET_HEAD_SIZE for _, r in markets], color=heads, line=dict(width=1, color=heads)), - hovertext=[_hover(r, model) for _, r in markets], + hovertext=[_hover(r, model, compare) for _, r in markets], hoverinfo="text", showlegend=False, )) @@ -719,7 +886,8 @@ def build_figure(rows, model, colors, palette, background=vc.BACKGROUND_COLOR): # No connector to the current mark. The reference charts that do this well draw the # two positions and let the row pair them, and 42 connectors is a lot of line for a # move that is usually a few points wide. - prior = [(i, r.prior) for i, r in markets if r.prior is not None] + prior = ([(i, r.prior) for i, r in markets if r.prior is not None] + if compare == COMPARE_PRIOR else []) if prior: # `color`, not just `line.color`. An OPEN symbol draws its outline from # marker.color; marker.line is a second stroke around that. Setting only the @@ -735,6 +903,46 @@ def build_figure(rows, model, colors, palette, background=vc.BACKGROUND_COLOR): hoverinfo="skip", showlegend=False, )) + # The same leg, the same window, measured in dollars at risk: a hollow diamond at + # the dollar reading and a hairline back to the head it disagrees with. + # + # The line first, so the two marks sit on top of it rather than behind it. One + # trace with None breaks between segments rather than one per row: Plotly colours a + # line per trace, so per-row colour would mean forty traces, and a single quiet + # neutral is the right answer anyway. The marks at either end carry the row's tier; + # the line only has to say which two belong together. + # + # Ink is self-limiting here, which is the property that makes it safe to leave on + # for the whole board: a market where money and contracts agree draws a diamond + # around its own head and no visible line, and the rows that draw a long connector + # are exactly the rows worth reading. + wedge = [(i, r) for i, r in markets + if r.dollar is not None and r.dollar.index is not None] + if compare == COMPARE_DOLLARS and wedge: + xs, ys = [], [] + for i, r in wedge: + xs += [r.comm, r.dollar.index, None] + ys += [i, i, None] + fig.add_trace(go.Scatter( + x=xs, y=ys, mode="lines", + line=dict(color=hex_to_rgba(vc.BRIGHTER_TEXT_COLOR, WEDGE_ALPHA), + width=WEDGE_WIDTH), + marker=dict(color=hex_to_rgba(vc.BRIGHTER_TEXT_COLOR, WEDGE_ALPHA)), + hoverinfo="skip", showlegend=False, + )) + # `color`, not only `line.color`. Same trap the prior ring documents: an OPEN + # symbol draws its outline from marker.color, and leaving it unset takes the + # template's colourway rather than raising. + dollar_colours = [_mark_colour(r, colors, palette) for _, r in wedge] + fig.add_trace(go.Scatter( + x=[r.dollar.index for _, r in wedge], y=[i for i, _ in wedge], + mode="markers", + marker=dict(symbol=DOLLAR_SYMBOL, size=DOLLAR_SIZE, + color=dollar_colours, + line=dict(width=1.4, color=dollar_colours)), + hoverinfo="skip", showlegend=False, + )) + # One trace per speculator leg the model gates on, so the legend names them and a # reader can switch one off. Drawn as a tick rather than a dot: it marks a position # on the same axis as the bar, and a dot would read as a second measure. Lit when diff --git a/src/pages/analytics/strip.py b/src/pages/analytics/strip.py index ca73985..40c83da 100644 --- a/src/pages/analytics/strip.py +++ b/src/pages/analytics/strip.py @@ -21,11 +21,15 @@ letting the page imply a uniform basis it does not have. """ +import functools from datetime import datetime +import cotmetrics.constants as const import cotmetrics.models as models +import cotmetrics.utils as utils import dash import dash_bootstrap_components as dbc +from cotmetrics import exposure, indicators from cotmetrics.indexer import get_indexer from cotmetrics.reports import get_matrix_data from dash import Input, Output, State, callback, clientside_callback, dcc, html, no_update @@ -58,6 +62,9 @@ # folded away. Written twice they would drift, and the summary is exactly the thing # nobody would check. ORDER_LABELS = {SORT_BY_INDEX: "Crowding", SORT_ALPHA: "A-Z"} +COMPARE_LABELS = {strip_traces.COMPARE_PRIOR: f"{const.MOMENTUM_PERIOD}w ago", + strip_traces.COMPARE_DOLLARS: "Dollars", + strip_traces.COMPARE_NONE: "None"} SHOW_LABELS = {strip_traces.SHOW_ALL: "All", strip_traces.SHOW_SETUPS: "Setups", strip_traces.SHOW_SETUPS_NEAR: "+ Near"} @@ -70,8 +77,98 @@ def _options(labels): return [{"label": text, "value": value} for value, text in labels.items()] +# ── the dollar lens ─────────────────────────────────────────────────────────── +# The one thing on this page that reads a second store. Everything else comes off the +# Signal Matrix; this joins `cotmetrics.exposure` (prices, contract specs, volatility) +# onto the same rows, so the strip can draw the same leg over the same window measured +# in money instead of in contracts. +# +# Where the arithmetic lives, since this repo computes no metrics of its own: both +# halves are cotmetrics functions and this only composes them, the same shape as the +# Heatmap's `_spec_risk`, which pairs `market_exposure` with `expanding_pct_rank`. The +# composition is a range index of a dollar series, which nothing else in the app wants +# yet. The moment a second surface does, it moves to `cotmetrics.exposure` beside +# `windowed_pct_rank` rather than being copied. +# +# LEG_COMM, because the lollipop it is drawn against is Commercials. The mirror is +# exact rather than approximate (the Legacy legs sum to zero, so spec risk is minus +# commercial risk to the last decimal), so this is a presentation choice and not a +# different measurement. + + +def _window_weeks(asset, lookback): + """How many weeks the row's index was measured over. + + The dollar reading has to use the SAME window, or the wedge between the two marks + stops being a comparison of two units and becomes one of two stretches of time. + Under Custom that is the market's own tuned lookback, which is why this asks the + instrument rather than the control. + """ + if lookback in ("26", "52"): + return int(lookback) + instrument = get_indexer().get_instrument_from_name(asset) + custom = getattr(instrument, "custom_lookback", None) + try: + return int(custom) + except (TypeError, ValueError): + return 52 + + +@functools.lru_cache(maxsize=512) +def _dollar_reads(asset, window, newest_date): + """One market's weekly dollar readings, keyed by report date. + + `newest_date` is a cache-buster and nothing else: a Friday release must invalidate + this and nothing else does. `market_exposure` is always asked for "Custom" because + the lookback control does not touch what it reads (net contracts, price and + volatility); the window belongs to the index computed here, and it IS a cache key. + + Returns `{date_str: DollarRead}`, or None when the market has no dollar reading at + all: no contract multiplier in the specs table, or no bars to price it with. The + catch is broad on purpose. This is a display join, and one unpriceable market must + not take the other forty rows down with it. + """ + try: + ex = exposure.market_exposure(asset, leg=exposure.LEG_COMM, lookback="Custom") + risk, notional = ex["risk_usd"], ex["notional_usd"] + if not risk.notna().any(): + return None + risk_index = indicators.calculate_range_index(risk, window) + notional_index = indicators.calculate_range_index(notional, window) + except Exception as e: + utils.cot_logger.warning(f"strip: no dollar reading for {asset}: {e}") + return None + + def clean(value): + return float(value) if value == value else None + + return {ts.strftime('%Y-%m-%d'): strip_traces.DollarRead( + index=clean(idx), risk_usd=clean(level), + notional_index=clean(notional_idx), sigma_daily=clean(sigma), + weeks=window) + for ts, idx, level, notional_idx, sigma + in zip(risk.index, risk_index.to_numpy(), risk.to_numpy(), + notional_index.to_numpy(), ex["sigma_daily"].to_numpy())} + + +def dollar_table(df, lookback, newest_date): + """`{asset: DollarRead}` for the week each row is showing. + + Row by row on the row's OWN date rather than on the page's target date, matching + what the Heatmap's joins do: with no target selected each market shows its latest + week, and those can differ. + """ + table = {} + for asset, date in zip(df["Asset"], df["Date"]): + reads = _dollar_reads(asset, _window_weeks(asset, lookback), newest_date) + if reads and date in reads: + table[asset] = reads[date] + return table + + def controls_summary(target_date, model, lookback, sort_by, show, side, columns, - n_classes, n_all, hidden=0, skipped=0): + n_classes, n_all, hidden=0, skipped=0, + compare=strip_traces.COMPARE_PRIOR): """One line saying what the folded controls are set to. Folding the card hides seven controls, and a board drawn on a filter you cannot see @@ -93,6 +190,7 @@ class fraction and the column count because this line truncates on a narrow bits.append(ORDER_LABELS.get(sort_by, sort_by)) bits.append(SHOW_LABELS.get(show, show)) bits.append(SIDE_LABELS.get(side, side)) + bits.append(f"vs {COMPARE_LABELS.get(compare, compare)}") if hidden: bits.append(f"{hidden} hidden") if skipped: @@ -102,7 +200,8 @@ class fraction and the column count because this line truncates on a narrow return " · ".join(str(b) for b in bits) -def caption(report_date, lookback, model, skipped, hidden=0): +def caption(report_date, lookback, model, skipped, hidden=0, + compare=strip_traces.COMPARE_PRIOR, disagree=0, unpriced=0): """The two lines under the controls. Everything here is a fact the picture cannot carry on its own, and the list grew by @@ -140,6 +239,25 @@ def caption(report_date, lookback, model, skipped, hidden=0): if skipped: dropped += (f" {len(skipped)} market(s) have no index this week and are not " f"shown: {', '.join(sorted(skipped))}.") + # The dollar sentence carries two counts for the same reason the line above counts + # hidden markets: a row with no second mark and a row whose two marks agree look + # identical, and only one of them is a measurement. + if compare == strip_traces.COMPARE_DOLLARS: + unpriced_note = (f" {unpriced} market(s) cannot be priced (no contract " + f"multiplier or no bars) and carry no diamond." + if unpriced else "") + money = ( + f" The hollow diamond is the SAME leg over the SAME window measured in " + f"dollars at risk (contracts x point value x price x daily volatility) " + f"rather than in contracts, with a line back to the contract reading: " + f"where the two part, the crowd's money and its contract count disagree " + f"about how extreme this market is. {disagree} of the drawn markets " + f"disagree about being through a gate band this week.{unpriced_note}") + elif compare == strip_traces.COMPARE_PRIOR: + money = (f" The hollow ring is where the same index stood " + f"{const.MOMENTUM_PERIOD} weeks ago.") + else: + money = "" return ( f"Positioning as of Tuesday {pretty}, gated on {model.title}, measured over " f"{window}. The lollipop is the COMMERCIAL positioning index, 0-100 — the stem " @@ -148,10 +266,10 @@ def caption(report_date, lookback, model, skipped, hidden=0): f"{tick_note}" f"Its colour is the model's verdict on the whole row, not on its own value, so " f"a small faded lollipop deep in a band is a market at an extreme with another " - f"leg blocking it.{dropped}") + f"leg blocking it.{money}{dropped}") -def legend(model, colors, palette): +def legend(model, colors, palette, compare=strip_traces.COMPARE_PRIOR): """The figure key, rendered as one line of page chrome above both columns. `strip_traces.legend_items` says what the entries are; this only turns them into @@ -162,9 +280,10 @@ def legend(model, colors, palette): strip_traces.GLYPH_MARK: "●", strip_traces.GLYPH_TICK: "│", strip_traces.GLYPH_CIRCLE: "○", + strip_traces.GLYPH_DIAMOND: "◇", } groups = [] - for title, entries in strip_traces.legend_items(model, colors, palette): + for title, entries in strip_traces.legend_items(model, colors, palette, compare): bits = [html.Span(f"{title}:", style={"color": vc.TEXT_COLOR, "marginRight": "0.6rem"})] for label, colour, glyph in entries: @@ -334,6 +453,18 @@ def layout(**kwargs): ) ], xs=12, md=2, className="mb-3 mb-md-0 px-md-2"), + dbc.Col([ + html.Label("Compare", style={**vc.label_style, "fontSize": "0.8rem", "textTransform": "uppercase"}), + dbc.RadioItems( + persistence='session', + id='strip_compare_selector', + options=_options(COMPARE_LABELS), + value=strip_traces.COMPARE_PRIOR, + inline=True, + style={"color": vc.BRIGHTER_TEXT_COLOR, "fontSize": "0.85rem"}, + ) + ], xs=12, md=2, className="mb-3 mb-md-0 px-md-2"), + dbc.Col([ html.Label("Asset Classes", style={**vc.label_style, "fontSize": "0.8rem", "textTransform": "uppercase"}), dbc.Checklist( @@ -502,11 +633,12 @@ def follow_global_lookback(value, current_local_val): Input('strip_show_selector', 'value'), Input('strip_side_selector', 'value'), Input('strip_columns_selector', 'value'), + Input('strip_compare_selector', 'value'), Input('session_palette_theme_asset_store', 'data'), Input('strip_date_selector', 'value')] ) def render_strip(asset_classes, lookback, model_key, sort_by, show, side, columns, - palette_name, target_date): + compare, palette_name, target_date): empty = html.P("Select an asset class to draw the strip.", style={'textAlign': 'center', 'color': vc.TEXT_COLOR}) all_classes = get_indexer().get_asset_classes() @@ -518,11 +650,12 @@ def render_strip(asset_classes, lookback, model_key, sort_by, show, side, column model = models.resolve(model_key) show = show or strip_traces.SHOW_ALL side = side or strip_traces.SIDE_BOTH + compare = compare or strip_traces.COMPARE_PRIOR def summarise(hidden=0, skipped=0): return controls_summary(target_date, model, lookback, sort_by, show, side, columns, len(asset_classes), len(all_classes), - hidden=hidden, skipped=skipped) + hidden=hidden, skipped=skipped, compare=compare) df = get_matrix_data(asset_classes, lookback, target_date) if df.empty: @@ -530,9 +663,18 @@ def summarise(hidden=0, skipped=0): style={'textAlign': 'center', 'color': vc.TEXT_COLOR}), "", [], summarise()) + # Only when the mark is on. The dollar join reads the price store market by + # market, and paying for it on every render of a board that is not drawing it + # would put a second store in the path of a page that otherwise needs one. + dollars = None + if compare == strip_traces.COMPARE_DOLLARS: + available = get_indexer().get_available_dates() + dollars = dollar_table(df, lookback, available[0] if available else None) + rows, skipped = strip_traces.build_rows( df, model, sort_by_index=(sort_by != SORT_ALPHA), - show=show, side=side) + show=show, side=side, dollars=dollars) + disagree, unpriced = strip_traces.dollar_split(rows, model) # What the filters removed, said rather than left to be noticed. The board is the # page's whole claim, so a filtered view that looks like a full one is the one # failure mode worth spending a sentence on. @@ -541,7 +683,8 @@ def summarise(hidden=0, skipped=0): palette = viz_config.get_palette(palette_name) colors = grid_colors(palette) chunks = strip_traces.split_columns(rows, int(columns or 1)) - figures = [strip_traces.build_figure(chunk, model, colors, palette) + figures = [strip_traces.build_figure(chunk, model, colors, palette, + compare=compare) for chunk in chunks] report_date = target_date or df.iloc[0]["Date"] @@ -569,8 +712,9 @@ def summarise(hidden=0, skipped=0): # its rows landed on a ~23px pitch beside the other column's 19px, two visibly # different densities on one board. ], className="g-0", align="start"), - caption(report_date, lookback, model, skipped, hidden), - legend(model, colors, palette), + caption(report_date, lookback, model, skipped, hidden, compare=compare, + disagree=disagree, unpriced=unpriced), + legend(model, colors, palette, compare), summarise(hidden=hidden, skipped=len(skipped)), ) diff --git a/tests/test_strip_copy.py b/tests/test_strip_copy.py index 5c1ceb4..d721dc6 100644 --- a/tests/test_strip_copy.py +++ b/tests/test_strip_copy.py @@ -19,6 +19,7 @@ # is enough and keeps Dash from walking the tree. dash.Dash(__name__, use_pages=True, pages_folder='') +import components.strip_traces as strip_traces # noqa: E402 from pages.analytics.strip import caption, controls_summary # noqa: E402 MODEL = models.RAW_PF @@ -80,3 +81,43 @@ def test_the_summary_is_silent_when_the_board_is_whole(): text = _summary() assert "hidden" not in text assert "no index" not in text + + +# ── the dollar comparison ───────────────────────────────────────────────────── + +def test_the_caption_explains_the_diamond_and_counts_the_disagreement(): + """The comparison is the reason to switch this on, so the sentence says what the + two marks are and how many markets they part on this week.""" + text = caption("2026-08-18", "52", MODEL, set(), + compare=strip_traces.COMPARE_DOLLARS, disagree=12, unpriced=2) + assert "dollars at risk" in text + assert "12 of the drawn markets disagree" in text + assert "2 market(s) cannot be priced" in text + + +def test_a_fully_priced_board_claims_nothing_about_pricing(): + text = caption("2026-08-18", "52", MODEL, set(), + compare=strip_traces.COMPARE_DOLLARS, disagree=3, unpriced=0) + assert "cannot be priced" not in text + + +def test_the_caption_explains_whichever_mark_is_on(): + """Each comparison gets its own sentence, and neither is claimed when the mark is + switched off: the ring and the diamond are different questions about the same + lollipop, and a caption naming one while the other is drawn is worse than silence.""" + ring = caption("2026-08-18", "52", MODEL, set(), + compare=strip_traces.COMPARE_PRIOR) + assert "weeks ago" in ring + assert "dollars at risk" not in ring + + neither = caption("2026-08-18", "52", MODEL, set(), + compare=strip_traces.COMPARE_NONE) + assert "weeks ago" not in neither + assert "dollars at risk" not in neither + + +def test_the_summary_says_which_comparison_is_on(): + """It is a filter on what the board shows, so it belongs on the one line that + survives the controls being folded away.""" + assert "vs Dollars" in _summary(compare=strip_traces.COMPARE_DOLLARS) + assert "vs 6w ago" in _summary(compare=strip_traces.COMPARE_PRIOR) diff --git a/tests/test_strip_traces.py b/tests/test_strip_traces.py index f3a58be..4a991d1 100644 --- a/tests/test_strip_traces.py +++ b/tests/test_strip_traces.py @@ -722,15 +722,22 @@ def test_the_prior_mark_is_the_neutral_colour_the_legend_promises(): assert key == [(COLORS.dim, st.GLYPH_CIRCLE)] -def test_no_drawn_mark_falls_through_to_the_template_colourway(): +@pytest.mark.parametrize("compare", [st.COMPARE_PRIOR, st.COMPARE_DOLLARS, + st.COMPARE_NONE]) +def test_no_drawn_mark_falls_through_to_the_template_colourway(compare): """The general form of the bug above: an unset colour is not an error, it is the - theme's colour, and it only shows up by disagreeing with something else.""" + theme's colour, and it only shows up by disagreeing with something else. + + Run over every comparison, because each one puts different traces on the figure and + the bug is invisible until some other element disagrees with the one that fell + through.""" df = frame( matrix_row("Copper", "Metals", 98, 2, 4, state_cls=const.SETUP_BULL, move=8), matrix_row("Gold", "Metals", 55, 50, 50, move=-3), ) - rows, _ = st.build_rows(df, models.RAW_PF) - fig = st.build_figure(rows, models.RAW_PF, COLORS, PALETTE) + rows, _ = st.build_rows(df, models.RAW_PF, dollars={ + "Copper": st.DollarRead(index=40.0), "Gold": st.DollarRead(index=61.0)}) + fig = st.build_figure(rows, models.RAW_PF, COLORS, PALETTE, compare=compare) for trace in fig.data: # The empty trace that exists only to make Plotly draw the top axis has # nothing to colour. @@ -738,3 +745,176 @@ def test_no_drawn_mark_falls_through_to_the_template_colourway(): continue assert trace.marker.color is not None, ( f"{trace.type}/{trace.marker.symbol} takes its colour from the theme") + + +# ── the dollar comparison ───────────────────────────────────────────────────── +# The same leg over the same window, measured in dollars at risk instead of in +# contracts. What is pinned here is that the second mark is a COMPARISON: it is drawn +# at its own value with a line back to the one it disagrees with, it never appears +# beside the six-weeks-ago ring, and a market that cannot be priced is counted rather +# than left as a row that looks like agreement. + + +def dollars_for(**reads): + return {asset: st.DollarRead(**kwargs) for asset, kwargs in reads.items()} + + +def _diamonds(fig): + return [t for t in fig.data if t.type == "scatter" + and t.marker.symbol == st.DOLLAR_SYMBOL] + + +def _rings(fig): + return [t for t in fig.data if t.type == "scatter" + and t.marker.symbol == "circle-open"] + + +def _wedges(fig): + return [t for t in fig.data if t.type == "scatter" and t.mode == "lines"] + + +def test_the_dollar_mark_sits_at_the_dollar_reading(): + df = frame(matrix_row("Silver", "Metals", 0, 50, 50)) + rows, _ = st.build_rows(df, models.RAW_PF, + dollars=dollars_for(Silver=dict(index=96.0))) + fig = st.build_figure(rows, models.RAW_PF, COLORS, PALETTE, + compare=st.COMPARE_DOLLARS) + assert list(_diamonds(fig)[0].x) == [96.0] + + +def test_the_wedge_runs_from_the_contract_reading_to_the_dollar_one(): + """The gap is the subject, so unlike the prior ring this mark carries a connector. + One trace with None breaks, because Plotly colours a line per trace and per-row + colour would mean one trace per market.""" + df = frame(matrix_row("Silver", "Metals", 0, 50, 50), + matrix_row("Gold", "Metals", 20, 50, 50)) + rows, _ = st.build_rows(df, models.RAW_PF, + dollars=dollars_for(Silver=dict(index=96.0), + Gold=dict(index=63.0))) + fig = st.build_figure(rows, models.RAW_PF, COLORS, PALETTE, + compare=st.COMPARE_DOLLARS) + wedges = _wedges(fig) + assert len(wedges) == 1 + # Gold sorts above Silver (crowding order), so its segment comes first. + assert list(wedges[0].x) == [20, 63.0, None, 0, 96.0, None] + + +def test_only_one_reference_mark_is_drawn_at_a_time(): + """A ring and a diamond a few points apart at ROW_PX are one smudge, and on a quiet + row they are the same colour too. The selector exists so every mark on the row can + be named.""" + df = frame(matrix_row("Silver", "Metals", 30, 50, 50, move=8)) + rows, _ = st.build_rows(df, models.RAW_PF, + dollars=dollars_for(Silver=dict(index=96.0))) + + prior = st.build_figure(rows, models.RAW_PF, COLORS, PALETTE, + compare=st.COMPARE_PRIOR) + assert _rings(prior) and not _diamonds(prior) and not _wedges(prior) + + money = st.build_figure(rows, models.RAW_PF, COLORS, PALETTE, + compare=st.COMPARE_DOLLARS) + assert _diamonds(money) and _wedges(money) and not _rings(money) + + neither = st.build_figure(rows, models.RAW_PF, COLORS, PALETTE, + compare=st.COMPARE_NONE) + assert not _rings(neither) and not _diamonds(neither) and not _wedges(neither) + + +def test_a_market_that_cannot_be_priced_simply_has_no_diamond(): + """No contract multiplier or no bars. The row still draws; the caption counts it.""" + df = frame(matrix_row("MSCI EAFE", "Equity Index", 40, 50, 50), + matrix_row("Gold", "Metals", 90, 50, 50)) + rows, _ = st.build_rows(df, models.RAW_PF, + dollars=dollars_for(Gold=dict(index=63.0))) + fig = st.build_figure(rows, models.RAW_PF, COLORS, PALETTE, + compare=st.COMPARE_DOLLARS) + assert list(_diamonds(fig)[0].x) == [63.0] + assert st.dollar_split(rows, models.RAW_PF)[1] == 1 + + +def test_the_dollar_mark_takes_its_rows_tier_rather_than_a_colour_of_its_own(): + """Colour on this figure means verdict, and the palette has no free slot for a + third meaning anyway: slot 3 is Price AND the bull colour, slot 0 is Commercials + AND the bear colour.""" + df = frame(matrix_row("Copper", "Metals", 98, 2, 4, state_cls=const.SETUP_BULL), + matrix_row("Gold", "Metals", 55, 50, 50)) + rows, _ = st.build_rows(df, models.RAW_PF, + dollars=dollars_for(Copper=dict(index=70.0), + Gold=dict(index=40.0))) + fig = st.build_figure(rows, models.RAW_PF, COLORS, PALETTE, + compare=st.COMPARE_DOLLARS) + assert list(_diamonds(fig)[0].marker.color) == [COLORS.bull, COLORS.dim] + + +def test_disagreement_is_counted_on_the_gate_bands_not_on_a_gap_in_points(): + """The bands are where this page's decisions are made. Contracts at 98 against + dollars at 90 is a wide gap the model answers the same way twice; 96 against 94 + under RAW PF straddles the line.""" + df = frame(matrix_row("Wide", "Metals", 98, 50, 50), + matrix_row("Straddle", "Metals", 96, 50, 50), + matrix_row("Quiet", "Metals", 50, 50, 50)) + rows, _ = st.build_rows(df, models.RAW_PF, + dollars=dollars_for(Wide=dict(index=96.0), + Straddle=dict(index=94.0), + Quiet=dict(index=51.0))) + assert st.dollar_split(rows, models.RAW_PF) == (1, 0) + + +def test_the_two_ends_of_the_axis_are_not_the_same_band(): + """Silver on 2026-08-18: the bottom of its contract range and 96 on dollars at + risk. Asking only whether each reading is through A gate scores the sharpest + disagreement the board can carry as agreement, because both are extremes.""" + df = frame(matrix_row("Silver", "Metals", 0, 50, 50)) + rows, _ = st.build_rows(df, models.RAW_PF, + dollars=dollars_for(Silver=dict(index=96.0))) + assert st.dollar_split(rows, models.RAW_PF) == (1, 0) + assert st.band_of(0, models.RAW_PF) == -1 + assert st.band_of(96, models.RAW_PF) == 1 + assert st.band_of(50, models.RAW_PF) == 0 + + +def test_the_legend_names_the_comparison_actually_on_screen(): + """A key for a mark the figure is not drawing is worse than no key: the legend is + where a reader goes to find out what they are looking at.""" + def labels(compare): + return [label + for _, entries in st.legend_items(models.RAW_PF, COLORS, PALETTE, + compare) + for label, _, _ in entries] + + assert any(label.endswith("w ago") for label in labels(st.COMPARE_PRIOR)) + assert "Same, in $ at risk" not in labels(st.COMPARE_PRIOR) + + money = labels(st.COMPARE_DOLLARS) + assert "Same, in $ at risk" in money + assert not any(label.endswith("w ago") for label in money) + + none = labels(st.COMPARE_NONE) + assert "Same, in $ at risk" not in none + assert not any(label.endswith("w ago") for label in none) + + +def test_the_hover_carries_the_dollar_figures_only_when_they_are_drawn(): + df = frame(matrix_row("Silver", "Metals", 0, 50, 50)) + rows, _ = st.build_rows(df, models.RAW_PF, dollars=dollars_for( + Silver=dict(index=96.0, risk_usd=-384_800_000.0, notional_index=57.7, + sigma_daily=0.027, weeks=24))) + market = [r for r in rows if r.kind == "market"][0] + + money = st._hover(market, models.RAW_PF, st.COMPARE_DOLLARS) + assert "In dollars at risk (24w): 96" in money + assert "-$384.8m" in money + # The notional reading is hover-only: over a rolling window it is very nearly the + # contract count again, so a second mark for it would sit on the first and say + # nothing, but it is what the printed reports plot. + assert "Same, on notional: 58" in money + assert "Daily vol: 2.7%" in money + + assert "dollars at risk" not in st._hover(market, models.RAW_PF, st.COMPARE_PRIOR) + + +def test_an_unpriceable_market_says_so_on_its_own_hover(): + df = frame(matrix_row("MSCI EAFE", "Equity Index", 40, 50, 50)) + rows, _ = st.build_rows(df, models.RAW_PF, dollars=None) + market = [r for r in rows if r.kind == "market"][0] + assert "No dollar reading" in st._hover(market, models.RAW_PF, st.COMPARE_DOLLARS) From 4889b578e48ae4d6fbd2e897edfa664be029fd7e Mon Sep 17 00:00:00 2001 From: Matt Spinola Date: Mon, 24 Aug 2026 16:49:46 -0400 Subject: [PATCH 2/3] Say "index" on the dollar mark, in every place it is named The first reader of the new mark asked whether the diamond was a percentile. It is not: it is the same range index the lollipop beside it carries, position between the window's min and max, computed on dollar risk instead of on contracts. Holding the statistic and the window fixed is what makes the pair a comparison of UNITS, and the percentile version of the question is the /exposure page's. The word now appears where the mark is, not only in the design doc: "Same index in $ at risk (24w)" and "Same index on notional" in the hover, "Same index, in $ at risk" in the legend key, and a caption that says a range position and not a percentile in as many words. The caption also drops "through a gate band" for "which band they are in", which is what the three-band count actually measures. Co-Authored-By: Claude Opus 5 --- docs/design/positioning-report-view-ideas.md | 13 +++++++++++++ src/components/strip_traces.py | 11 ++++++++--- src/pages/analytics/strip.py | 13 +++++++------ tests/test_strip_copy.py | 3 +++ tests/test_strip_traces.py | 12 ++++++------ 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/docs/design/positioning-report-view-ideas.md b/docs/design/positioning-report-view-ideas.md index 5aa9467..40c087d 100644 --- a/docs/design/positioning-report-view-ideas.md +++ b/docs/design/positioning-report-view-ideas.md @@ -910,6 +910,19 @@ ENTIRE history, where notional carries the price level and therefore says a grea ranks against a rolling window, which renormalizes exactly that away. The all-history version of the question already has a home on `/exposure`, and its expanding percentile is what answers it. +**A range index, not a percentile, and the difference is the whole reason the two marks +can share an axis.** The dollar mark is `calculate_range_index` on the dollar series: position +between the window's min and max, which is exactly what the lollipop beside it measures on +contracts. A percentile rank over the same window is a different statistic, set by the whole +distribution rather than by two observations, and drawing one against the other would vary the +STATISTIC and the UNIT at once, leaving a reader unable to say which difference they were looking +at. This document already warns about that confusion in the other direction ("Whichever we draw, +label it as what it is") and the page had to learn it again the hard way: the first reader of the +new mark asked whether the diamond was a percentile, so the word "index" now appears on the hover, +in the legend key and in the caption rather than only here. The percentile version of this question +is the `/exposure` page's, where the rank is expanding or windowed and the units are dollars +throughout. + **One reference mark per row, chosen by a control, rather than a fifth mark.** A row already carries the head, its stem and a tick per gated leg. The two comparisons worth putting beside them are the same shape of question, "this leg measured differently", and they compete for the same few diff --git a/src/components/strip_traces.py b/src/components/strip_traces.py index e578375..be7c5a0 100644 --- a/src/components/strip_traces.py +++ b/src/components/strip_traces.py @@ -687,10 +687,15 @@ def _dollar_hover(row): return "
No dollar reading: no contract multiplier or no bars." window = f" ({d.weeks}w)" if d.weeks else "" notional = ("" if d.notional_index is None - else f"
Same, on notional: {d.notional_index:.0f}") + else f"
Same index on notional: {d.notional_index:.0f}") sigma = ("" if d.sigma_daily is None else f"
Daily vol: {d.sigma_daily * 100:.1f}%") - return (f"

In dollars at risk{window}: {d.index:.0f}" + # "Index", named every time it appears. This is the SAME range index the head + # carries (position between the window's min and max), computed on a dollar series; + # it is not a percentile rank, which is what /exposure draws and what the printed + # reports print. The first reader of this mark asked which one it was, so the word + # goes on the mark rather than only in the caption. + return (f"

Same index in $ at risk{window}: {d.index:.0f}" f"
Level: {_money(d.risk_usd)}{notional}{sigma}") @@ -750,7 +755,7 @@ def legend_items(model, colors, palette, compare=COMPARE_PRIOR): # one place a reader goes to find out what they are looking at. reference = { COMPARE_PRIOR: [(f"{const.MOMENTUM_PERIOD}w ago", colors.dim, GLYPH_CIRCLE)], - COMPARE_DOLLARS: [("Same, in $ at risk", colors.dim, GLYPH_DIAMOND)], + COMPARE_DOLLARS: [("Same index, in $ at risk", colors.dim, GLYPH_DIAMOND)], }.get(compare, []) # The neutral keys are as dim as the marks they stand for: a full-strength # "No setup" swatch would promise a colour the plot never draws. diff --git a/src/pages/analytics/strip.py b/src/pages/analytics/strip.py index 40c83da..ef301fb 100644 --- a/src/pages/analytics/strip.py +++ b/src/pages/analytics/strip.py @@ -247,12 +247,13 @@ def caption(report_date, lookback, model, skipped, hidden=0, f"multiplier or no bars) and carry no diamond." if unpriced else "") money = ( - f" The hollow diamond is the SAME leg over the SAME window measured in " - f"dollars at risk (contracts x point value x price x daily volatility) " - f"rather than in contracts, with a line back to the contract reading: " - f"where the two part, the crowd's money and its contract count disagree " - f"about how extreme this market is. {disagree} of the drawn markets " - f"disagree about being through a gate band this week.{unpriced_note}") + f" The hollow diamond is this SAME 0-100 index, over the same window and " + f"on the same leg, computed on dollars at risk (contracts x point value x " + f"price x daily volatility) instead of on contracts: a range position like " + f"the lollipop, not a percentile. The line runs back to the contract " + f"reading, and where the two part, the crowd's money and its contract " + f"count disagree about how extreme this market is. {disagree} of the drawn " + f"markets disagree about which band they are in this week.{unpriced_note}") elif compare == strip_traces.COMPARE_PRIOR: money = (f" The hollow ring is where the same index stood " f"{const.MOMENTUM_PERIOD} weeks ago.") diff --git a/tests/test_strip_copy.py b/tests/test_strip_copy.py index d721dc6..3c8caca 100644 --- a/tests/test_strip_copy.py +++ b/tests/test_strip_copy.py @@ -91,6 +91,9 @@ def test_the_caption_explains_the_diamond_and_counts_the_disagreement(): text = caption("2026-08-18", "52", MODEL, set(), compare=strip_traces.COMPARE_DOLLARS, disagree=12, unpriced=2) assert "dollars at risk" in text + # The statistic, named in the caption too: the first reader of this mark asked + # whether the diamond was a percentile. It is a range position, like the lollipop. + assert "not a percentile" in text assert "12 of the drawn markets disagree" in text assert "2 market(s) cannot be priced" in text diff --git a/tests/test_strip_traces.py b/tests/test_strip_traces.py index 4a991d1..aaea396 100644 --- a/tests/test_strip_traces.py +++ b/tests/test_strip_traces.py @@ -883,14 +883,14 @@ def labels(compare): for label, _, _ in entries] assert any(label.endswith("w ago") for label in labels(st.COMPARE_PRIOR)) - assert "Same, in $ at risk" not in labels(st.COMPARE_PRIOR) + assert "Same index, in $ at risk" not in labels(st.COMPARE_PRIOR) money = labels(st.COMPARE_DOLLARS) - assert "Same, in $ at risk" in money + assert "Same index, in $ at risk" in money assert not any(label.endswith("w ago") for label in money) none = labels(st.COMPARE_NONE) - assert "Same, in $ at risk" not in none + assert "Same index, in $ at risk" not in none assert not any(label.endswith("w ago") for label in none) @@ -902,15 +902,15 @@ def test_the_hover_carries_the_dollar_figures_only_when_they_are_drawn(): market = [r for r in rows if r.kind == "market"][0] money = st._hover(market, models.RAW_PF, st.COMPARE_DOLLARS) - assert "In dollars at risk (24w): 96" in money + assert "Same index in $ at risk (24w): 96" in money assert "-$384.8m" in money # The notional reading is hover-only: over a rolling window it is very nearly the # contract count again, so a second mark for it would sit on the first and say # nothing, but it is what the printed reports plot. - assert "Same, on notional: 58" in money + assert "Same index on notional: 58" in money assert "Daily vol: 2.7%" in money - assert "dollars at risk" not in st._hover(market, models.RAW_PF, st.COMPARE_PRIOR) + assert "in $ at risk" not in st._hover(market, models.RAW_PF, st.COMPARE_PRIOR) def test_an_unpriceable_market_says_so_on_its_own_hover(): From 7a15d1a54c0b9b39fe6bfd2ad0b843daad4e4126 Mon Sep 17 00:00:00 2001 From: Matt Spinola Date: Mon, 24 Aug 2026 17:54:34 -0400 Subject: [PATCH 3/3] Credit the other route to the open-interest identity The share-of-open-interest cancellation this measured from the contracts side was frozen independently as P5 of the pre-registration behind cotmetrics 0.8.0 and is asserted in that package's tests from the NUMERAIRE side: a share must not move when the Gold switch does. That is the half worth asserting in code, because it is the half that can break, and it did once, in a cut that deflated the position columns and not the open-interest ones so the share carried 1/gold. The residual reported here is floating-point division rather than a measurement, and the script applies no numeraire, so it cannot break that way. Co-Authored-By: Claude Opus 5 --- docs/analysis/2026-08-24-contracts-against-dollars.md | 10 +++++++++- docs/design/positioning-report-view-ideas.md | 8 +++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/analysis/2026-08-24-contracts-against-dollars.md b/docs/analysis/2026-08-24-contracts-against-dollars.md index 7c27894..86745d7 100644 --- a/docs/analysis/2026-08-24-contracts-against-dollars.md +++ b/docs/analysis/2026-08-24-contracts-against-dollars.md @@ -118,7 +118,15 @@ Across all 45 priceable markets and every week in the store, the position's shar computed in contracts and computed in dollars at risk differ by at most **2.2e-16**. This is algebra rather than a finding (the point value, the price and the volatility all cancel -between numerator and denominator), and it decides something concrete: **there is no dollar +between numerator and denominator, so the residual above is floating-point division and not a +measurement of anything), and the same identity was frozen independently as P5 of the pre-registration +behind `cotmetrics` 0.8.0 and is asserted in that package's own tests, from the numeraire side: +a share does not move when the Gold switch does. That is the side worth asserting in code, because +it is the side that can break. It did break once, in a first cut that deflated the position columns +and not the open-interest ones, so the share carried 1/gold. The version measured here cannot break +that way, because nothing in this script applies a numeraire. + +It decides something concrete: **there is no dollar version of the NPF basis to draw.** The dollar lens is inherently a LEVEL lens, so under NPF the mark compares two different normalizations, and the caption has to say the window and the unit rather than pretend only one thing changed. diff --git a/docs/design/positioning-report-view-ideas.md b/docs/design/positioning-report-view-ideas.md index 40c087d..0285793 100644 --- a/docs/design/positioning-report-view-ideas.md +++ b/docs/design/positioning-report-view-ideas.md @@ -977,9 +977,11 @@ app wants one yet. The moment a second surface does, it moves to `cotmetrics.exp **One thing that fell out of the arithmetic and constrains what can ever be drawn here.** A position's share of open interest is the same number in contracts and in dollars, to 2.2e-16 across the universe, because the point value, the price and the volatility cancel between the -position and the market it sits in. So there is no dollar version of the NPF basis: the dollar -lens is inherently a LEVEL lens, and under NPF the two marks differ in normalizer as well as in -unit. That is the one place this comparison is not a controlled one, it is stated in the caption +position and the market it sits in. The same identity was frozen independently as P5 of the +pre-registration behind `cotmetrics` 0.8.0, where it is asserted from the numeraire side (a share +must not move when the Gold switch does), which is the side that can actually break and did once. +So there is no dollar version of the NPF basis: the dollar lens is inherently a LEVEL lens, and +under NPF the two marks differ in normalizer as well as in unit. That is the one place this comparison is not a controlled one, it is stated in the caption rather than hidden, and it is also why the NPF disagreement rate (20.7% of weeks) is the largest of the three measured.