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 1e673bd..655a810 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: @@ -558,6 +567,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 +630,43 @@ 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."), + ("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: 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 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 " + "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 " @@ -802,7 +863,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 "") @@ -928,6 +989,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", @@ -937,8 +1010,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"), @@ -1047,6 +1121,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. @@ -1062,7 +1137,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 @@ -1173,14 +1251,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) @@ -1188,7 +1275,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"}) @@ -1227,7 +1314,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. @@ -1277,12 +1365,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 @@ -1316,6 +1405,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) @@ -1334,7 +1426,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 1f7874b..5e1fabd 100644 --- a/tests/test_exposure_copy.py +++ b/tests/test_exposure_copy.py @@ -249,10 +249,12 @@ 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) == 17 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", "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 @@ -718,8 +720,16 @@ 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. + + 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 for word in ("inflation", "consumer price", "cpi", "real terms"): @@ -734,7 +744,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 ───────────────────────────── @@ -1042,3 +1074,87 @@ 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_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 "general price index" 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 "that ONE index rather than the whole idea" 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, "")