Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions src/components/exposure_traces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]

Expand Down Expand Up @@ -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}<br>%{text} percentile<br>%{customdata:,.1f}"
+ unit_scale(frame[unit])[1] + f" {base}<extra></extra>" if ranked else
+ unit_scale(frame[unit])[1] + f" {hover_unit}<extra></extra>" if ranked else
"%{x|%b %d, %Y}<br>%{y:,.1f}" + suffix
+ f" {base}<br>" + "%{text} percentile of its own history"
+ f" {hover_unit}<br>" + "%{text} percentile of its own history"
+ "<extra></extra>"
)),
row=2, col=1)
Expand Down Expand Up @@ -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<extra>" if ranked
else "%{y:,.1f}" + suffix + f" {base}<extra>")
else "%{y:,.1f}" + suffix + f" {hover_unit}<extra>")
+ exposure.LEG_LABELS[part_leg] + "</extra>")),
row=3, col=1)
drew_companion = True
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/test_exposure_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")

48 changes: 48 additions & 0 deletions tests/test_exposure_traces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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
Loading