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.