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