From daac83c73b631b7278062286037624271e20a73f Mon Sep 17 00:00:00 2001 From: Kevin Taylor <2325494+tkdtaylor@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:39:58 -0400 Subject: [PATCH 1/2] Add shape and size for scatter plot --- backtesting/_plotting.py | 38 ++++++++++++++---- backtesting/backtesting.py | 9 +++++ backtesting/test/_test.py | 80 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/backtesting/_plotting.py b/backtesting/_plotting.py index faccc61db..e348c0ac1 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,19 @@ 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') + if marker not in MARKER_FUNCTIONS: + warnings.warn(f"Unknown marker type '{marker}', falling back to 'circle'") + marker = 'circle' + value._opts['marker'] = marker + marker_func = MARKER_FUNCTIONS[marker] + + marker_size = value._opts.get('marker_size') + if marker_size is None: + marker_size = BAR_WIDTH / 2 * (.9 if is_overlay else .6) if isinstance(value.name, str): tooltip_label = value.name @@ -582,11 +604,12 @@ 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( + 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 +617,11 @@ def __eq__(self, other): line_width=1.3) else: if is_scatter: - r = fig.circle( - 'index', source_name, source=source, + 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..a938b6416 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) diff --git a/backtesting/test/_test.py b/backtesting/test/_test.py index 366d54c4d..26c5477ae 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 @@ -1138,3 +1139,82 @@ 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_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) + + 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 + self.assertEqual(len(strategy.shapes), 7 * 3) + + # 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']) From 41968b3d9035d9ba06e908356d391ac3ef776cec Mon Sep 17 00:00:00 2001 From: Kevin Taylor <2325494+tkdtaylor@users.noreply.github.com> Date: Sat, 4 Oct 2025 08:19:57 -0400 Subject: [PATCH 2/2] add the option to remove the progress bar --- backtesting/_plotting.py | 35 +++- backtesting/backtesting.py | 12 +- backtesting/test/_test.py | 372 ++++++++++++++++++++++++++++++------- 3 files changed, 345 insertions(+), 74 deletions(-) diff --git a/backtesting/_plotting.py b/backtesting/_plotting.py index e348c0ac1..065a5528e 100644 --- a/backtesting/_plotting.py +++ b/backtesting/_plotting.py @@ -577,15 +577,32 @@ def __eq__(self, other): ) marker = value._opts.get('marker', 'circle') - if marker not in MARKER_FUNCTIONS: - warnings.warn(f"Unknown marker type '{marker}', falling back to 'circle'") - marker = 'circle' - value._opts['marker'] = marker - marker_func = MARKER_FUNCTIONS[marker] + 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') - if marker_size is None: - marker_size = BAR_WIDTH / 2 * (.9 if is_overlay else .6) + # 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 @@ -596,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) @@ -604,6 +623,7 @@ def __eq__(self, other): if is_overlay: ohlc_extreme_values[source_name] = arr if is_scatter: + marker_func = MARKER_FUNCTIONS[marker] marker_func( fig, x='index', y=source_name, source=source, @@ -617,6 +637,7 @@ def __eq__(self, other): line_width=1.3) else: if is_scatter: + marker_func = MARKER_FUNCTIONS[marker] r = marker_func( fig, x='index', y=source_name, source=source, diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index a938b6416..3d83b2da4 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -1249,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() @@ -1298,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) @@ -1317,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 26c5477ae..f29487303 100644 --- a/backtesting/test/_test.py +++ b/backtesting/test/_test.py @@ -236,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() @@ -827,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): @@ -1050,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() @@ -1145,76 +1353,112 @@ class TestPlotting(unittest.TestCase): def setUp(self): self.data = GOOG.copy() - def test_marker_shapes_and_sizes(self): - class MarkerStrategy(Strategy): - shapes = [] - invalid_checks = [] - default_checks = [] + 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 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 + # 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 + 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) + 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 - def get_default_size(self): - return self.default_size - - bt = Backtest(self.data, MarkerStrategy) + bt = Backtest(self.data, MarkerArrayStrategy) stats = bt.run() fig = bt.plot() - # Verify all indicators were created + # Verify indicators were created strategy = bt._strategy - self.assertEqual(len(strategy.shapes), 7 * 3) - - # 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 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])