diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 6309b784..63ed00fd 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -22,6 +22,7 @@ import vectorbt as vbt from vectorbt.generic.plotting import Scatter, Bar, Histogram, Heatmap, Gauge, Box, Volume, TraceUpdater +from vectorbt.portfolio.base import Portfolio from vectorbt.utils.figure import make_figure, make_subplots, Figure, FigureWidget @@ -625,6 +626,45 @@ def test_trades_plot_pnl_pct_scale(self): # pct_scale=False: y-values are absolute PnL np.testing.assert_array_almost_equal(fig_abs.data[0].y, [1.78, 2.77]) + def test_trades_plot_pnl_non_finite_returns(self): + """Non-finite returns should not invalidate Plotly marker metadata.""" + # Cover the issue's exact case where no finite return is available for scaling. + open_pf = Portfolio.from_orders( + pd.Series([1.0, np.nan]), + pd.Series([1.0, 0.0]), + ffill_val_price=False, + ) + + # Exercise both the direct records API and its Portfolio convenience delegate. + for fig in (open_pf.get_trades().plot_pnl(), open_pf.plot_trade_pnl()): + assert isinstance(fig, BaseFigure) + nt = named_traces(fig) + + # Without a finite reference, the open marker should use both lower bounds. + np.testing.assert_array_equal(nt["Open"].marker.size, [7.0]) + np.testing.assert_array_equal(nt["Open"].marker.opacity, [0.75]) + + # One unknown open trade must not alter the scaling of finite closed trades. + close = pd.Series([1.0, 2.0, 1.0, 3.0, 1.0, np.nan]) + size = pd.Series([1.0, -1.0, 1.0, -1.0, 1.0, 0.0]) + pf = Portfolio.from_orders(close, size, ffill_val_price=False) + + # Require identical marker behavior from the direct and delegated plotting paths. + for fig in (pf.get_trades().plot_pnl(), pf.plot_trade_pnl()): + assert isinstance(fig, BaseFigure) + nt = named_traces(fig) + + # Finite trades keep their relative endpoints while the unknown trade uses the minima. + np.testing.assert_array_equal(nt["Closed - Profit"].marker.size, [7.0, 14.0]) + np.testing.assert_array_equal(nt["Closed - Profit"].marker.opacity, [0.75, 0.9]) + np.testing.assert_array_equal(nt["Open"].marker.size, [7.0]) + np.testing.assert_array_equal(nt["Open"].marker.opacity, [0.75]) + + # Guard every emitted trace against metadata that Plotly would reject. + for trace in fig.data: + assert np.all(np.isfinite(trace.marker.size)) + assert np.all(np.isfinite(trace.marker.opacity)) + class TestDrawdownsPlot: def test_drawdowns_plot_with_zones(self): diff --git a/vectorbt/portfolio/trades.py b/vectorbt/portfolio/trades.py index f4291fa6..bb96021c 100644 --- a/vectorbt/portfolio/trades.py +++ b/vectorbt/portfolio/trades.py @@ -910,8 +910,17 @@ def plot_pnl( profit_mask = pnl > 0 loss_mask = pnl < 0 - marker_size = min_rel_rescale(np.abs(returns), marker_size_range) - opacity = max_rel_rescale(np.abs(returns), opacity_range) + # Exclude NaN and infinity so they cannot contaminate scaling for every trade marker. + abs_returns = np.abs(returns) + finite_mask = np.isfinite(abs_returns) + + # Start unknown returns at the lower bounds, which are valid Plotly marker values. + marker_size = np.full(abs_returns.shape, marker_size_range[0], dtype=float) + opacity = np.full(abs_returns.shape, opacity_range[0], dtype=float) + if np.any(finite_mask): + # Preserve the existing relative scale for finite trades only. + marker_size[finite_mask] = min_rel_rescale(abs_returns[finite_mask], marker_size_range) + opacity[finite_mask] = max_rel_rescale(abs_returns[finite_mask], opacity_range) open_mask = status == TradeStatus.Open closed_profit_mask = (~open_mask) & profit_mask