Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions tests/test_plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down
13 changes: 11 additions & 2 deletions vectorbt/portfolio/trades.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down