From ffb0680622ff59de390b322ed38d0362622e8ffe Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 22 Jun 2026 13:54:50 +0000
Subject: [PATCH 1/2] Add synchronised cross-pane tooltips and value-coloured
line subplot
Add tooltips synchronised across a price chart and its subplots: when any
pane is hovered, every other pane shows its own value for the hovered bar,
and all are hidden when the cursor leaves the hovered pane.
- `charts.SyncedTooltip` mixin: a per-pane `bq.Label` "value at bar"
tooltip, shown/hidden via `show_synced_tooltip`/`hide_synced_tooltip`.
Mixed into `BasePrice` (price charts) and `BaseSubplot` (subplots).
- `guis.BasePrice` coordinates the panes: it wires each pane's mark hover
to show the others' tooltips, and uses `ipyevents` mouseleave on each
figure to hide them (bqplot emits no un-hover event). `GuiOHLCCaseBase`
also wires the case scatter marks, so hovering a case (e.g. a position
entry/exit) shows the subplots' tooltips for that bar.
- `charts.SubplotLineColored`: base for a line subplot coloured by value
(segments coloured via a `bq.ColorScale` fixed to the full value range;
colour axis suppressed). Generalised from downstream use.
Adds `ipyevents` dependency.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_015xReNp6C4sSNy6Nc7K1hzM
---
pyproject.toml | 1 +
src/market_analy/charts.py | 273 ++++++++++++++++++++++++++++++++++++-
src/market_analy/guis.py | 113 ++++++++++++++-
tests/test_charts.py | 107 +++++++++++++++
tests/test_guis.py | 54 ++++++++
uv.lock | 14 ++
6 files changed, 558 insertions(+), 4 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 3d9130b..737ee6d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,6 +53,7 @@ classifiers = [
dependencies = [
"bqplot",
"exchange-calendars",
+ "ipyevents",
"Jinja2",
"market-prices",
"matplotlib",
diff --git a/src/market_analy/charts.py b/src/market_analy/charts.py
index 09ebbc8..b4f6b6f 100644
--- a/src/market_analy/charts.py
+++ b/src/market_analy/charts.py
@@ -58,7 +58,7 @@
import market_analy.utils.bq_utils as ubq
import market_analy.utils.ipywidgets_utils as wu
import market_analy.utils.pandas_utils as upd
-from market_analy.formatters import FORMATTERS, formatter_datetime
+from market_analy.formatters import FORMATTERS, formatter_datetime, formatter_float
from market_analy.utils.dict_utils import set_kwargs_from_dflt
from market_analy.utils.maths_utils import discretize_range_nicely
@@ -1664,7 +1664,122 @@ def update(
self.title = title
-class BasePrice(BaseSubsetDD):
+class SyncedTooltip:
+ """Mixin adding a synchronised 'value at bar' tooltip to a pane.
+
+ A price gui stacks a price chart above zero or more subplots, all
+ sharing the same x-axis (and hence the same bars). This mixin lets
+ each such pane show a lightweight tooltip giving the pane's value at
+ a given bar, so that a gui can show every pane's value for the same
+ bar when any one pane is hovered (see `guis.BasePrice`).
+
+ The tooltip is a `bq.Label` mark, hidden until shown at a bar.
+
+ Methods
+ -------
+ show_synced_tooltip(x):
+ Show the tooltip at the bar with x-tick `x`.
+
+ hide_synced_tooltip():
+ Hide the tooltip.
+
+ Notes
+ -----
+ A host class must be a `BaseSubsetDD` subclass (for `x_ticks`,
+ `scales`, the principal `mark`, `figure` and `add_marks`).
+ `_init_synced_tooltip` must be called, after the host's chart has been
+ created, to create the tooltip mark.
+
+ A concrete host should implement `_synced_tooltip_series` (the values
+ to show, by bar) and can override `_format_synced_value`,
+ `_synced_tooltip_prefix` and `_event_x` as required.
+ """
+
+ if TYPE_CHECKING:
+ # attributes provided by the `BaseSubsetDD` host
+ scales: dict[str, bq.Scale]
+ mark: bq.Mark
+ title: str | None
+ x_ticks: pd.DatetimeIndex
+
+ def add_marks(
+ self, marks: list[bq.Mark], group: AddedMarkKeys, under: bool = False
+ ) -> None: ...
+
+ SYNCED_TOOLTIP_COLOR = "yellow"
+
+ def _init_synced_tooltip(self) -> None:
+ """Create the (hidden) synced-tooltip label mark."""
+ self._synced_tooltip_mark = bq.Label(
+ x=[],
+ y=[],
+ text=[],
+ scales={"x": self.scales["x"], "y": self.scales["y"]},
+ colors=[self.SYNCED_TOOLTIP_COLOR],
+ default_size=12,
+ font_weight="bold",
+ align="middle",
+ y_offset=-10,
+ visible=False,
+ )
+ self.add_marks([self._synced_tooltip_mark], Groups.PERSIST)
+
+ def _synced_tooltip_series(self) -> pd.Series | None:
+ """Values to show by the synced tooltip, indexed by x-tick.
+
+ Returns None to disable the synced tooltip for the pane.
+
+ A concrete host should override.
+ """
+ return None
+
+ def _format_synced_value(self, value: float) -> str:
+ """Format a value for display by the synced tooltip."""
+ return formatter_float(value)
+
+ @property
+ def _synced_tooltip_prefix(self) -> str:
+ """Label prefixing the synced-tooltip value."""
+ return self.title or "Value"
+
+ def _event_x(self, mark: bq.Mark, event: dict) -> pd.Timestamp | None: # noqa: ARG002
+ """x-tick of the bar of a hovered element of the principal mark.
+
+ A host whose principal mark does not hold the full data, one
+ element per bar, should override.
+ """
+ index = event["data"]["index"]
+ ticks = self.x_ticks
+ return ticks[index] if 0 <= index < len(ticks) else None
+
+ def show_synced_tooltip(self, x: pd.Timestamp) -> None:
+ """Show the synced tooltip for the bar with x-tick `x`.
+
+ Hides the tooltip if the pane has no (or no valid) value at `x`.
+ """
+ series = self._synced_tooltip_series()
+ if series is None or x not in series.index:
+ self.hide_synced_tooltip()
+ return
+ value = series.loc[x]
+ if pd.isna(value):
+ self.hide_synced_tooltip()
+ return
+ text = f"{self._synced_tooltip_prefix}: {self._format_synced_value(value)}"
+ mark = self._synced_tooltip_mark
+ with mark.hold_sync():
+ mark.x = [x]
+ mark.y = [value]
+ mark.text = [text]
+ mark.visible = True
+
+ def hide_synced_tooltip(self) -> None:
+ """Hide the synced tooltip."""
+ if getattr(self, "_synced_tooltip_mark", None) is not None:
+ self._synced_tooltip_mark.visible = False
+
+
+class BasePrice(SyncedTooltip, BaseSubsetDD):
"""Base class for price charts.
Concretes `BaseSubsetDD` with y-axis defined for prices. Provides price
@@ -1985,6 +2100,14 @@ def _y_data(self) -> Series:
def _get_mark_y_data(self) -> Series:
return self._y_data
+ def _synced_tooltip_series(self) -> pd.Series:
+ """Close prices, indexed by x-tick, for the synced tooltip."""
+ return pd.Series(self._y_data.to_numpy(), index=self.x_ticks)
+
+ @property
+ def _synced_tooltip_prefix(self) -> str:
+ return "Close"
+
@property
def MarkCls(self) -> type[bq.Mark]:
return bq.Lines
@@ -2384,6 +2507,14 @@ def _tooltip_value(self, mark: bq.OHLC, event: dict) -> str:
s += "
"
return s
+ def _synced_tooltip_series(self) -> pd.Series:
+ """Close prices, indexed by x-tick, for the synced tooltip."""
+ return pd.Series(self.data["close"].to_numpy(), index=self.x_ticks)
+
+ @property
+ def _synced_tooltip_prefix(self) -> str:
+ return "Close"
+
def _axes_kwargs(
self, axes_kwargs: AxesKwargs | None = None, **general_kwargs
) -> AxesKwargs:
@@ -3176,7 +3307,7 @@ def select_previous_case(self):
# --------
-class BaseSubplot(BaseSubsetDD):
+class BaseSubplot(SyncedTooltip, BaseSubsetDD):
"""Base class for a subplot associated with a price chart.
A subplot shares the x-axis of an accompanying price chart by reusing
@@ -3311,6 +3442,16 @@ def _default_colors(self, data: pd.DataFrame | pd.Series) -> list[str] | None:
return list(main_colors)
return None
+ def _synced_tooltip_series(self) -> pd.Series | None:
+ """Subplot values, indexed by x-tick, for the synced tooltip.
+
+ Returns None (disabling the synced tooltip) where the subplot
+ covers multiple symbols, there being no single value per bar.
+ """
+ if isinstance(self.data, pd.Series):
+ return pd.Series(self.data.to_numpy(), index=self.x_ticks)
+ return None
+
@property
def _y_data(self) -> pd.DataFrame | pd.Series:
return self.data
@@ -3495,6 +3636,9 @@ def _format_value(y: float) -> str:
"""Format a value with thousands separators."""
return f"{int(y):,}" if y.is_integer() else f"{y:,.2f}"
+ def _format_synced_value(self, value: float) -> str:
+ return self._format_value(float(value))
+
def _tooltip_value(self, mark: bq.Bars, event: dict) -> str:
"""Show data for hovered bar.
@@ -3555,6 +3699,129 @@ def _get_mark_y_plotted_data(self):
return super()._get_mark_y_plotted_data(multiple_symbols=multiple_symbols)
+class SubplotLineColored(SubplotLines):
+ """Base for a line subplot coloured by value.
+
+ Colours the line according to the value at each bar relative to all
+ other values over the data: the colour graduates from the first to
+ the last colour of `COLOR_SCALE` as the value rises from its lowest
+ to its highest (by default from blue, for the lowest value, through
+ to red, for the highest).
+
+ See `BaseSubplot` and `SubplotLines` for documentation of inherited
+ methods and attributes and for how to implement a concrete subplot
+ (a subclass must implement `get_subplot_data`).
+
+ Attributes
+ ----------
+ COLOR_SCALE
+ Colours between which the line graduates, from the colour for the
+ lowest value through to the colour for the highest value.
+
+ Notes
+ -----
+ A `bq.Lines` mark colours each of its lines as a whole rather than
+ along the line's length. The line is therefore coloured by value by
+ splitting it into one bqplot line per pair of adjacent bars. Each
+ such segment is assigned the mean of the value at its two end bars,
+ mapped to a colour via a `bq.ColorScale` fixed to the full value
+ range. Fixing the scale to the full range (rather than to the range
+ currently in view) ensures a given value always maps to the same
+ colour, such that the colour reflects the value relative to all other
+ values.
+ """
+
+ COLOR_SCALE = ["blue", "red"]
+
+ def _create_scales(self) -> dict[ubq.ScaleKeys, bq.Scale]:
+ scales = super()._create_scales()
+ lo, hi = self._color_scale_limits()
+ scales["color"] = bq.ColorScale(colors=list(self.COLOR_SCALE), min=lo, max=hi)
+ return scales
+
+ def _axes_kwargs(
+ self, axes_kwargs: AxesKwargs | None = None, **general_kwargs
+ ) -> AxesKwargs:
+ # Suppress the colour axis. The base creates an axis for every scale,
+ # which for the colour scale is a colourbar. The hue along the line
+ # already conveys the value, so the colourbar is redundant.
+ kwargs = super()._axes_kwargs(axes_kwargs, **general_kwargs)
+ kwargs.pop("color", None)
+ return kwargs
+
+ def _color_scale_limits(self) -> tuple[float, float]:
+ """Lowest and highest value over all the data.
+
+ Returns (0.0, 1.0) if no value can be evaluated (all nan).
+ """
+ values = np.asarray(self.data.to_numpy(), dtype="float64")
+ valid = values[~np.isnan(values)]
+ if not valid.size:
+ return 0.0, 1.0
+ lo, hi = float(valid.min()), float(valid.max())
+ if hi == lo:
+ hi = lo + 1.0
+ return lo, hi
+
+ def _set_mark_to_plotted(self) -> None:
+ """Set the mark to the plotted data, coloured by value.
+
+ Plots the line as one bqplot line per pair of adjacent bars, each
+ coloured by value (see the class NOTES). Replaces, rather than
+ extends, `SubplotLines._set_mark_to_plotted`.
+ """
+ x = self.plotted_x_ticks.to_numpy()
+ y = np.asarray(self._get_mark_y_plotted_data(), dtype="float64")
+ x_seg, y_seg, color = self._segments(x, y)
+ self.mark.x = x_seg
+ self.mark.y = y_seg
+ self.mark.color = color
+
+ @staticmethod
+ def _segments(
+ x: np.ndarray, y: np.ndarray
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+ """Split a line into one 2-point segment per pair of adjacent bars.
+
+ Returns a 3-tuple (x_seg, y_seg, color) where `x_seg` and `y_seg`
+ are 2D arrays with one row per segment (each row holding the two
+ end values of a segment) and `color` is the per-segment value,
+ taken as the mean of the value at the segment's two end bars.
+
+ If fewer than two values are received the values are returned
+ unchanged (as `x`, `y` and `y`), there being no segment to form.
+ """
+ if len(x) < 2:
+ return x, y, y
+ x_seg = np.column_stack([x[:-1], x[1:]])
+ y_seg = np.column_stack([y[:-1], y[1:]])
+ color = (y[:-1] + y[1:]) / 2.0
+ return x_seg, y_seg, color
+
+ def _event_x(self, mark: bq.Mark, event: dict) -> pd.Timestamp | None: # noqa: ARG002
+ # the mark is segmented (one line per adjacent pair of bars), so a
+ # hovered line index identifies the segment's left bar.
+ index = event["data"]["index"]
+ ticks = self.plotted_x_ticks
+ return ticks[index] if 0 <= index < len(ticks) else None
+
+ def _tooltip_value(self, mark: bq.Mark, event: dict) -> str:
+ """Show the value at the hovered bar.
+
+ See `Base._tooltip_value` for the hook's contract.
+ """
+ x = self._event_x(mark, event)
+ series = self._synced_tooltip_series()
+ if x is None or series is None or x not in series.index:
+ return ""
+ value = series.loc[x]
+ style = tooltip_html_style(color=self.TOOLTIP_TEXT_COLOR, line_height=1.3)
+ prefix = self._synced_tooltip_prefix
+ s = f"Bar: " + formatter_datetime(x)
+ s += f"
{prefix}: {self._format_synced_value(value)}
"
+ return s
+
+
class SubplotVolume(SubplotBars):
"""Volume subplot."""
diff --git a/src/market_analy/guis.py b/src/market_analy/guis.py
index c5e8fc5..21c91ef 100644
--- a/src/market_analy/guis.py
+++ b/src/market_analy/guis.py
@@ -55,6 +55,8 @@
from copy import deepcopy
from typing import TYPE_CHECKING, Literal
+import bqplot as bq
+import ipyevents
import IPython
import ipyvuetify as v
import ipywidgets as w
@@ -68,7 +70,11 @@
from market_analy import analysis as ma_analysis
from market_analy import charts, gui_parts
from market_analy.standalone import get_pct_off_high
-from market_analy.utils.bq_utils import Crosshairs, FastIntervalSelectorDD
+from market_analy.utils.bq_utils import (
+ Crosshairs,
+ FastIntervalSelectorDD,
+ discontinuous_date_to_timestamp,
+)
from market_analy.utils.dict_utils import set_kwargs_from_dflt
if TYPE_CHECKING:
@@ -1077,12 +1083,94 @@ def _create_gui_parts(self):
self._create_subplots()
self._crosshairs = Crosshairs(self.chart.figure)
self._set_mark_handlers()
+ self._init_synced_tooltips()
self._icon_row_top: gui_parts.IconRowTop = self._create_icon_row_top()
self._selector_boxes: w.HBox | None = self._create_selector_boxes()
self.date_slider: wu.DateRangeSlider = self._create_date_slider()
self._html_output: w.HTML = self._create_html_output()
self.tabs_control: gui_parts.TabsControl = self._create_tabs_control()
+ # SYNCED TOOLTIPS
+ def _init_synced_tooltips(self):
+ """Set up tooltips synchronised across the chart and subplots.
+
+ When the chart or any subplot is hovered, every other pane shows
+ its own value for the hovered bar (the hovered pane shows its own
+ native tooltip). All synced tooltips are hidden when the cursor
+ leaves the hovered pane.
+
+ Has no effect if there are no subplots, there then being no other
+ pane to synchronise with.
+ """
+ if not self._subplots:
+ self._synced_panes: list[charts.SyncedTooltip] = []
+ return
+ self._synced_panes = [self.chart, *self._subplots]
+ for pane in self._synced_panes:
+ pane._init_synced_tooltip() # noqa: SLF001
+ self._watch_mark_for_synced_tooltip(pane.mark, pane, pane._event_x) # noqa: SLF001
+ self._wire_extra_synced_marks()
+ self._synced_tooltip_listeners = []
+ for pane in self._synced_panes:
+ listener = ipyevents.Event(
+ source=pane.figure, watched_events=["mouseleave"]
+ )
+ listener.on_dom_event(lambda _event: self._clear_synced_tooltips())
+ self._synced_tooltip_listeners.append(listener)
+
+ def _watch_mark_for_synced_tooltip(
+ self,
+ mark: bq.Mark,
+ source: charts.SyncedTooltip,
+ x_of_event: Callable[[bq.Mark, dict], pd.Timestamp | None],
+ ):
+ """Trigger the synced tooltips when `mark` is hovered.
+
+ Parameters
+ ----------
+ mark
+ Mark to watch for hover.
+
+ source
+ Pane on which `mark` is plotted (and which will show its own
+ native tooltip rather than a synced tooltip).
+
+ x_of_event
+ Callable returning the x-tick of the hovered element (or None).
+ """
+
+ def handler(hovered_mark: bq.Mark, event: dict):
+ x = x_of_event(hovered_mark, event)
+ if x is not None:
+ self._show_synced_tooltips(x, source)
+
+ mark.on_hover(handler)
+
+ def _wire_extra_synced_marks(self):
+ """Watch marks, beyond each pane's principal mark, for hover.
+
+ No-op by default. A subclass can override to additionally trigger
+ the synced tooltips from other marks (see `GuiOHLCCaseBase`).
+ """
+ return
+
+ def _show_synced_tooltips(self, x: pd.Timestamp, source: charts.SyncedTooltip):
+ """Show every pane's synced tooltip for the bar at x-tick `x`.
+
+ The `source` pane (the hovered pane) shows its own native tooltip,
+ so its synced tooltip is instead hidden.
+ """
+ for pane in self._synced_panes:
+ if pane is source:
+ pane.hide_synced_tooltip()
+ else:
+ pane.show_synced_tooltip(x)
+
+ def _clear_synced_tooltips(self):
+ """Hide the synced tooltip on every pane."""
+ for pane in getattr(self, "_synced_panes", []):
+ pane.hide_synced_tooltip()
+
# SUB-PLOTS
def _build_subplot(
self, subplot_cls: type[charts.BaseSubplot]
@@ -1144,6 +1232,8 @@ def close(self):
"""Close all gui widgets, including any subplots."""
# Necessary to independenty close self._subplots as each subplot figture
# and associated widgets are stored in subplot._widgets, not self._widgets.
+ for listener in getattr(self, "_synced_tooltip_listeners", []):
+ listener.close()
for subplot in self._subplots:
subplot.close()
super().close()
@@ -2129,6 +2219,27 @@ def _gui_handler_click_case(
html = self.cases.get_case_html(case)
self.html_output.display(html)
+ @staticmethod
+ def _scatter_event_x(mark: bq.Scatter, event: dict) -> pd.Timestamp | None: # noqa: ARG004
+ """x-tick of a hovered case scatter point."""
+ return discontinuous_date_to_timestamp(event["data"]["x"])
+
+ def _wire_extra_synced_marks(self):
+ """Also trigger the synced tooltips from the case scatter marks.
+
+ Hovering a point on a case scatter (for example a position's entry
+ or exit) shows the subplots' tooltips for that bar, the chart
+ itself showing the scatter point's own native tooltip.
+
+ See `BasePrice._wire_extra_synced_marks` for the method's super.
+ """
+ super()._wire_extra_synced_marks()
+ scatters = self.chart.added_marks.get(charts.Groups.CASES_SCATTERS, [])
+ for scatter in scatters:
+ self._watch_mark_for_synced_tooltip(
+ scatter, self.chart, self._scatter_event_x
+ )
+
def _show_all_but_handler(self, but: vu.IconBut, event: str, data: dict):
if but.is_light:
self.chart.hide_cases()
diff --git a/tests/test_charts.py b/tests/test_charts.py
index b7eaa34..818c1f2 100644
--- a/tests/test_charts.py
+++ b/tests/test_charts.py
@@ -390,3 +390,110 @@ def test_missing_volume_col_raises_on_construction(self):
prices = _make_prices(["AZN.L"], with_volume=False)
with pytest.raises(ValueError, match="does not include a 'volume' column"):
charts.SubplotVolume(_mock_chart(prices), prices)
+
+
+@pytest.fixture
+def SubplotColored() -> type[charts.SubplotLineColored]:
+ """Thin `SubplotLineColored` subclass plotting close price."""
+
+ class _SubplotColored(charts.SubplotLineColored):
+ TITLE = "Close"
+
+ def get_subplot_data(self, prices):
+ return prices.xs("close", axis=1, level=-1).iloc[:, 0].rename("close")
+
+ return _SubplotColored
+
+
+class TestSubplotLineColored:
+ """Tests for the value-coloured line subplot base class."""
+
+ def test_is_lines_subplot(self, SubplotColored):
+ prices = _make_prices(["AZN.L"])
+ pane = SubplotColored(_mock_chart(prices), prices)
+ assert isinstance(pane, charts.SubplotLines)
+ assert pane.MarkCls is bq.Lines
+
+ def test_line_is_segmented_per_pair_of_bars(self, SubplotColored):
+ """The line is plotted as one bqplot line per adjacent pair of bars."""
+ prices = _make_prices(["AZN.L"], n=8)
+ pane = SubplotColored(_mock_chart(prices), prices)
+ n = len(prices)
+ assert np.asarray(pane.mark.x).shape == (n - 1, 2)
+ assert np.asarray(pane.mark.y).shape == (n - 1, 2)
+ assert len(np.asarray(pane.mark.color)) == n - 1
+
+ def test_segment_colour_is_mean_of_end_bars(self, SubplotColored):
+ prices = _make_prices(["AZN.L"], n=8)
+ pane = SubplotColored(_mock_chart(prices), prices)
+ values = pane.data.to_numpy()
+ expected = (values[:-1] + values[1:]) / 2.0
+ np.testing.assert_allclose(np.asarray(pane.mark.color), expected)
+
+ def test_colour_scale_spans_full_value_range(self, SubplotColored):
+ """The colour scale is fixed to the full value range over the data."""
+ prices = _make_prices(["AZN.L"], n=8)
+ pane = SubplotColored(_mock_chart(prices), prices)
+ values = pane.data.to_numpy()
+ scale = pane.scales["color"]
+ assert scale.colors == ["blue", "red"]
+ assert scale.min == pytest.approx(np.nanmin(values))
+ assert scale.max == pytest.approx(np.nanmax(values))
+
+ def test_no_colour_axis(self, SubplotColored):
+ """No colourbar is created for the colour scale."""
+ prices = _make_prices(["AZN.L"], n=8)
+ pane = SubplotColored(_mock_chart(prices), prices)
+ assert not any(isinstance(ax, bq.ColorAxis) for ax in pane.axes)
+ assert "color" in pane.mark.scales
+
+ def test_event_x_maps_segment_to_left_bar(self, SubplotColored):
+ prices = _make_prices(["AZN.L"], n=8)
+ pane = SubplotColored(_mock_chart(prices), prices)
+ x = pane._event_x(pane.mark, {"data": {"index": 3}})
+ assert x == pane.plotted_x_ticks[3]
+
+
+class TestSyncedTooltip:
+ """Tests for the `SyncedTooltip` mixin (via a subplot host)."""
+
+ def test_series_indexed_by_x_ticks(self, SubplotColored):
+ prices = _make_prices(["AZN.L"], n=8)
+ pane = SubplotColored(_mock_chart(prices), prices)
+ series = pane._synced_tooltip_series()
+ assert series.index.equals(pane.x_ticks)
+ np.testing.assert_allclose(series.to_numpy(), pane.data.to_numpy())
+
+ def test_show_and_hide(self, SubplotColored):
+ prices = _make_prices(["AZN.L"], n=8)
+ pane = SubplotColored(_mock_chart(prices), prices)
+ pane._init_synced_tooltip()
+ label = pane._synced_tooltip_mark
+ assert label.visible is False
+ x = pane.x_ticks[4]
+ pane.show_synced_tooltip(x)
+ assert label.visible is True
+ assert list(label.x) == [x]
+ assert label.y[0] == pytest.approx(pane.data.to_numpy()[4])
+ assert label.text[0].startswith("Close: ")
+ pane.hide_synced_tooltip()
+ assert label.visible is False
+
+ def test_show_hidden_when_no_value_at_x(self, SubplotColored):
+ prices = _make_prices(["AZN.L"], n=8)
+ pane = SubplotColored(_mock_chart(prices), prices)
+ pane._init_synced_tooltip()
+ pane.show_synced_tooltip(pane.x_ticks[4]) # show first
+ foreign_x = pane.x_ticks[-1] + pd.Timedelta("365D")
+ pane.show_synced_tooltip(foreign_x)
+ assert pane._synced_tooltip_mark.visible is False
+
+ def test_volume_subplot_formats_value(self):
+ prices = _make_prices(["AZN.L"])
+ pane = charts.SubplotVolume(_mock_chart(prices), prices)
+ pane._init_synced_tooltip()
+ pane.show_synced_tooltip(pane.x_ticks[2])
+ text = pane._synced_tooltip_mark.text[0]
+ assert text.startswith("Volume: ")
+ # value formatted with thousands separator (no decimal for integers)
+ assert "." not in text.split(": ")[1]
diff --git a/tests/test_guis.py b/tests/test_guis.py
index 89765e0..fbe0879 100644
--- a/tests/test_guis.py
+++ b/tests/test_guis.py
@@ -189,6 +189,60 @@ def test_close(self, analy, pp, SubplotVol):
gui.close()
+class TestSyncedTooltips:
+ """Tests for tooltips synchronised across the chart and subplots."""
+
+ @pytest.fixture
+ def analy(self, prices_analysis) -> analysis.Analysis:
+ return analysis.Analysis(prices_analysis)
+
+ @pytest.fixture
+ def pp(self) -> dict:
+ return {"start": pd.Timestamp("2023-01-06"), "end": pd.Timestamp("2023-01-10")}
+
+ def test_panes_are_chart_and_subplots(self, analy, pp, SubplotVol, SubplotClose):
+ gui = analy.plot(**pp, subplots=[SubplotVol, SubplotClose], display=False)
+ assert gui._synced_panes == [gui.chart, *gui.subplots]
+ # each pane has a synced-tooltip mark, hidden initially
+ for pane in gui._synced_panes:
+ assert pane._synced_tooltip_mark.visible is False
+
+ def test_no_subplots_no_panes(self, analy, pp):
+ gui = analy.plot(**pp, subplots=False, display=False)
+ assert gui._synced_panes == []
+
+ def test_show_synced_tooltips_shows_others_hides_source(
+ self, analy, pp, SubplotVol
+ ):
+ """Hovering one pane shows the others' tooltips, hides the source's."""
+ gui = analy.plot(**pp, subplots=[SubplotVol], display=False)
+ chart, pane = gui.chart, gui.subplots[0]
+ x = chart.x_ticks[2]
+ gui._show_synced_tooltips(x, source=chart)
+ # subplot (not the source) shows its tooltip for the bar
+ assert pane._synced_tooltip_mark.visible is True
+ assert list(pane._synced_tooltip_mark.x) == [x]
+ # the source chart shows its own native tooltip, not a synced one
+ assert chart._synced_tooltip_mark.visible is False
+
+ def test_clear_hides_all(self, analy, pp, SubplotVol):
+ gui = analy.plot(**pp, subplots=[SubplotVol], display=False)
+ gui._show_synced_tooltips(gui.chart.x_ticks[2], source=gui.chart)
+ gui._clear_synced_tooltips()
+ for pane in gui._synced_panes:
+ assert pane._synced_tooltip_mark.visible is False
+
+ def test_hover_dispatch_triggers_sync(self, analy, pp, SubplotVol):
+ """Firing the chart mark's hover shows the subplot's synced tooltip."""
+ gui = analy.plot(**pp, subplots=[SubplotVol], display=False)
+ pane = gui.subplots[0]
+ i = 2
+ # invoke the mark's hover callbacks as bqplot's frontend would
+ gui.chart.mark._hover_handlers(gui.chart.mark, {"data": {"index": i}})
+ assert pane._synced_tooltip_mark.visible is True
+ assert list(pane._synced_tooltip_mark.x) == [gui.chart.x_ticks[i]]
+
+
class TestGuiMultLineSubplots:
"""Tests for subplots associated with a multi-symbol (Compare) price chart.
diff --git a/uv.lock b/uv.lock
index d001d5f..644a79a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -918,6 +918,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
+[[package]]
+name = "ipyevents"
+version = "2.0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ipywidgets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/82/e5/ac7e199dd22ce3f7e0d43c6433fef5450197fa81994393e3381893b12d61/ipyevents-2.0.4.tar.gz", hash = "sha256:bd8c66a9ff26f481494cda1ec1d979722ca9eba19f8ef52538c7cc2db2a1a139", size = 210601, upload-time = "2025-09-15T14:02:34.29Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ca/d3/642a6dc3db8ea558a9b5fbc83815b197861868dc98f98a789b85c7660670/ipyevents-2.0.4-py3-none-any.whl", hash = "sha256:e532e05b037f70373850723f483d2830cb8a633e5aa19637ee9e7adaf41421f1", size = 102433, upload-time = "2025-09-15T14:02:33.255Z" },
+]
+
[[package]]
name = "ipykernel"
version = "7.3.0"
@@ -1715,6 +1727,7 @@ source = { editable = "." }
dependencies = [
{ name = "bqplot" },
{ name = "exchange-calendars" },
+ { name = "ipyevents" },
{ name = "ipyvuetify" },
{ name = "ipywidgets" },
{ name = "jinja2" },
@@ -1755,6 +1768,7 @@ test = [
requires-dist = [
{ name = "bqplot" },
{ name = "exchange-calendars" },
+ { name = "ipyevents" },
{ name = "ipyvuetify" },
{ name = "ipywidgets" },
{ name = "jinja2" },
From accfd91a32217b805c02cf81820056424aba9a0f Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 24 Jun 2026 07:50:25 +0000
Subject: [PATCH 2/2] Guard colour-line tooltip against nan and test scatter
bar mapping
Skip the value-coloured line's native tooltip where the hovered bar has
no value (nan). Add a unit test for `GuiOHLCCaseBase._scatter_event_x`,
which maps a case scatter's hover to the corresponding bar.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_015xReNp6C4sSNy6Nc7K1hzM
---
src/market_analy/charts.py | 2 ++
tests/test_guis.py | 18 ++++++++++++++++++
2 files changed, 20 insertions(+)
diff --git a/src/market_analy/charts.py b/src/market_analy/charts.py
index b4f6b6f..b3e2346 100644
--- a/src/market_analy/charts.py
+++ b/src/market_analy/charts.py
@@ -3815,6 +3815,8 @@ def _tooltip_value(self, mark: bq.Mark, event: dict) -> str:
if x is None or series is None or x not in series.index:
return ""
value = series.loc[x]
+ if pd.isna(value):
+ return ""
style = tooltip_html_style(color=self.TOOLTIP_TEXT_COLOR, line_height=1.3)
prefix = self._synced_tooltip_prefix
s = f"Bar: " + formatter_datetime(x)
diff --git a/tests/test_guis.py b/tests/test_guis.py
index fbe0879..28cfb66 100644
--- a/tests/test_guis.py
+++ b/tests/test_guis.py
@@ -5,6 +5,8 @@
import pytest
from market_analy import analysis, charts
+from market_analy.guis import GuiOHLCCaseBase
+from market_analy.utils.bq_utils import dates_to_posix
# NOTE: tests are extremely incomplete! Currently limited to only testing
# Subplots
@@ -243,6 +245,22 @@ def test_hover_dispatch_triggers_sync(self, analy, pp, SubplotVol):
assert list(pane._synced_tooltip_mark.x) == [gui.chart.x_ticks[i]]
+class TestScatterSyncedTooltip:
+ """Tests for triggering synced tooltips from case scatter marks."""
+
+ def test_scatter_event_x_maps_to_bar(self):
+ """A case scatter's hover x maps to the corresponding x-tick.
+
+ A scatter point's `event['data']['x']` is the posix value of the
+ bar's x-tick (as set on the shared ordinal scale), which
+ `_scatter_event_x` must invert back to the bar's timestamp.
+ """
+ tick = pd.Timestamp("2023-01-06")
+ posix = dates_to_posix(pd.DatetimeIndex([tick]).as_unit("ns"))[0]
+ event = {"data": {"x": posix}}
+ assert GuiOHLCCaseBase._scatter_event_x(None, event) == tick
+
+
class TestGuiMultLineSubplots:
"""Tests for subplots associated with a multi-symbol (Compare) price chart.