diff --git a/backtesting/_plotting.py b/backtesting/_plotting.py index faccc61db..065a5528e 100644 --- a/backtesting/_plotting.py +++ b/backtesting/_plotting.py @@ -522,6 +522,16 @@ def _plot_ohlc_trades(): legend_label=f'Trades ({len(trades)})', line_width=8, line_alpha=1, line_dash='dotted') + MARKER_FUNCTIONS = { + 'circle': lambda fig, **kwargs: fig.scatter(marker='circle', **kwargs), + 'square': lambda fig, **kwargs: fig.scatter(marker='square', **kwargs), + 'triangle': lambda fig, **kwargs: fig.scatter(marker='triangle', **kwargs), + 'diamond': lambda fig, **kwargs: fig.scatter(marker='diamond', **kwargs), + 'cross': lambda fig, **kwargs: fig.scatter(marker='cross', **kwargs), + 'x': lambda fig, **kwargs: fig.scatter(marker='x', **kwargs), + 'star': lambda fig, **kwargs: fig.scatter(marker='star', **kwargs), + } + def _plot_indicators(): """Strategy indicators""" @@ -563,7 +573,36 @@ def __eq__(self, other): tooltips = [] colors = value._opts['color'] colors = colors and cycle(_as_list(colors)) or ( - cycle([next(ohlc_colors)]) if is_overlay else colorgen()) + cycle([next(ohlc_colors)]) if is_overlay else colorgen() + ) + + marker = value._opts.get('marker', 'circle') + marker_list = _as_list(marker) + + # Check for invalid markers and replace them with 'circle' + if any(m not in MARKER_FUNCTIONS for m in marker_list): + if len(marker_list) == 1: + # If it's a single invalid marker, replace it + warnings.warn(f"Unknown marker type '{marker}', falling back to 'circle'") + marker = 'circle' + value._opts['marker'] = marker + marker_list = ['circle'] + else: + # If it's an array with some invalid markers, replace only the invalid ones + warnings.warn(f"Unknown marker type(s) in '{marker}', replacing invalid markers with 'circle'") + marker_list = [m if m in MARKER_FUNCTIONS else 'circle' for m in marker_list] + value._opts['marker'] = marker_list + + markers = cycle(marker_list) + + marker_size = value._opts.get('marker_size') + # Handle marker_size as either a single value or an array + if marker_size is not None: + marker_size_list = _as_list(marker_size) + marker_sizes = cycle(marker_size_list) + else: + default_size = BAR_WIDTH / 2 * (.9 if is_overlay else .6) + marker_sizes = cycle([default_size]) if isinstance(value.name, str): tooltip_label = value.name @@ -574,6 +613,8 @@ def __eq__(self, other): for j, arr in enumerate(value): color = next(colors) + marker = next(markers) + marker_size = next(marker_sizes) source_name = f'{legend_labels[j]}_{i}_{j}' if arr.dtype == bool: arr = arr.astype(int) @@ -582,11 +623,13 @@ def __eq__(self, other): if is_overlay: ohlc_extreme_values[source_name] = arr if is_scatter: - fig.circle( - 'index', source_name, source=source, + marker_func = MARKER_FUNCTIONS[marker] + marker_func( + fig, + x='index', y=source_name, source=source, legend_label=legend_labels[j], color=color, line_color='black', fill_alpha=.8, - radius=BAR_WIDTH / 2 * .9) + size=marker_size) else: fig.line( 'index', source_name, source=source, @@ -594,10 +637,12 @@ def __eq__(self, other): line_width=1.3) else: if is_scatter: - r = fig.circle( - 'index', source_name, source=source, + marker_func = MARKER_FUNCTIONS[marker] + r = marker_func( + fig, + x='index', y=source_name, source=source, legend_label=legend_labels[j], color=color, - radius=BAR_WIDTH / 2 * .6) + size=marker_size) else: r = fig.line( 'index', source_name, source=source, diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index a85f5c9da..3d83b2da4 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -74,6 +74,7 @@ def _check_params(self, params): def I(self, # noqa: E743 func: Callable, *args, name=None, plot=True, overlay=None, color=None, scatter=False, + marker='circle', marker_size=None, **kwargs) -> np.ndarray: """ Declare an indicator. An indicator is just an array of values @@ -106,6 +107,13 @@ def I(self, # noqa: E743 If `scatter` is `True`, the plotted indicator marker will be a circle instead of a connected line segment (default). + `marker` sets the marker shape for scatter plots. Available options: + 'circle', 'square', 'triangle', 'diamond', 'cross', 'x', 'star'. + Default is 'circle'. + + `marker_size` sets the size of scatter plot markers. If None, + defaults to a size relative to the bar width. + Additional `*args` and `**kwargs` are passed to `func` and can be used for parameters. @@ -173,6 +181,7 @@ def _format_name(name: str) -> str: value = _Indicator(value, name=name, plot=plot, overlay=overlay, color=color, scatter=scatter, + marker=marker, marker_size=marker_size, # _Indicator.s Series accessor uses this: index=self.data.index) self._indicators.append(value) @@ -1240,10 +1249,13 @@ def __init__(self, self._results: Optional[pd.Series] = None self._finalize_trades = bool(finalize_trades) - def run(self, **kwargs) -> pd.Series: + def run(self, show_progress: bool = True, **kwargs) -> pd.Series: """ Run the backtest. Returns `pd.Series` with results and statistics. + 'show_progress' : bool, default True + Whether to show the progress bar during backtest execution. + Keyword arguments are interpreted as strategy parameters. >>> Backtest(GOOG, SmaCross).run() @@ -1289,6 +1301,7 @@ def run(self, **kwargs) -> pd.Series: begin on bar 201. The actual length of delay is equal to the lookback period of the `Strategy.I` indicator which lags the most. Obviously, this can affect results. + """ data = _Data(self._data.copy(deep=False)) broker: _Broker = self._broker(data=data) @@ -1308,8 +1321,10 @@ def run(self, **kwargs) -> pd.Series: # np.nan >= 3 is not invalid; it's False. with np.errstate(invalid='ignore'): - for i in _tqdm(range(start, len(self._data)), desc=self.run.__qualname__, - unit='bar', mininterval=2, miniters=100): + seq = range(start, len(self._data)) + if show_progress: + seq = _tqdm(seq, desc=self.run.__qualname__, unit='bar', mininterval=2, miniters=100) + for i in seq: # Prepare data and indicators for `next` call data._set_length(i + 1) for attr, indicator in indicator_attrs: diff --git a/backtesting/test/_test.py b/backtesting/test/_test.py index 366d54c4d..f29487303 100644 --- a/backtesting/test/_test.py +++ b/backtesting/test/_test.py @@ -32,6 +32,7 @@ resample_apply, ) from backtesting.test import BTCUSD, EURUSD, GOOG, SMA +import itertools SHORT_DATA = GOOG.iloc[:20] # Short data for fast tests with no indicator lag @@ -235,7 +236,7 @@ def init(self): self.done = False def next(self): - if not self.position: + if not self.done: self.buy() else: self.position.close() @@ -826,6 +827,214 @@ def next(self): plot_drawdown=False, plot_equity=False, plot_pl=False, plot_volume=False, open_browser=False) + def test_marker_shapes_and_sizes(self): + class MarkerStrategy(Strategy): + shapes = [] + invalid_checks = [] + default_checks = [] + + def init(self): + # Test all marker shapes with different sizes + markers = ['circle', 'square', 'triangle', 'diamond', 'cross', 'x', 'star'] + sizes = [6, 12, 18] # Test different sizes + + for i, (marker, size) in enumerate(itertools.product(markers, sizes)): + # Copy data.Close and add a value to it so the shapes don't overlap + close = self.data.Close.copy() + close += i * 10 + size * 2 + # Clear values except when i*7 divides into it + close[close % (i*3) != 0] = np.nan + + ind = self.I(lambda x: x, close, + name=f'{marker}_{size}', + scatter=True, + marker=marker, + marker_size=size, + overlay=i % 2 == 0) # Alternate between overlay and separate + self.shapes.append(ind) + + # Test invalid marker fallback + invalid_close = self.data.Close.copy() + invalid_close[invalid_close % 10 != 0] = np.nan + self.invalid = self.I(lambda x: x, invalid_close, + scatter=True, + marker='invalid_shape', + marker_size=10) + self.invalid_checks.append(self.invalid) + + # Test default size + default_close = self.data.Close.copy() + default_close[default_close % 15 != 0] = np.nan + self.default_size = self.I(lambda x: x, default_close, + scatter=True, + marker='circle') + self.default_checks.append(self.default_size) + + # Test array of marker sizes + size_array = [5, 10, 15] + ind4 = self.I(lambda x: x, stacked, + name=['Size1', 'Size2', 'Size3'], + scatter=True, + marker='circle', + marker_size=size_array, + overlay=True) + self.array_markers.append(ind4) + + # Test mismatched array lengths for marker and marker_size + ind5 = self.I(lambda x: x, stacked, + name=['Mix1', 'Mix2', 'Mix3'], + scatter=True, + marker=['circle', 'square'], + marker_size=[8, 12, 16, 20], + overlay=False) + self.array_markers.append(ind5) + + def next(self): + pass + + def get_default_size(self): + return self.default_size + + bt = Backtest(self.data, MarkerStrategy) + stats = bt.run() + fig = bt.plot() + + # Verify all indicators were created + strategy = bt._strategy + + # Verify each indicator has correct options + markers = ['circle', 'square', 'triangle', 'diamond', 'cross', 'x', 'star'] + sizes = [6, 12, 18] + + for ind, (marker, size) in zip(strategy.shapes, itertools.product(markers, sizes)): + self.assertTrue(ind._opts['scatter']) + self.assertEqual(ind._opts['marker'], marker) + self.assertEqual(ind._opts['marker_size'], size) + + # Verify invalid marker falls back to 'circle' + self.assertEqual(strategy.invalid_checks[0]._opts['marker'], 'circle') + self.assertEqual(strategy.invalid_checks[0]._opts['marker_size'], 10) + + # Verify default size is None (will be set relative to bar width) + self.assertEqual(strategy.default_checks[0]._opts['marker'], 'circle') + self.assertIsNone(strategy.default_checks[0]._opts['marker_size']) + + # Verify marker_size array was properly set + self.assertEqual(strategy.array_markers[1]._opts['marker_size'], [5, 10, 15]) + + # Verify mismatched arrays work correctly (each cycles independently) + self.assertEqual(strategy.array_markers[2]._opts['marker'], ['circle', 'square']) + self.assertEqual(strategy.array_markers[2]._opts['marker_size'], [8, 12, 16, 20]) + + def test_marker_array(self): + class MarkerArrayStrategy(Strategy): + array_markers = [] + multi_array_markers = [] + invalid_array_markers = [] + size_array_markers = [] + combined_array_markers = [] + + def init(self): + # Test array of markers for a single indicator + marker_array = ['circle', 'square', 'triangle'] + + # Create a multi-line indicator with 3 values + values = [] + for i in range(3): + close = self.data.Close.copy() + close += i * 10 # Offset to avoid overlap + close[close % (i+2) != 0] = np.nan # Create some gaps + values.append(close) + + # Stack the values to create a multi-line indicator + stacked = np.vstack(values) + + # Create indicator with array of markers + ind = self.I(lambda x: x, stacked, + name=['Line1', 'Line2', 'Line3'], + scatter=True, + marker=marker_array, + marker_size=10, + overlay=False) + self.array_markers.append(ind) + + # Test array of markers with different length than values + longer_array = ['circle', 'square', 'triangle', 'diamond', 'cross'] + ind2 = self.I(lambda x: x, stacked, + name=['LineA', 'LineB', 'LineC'], + scatter=True, + marker=longer_array, + marker_size=10, + overlay=True) + self.multi_array_markers.append(ind2) + + # Test array with invalid marker + invalid_array = ['circle', 'invalid_shape', 'triangle'] + ind3 = self.I(lambda x: x, stacked, + name=['X', 'Y', 'Z'], + scatter=True, + marker=invalid_array, + overlay=False) + self.invalid_array_markers.append(ind3) + + # Test array of marker sizes + size_array = [5, 10, 15] + ind4 = self.I(lambda x: x, stacked, + name=['Size1', 'Size2', 'Size3'], + scatter=True, + marker='circle', + marker_size=size_array, + overlay=True) + self.size_array_markers.append(ind4) + + # Test combined arrays of markers and sizes + ind5 = self.I(lambda x: x, stacked, + name=['Combined1', 'Combined2', 'Combined3'], + scatter=True, + marker=['circle', 'square', 'triangle'], + marker_size=[8, 12, 16], + overlay=False) + self.combined_array_markers.append(ind5) + + # Test mismatched array lengths for marker and marker_size + ind6 = self.I(lambda x: x, stacked, + name=['Mix1', 'Mix2', 'Mix3'], + scatter=True, + marker=['circle', 'square'], + marker_size=[8, 12, 16, 20], + overlay=False) + self.combined_array_markers.append(ind6) + + def next(self): + pass + + bt = Backtest(self.data, MarkerArrayStrategy) + stats = bt.run() + fig = bt.plot() + + # Verify indicators were created + strategy = bt._strategy + + # Verify marker array was properly set + self.assertEqual(strategy.array_markers[0]._opts['marker'], ['circle', 'square', 'triangle']) + + # Verify longer array was properly set + self.assertEqual(strategy.multi_array_markers[0]._opts['marker'], + ['circle', 'square', 'triangle', 'diamond', 'cross']) + + # Verify invalid array falls back to 'circle' + self.assertEqual(strategy.invalid_array_markers[0]._opts['marker'], ['circle', 'circle', 'triangle']) + + # Verify marker_size array was properly set + self.assertEqual(strategy.size_array_markers[0]._opts['marker_size'], [5, 10, 15]) + + # Verify combined arrays work correctly + self.assertEqual(strategy.combined_array_markers[0]._opts['marker'], ['circle', 'square', 'triangle']) + self.assertEqual(strategy.combined_array_markers[0]._opts['marker_size'], [8, 12, 16]) + + # Verify mismatched arrays work correctly (each cycles independently) + self.assertEqual(strategy.combined_array_markers[1]._opts['marker'], ['circle', 'square']) + self.assertEqual(strategy.combined_array_markers[1]._opts['marker_size'], [8, 12, 16, 20]) class TestLib(TestCase): def test_barssince(self): @@ -1049,7 +1258,7 @@ def next(self): df = pd.DataFrame({'Open': arr, 'High': arr, 'Low': arr, 'Close': arr}) with self.assertWarnsRegex(UserWarning, 'index is not datetime'): bt = Backtest(df, S, cash=100, trade_on_close=True) - self.assertEqual(bt.run()._trades['ExitPrice'][0], 50) + self.assertEqual(bt.run()._trades.iloc[0].ExitPrice, 50) def test_stats_annualized(self): stats = Backtest(GOOG.resample('W').agg(OHLCV_AGG), SmaCross).run() @@ -1138,3 +1347,118 @@ def test_optimize_datetime_index_with_timezone(self): data.index = data.index.tz_localize('Asia/Kolkata') res = Backtest(data, SmaCross).optimize(fast=range(2, 3), slow=range(4, 5)) self.assertGreater(res['# Trades'], 0) + + +class TestPlotting(unittest.TestCase): + def setUp(self): + self.data = GOOG.copy() + + def test_marker_array(self): + class MarkerArrayStrategy(Strategy): + array_markers = [] + multi_array_markers = [] + invalid_array_markers = [] + size_array_markers = [] + combined_array_markers = [] + + def init(self): + # Test array of markers for a single indicator + marker_array = ['circle', 'square', 'triangle'] + + # Create a multi-line indicator with 3 values + values = [] + for i in range(3): + close = self.data.Close.copy() + close += i * 10 # Offset to avoid overlap + close[close % (i+2) != 0] = np.nan # Create some gaps + values.append(close) + + # Stack the values to create a multi-line indicator + stacked = np.vstack(values) + + # Create indicator with array of markers + ind = self.I(lambda x: x, stacked, + name=['Line1', 'Line2', 'Line3'], + scatter=True, + marker=marker_array, + marker_size=10, + overlay=False) + self.array_markers.append(ind) + + # Test array of markers with different length than values + longer_array = ['circle', 'square', 'triangle', 'diamond', 'cross'] + ind2 = self.I(lambda x: x, stacked, + name=['LineA', 'LineB', 'LineC'], + scatter=True, + marker=longer_array, + marker_size=10, + overlay=True) + self.multi_array_markers.append(ind2) + + # Test array with invalid marker + invalid_array = ['circle', 'invalid_shape', 'triangle'] + ind3 = self.I(lambda x: x, stacked, + name=['X', 'Y', 'Z'], + scatter=True, + marker=invalid_array, + overlay=False) + self.invalid_array_markers.append(ind3) + + # Test array of marker sizes + size_array = [5, 10, 15] + ind4 = self.I(lambda x: x, stacked, + name=['Size1', 'Size2', 'Size3'], + scatter=True, + marker='circle', + marker_size=size_array, + overlay=True) + self.size_array_markers.append(ind4) + + # Test combined arrays of markers and sizes + ind5 = self.I(lambda x: x, stacked, + name=['Combined1', 'Combined2', 'Combined3'], + scatter=True, + marker=['circle', 'square', 'triangle'], + marker_size=[8, 12, 16], + overlay=False) + self.combined_array_markers.append(ind5) + + # Test mismatched array lengths for marker and marker_size + ind6 = self.I(lambda x: x, stacked, + name=['Mix1', 'Mix2', 'Mix3'], + scatter=True, + marker=['circle', 'square'], + marker_size=[8, 12, 16, 20], + overlay=False) + self.combined_array_markers.append(ind6) + + def next(self): + pass + + bt = Backtest(self.data, MarkerArrayStrategy) + stats = bt.run() + fig = bt.plot() + + # Verify indicators were created + strategy = bt._strategy + + # Verify marker array was properly set + self.assertEqual(strategy.array_markers[0]._opts['marker'], ['circle', 'square', 'triangle']) + + # Verify longer array was properly set + self.assertEqual(strategy.multi_array_markers[0]._opts['marker'], + ['circle', 'square', 'triangle', 'diamond', 'cross']) + + # Verify invalid array falls back to 'circle' + self.assertEqual(strategy.invalid_array_markers[0]._opts['marker'], ['circle', 'circle', 'triangle']) + + # Verify marker_size array was properly set + self.assertEqual(strategy.size_array_markers[0]._opts['marker_size'], [5, 10, 15]) + + # Verify combined arrays work correctly + self.assertEqual(strategy.combined_array_markers[0]._opts['marker'], ['circle', 'square', 'triangle']) + self.assertEqual(strategy.combined_array_markers[0]._opts['marker_size'], [8, 12, 16]) + + # Verify mismatched arrays work correctly (each cycles independently) + self.assertEqual(strategy.combined_array_markers[1]._opts['marker'], ['circle', 'square']) + self.assertEqual(strategy.combined_array_markers[1]._opts['marker_size'], [8, 12, 16, 20])