From f3471578e1bfef6bd2c01f2a2cdcb9f71b600be3 Mon Sep 17 00:00:00 2001 From: Matt Spinola Date: Sat, 26 Sep 2026 19:52:56 -0400 Subject: [PATCH] /analysis: Speculator series and a flow-z strip on the Positioning Index panel "Scope, restated v2" (cotmetrics cross-universe handoff): one role series per market, added to the existing panel, nothing else. - The raw-basis Positioning Index panel gains "Speculator ()", the positioning index of the cohorts cotmetrics' FlowRoles table measures as the market's speculator (Managed Money on Gold and Corn, Asset Manager plus Leveraged Funds on the Euro), at the same per-symbol lookback, in palette slot 5, which differs from the three Legacy legs in every palette. - One strip under the lines, inside the panel below its zero: the speculator net's weekly change over its own 52-week sd, blue buying, red selling, zero near the background, clipped at 3; a "flow z" tick names it. - Retail is a label, not a line: Legacy Non-Reportable is the same data, so the panel says how retail behaves on the market (moves with price, does not, moves against it) from the same table. Where the table names no speculator (ZT) there is no line or strip and the label says Non-Commercial stands in. - Fetched only when a raw index panel is drawn (CotIndexer.get_speculator_data); the % of OI and grid views are unchanged. - The clientside autoscale leaves an axis carrying a heatmap alone: fitting the index axis to the lines in a zoomed window cut the strip off. The index panel therefore keeps 0 to 100 on zoom. cotmetrics floor 0.15.0 (mspinola/cotmetrics#52). Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 10 +- src/assets/clientside.js | 6 + src/components/plot_registry.py | 14 +- src/components/speculator_traces.py | 160 ++++++++++++++++++++ src/pages/analytics/analysis.py | 11 +- src/viz_constants.py | 4 + tests/test_speculator_traces.py | 226 ++++++++++++++++++++++++++++ 7 files changed, 425 insertions(+), 6 deletions(-) create mode 100644 src/components/speculator_traces.py create mode 100644 tests/test_speculator_traces.py 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")]