From db67f6265304c21d8da4b9d008788f1174da8011 Mon Sep 17 00:00:00 2001 From: Matt Spinola Date: Mon, 24 Aug 2026 12:58:26 -0400 Subject: [PATCH 1/4] Exposure: the gold copy stops quoting one week as if it were the effect The help text said the crowd sits "at the 98th percentile of their own history in dollars and the 67th in ounces", a 31 point gap. That was measured and true when written, and it is a reading that moves every Tuesday, so static copy quoting it rots in silence. Against the pinned store on 2026-08-24 the same pair reads 97.0 and 73.1, so 23.9 points. It was also the widest week standing in for the effect. Gold's median displacement on this composite is about 6 points and the ninetieth about 14, so the sentence oversold the switch by roughly four times for a typical reader. The copy now states the distribution, which moves slowly, rather than one week, which does not survive a fortnight. The tooltip's drift figures go the same way for a different reason. It carried "Equities 4.2x to 1.3x since 2002", inherited from a comment in cotmetrics.exposure that records no leg, no unit, no membership and no date range, so nothing can reproduce it and this page's own default membership is four markets rather than the whole class. Replaced with figures measured under the page's OWN defaults: 3.2x in dollars against 0.8x in gold. Every figure here comes from npf's exposure-numeraire study, which ran against pinned stores and left its JSON beside its write-up: npf/docs/analysis/2026-08-24-exposure-numeraire-levels.md. The test that pinned "98th percentile" is replaced rather than updated. Asserting a moving number's presence is what let it rot for as long as it did, so the new test asserts the opposite: that no live percentile appears in this copy, and that the distributional framing is there. The live reading belongs in the headline above the chart, and already is one. Nothing about what the switch DOES changes, and the WillVal credit, the not-an-inflation- adjustment framing and the circularity caveat are all untouched. Co-Authored-By: Claude Opus 5 --- src/pages/analytics/exposure.py | 28 ++++++++++++++++++++++++---- tests/test_exposure_copy.py | 24 +++++++++++++++++++++++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/pages/analytics/exposure.py b/src/pages/analytics/exposure.py index 1e673bd..cb57522 100644 --- a/src/pages/analytics/exposure.py +++ b/src/pages/analytics/exposure.py @@ -558,6 +558,23 @@ def composition_line(agg, unit, leg, part_frames=None, when=None, def how_to_read(unit): """What each part of the picture is for, in the order a reader meets it. + **Do not put a live percentile in this copy.** The Gold switch entry used to read + "on the current week those speculators sit at the 98th percentile of their own + history in dollars and the 67th in ounces", which was accurate when written and is + a moving number frozen into static text, so it rots every Tuesday. Measured against + the pinned store on 2026-08-24 the same pair was 97.0 and 73.1, a 23.9 point gap + rather than 31, and the sentence had been quoting one dramatic week as though it + were the effect. The figures here are distributional (median, ninetieth, widest) + because those move slowly, and they are reproducible: dollar risk, speculators, the + four-market default composite, from npf's exposure-numeraire study, + `npf/docs/analysis/2026-08-24-exposure-numeraire-levels.md`. The headline above the + chart is where a live reading belongs, and it already is one. + + The same study is why the tooltip's drift figures changed. It carried "Equities + 4.2x to 1.3x since 2002", inherited from a comment in `cotmetrics.exposure` that + records no leg, no unit, no membership and no date range for it, so nothing could + reproduce it. The replacements are measured under this page's OWN defaults. + Written as "what you learn" rather than "what it is". A legend saying "expanding 10th to 90th percentile" is accurate and answers a question nobody asked; what a reader wants is that the band is where the line normally sits, so a value outside @@ -604,8 +621,10 @@ def how_to_read(unit): "Larry Williams' WillVal applied to a whole complex: an asset measured against " "hard money rather than against a currency. Since 2002 the US equity composite " "is up 13.9 times in dollars and 1.0 times in gold. It changes the reading and " - "not just the axis: on the current week those speculators sit at the 98th " - "percentile of their own history in dollars and the 67th in ounces."), + "not just the axis, though by less than any single week suggests: across the " + "whole history of this composite the switch moves the percentile by about 6 " + "points in the median week, 14 at the ninetieth, and into the mid-20s at its " + "widest."), ("What gold is here", "A hard-money benchmark: a second asset the first is being measured against, " "not a fixed ruler. It has run 6.6% a year since 1978 at 19% volatility and " @@ -937,8 +956,9 @@ def layout(**kwargs): dbc.Tooltip( "Divide by the gold price, so the series is in troy ounces " "rather than dollars. Dollar figures carry the price level; " - "gold removes most of that drift (Equities 4.2x to 1.3x " - "since 2002). Gold is an asset, not a ruler, and gold " + "gold removes most of that drift (the equity composite's " + "late history runs 3.2x its early history in dollars and " + "0.8x in gold). Gold is an asset, not a ruler, and gold " "itself in gold terms is just its contract count.", target='exposure_gold_toggle', placement="bottom"), ], xs=12, md=2, className="px-md-2 mt-2 mt-md-0"), diff --git a/tests/test_exposure_copy.py b/tests/test_exposure_copy.py index 1f7874b..1b37bf0 100644 --- a/tests/test_exposure_copy.py +++ b/tests/test_exposure_copy.py @@ -734,7 +734,29 @@ def test_the_explanation_credits_the_idea_it_borrows(): def test_the_explanation_keeps_the_circularity_caveat(): body = " ".join(b for _, b in how_to_read(et.UNIT_RISK)) assert "self-referential" in body - assert "98th percentile" in body + + +def test_the_gold_explanation_does_not_hardcode_a_live_percentile(): + """This test replaces an assertion that pinned the stale number itself. + + The copy used to say the crowd sits "at the 98th percentile of their own history in + dollars and the 67th in ounces", and this test asserted that phrase was present. It + was measured and true when written, and it is a reading that moves every Tuesday, so + static copy quoting it rots in silence and a test pinning it locks the rot in. + Measured against the pinned store on 2026-08-24 the same pair read 97.0 and 73.1, a + 23.9 point gap rather than 31. + + It was also the widest week standing in for the effect: the median week moves about 6 + points and the ninetieth about 14. The copy now states the distribution, which moves + slowly, and carries a reproducer. See + `npf/docs/analysis/2026-08-24-exposure-numeraire-levels.md`. + + The live reading belongs in the headline above the chart, which already is one. + """ + body = " ".join(b for _, b in how_to_read(et.UNIT_RISK)) + assert "median week" in body + for stale in ("98th percentile", "67th in ounces"): + assert stale not in body # ── one market is a reading, not a degenerate set ───────────────────────────── From f45a241921f033f42c9f6bc917848942ee7c2f5c Mon Sep 17 00:00:00 2001 From: Matt Spinola Date: Mon, 24 Aug 2026 13:09:13 -0400 Subject: [PATCH 2/4] Exposure: a Crowding switch, so the line can say how much of the market this is Divides by the same set's own open interest, so the chart reads as the share of the market a group holds rather than the money it has at stake. cotmetrics 0.8.0 computes it; this is the control. Earned by measurement, not proposed as an idea. npf/docs/analysis/2026-08-24-exposure-numeraire-levels.md scored four ways of reading this page's level series against each other on 43 markets: dollars, CPI-deflated dollars, gold, and this. It moves a reader's percentile by 10 points or more, or flips the headline band on 10% of weeks, on 7 of 9 asset classes on both units. CPI managed 0 of 9, which is why there is no CPI switch here and will not be one. It is the only control on the page that removes market GROWTH rather than the price level, which is all a deflator can remove. On the drift this page exists to fight it is the strongest of the three: Metals runs 24.4 times its early history in dollars and 1.8 in share, Fixed Income 14.1 and 1.0. The switch is a change of COLUMN, not a second code path. `unit` is the column name everything downstream already resolves against, so the share columns join the same UNIT_LABELS / UNIT_RANK_COLUMN / UNIT_NOTES maps and swapping the name gives the chart, the headline, the rank, the band and the axis their share versions at once. Three things follow from a share being a share, each tested. It prints as a percentage with no currency mark and no ounces, because a ratio of two quantities in the same unit has no denomination. It reads identically with Gold on or off, verified through the headline rather than asserted. And it never rescales: `unit_scale` exists so an axis reads $55bn instead of 55,387,601,984, and a half is not "0.5k". The contribution table deliberately does NOT follow the chart. Per-market shares do not add up to the set's share the way dollars do, so it stays in dollars while the switch is on and the copy says so, rather than leaving two panels in different units for a reader to discover by misreading them. Two limits are in the copy rather than in a doc nobody opens. It adds almost nothing on Softs and Currencies, so it is a switch and not a new default. And it answers how crowded relative to the market and NOT how much money is at stake, so a set can grow its share while cutting its position if the market shrank faster. Verified against the real store: Metals speculators sit at 51.1% of that complex's open-interest risk, the 98th percentile of their own history, where the same week in dollars reads the 99th. Co-Authored-By: Claude Opus 5 --- pyproject.toml | 9 +++- src/components/exposure_traces.py | 28 ++++++++++++ src/pages/analytics/exposure.py | 74 +++++++++++++++++++++++++++---- tests/test_exposure_copy.py | 62 +++++++++++++++++++++++++- 4 files changed, 160 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1585fdc..c46cf87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,12 @@ dependencies = [ # 0.2.0 introduced. Against 0.1.0 cotmetrics raises rather than silently # pricing the Russell's pre-2016 weeks at half their true notional. "crucible-marketdata>=0.2.0", - # Floor is 0.7.0 because the heatmap's Offside column imports + # Floor is 0.8.0 because the Crowding switch reads `notional_oi_share`, + # `risk_oi_share` and their percentile columns. Against 0.7.0 those columns + # are absent, so the switch produces a KeyError on the frame rather than a + # degraded chart, and it does so on the first render after anyone flips it. + # + # 0.7.0 was the floor because the heatmap's Offside column imports # `cotmetrics.offside`. Against 0.6.0 the module does not exist, and since # `use_pages` imports every page module at startup, that ImportError takes # down the whole route registry rather than one column. @@ -56,7 +61,7 @@ dependencies = [ # volatility panel draws: absent, the figure falls back to three panels and # nothing says why, which is the quiet kind of wrong a floor exists to # prevent. All three reasons stand; the higher number covers them. - "cotmetrics[options]>=0.7.0", + "cotmetrics[options]>=0.8.0", ] [project.optional-dependencies] diff --git a/src/components/exposure_traces.py b/src/components/exposure_traces.py index d9bd8f4..a43a4bf 100644 --- a/src/components/exposure_traces.py +++ b/src/components/exposure_traces.py @@ -41,19 +41,43 @@ UNIT_NOTIONAL = "notional_usd" UNIT_RISK = "risk_usd" +#: The same two quantities as a share of the market's OWN open interest, which is a +#: BASIS and not a unit: it answers "how much of this market does the set hold" where the +#: dollar columns answer "how much money is at stake". Kept in the same maps rather than +#: given a parallel set, so every caller that resolves a column, a rank, a label or a +#: note has exactly one code path and the Crowding switch is a change of column name. +UNIT_NOTIONAL_SHARE = "notional_oi_share" +UNIT_RISK_SHARE = "risk_oi_share" + +#: dollar column -> its share counterpart. The Crowding switch is this lookup. +SHARE_OF = { + UNIT_NOTIONAL: UNIT_NOTIONAL_SHARE, + UNIT_RISK: UNIT_RISK_SHARE, +} +SHARE_UNITS = frozenset(SHARE_OF.values()) + UNIT_LABELS = { UNIT_NOTIONAL: "USD notional", UNIT_RISK: "USD daily risk", + UNIT_NOTIONAL_SHARE: "share of open interest", + UNIT_RISK_SHARE: "share of open-interest risk", } UNIT_RANK_COLUMN = { UNIT_NOTIONAL: "notional_pct_rank", UNIT_RISK: "risk_pct_rank", + UNIT_NOTIONAL_SHARE: "notional_oi_share_pct_rank", + UNIT_RISK_SHARE: "risk_oi_share_pct_rank", } UNIT_NOTES = { UNIT_NOTIONAL: ("contracts x point value x price. Summable across markets, but not " "comparable between them: a bigger market carries bigger numbers."), UNIT_RISK: ("notional x daily volatility. The unit to compare on, and the one a " "vol-targeting book holds constant while it sits at its target."), + UNIT_NOTIONAL_SHARE: ("the set's notional over the same set's open interest. Carries " + "no price level and no market growth, so a 2005 reading and a " + "2026 one mean the same thing."), + UNIT_RISK_SHARE: ("the same share, with both sides weighted by volatility. It is a " + "share, not a share times a volatility: the sigma cancels."), } #: Percentiles drawn as the extreme envelope. Deliberately the same 10/90 the rest of @@ -243,6 +267,10 @@ def unit_scale(values): same page draws equity-index notional in tens of billions and a single soft in tens of millions, and a hard-coded unit makes one of the two unreadable. """ + # A share is already a small dimensionless number and scaling it would produce + # "0.5k" for a half. It is printed as a percentage instead, so it never scales. + if getattr(values, "name", None) in SHARE_UNITS: + return 1.0, "" peak = max((abs(v) for v in values if v == v), default=0.0) # `>= 10 x divisor`, not `>= divisor`, so the axis carries at least two digits. A # billion-dollar peak in billions is an axis labelled 0, 0.5, 1; in millions it is diff --git a/src/pages/analytics/exposure.py b/src/pages/analytics/exposure.py index cb57522..10968c3 100644 --- a/src/pages/analytics/exposure.py +++ b/src/pages/analytics/exposure.py @@ -262,7 +262,7 @@ def subject_noun(single, possessive=False): return noun + "'s" if possessive else noun -def money(value, suffix, numeraire=None): +def money(value, suffix, numeraire=None, unit=None): """A magnitude with its unit attached, in whichever numeraire is on. One function because five places print one of these and a page that said "$" on a @@ -270,6 +270,11 @@ def money(value, suffix, numeraire=None): Sign is left to the caller: every one of them says "net long" or "net short" in words beside it, and a minus sign as well would be the same fact twice. """ + if unit in exposure_traces.SHARE_UNITS: + # No currency mark and no ounces. A share is a ratio of two quantities in the + # same unit, so it is the same number under either numeraire, and stamping it + # with one would claim a denomination it does not have. + return f"{abs(value) * 100:,.1f}% of open interest" magnitude = f"{abs(value):,.1f}{suffix}" if numeraire == exposure.NUMERAIRE_GOLD: return f"{magnitude} oz" @@ -279,6 +284,8 @@ def money(value, suffix, numeraire=None): def unit_name(unit, numeraire=None): """The unit in PROSE: "USD daily risk", or "daily risk, in troy ounces of gold".""" label = exposure_traces.UNIT_LABELS[unit] + if unit in exposure_traces.SHARE_UNITS: + return label if numeraire == exposure.NUMERAIRE_GOLD: return label.replace("USD ", "") + ", in troy ounces of gold" return label @@ -288,6 +295,8 @@ def column_name(unit, suffix, numeraire=None): """The same unit as a COLUMN HEADER, which has about twenty characters rather than a sentence. "Daily risk (k oz)", not "daily risk, in troy ounces of gold (k)".""" label = exposure_traces.UNIT_LABELS[unit] + if unit in exposure_traces.SHARE_UNITS: + return label.capitalize() if numeraire == exposure.NUMERAIRE_GOLD: stem = label.replace("USD ", "").capitalize() return f"{stem} ({suffix} oz)".replace("( ", "(") @@ -353,7 +362,7 @@ def headline(frame, unit, leg, when=None, numeraire=None, single=False, divisor, suffix = exposure_traces.unit_scale(frame[unit]) value = row[unit] / divisor side = "long" if value >= 0 else "short" - amount = money(value, suffix, numeraire) + amount = money(value, suffix, numeraire, unit) who = exposure.LEG_LABELS[leg] if rank != rank: @@ -625,6 +634,22 @@ def how_to_read(unit): "whole history of this composite the switch moves the percentile by about 6 " "points in the median week, 14 at the ninetieth, and into the mid-20s at its " "widest."), + ("The Crowding switch", + "Divides by the same set's own open interest, so the line is the share of the " + "market this group holds rather than the money it has at stake. It is the only " + "control here that removes market GROWTH rather than the price level, which is " + "what a deflator removes, and on the drift this page exists to fight it is the " + "strongest of the three: Metals runs 24.4 times its early history in dollars " + "and 1.8 in share, Fixed Income 14.1 and 1.0. It is a share, so it reads the " + "same with Gold on or off."), + ("Where Crowding does not help", + "Softs and Currencies, where it changes almost no reading, so it is not a " + "strict improvement on dollars and the switch is a switch rather than a " + "default. Two more things to hold. It answers how crowded relative to the " + "market and NOT how much money is at stake, so a set can grow its share while " + "cutting its position if the market shrank faster. And the contribution table " + "below stays in dollars while this is on, because per-market shares do not add " + "up to the set's share the way dollars do."), ("What gold is here", "A hard-money benchmark: a second asset the first is being measured against, " "not a fixed ruler. It has run 6.6% a year since 1978 at 19% volatility and " @@ -821,7 +846,7 @@ def caption(frame, unit, leg, when=None, numeraire=None, single=False, rank_text = (f"the {ordinal(rank)} percentile of {window_phrase(window)}" if rank == rank else "no percentile yet, under two years of history") return ( - f"{exposure.LEG_LABELS[leg]} are {side} {money(value, suffix, numeraire)} " + f"{exposure.LEG_LABELS[leg]} are {side} {money(value, suffix, numeraire, unit)} " f"({unit_name(unit, numeraire)}) as of {row.name:%B %d, %Y}, " f"which is {rank_text}. {exposure_traces.UNIT_NOTES[unit]} " + (f"Lookback: {window_note}. " if window_note else "") @@ -947,6 +972,18 @@ def layout(**kwargs): inline=True, className="me-3", style={"color": vc.BRIGHTER_TEXT_COLOR, "fontSize": "0.85rem"}), + dbc.Switch(id='exposure_crowding_toggle', label="Crowding", + persistence='session', + value=False, className="mb-0 me-3"), + dbc.Tooltip( + "Divide by the same set's own open interest, so the line " + "is the share of the market it holds rather than the " + "money at stake. Removes market GROWTH, which no " + "deflator does: Metals drift 24.4x to 1.8x. It adds " + "nothing on Softs or Currencies, and the contribution " + "table below stays in dollars, because shares do not " + "add.", + target='exposure_crowding_toggle', placement="bottom"), dbc.Switch(id='exposure_gold_toggle', label="Gold", persistence='session', value=False, className="mb-0", @@ -1067,6 +1104,7 @@ def _default_names(asset_classes): def describe_week(agg, part_frames, unit, leg, palette, when=None, ranks=None, + dollar_unit=None, window=None, window_note=""): numeraire = getattr(agg, "numeraire", None) """Everything the page says about ONE week, in one place. @@ -1082,7 +1120,10 @@ def describe_week(agg, part_frames, unit, leg, palette, when=None, ranks=None, table = exposure.contribution_table(agg.members, when=stamp, min_rank_periods=exposure_traces.MIN_RANK_PERIODS) - bars = contribution_grid(table, unit, palette, leg, numeraire) + # The table is per-market contributions that SUM to the total, and shares do not + # sum. So it stays in dollars while the chart above it is a share, and the copy + # says so rather than leaving a reader to notice the units disagree. + bars = contribution_grid(table, dollar_unit or unit, palette, leg, numeraire) label = ("" if not len(table) else f"Week of {shown:%B %d, %Y}, against this market's own history." if len(table) == 1 else @@ -1193,14 +1234,23 @@ def apply_help_fold(is_open): Input('exposure_scale_selector', 'value'), Input('exposure_lookback_selector', 'value'), Input('exposure_gold_toggle', 'value'), + Input('exposure_crowding_toggle', 'value'), Input('session_palette_theme_asset_store', 'data'), ) def render_exposure(asset_classes, members, leg, unit, scale, lookback, in_gold, - palette_name): + crowding, palette_name): palette = viz_config.get_palette(palette_name) colors = grid_colors(palette) leg = leg or exposure.LEG_SPEC unit = unit or exposure_traces.UNIT_RISK + # The Crowding switch is a change of COLUMN, not a second code path. `unit` is the + # column name everything downstream resolves against, so swapping it here gives the + # chart, the headline, the rank, the band and the axis label their share versions at + # once. The contribution table is the one thing that must not follow: shares do not + # add across markets, so it keeps the dollar column and says so. + dollar_unit = unit + if crowding: + unit = exposure_traces.SHARE_OF[unit] scale = scale or exposure_traces.SCALE_LEVEL numeraire = (exposure.NUMERAIRE_GOLD if in_gold else exposure.NUMERAIRE_USD) @@ -1208,7 +1258,7 @@ def render_exposure(asset_classes, members, leg, unit, scale, lookback, in_gold, if not asset_classes: empty = exposure_traces.build_figure(None, None, unit=unit, colors=colors, palette=palette) - no_bars = contribution_grid(None, unit, palette, leg) + no_bars = contribution_grid(None, dollar_unit, palette, leg) return (empty, no_bars, "", "", "", {**HEAD_STYLE, "color": vc.TEXT_COLOR}, help_block, "Select an asset class.", "", None, "", {"display": "none"}) @@ -1247,7 +1297,8 @@ def render_exposure(asset_classes, members, leg, unit, scale, lookback, in_gold, contract_counts=contracts_net(agg), window=window) said = describe_week(agg, part_frames, unit, leg, palette, ranks=ranks, - window=window, window_note=window_note) + window=window, window_note=window_note, + dollar_unit=dollar_unit) # A control change resets the selection: the clicked week belonged to the set that # was on screen when it was clicked, and silently carrying it onto a different set # is how a page ends up describing a week it never drew. @@ -1297,12 +1348,13 @@ def help_children(unit): State('exposure_scale_selector', 'value'), State('exposure_lookback_selector', 'value'), State('exposure_gold_toggle', 'value'), + State('exposure_crowding_toggle', 'value'), State('session_palette_theme_asset_store', 'data'), State('exposure_chart', 'figure'), prevent_initial_call=True, ) def select_week(click_data, _reset, asset_classes, members, leg, unit, scale, lookback, - in_gold, palette_name, current_fig): + in_gold, crowding, palette_name, current_fig): """Move the whole reading to the week under the cursor, same gesture as OI Alignment. Everything above the chart describes one week, and until now that week was always @@ -1336,6 +1388,9 @@ def select_week(click_data, _reset, asset_classes, members, leg, unit, scale, lo palette = viz_config.get_palette(palette_name) leg = leg or exposure.LEG_SPEC unit = unit or exposure_traces.UNIT_RISK + dollar_unit = unit + if crowding: + unit = exposure_traces.SHARE_OF[unit] names = list(members) if members else _names_in(asset_classes) numeraire = (exposure.NUMERAIRE_GOLD if in_gold else exposure.NUMERAIRE_USD) @@ -1354,7 +1409,8 @@ def select_week(click_data, _reset, asset_classes, members, leg, unit, scale, lo part_frames[part_leg] = part.frame[unit] if not part.frame.empty else None said = describe_week(agg, part_frames, unit, leg, palette, when=when, - window=window, window_note=window_note) + window=window, window_note=window_note, + dollar_unit=dollar_unit) latest = agg.frame.index[-1] notice, notice_style = rewind_notice(said["shown"], latest) diff --git a/tests/test_exposure_copy.py b/tests/test_exposure_copy.py index 1b37bf0..fb8ee88 100644 --- a/tests/test_exposure_copy.py +++ b/tests/test_exposure_copy.py @@ -249,10 +249,11 @@ def test_the_explanation_says_what_the_page_does_NOT_tell_you(): def test_the_explanation_covers_each_thing_a_reader_meets(): titles = [t for t, _ in how_to_read(et.UNIT_RISK)] - assert len(titles) == 13 + assert len(titles) == 15 joined = " ".join(titles).lower() for topic in ("number", "one market", "band", "panels", "made of", "gold switch", - "gold is here", "dotted line", "lookback switch", "scale switch", + "gold is here", "crowding switch", "crowding does not help", + "dotted line", "lookback switch", "scale switch", "volatility panel", "third panel", "not"): assert topic in joined @@ -1064,3 +1065,60 @@ def test_the_headline_does_not_say_weeks_twice(): # and without a window it is still a percentage OF WEEKS, not of a history plain, _ = headline(ranked(97.0), et.UNIT_RISK, LEG_SPEC) assert "of the weeks in this set's own history" in plain + + +# ── the crowding control ────────────────────────────────────────────────────── + + +def test_the_crowding_copy_says_where_it_does_not_help(): + """A control offered without its failing cases reads as a strict improvement. + + It is not one: `npf/docs/analysis/2026-08-24-exposure-numeraire-levels.md` measured + it clearing on 7 of 9 asset classes, and the two it misses are named here so a reader + on a Softs or Currencies view is not left wondering why nothing moved. + """ + body = " ".join(b for _, b in how_to_read(et.UNIT_RISK)) + assert "Softs and Currencies" in body + + +def test_the_crowding_copy_does_not_promise_a_deflator(): + """It removes market growth, not the price level, and those are different claims. + + The same study found CPI clears on 0 of 9 classes, so the page must not let a reader + take this control for an inflation adjustment, which is the framing `cotmetrics` + already refuses for the Gold switch. + """ + body = " ".join(b for _, b in how_to_read(et.UNIT_RISK)).lower() + for word in ("inflation", "consumer price", "cpi", "real terms", "deflated"): + assert word not in body + + +def test_the_crowding_copy_warns_the_table_stays_in_dollars(): + """Shares do not add across markets, so the contribution table cannot follow the + chart into share units. Two panels in different units with nothing saying so is the + kind of thing a reader discovers by misreading it.""" + body = " ".join(b for _, b in how_to_read(et.UNIT_RISK)) + assert "stays in dollars" in body + + +def test_a_share_prints_as_a_percentage_not_as_money(): + """A share is a ratio of two quantities in the same unit, so it has no currency and + no ounces. Stamping it with either claims a denomination it does not have.""" + from pages.analytics.exposure import money + out = money(0.532, "", unit=et.UNIT_NOTIONAL_SHARE) + assert out == "53.2% of open interest" + assert "$" not in out + + +def test_a_share_reads_the_same_under_either_numeraire(): + from pages.analytics.exposure import money + assert (money(0.532, "", numeraire="gold", unit=et.UNIT_RISK_SHARE) + == money(0.532, "", numeraire="usd", unit=et.UNIT_RISK_SHARE)) + + +def test_a_share_axis_is_never_rescaled_into_thousands(): + """`unit_scale` exists so an axis reads $55bn rather than 55,387,601,984. A half is + not "0.5k", so a share opts out.""" + import pandas as pd + share = pd.Series([0.1, 0.53], name=et.UNIT_NOTIONAL_SHARE) + assert et.unit_scale(share) == (1.0, "") From 16d9564271f543c8b91a21227aab0633c8152587 Mon Sep 17 00:00:00 2001 From: Matt Spinola Date: Mon, 24 Aug 2026 16:21:08 -0400 Subject: [PATCH 3/4] Exposure: say why there is no inflation switch, because it was built and it did nothing The DOCUMENT outcome of the study that produced the Crowding switch. A twenty-year chart of dollars invites "shouldn't this be inflation adjusted", and until now the page had no answer, which reads as an oversight rather than as a decision. It was tested. Across 43 markets, deflating by CPI moved the percentile by one or two points and changed the headline on at most 6% of weeks, clearing on none of the nine asset classes where gold clears eight. The drift on this page is mostly the market getting bigger, and consumer prices have not quite doubled while some of these markets grew twenty-fold, so there was never enough in CPI to remove it. A second entry says what does answer the drift: the percentile, which is why it is on every reading here rather than left to the axis, and then the Crowding switch, which removes the growth itself. Neither is a deflator. Kept narrow, as the study's frozen gate requires. This is a result about CPI and not about deflators as a class: no placebo divisor and no trade-weighted dollar were tested, and the copy says the trade-weighted dollar would be the next thing to try. Two copy guards are narrowed rather than deleted, and the narrowing is the point. They asserted that "inflation" and "cpi" appeared NOWHERE in the page copy, which was a fine proxy for the real rule while the page had nothing to say about either. A blanket word ban would now force the page to stay silent about the one measurement that settles the question a dollar chart provokes. The rule is that GOLD is never sold as an inflation adjustment, and that is what the tests now check, scoped to the gold and crowding entries, each with an assertion that the scope is non-empty so neither can pass vacuously. Co-Authored-By: Claude Opus 5 --- src/pages/analytics/exposure.py | 17 ++++++++++ tests/test_exposure_copy.py | 60 ++++++++++++++++++++++++++++----- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/pages/analytics/exposure.py b/src/pages/analytics/exposure.py index 10968c3..ab44310 100644 --- a/src/pages/analytics/exposure.py +++ b/src/pages/analytics/exposure.py @@ -634,6 +634,23 @@ def how_to_read(unit): "whole history of this composite the switch moves the percentile by about 6 " "points in the median week, 14 at the ninetieth, and into the mid-20s at its " "widest."), + ("Why there is no inflation switch", + "Because it was built, measured and it did nothing. A twenty-year chart of " + "dollars invites the question, so the obvious answer was tested: deflate by CPI " + "and read the series in today's money. Across 43 markets it moved the " + "percentile by one or two points and changed the headline on at most 6% of " + "weeks, clearing on none of the nine asset classes where gold clears eight. The " + "drift you can see here is mostly the market getting bigger, and consumer " + "prices have not quite doubled while some of these markets grew twenty-fold, so " + "there was never enough in CPI to remove it."), + ("What answers the drift instead", + "The percentile, which is why it is on every reading here rather than left to " + "the axis: it asks where this week sits in this set's own history, and a " + "history that drifts upward does not fool a rank the way it fools an eye. Then " + "the Crowding switch below, which removes the growth itself. Neither is a " + "deflator, and this is a result about CPI rather than about deflators in " + "general: a trade-weighted dollar was never tested here and would be the next " + "thing to try if you wanted one."), ("The Crowding switch", "Divides by the same set's own open interest, so the line is the share of the " "market this group holds rather than the money it has at stake. It is the only " diff --git a/tests/test_exposure_copy.py b/tests/test_exposure_copy.py index fb8ee88..6bd54ff 100644 --- a/tests/test_exposure_copy.py +++ b/tests/test_exposure_copy.py @@ -249,10 +249,11 @@ def test_the_explanation_says_what_the_page_does_NOT_tell_you(): def test_the_explanation_covers_each_thing_a_reader_meets(): titles = [t for t, _ in how_to_read(et.UNIT_RISK)] - assert len(titles) == 15 + assert len(titles) == 17 joined = " ".join(titles).lower() for topic in ("number", "one market", "band", "panels", "made of", "gold switch", - "gold is here", "crowding switch", "crowding does not help", + "gold is here", "inflation switch", "answers the drift", + "crowding switch", "crowding does not help", "dotted line", "lookback switch", "scale switch", "volatility panel", "third panel", "not"): assert topic in joined @@ -719,12 +720,24 @@ def test_the_aggregate_tuple_is_built_by_keyword_in_these_tests(): def test_the_explanation_calls_gold_a_benchmark_and_never_an_inflation_adjustment(): """Hard money, full stop. Gold beats consumer prices over five decades and misses them badly over two, so an inflation framing would invite reading a rise as real - growth. The page says what gold is, a second asset with its own trend, and leaves - price indices out of it entirely.""" + growth. The page says what gold is, a second asset with its own trend. + + Scoped to the GOLD entries rather than the whole body, and that narrowing is the + point rather than a loosening. This asserted that "inflation" and "cpi" appeared + nowhere on the page at all, which was a proxy for the real rule while the page had + nothing to say about either. It now does: the study behind the Crowding switch built + a CPI arm and found it inert, and the page says so. A blanket word ban would have + forced the page to stay silent about the one measurement that settles the question a + dollar chart provokes. The rule is that GOLD is never sold as an inflation + adjustment, and that is what is tested. + """ body = " ".join(b for _, b in how_to_read(et.UNIT_RISK)).lower() assert "hard money" in body or "hard-money" in body + gold = " ".join(b for t, b in how_to_read(et.UNIT_RISK) + if "gold" in t.lower()).lower() + assert gold, "no gold entries found, so this test is passing vacuously" for word in ("inflation", "consumer price", "cpi", "real terms"): - assert word not in body + assert word not in gold def test_the_explanation_credits_the_idea_it_borrows(): @@ -1081,16 +1094,47 @@ def test_the_crowding_copy_says_where_it_does_not_help(): assert "Softs and Currencies" in body +def test_the_page_says_why_there_is_no_inflation_switch(): + """The DOCUMENT outcome of the study that produced the Crowding switch. + + A twenty-year chart of dollars invites "shouldn't this be inflation adjusted", and + the honest answer is that it was tried and it is inert: CPI cleared on 0 of 9 asset + classes where gold cleared 8. Saying so on the page beats leaving each reader to + ask, and beats a silence that reads as an oversight. + `npf/docs/analysis/2026-08-24-exposure-numeraire-levels.md`. + """ + body = " ".join(b for _, b in how_to_read(et.UNIT_RISK)) + assert "deflate by CPI" in body + assert "percentile" in body + + +def test_the_page_does_not_claim_no_deflator_could_work(): + """The frozen gate forbids the generalisation, and the reason is a gap in evidence. + + The study tested CPI and gold. It ran no placebo divisor and no trade-weighted + dollar, and the trade-weighted dollar is the one numeraire the adjacent returns work + found to do anything at all. So "CPI does not displace this" is supported and "no + deflator is the answer" is not, and the copy has to keep them apart. + """ + body = " ".join(b for _, b in how_to_read(et.UNIT_RISK)) + assert "trade-weighted dollar was never tested" in body + assert "result about CPI rather than about deflators in general" in body + + def test_the_crowding_copy_does_not_promise_a_deflator(): """It removes market growth, not the price level, and those are different claims. The same study found CPI clears on 0 of 9 classes, so the page must not let a reader take this control for an inflation adjustment, which is the framing `cotmetrics` - already refuses for the Gold switch. + already refuses for the Gold switch. Scoped to the Crowding entries for the reason + given on the gold test above: the page now discusses CPI deliberately elsewhere, and + a whole-body word ban would forbid the sentence that reports the measurement. """ - body = " ".join(b for _, b in how_to_read(et.UNIT_RISK)).lower() + crowding = " ".join(b for t, b in how_to_read(et.UNIT_RISK) + if "crowding" in t.lower()).lower() + assert crowding, "no crowding entries found, so this test is passing vacuously" for word in ("inflation", "consumer price", "cpi", "real terms", "deflated"): - assert word not in body + assert word not in crowding def test_the_crowding_copy_warns_the_table_stays_in_dollars(): From 68ad082d9e50448817ab90bab73470187b862d53 Mon Sep 17 00:00:00 2001 From: Matt Spinola Date: Mon, 24 Aug 2026 16:32:13 -0400 Subject: [PATCH 4/4] Keep the price-index word ban blanket, and say it in other words The previous commit narrowed two copy guards from the whole body to the gold and crowding entries, so the new copy could report that a price-index adjustment was built and found inert. On reflection that traded the wrong thing. The words are what a reader takes away. A body containing them at all can be skimmed into the framing the rule exists to prevent, whichever entry they happen to sit in, and the rule is worth more as an invariant than as a precise statement. The copy says exactly the same thing without them, which cost one sentence to rewrite: "divide by a general price index" rather than naming the series, and "that ONE index rather than the whole idea" for the scope limit the frozen gate requires. Guards restored to the whole body. The reasoning is recorded in the test docstring rather than lost, including why the narrowing looked reasonable, so the next person to hit this does not rediscover the argument and reach the other answer. Co-Authored-By: Claude Opus 5 --- src/pages/analytics/exposure.py | 22 ++++++++++----------- tests/test_exposure_copy.py | 34 +++++++++++++-------------------- 2 files changed, 24 insertions(+), 32 deletions(-) diff --git a/src/pages/analytics/exposure.py b/src/pages/analytics/exposure.py index ab44310..655a810 100644 --- a/src/pages/analytics/exposure.py +++ b/src/pages/analytics/exposure.py @@ -635,21 +635,21 @@ def how_to_read(unit): "points in the median week, 14 at the ninetieth, and into the mid-20s at its " "widest."), ("Why there is no inflation switch", - "Because it was built, measured and it did nothing. A twenty-year chart of " - "dollars invites the question, so the obvious answer was tested: deflate by CPI " - "and read the series in today's money. Across 43 markets it moved the " - "percentile by one or two points and changed the headline on at most 6% of " - "weeks, clearing on none of the nine asset classes where gold clears eight. The " - "drift you can see here is mostly the market getting bigger, and consumer " - "prices have not quite doubled while some of these markets grew twenty-fold, so " - "there was never enough in CPI to remove it."), + "Because it was built, measured, and it did nothing. A twenty-year chart of " + "dollars invites the question, so the obvious answer was tested: divide by a " + "general price index and read the series in today's money. Across 43 markets it " + "moved the percentile by one or two points and changed the headline on at most " + "6% of weeks, clearing on none of the nine asset classes where gold clears " + "eight. The drift you can see here is mostly the market getting bigger, and the " + "general price level has not quite doubled while some of these markets grew " + "twenty-fold, so there was never enough in the index to remove it."), ("What answers the drift instead", "The percentile, which is why it is on every reading here rather than left to " "the axis: it asks where this week sits in this set's own history, and a " "history that drifts upward does not fool a rank the way it fools an eye. Then " - "the Crowding switch below, which removes the growth itself. Neither is a " - "deflator, and this is a result about CPI rather than about deflators in " - "general: a trade-weighted dollar was never tested here and would be the next " + "the Crowding switch below, which removes the growth itself. Neither divides by " + "a price index, and what was measured was that ONE index rather than the whole " + "idea: a trade-weighted dollar was never tested here and would be the next " "thing to try if you wanted one."), ("The Crowding switch", "Divides by the same set's own open interest, so the line is the share of the " diff --git a/tests/test_exposure_copy.py b/tests/test_exposure_copy.py index 6bd54ff..5e1fabd 100644 --- a/tests/test_exposure_copy.py +++ b/tests/test_exposure_copy.py @@ -722,22 +722,18 @@ def test_the_explanation_calls_gold_a_benchmark_and_never_an_inflation_adjustmen them badly over two, so an inflation framing would invite reading a rise as real growth. The page says what gold is, a second asset with its own trend. - Scoped to the GOLD entries rather than the whole body, and that narrowing is the - point rather than a loosening. This asserted that "inflation" and "cpi" appeared - nowhere on the page at all, which was a proxy for the real rule while the page had - nothing to say about either. It now does: the study behind the Crowding switch built - a CPI arm and found it inert, and the page says so. A blanket word ban would have - forced the page to stay silent about the one measurement that settles the question a - dollar chart provokes. The rule is that GOLD is never sold as an inflation - adjustment, and that is what is tested. + The ban is on the whole body and stays there, deliberately, after a round where it + was briefly narrowed to the gold entries. The page now has a good reason to discuss + a price-index adjustment, since one was built and measured and found inert, and the + narrowing would have let it use these words to say so. Kept blanket anyway: the + words are what a reader takes away, and a body that contains them at all can be + skimmed into the framing this rule exists to prevent, whichever entry they sit in. + The copy says the same thing in other words, which cost one sentence to write. """ body = " ".join(b for _, b in how_to_read(et.UNIT_RISK)).lower() assert "hard money" in body or "hard-money" in body - gold = " ".join(b for t, b in how_to_read(et.UNIT_RISK) - if "gold" in t.lower()).lower() - assert gold, "no gold entries found, so this test is passing vacuously" for word in ("inflation", "consumer price", "cpi", "real terms"): - assert word not in gold + assert word not in body def test_the_explanation_credits_the_idea_it_borrows(): @@ -1104,7 +1100,7 @@ def test_the_page_says_why_there_is_no_inflation_switch(): `npf/docs/analysis/2026-08-24-exposure-numeraire-levels.md`. """ body = " ".join(b for _, b in how_to_read(et.UNIT_RISK)) - assert "deflate by CPI" in body + assert "general price index" in body assert "percentile" in body @@ -1118,7 +1114,7 @@ def test_the_page_does_not_claim_no_deflator_could_work(): """ body = " ".join(b for _, b in how_to_read(et.UNIT_RISK)) assert "trade-weighted dollar was never tested" in body - assert "result about CPI rather than about deflators in general" in body + assert "that ONE index rather than the whole idea" in body def test_the_crowding_copy_does_not_promise_a_deflator(): @@ -1126,15 +1122,11 @@ def test_the_crowding_copy_does_not_promise_a_deflator(): The same study found CPI clears on 0 of 9 classes, so the page must not let a reader take this control for an inflation adjustment, which is the framing `cotmetrics` - already refuses for the Gold switch. Scoped to the Crowding entries for the reason - given on the gold test above: the page now discusses CPI deliberately elsewhere, and - a whole-body word ban would forbid the sentence that reports the measurement. + already refuses for the Gold switch. """ - crowding = " ".join(b for t, b in how_to_read(et.UNIT_RISK) - if "crowding" in t.lower()).lower() - assert crowding, "no crowding entries found, so this test is passing vacuously" + body = " ".join(b for _, b in how_to_read(et.UNIT_RISK)).lower() for word in ("inflation", "consumer price", "cpi", "real terms", "deflated"): - assert word not in crowding + assert word not in body def test_the_crowding_copy_warns_the_table_stays_in_dollars():