From 5fd7c13a76a7eab1dd8f655fc4f448fd4f2b1967 Mon Sep 17 00:00:00 2001 From: Matt Spinola Date: Mon, 24 Aug 2026 17:52:34 -0400 Subject: [PATCH] Exposure: a share is not denominated in anything, so stop labelling it USD With Crowding on, the exposure panel's y axis read "USD" while plotting a fraction, and the hovers said "0.5 USD" for half a market. A share of open interest is a ratio of two quantities in the same unit and has no denomination at all. The trap that made this survive review is that the SAME figure has a panel which legitimately is in dollars. Panel 1 is the price composite and follows the numeraire, so it is right to say "Index in USD (=100)" over panels 2 and 3 showing a percentage. The fix could not be swapping one string everywhere, which is presumably why the original reached for `base` and got it wrong. Two variables now, named for what they are. `base` is the price composite's denomination. `hover_unit` is the short form the hovers append to a number, deliberately not called `measure` because that name was already taken further down for the chart title, which builds from UNIT_LABELS and wants the long form. Getting that collision wrong is how the axis title started reading correctly by accident during this fix rather than on purpose. Shares are also drawn in percentage POINTS now, so the axis runs 0 to 60 rather than 0.0 to 0.6 and agrees with the headline, which already prints 32.0% for the same week. The factor lives in build_figure rather than in unit_scale because the headline reads that function too and multiplies by 100 itself, so moving it there would print "3,200% of open interest". Four tests, including one that panel 1 KEEPS its currency, since relabelling it alongside the exposure panel would have been the opposite error. Verified in the running app, not only in tests: with Crowding on the axis reads "share of open-interest risk" with ticks at 0, 20, 40, and panel 1 still reads "Index in USD (=100)". Co-Authored-By: Claude Opus 5 --- src/components/exposure_traces.py | 34 ++++++++++++++++++---- tests/test_exposure_copy.py | 1 + tests/test_exposure_traces.py | 48 +++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/components/exposure_traces.py b/src/components/exposure_traces.py index a43a4bf..2b62817 100644 --- a/src/components/exposure_traces.py +++ b/src/components/exposure_traces.py @@ -328,9 +328,26 @@ def build_figure(frame, composite, *, unit=UNIT_NOTIONAL, colors, palette, ranked = scale == SCALE_RANK # What the money is denominated in, needed by the hovers as well as the axis. base = "oz gold" if numeraire == exposure.NUMERAIRE_GOLD else "USD" + # What the EXPOSURE panels are denominated in, which is NOT always `base`. A share + # of open interest is a ratio of two quantities in the same unit, so it has no + # denomination at all, and labelling its axis "USD" was wrong in the one way this + # feature can be wrong. `base` still describes panel 1, the price composite, which + # follows the numeraire and is genuinely in dollars or ounces whatever panels 2 and + # 3 are showing. + share = unit in SHARE_UNITS + # Deliberately NOT called `measure`: that name is taken further down for the chart + # title, which is built from UNIT_LABELS and needs the long form. This is the short + # form the hovers append to a number, where "share of open interest" would not fit. + hover_unit = "% of OI" if share else base rank_column = UNIT_RANK_COLUMN[unit] values = frame[rank_column] if ranked else frame[unit] divisor, suffix = (1.0, "") if ranked else unit_scale(values) + # Percentage POINTS on the axis, so it reads 0 to 60 rather than 0.0 to 0.6. Done + # here rather than in `unit_scale` on purpose: the headline reads that function too + # and already multiplies by 100 itself, so moving the factor there would print + # "3,200% of open interest". + if share and not ranked: + divisor = 0.01 scaled = values / divisor leg_colour = palette[LEG_PALETTE_SLOT.get(leg, 0)] @@ -431,17 +448,20 @@ def build_figure(frame, composite, *, unit=UNIT_NOTIONAL, colors, palette, # Each scale's hover carries the OTHER quantity, so neither view hides what the # other one is for: the level cannot answer "is this a lot" on its own, and the # percentile cannot say how much money that is. - customdata=(frame[unit] / unit_scale(frame[unit])[0]).to_numpy() if ranked - else frame[rank_column].to_numpy(), + # On the %ile scale the level rides along as customdata, and it needs the SAME + # scaling the level axis would have given it, percentage points included, or the + # hover reads "0.3 % of OI" for a third of the market. + customdata=(frame[unit] / (0.01 if share else unit_scale(frame[unit])[0]) + ).to_numpy() if ranked else frame[rank_column].to_numpy(), # The percentile as a WORD, because a template can only append a fixed suffix # and three values in ten do not end in "th". Same series either way: it is the # y value on the ranked scale and the customdata on the level one. text=ordinals(frame[rank_column]), hovertemplate=( "%{x|%b %d, %Y}
%{text} percentile
%{customdata:,.1f}" - + unit_scale(frame[unit])[1] + f" {base}" if ranked else + + unit_scale(frame[unit])[1] + f" {hover_unit}" if ranked else "%{x|%b %d, %Y}
%{y:,.1f}" + suffix - + f" {base}
" + "%{text} percentile of its own history" + + f" {hover_unit}
" + "%{text} percentile of its own history" + "" )), row=2, col=1) @@ -480,7 +500,7 @@ def build_figure(frame, composite, *, unit=UNIT_NOTIONAL, colors, palette, width=PART_WIDTH), text=ordinals(aligned) if ranked else None, hovertemplate=(("%{text} percentile" if ranked - else "%{y:,.1f}" + suffix + f" {base}") + else "%{y:,.1f}" + suffix + f" {hover_unit}") + exposure.LEG_LABELS[part_leg] + "")), row=3, col=1) drew_companion = True @@ -510,7 +530,9 @@ def build_figure(frame, composite, *, unit=UNIT_NOTIONAL, colors, palette, # Parenthesised, because the two numeraires want opposite word orders otherwise: # "USD m" is the established form and "m USD" is not, while "k oz gold" is right and # "oz gold k" is not. "USD (m)" and "oz gold (k)" are both fine and are one rule. - usd = "Percentile" if ranked else (f"{base} ({suffix})" if suffix else base) + # A share carries no scale suffix and no currency, so it is the label on its own. + # This line used `base` before, which hardcoded "USD" over a fraction. + usd = "Percentile" if ranked else (f"{measure} ({suffix})" if suffix else measure) price_axis = price_axis_type(composite) # Plotly's default on a log axis puts a tick at every digit, which in a panel this # short (26% of the figure, about 180px) renders as a column of stacked single diff --git a/tests/test_exposure_copy.py b/tests/test_exposure_copy.py index 5e1fabd..954f58a 100644 --- a/tests/test_exposure_copy.py +++ b/tests/test_exposure_copy.py @@ -1158,3 +1158,4 @@ def test_a_share_axis_is_never_rescaled_into_thousands(): import pandas as pd share = pd.Series([0.1, 0.53], name=et.UNIT_NOTIONAL_SHARE) assert et.unit_scale(share) == (1.0, "") + diff --git a/tests/test_exposure_traces.py b/tests/test_exposure_traces.py index ce76f6e..3c4a411 100644 --- a/tests/test_exposure_traces.py +++ b/tests/test_exposure_traces.py @@ -30,6 +30,12 @@ def frame(values, ranks=None, weeks=6): "n_markets": 3, "notional_pct_rank": ranks or [50.0] * len(values), "risk_pct_rank": ranks or [50.0] * len(values), + # Share of open interest, as a FRACTION, which is how cotmetrics emits it. + # 0.32 is roughly where copper's speculators actually sit. + "notional_oi_share": [0.32] * len(values), + "risk_oi_share": [0.30] * len(values), + "notional_oi_share_pct_rank": ranks or [50.0] * len(values), + "risk_oi_share_pct_rank": ranks or [50.0] * len(values), }, index=idx) @@ -742,3 +748,45 @@ def test_every_ranked_trace_carries_its_ordinals(): assert list(subject.text) == ["50th", "50th", "50th"] vol = next(t for t in fig.data if (t.name or "").startswith("Volatility")) assert vol.text is not None + + +# ── a share is not denominated in anything ──────────────────────────────────── + + +def test_a_share_axis_is_never_labelled_in_a_currency(): + """It said USD while plotting a fraction, which is the one way this can be wrong. + + The trap is that the same figure has a panel that legitimately IS in dollars: panel + 1 is the price composite and follows the numeraire, so the fix could not be swapping + one string everywhere. A share of open interest is a ratio of two quantities in the + same unit and has no denomination at all. + """ + fig = build(frame([1e9, 2e9]), unit=et.UNIT_NOTIONAL_SHARE) + exposure_axis = fig.layout.yaxis2.title.text + assert "USD" not in exposure_axis + assert "oz gold" not in exposure_axis + assert "open interest" in exposure_axis + + +def test_the_price_panel_keeps_its_currency_when_the_exposure_panel_is_a_share(): + """Panel 1 is a price index and follows the numeraire, not the basis. Relabelling it + alongside the exposure panel would have been the opposite error.""" + fig = build(frame([1e9, 2e9]), unit=et.UNIT_NOTIONAL_SHARE) + assert "USD" in fig.layout.yaxis.title.text + + +def test_a_share_is_drawn_in_percentage_points(): + """0 to 60, not 0.0 to 0.6, so the axis agrees with the headline, which already + prints 32.0% for the same week.""" + fig = build(frame([1e9, 2e9]), unit=et.UNIT_NOTIONAL_SHARE) + drawn = [v for t in fig.data if t.y is not None for v in t.y if v == v] + peak = max(drawn) + assert peak > 1.0, f"share drawn as a fraction, peak {peak}" + assert 25 < peak < 100 + + +def test_the_share_hover_says_what_the_number_is(): + fig = build(frame([1e9, 2e9]), unit=et.UNIT_NOTIONAL_SHARE) + hovers = " ".join(t.hovertemplate or "" for t in fig.data) + assert "% of OI" in hovers + assert " USD" not in hovers