diff --git a/pyproject.toml b/pyproject.toml index e2a8b19..e216164 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,13 @@ dependencies = [ # does not exist: the registry refuses those symbols at import and the whole # route registry goes down with it, `use_pages` importing every page at boot. "crucible-marketdata>=0.3.0", - # Floor is 0.13.0 because the crowd page's breadth panel calls + # Floor is 0.15.0 because the Positioning Index panel on /analysis calls + # `CotIndexer.get_speculator_data` (and speculator_traces imports + # `cotmetrics.flows`). Against 0.14.x the method does not exist and the + # AttributeError lands in the stack's render callback, so the whole stack + # fails rather than one line. + # + # 0.13.0 was the floor because the crowd page's breadth panel calls # `indicators.fomo_zones` and `net_highs_regime`. Against 0.12.x those do not # exist, and the AttributeError lands in the render callback of a page the # route registry has already accepted, so the board draws and the panel dies. @@ -99,7 +105,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.13.0", + "cotmetrics[options]>=0.15.0", ] [project.optional-dependencies] diff --git a/src/assets/clientside.js b/src/assets/clientside.js index 9df9f28..0f3f402 100644 --- a/src/assets/clientside.js +++ b/src/assets/clientside.js @@ -319,6 +319,12 @@ window.dash_clientside = Object.assign({}, window.dash_clientside, { } var liveAxes = {}; (figure.data || []).forEach(function(t) { liveAxes[axisKeyOf(t)] = true; }); + // An axis carrying a heatmap is a fixed grid, not a scale to fit: the + // Positioning Index panel's speculator flow strip sits below its zero, + // and fitting the axis to the lines in a zoomed window cut the strip off. + (figure.data || []).forEach(function(t) { + if (t.type === 'heatmap') { delete liveAxes[axisKeyOf(t)]; } + }); // A figure may ship its own rules in layout.meta.refit: which axes are // fitted at all, and the ratio at which a price panel earns a log scale. diff --git a/src/components/plot_registry.py b/src/components/plot_registry.py index 59c92e5..a2f0f84 100644 --- a/src/components/plot_registry.py +++ b/src/components/plot_registry.py @@ -82,6 +82,10 @@ class PlotCtx: showlegend: bool = True setup_comms_only: bool = False smooth_indexing: bool = False + # CotIndexer.get_speculator_data for this asset and lookback, set by the page on + # a raw-basis index panel only (the speculator series is contracts-basis). None + # draws the panel exactly as before. + speculator: object = None # When a panel needs a secondary y-axis. @@ -144,9 +148,13 @@ def _index(ctx): comm, lrg, sml = ctx.idx_cols low = ctx.model.low if ctx.model else None high = ctx.model.high if ctx.model else None - return h.get_index_plot(ctx.fig, ctx.df, comm, lrg, sml, ctx.row, ctx.col, - ctx.palette, low, high, ctx.show_price, - ctx.smooth_indexing) + fig = h.get_index_plot(ctx.fig, ctx.df, comm, lrg, sml, ctx.row, ctx.col, + ctx.palette, low, high, ctx.show_price, + ctx.smooth_indexing) + if ctx.speculator is not None: + import components.speculator_traces as st + fig = st.add_speculator(fig, ctx.speculator, ctx.row, ctx.col, ctx.palette) + return fig def _setup_highlight(ctx): diff --git a/src/components/speculator_traces.py b/src/components/speculator_traces.py new file mode 100644 index 0000000..03f84cd --- /dev/null +++ b/src/components/speculator_traces.py @@ -0,0 +1,160 @@ +"""The Speculator series and its flow-z strip, on the /analysis Positioning Index panel. + +"Scope, restated v2" (cotmetrics `docs/handoffs/2026-09-26-cot-flow-states-cross- +universe.md`): one role series per market. The panel already draws the Legacy +Commercial, Large Spec and Small index; this adds + +* **Speculator**: the positioning index of the cohorts cotmetrics' `flow_roles` table + measures as this market's speculator (Managed Money on most physicals, Asset Manager + plus Leveraged Funds in most currencies, Asset Manager on equity index), at the same + per-symbol lookback as the other three lines; +* **one strip** under the lines: the speculator net's weekly change over its own 52-week + standard deviation, blue buying and red selling, clipped at 3; +* **Retail** as a label rather than a line: Legacy Small IS Non-Reportable, so a Retail + line would sit exactly on the Small line. The panel says how retail behaves on this + market instead (moves with price, does not, or moves against it, from the same table). + +Where the table names no speculator (ZT) there is no line and no strip, and the label +says Large Spec, Legacy's Non-Commercial, stands in. + +Everything is read from `CotIndexer.get_speculator_data`; nothing here computes a role, +an index or a z. Store-free, so every builder is testable under CI's empty +COTDATA_STORE. Nothing here carries a forward return: the flow-state test failed the +crucible gauntlet on 2026-09-26. +""" + +import re + +import cotmetrics.constants as const +import cotmetrics.flows as flows +import pandas as pd +import plotly.graph_objects as go + +import viz_constants as vc +from components.plot_traces import LRG_LABEL, SML_LABEL + +SPEC = const.SPECULATOR + +# Display clip only: the hover prints the real z. Past three sigma the eye cannot rank +# the shades, and one outlier week would wash every ordinary week to the midpoint. +FLOW_Z_CLIP = 3.0 + +# The strip lives inside the index panel, below its zero: edges on the index axis. +# The axis is widened to show it and its ticks stay on the 0 to 100 scale. +STRIP_EDGES = [-15.0, -4.0] +INDEX_RANGE = [-17.0, 100.0] +# The strip is named by a tick at its own height rather than by a legend entry. +STRIP_TICK = (STRIP_EDGES[0] + STRIP_EDGES[1]) / 2 +INDEX_TICKS = [STRIP_TICK, 0, 20, 50, 80, 100] +INDEX_TICK_TEXT = ["flow z", "0", "20", "50", "80", "100"] + + +def _composite_over(rgba, hex_bg): + """The opaque colour an rgba() paints over an opaque hex background.""" + m = re.match(r"rgba?\(([^)]*)\)", str(rgba)) + parts = [float(x) for x in m.group(1).split(",")] if m else [] + if len(parts) < 3: + return hex_bg + alpha = parts[3] if len(parts) > 3 else 1.0 + h = str(hex_bg).lstrip("#") + bg = [int(h[i:i + 2], 16) for i in (0, 2, 4)] + out = [round(b + (c - b) * alpha) for c, b in zip(parts[:3], bg)] + return "#{:02x}{:02x}{:02x}".format(*out) + + +# Polarity, not identity: a true diverging scale anchored at zero, where zero is barely +# lifted off the panel background so only real moves carry colour, one sd is a muted +# blue or red, and three sd is saturated. Stops at -3, -1, 0, +1, +3. +FLOW_ZERO = _composite_over("rgba(255, 255, 255, 0.06)", vc.BACKGROUND_COLOR) +FLOW_COLORSCALE = [ + [0.0, "#ff4b3e"], + [1 / 3, "#8a3129"], + [0.5, FLOW_ZERO], + [2 / 3, "#285c8e"], + [1.0, "#3fa0ff"], +] + +RETAIL_WORDS = { + "spec-like": "moves with price here", + "neutral": "does not move with price here", + "cp-like": "moves against price here", +} + + +def _index_col(df): + header = df.attrs.get("lookback_header") + return flows.index_col(SPEC, header) if header is not None else None + + +def panel_note(df): + """One short line: who the speculator is, and how retail behaves here. + + Short on purpose: it sits inside the panel above the lines, and the panel is a + third of the page width in a three-column stack. + """ + roles = df.attrs.get("flow_roles") or {} + label = df.attrs.get("speculator_label") + parts = [f"Speculator = {label}" if label + else f"No speculator role here; {LRG_LABEL} stands in"] + behaves = RETAIL_WORDS.get(roles.get("retail_behaves")) + if behaves: + parts.append(f"retail ({SML_LABEL}) {behaves}") + return ", ".join(parts) + + +def strip_hover(df, label): + z = df[flows.flow_z_col(SPEC)] + dnet = df[flows.flow_col(SPEC)] if flows.flow_col(SPEC) in df.columns else None + cells = [] + for i in range(len(df)): + zv = z.iloc[i] + if pd.isna(zv): + cells.append(None) + continue + text = f"Speculator flow z {zv:+.2f} vs own {const.FLOW_Z_WEEKS}-week sd" + if dnet is not None and not pd.isna(dnet.iloc[i]): + text += f"
net {dnet.iloc[i]:+,.0f} contracts ({label})" + cells.append(text) + return [cells] + + +def add_speculator(fig, df, row, col, palette): + """Add the Speculator line, its flow strip and the panel note to an index panel. + + `df` is `CotIndexer.get_speculator_data(asset, lookback)`; None or empty adds + nothing. Call after `get_index_plot`, which sets the 0 to 100 axis this widens. + """ + if df is None or df.empty: + return fig + label = df.attrs.get("speculator_label") + idx = _index_col(df) + z_col = flows.flow_z_col(SPEC) + has_line = bool(label) and idx in df.columns + has_strip = bool(label) and z_col in df.columns + + if has_line: + fig.add_trace(go.Scatter( + x=df.index, y=df[idx], mode="lines", + name=f"Speculator ({label})", legendgroup="speculator", + line=dict(color=palette[vc.SPECULATOR_SLOT], width=2.2), + hovertemplate="%{y:.0f}", showlegend=True, + ), row=row, col=col, secondary_y=False) + + if has_strip: + fig.add_trace(go.Heatmap( + x=df.index, y=STRIP_EDGES, + z=[df[z_col].astype(float).tolist()], + text=strip_hover(df, label), hovertemplate="%{text}", + zmin=-FLOW_Z_CLIP, zmax=FLOW_Z_CLIP, zmid=0, + colorscale=FLOW_COLORSCALE, opacity=1, xgap=0, + hoverongaps=False, showscale=False, + name="Speculator flow z", showlegend=False, + ), row=row, col=col, secondary_y=False) + fig.update_yaxes(row=row, col=col, secondary_y=False, range=INDEX_RANGE, + tickvals=INDEX_TICKS, ticktext=INDEX_TICK_TEXT) + + fig.add_annotation( + text=panel_note(df), showarrow=False, xanchor="left", yanchor="top", + x=0.005, y=0.995, xref="x domain", yref="y domain", + font=dict(size=9, color=vc.TEXT_COLOR), row=row, col=col) + return fig diff --git a/src/pages/analytics/analysis.py b/src/pages/analytics/analysis.py index 55115b1..e598fab 100644 --- a/src/pages/analytics/analysis.py +++ b/src/pages/analytics/analysis.py @@ -521,6 +521,12 @@ def resolve(pid): fig = helpers.get_make_subplots_for_plots(num_rows, num_cols, titles, specs, shared_xaxes=is_shared_x) fig = registry.apply_title_hints(fig, [resolve(p)[0] for p in selected_plots]) + # The Speculator series (cotmetrics' one role series per market) rides on the + # raw-basis Positioning Index panel; only fetched when that panel is drawn. + speculator = None + if any(resolve(p) == ("index", const.BASIS_RAW) for p in selected_plots): + speculator = get_indexer().get_speculator_data(asset, lookback) + plot_idx = 0 for r in range(1, num_rows + 1): for c in range(1, num_cols + 1): @@ -562,7 +568,10 @@ def resolve(pid): show_price=show_price, asset=asset, model=model, net_cols=(comm_net, lrg_net, sml_net), y_title=net_y_title, range_weeks=controls.lookback_weeks(lookback, asset), - setup_comms_only=setup_comms_only) + setup_comms_only=setup_comms_only, + speculator=(speculator if (p == "index" + and basis_view == const.BASIS_RAW) + else None)) fig = spec.build(ctx) or fig if spec.decorate: ctx.fig = fig diff --git a/src/viz_constants.py b/src/viz_constants.py index 2041a95..e717acb 100644 --- a/src/viz_constants.py +++ b/src/viz_constants.py @@ -296,6 +296,10 @@ def positioning_tooltip(state, model, is_equity=False): LAYOUT_OVERLAY: "Overlay", LAYOUT_FACET: "Small multiples", } +# The Speculator line on the /analysis Positioning Index panel (components. +# speculator_traces). Slots 0-2 are the three Legacy legs; 5 is free on that page +# (only Exposure uses it, for volume) and differs from all three in every palette. +SPECULATOR_SLOT = 5 CATEGORY_PRICE_SLOT = 3 CATEGORY_OI_SLOT = 4 diff --git a/tests/test_speculator_traces.py b/tests/test_speculator_traces.py new file mode 100644 index 0000000..06eaabd --- /dev/null +++ b/tests/test_speculator_traces.py @@ -0,0 +1,226 @@ +"""The Speculator series and flow strip on the /analysis Positioning Index panel. + +Store-free: the speculator frame is built from a synthetic CFTC-shaped report through +cotmetrics' own `categories.build_category_frame` and `flows.speculator_frame`, the +calls `CotIndexer.get_speculator_data` makes, so nothing here is hand-made. +""" + +import importlib +import sys + +import numpy as np +import pandas as pd +import pytest +from cotmetrics import categories as cot_categories +from cotmetrics import constants as cm_const +from cotmetrics import flows + +import components.plot_layout as layout_helpers +import components.plot_registry as registry +import components.speculator_traces as st +import viz_config +import viz_constants as vc +from components.plot_traces import LRG_LABEL, SML_LABEL + +PALETTE = viz_config.get_palette(sorted(viz_config.get_palette_names())[0]) +DISAGG = cot_categories.REPORT_DISAGG +TFF = cot_categories.REPORT_TFF +SPEC = cm_const.SPECULATOR + + +def _spec_frame(report=DISAGG, symbol="GC", n=160, seed=4, lookback=26): + rng = np.random.default_rng(seed) + idx = pd.date_range("2023-01-03", periods=n, freq="7D") + raw = pd.DataFrame(index=idx) + raw[cm_const.OPEN_INTEREST_XLS] = rng.integers(400_000, 600_000, n) + for spec in cot_categories.categories_for(report): + raw[spec.long_col] = 50_000 + np.cumsum(rng.normal(0, 3_000, n)).round() + raw[spec.short_col] = 50_000 + np.cumsum(rng.normal(0, 3_000, n)).round() + cat = cot_categories.build_category_frame(raw, report, lookback, + lookback_header=f" {lookback}") + return flows.speculator_frame(cat, report, symbol=symbol) + + +def _legacy_frame(index): + rng = np.random.default_rng(9) + df = pd.DataFrame(index=index) + for col in registry.IDX_COLS: + df[col] = rng.uniform(0, 100, len(index)) + df[cm_const.CLOSING_PRICE] = 100 + np.cumsum(rng.normal(0, 1, len(index))) + return df + + +def _index_panel(spec_df, with_speculator=True): + specs = registry.subplot_specs(["index"], show_price=True, num_cols=1) + fig = layout_helpers.get_make_subplots_for_plots(1, 1, ["Positioning Index"], specs) + ctx = registry.PlotCtx(fig=fig, df=_legacy_frame(spec_df.index), row=1, col=1, + palette=PALETTE, speculator=spec_df if with_speculator else None) + return registry.REGISTRY["index"].build(ctx) + + +def _by_name(fig, name): + got = [t for t in fig.data if t.name == name] + assert len(got) == 1, (name, [t.name for t in fig.data]) + return got[0] + + +def test_module_imports_without_the_data_layer(): + for name in [m for m in sys.modules if m.startswith("cotmetrics.indexer")]: + del sys.modules[name] + importlib.reload(st) + assert not any(m.startswith("cotmetrics.indexer") for m in sys.modules) + + +def test_panel_without_a_speculator_frame_is_unchanged(): + spec_df = _spec_frame() + fig = _index_panel(spec_df, with_speculator=False) + assert not [t for t in fig.data if t.type == "heatmap"] + assert not [t for t in fig.data if (t.name or "").startswith("Speculator")] + assert list(fig.layout.yaxis.range) == [0, 100] + assert not fig.layout.annotations[1:] # only the subplot title + + +@pytest.mark.parametrize("report,symbol,label", [ + (DISAGG, "GC", "Managed Money"), + (DISAGG, "ZC", "Managed Money"), + (TFF, "6E", "Asset Manager + Leveraged Funds"), +]) +def test_speculator_line_is_the_measured_groups_index(report, symbol, label): + spec_df = _spec_frame(report, symbol) + fig = _index_panel(spec_df) + line = _by_name(fig, f"Speculator ({label})") + col = flows.index_col(SPEC, spec_df.attrs["lookback_header"]) + got, want = list(line.y), spec_df[col].tolist() + assert all((pd.isna(a) and pd.isna(b)) or a == b for a, b in zip(got, want)) + assert line.line.color == PALETTE[vc.SPECULATOR_SLOT] + assert line.yaxis == "y" # the index axis, not the price axis + assert line.line.color not in PALETTE[:3] + + +def test_strip_is_the_flow_z_below_the_index_zero(): + spec_df = _spec_frame() + fig = _index_panel(spec_df) + strip = _by_name(fig, "Speculator flow z") + assert strip.type == "heatmap" and strip.yaxis == "y" + assert list(strip.y) == st.STRIP_EDGES and max(strip.y) < 0 + z = spec_df[flows.flow_z_col(SPEC)] + assert [None if pd.isna(v) else v for v in strip.z[0]] == \ + [None if pd.isna(v) else v for v in z.tolist()] + assert (strip.zmin, strip.zmax, strip.zmid) == (-3.0, 3.0, 0) + assert strip.showscale is False and strip.hoverongaps is False + assert strip.opacity == 1 + assert list(fig.layout.yaxis.range) == st.INDEX_RANGE + assert list(fig.layout.yaxis.tickvals) == st.INDEX_TICKS + assert list(fig.layout.yaxis.ticktext)[0] == "flow z" + assert st.STRIP_EDGES[0] < st.INDEX_TICKS[0] < st.STRIP_EDGES[1] + + +def test_strip_scale_is_diverging_with_zero_near_the_background(): + stops = dict((round(p, 3), c) for p, c in st.FLOW_COLORSCALE) + assert stops[0.5] == st.FLOW_ZERO + + def lum(hex_): + h = hex_.lstrip("#") + return sum(int(h[i:i + 2], 16) for i in (0, 2, 4)) + + bg = lum(vc.BACKGROUND_COLOR) + assert abs(lum(st.FLOW_ZERO) - bg) < 50 # near-zero reads as background + assert lum(stops[1.0]) > lum(stops[0.667]) > lum(st.FLOW_ZERO) + assert lum(stops[0.0]) > lum(stops[0.333]) > lum(st.FLOW_ZERO) + blue, red = stops[1.0].lstrip("#"), stops[0.0].lstrip("#") + assert int(blue[4:6], 16) > int(blue[0:2], 16) # buying is blue + assert int(red[0:2], 16) > int(red[4:6], 16) # selling is red + + +def test_strip_hover_carries_z_and_net_contracts(): + spec_df = _spec_frame() + strip = _by_name(_index_panel(spec_df), "Speculator flow z") + cells = strip.text[0] + assert all(c is None for c in cells[:cm_const.FLOW_Z_MIN_PERIODS]) + last = cells[-1] + assert last.startswith("Speculator flow z ") and "vs own 52-week sd" in last + assert "contracts (Managed Money)" in last + + +def test_note_names_the_speculator_and_how_retail_behaves(): + fig = _index_panel(_spec_frame(DISAGG, "ZC")) + note = fig.layout.annotations[-1].text + assert note == f"Speculator = Managed Money, retail ({SML_LABEL}) does not move with price here" + assert len(note) <= 90 + fig = _index_panel(_spec_frame(TFF, "6E")) + assert fig.layout.annotations[-1].text == ( + f"Speculator = Asset Manager + Leveraged Funds, retail ({SML_LABEL}) " + f"moves with price here") + + +def test_no_speculator_role_draws_no_line_or_strip_and_says_what_stands_in(): + spec_df = _spec_frame(TFF, "ZT") + fig = _index_panel(spec_df) + assert not [t for t in fig.data if t.type == "heatmap"] + assert not [t for t in fig.data if (t.name or "").startswith("Speculator")] + assert list(fig.layout.yaxis.range) == [0, 100] + note = fig.layout.annotations[-1].text + assert note.startswith(f"No speculator role here; {LRG_LABEL} stands in") + + +def test_copy_never_ranks_or_forecasts(): + banned = ("mover", "biggest move", "unusual", "next", "follow", "expect", + "forecast", "predict", "bullish", "bearish", "likely", "edge", "signal") + for report, symbol in ((DISAGG, "GC"), (DISAGG, "ZC"), (TFF, "6E"), (TFF, "ZT")): + fig = _index_panel(_spec_frame(report, symbol)) + texts = [a.text for a in fig.layout.annotations] + texts += [c for t in fig.data if t.type == "heatmap" for c in t.text[0] if c] + for text in texts: + assert not any(b in text.lower() for b in banned), text + assert "—" not in text + + +def test_page_fetches_the_speculator_only_for_a_raw_index_panel(monkeypatch): + import dash + from cotmetrics import models + + dash.Dash(__name__, use_pages=True, pages_folder="") + import pages.analytics.analysis as page + + calls = [] + spec_df = _spec_frame() + legacy = _legacy_frame(spec_df.index) + + class _Indexer: + def get_symbols_data(self, asset, lookback, basis=None): + return legacy + + def get_speculator_data(self, asset, lookback): + calls.append((asset, lookback)) + return spec_df + + def get_instrument_from_name(self, name): + return None + + def is_equity(self, name): + return False + + import components.controls as controls + + monkeypatch.setattr(page, "get_indexer", lambda: _Indexer()) + # The Net Positions range band asks the indexer for the lookback in weeks. + monkeypatch.setattr(controls, "lookback_weeks", lambda lookback, asset: 26) + palette = sorted(viz_config.get_palette_names())[0] + model = models.DEFAULT_MODEL.key + + out = page.update_analysis_stack(palette, "Gold", "26", ["index"], "1", model) + graphs = [c for c in out.children if getattr(c, "figure", None) is not None] \ + if hasattr(out, "children") and isinstance(out.children, list) else [out] + fig = next(g.figure for g in graphs if getattr(g, "figure", None) is not None) + assert calls == [("Gold", "26")] + assert [t for t in fig.data if t.name == "Speculator (Managed Money)"] + assert [t for t in fig.data if t.name == "Speculator flow z"] + + # The % of OI index panel is a different basis: no speculator fetch, no line. + calls.clear() + out = page.update_analysis_stack(palette, "Gold", "26", ["index_oinorm"], "1", model) + graphs = [c for c in out.children if getattr(c, "figure", None) is not None] \ + if hasattr(out, "children") and isinstance(out.children, list) else [out] + fig = next(g.figure for g in graphs if getattr(g, "figure", None) is not None) + assert calls == [] + assert not [t for t in fig.data if (t.name or "").startswith("Speculator")]