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