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..86745d7 --- /dev/null +++ b/docs/analysis/2026-08-24-contracts-against-dollars.md @@ -0,0 +1,156 @@ +# 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, 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. + +## 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..0285793 100644 --- a/docs/design/positioning-report-view-ideas.md +++ b/docs/design/positioning-report-view-ideas.md @@ -880,3 +880,113 @@ 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. + +**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 +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. 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. + +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..be7c5a0 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,46 @@ 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 index on notional: {d.notional_index:.0f}") + sigma = ("" if d.sigma_daily is None + else f"
Daily vol: {d.sigma_daily * 100:.1f}%") + # "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}") + + +def _hover(row, model, compare=COMPARE_PRIOR): legs = "".join( f"
{LEG_LABELS[leg]}: {value:.0f}" f"{' (through its gate)' if gate else ''}" @@ -546,8 +707,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 +730,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 +750,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 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. return [ @@ -592,15 +764,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 +881,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 +891,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 +908,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..ef301fb 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,26 @@ 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 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.") + 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 +267,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 +281,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 +454,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 +634,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 +651,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 +664,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 +684,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 +713,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..3c8caca 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,46 @@ 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 + # 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 + + +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..aaea396 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 index, in $ at risk" not in labels(st.COMPARE_PRIOR) + + money = labels(st.COMPARE_DOLLARS) + 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 index, 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 "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 index on notional: 58" in money + assert "Daily vol: 2.7%" in money + + 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(): + 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)