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")]