From e217ab8335470101d42550b9f0c546c364921e77 Mon Sep 17 00:00:00 2001 From: Jaco Ren Date: Wed, 12 Aug 2026 22:41:44 +0800 Subject: [PATCH] BUG: Resample multi-series indicators consistently --- backtesting/lib.py | 41 ++++++++++++++++++++++++++++----------- backtesting/test/_test.py | 29 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/backtesting/lib.py b/backtesting/lib.py index 3bbef0ed..49c355cf 100644 --- a/backtesting/lib.py +++ b/backtesting/lib.py @@ -239,9 +239,12 @@ def resample_apply(rule: str, which is suitable for closing prices, but you might prefer another (e.g. `"max"` for peaks, or similar). - Finally, any `*args` and `**kwargs` that are not already eaten by - implicit `backtesting.backtesting.Strategy.I` call - are passed to `func`. + Additional `*args` that are pandas objects or + `backtesting.backtesting.Strategy.data` arrays sharing `series`' index + are resampled alongside `series`, using the default `OHLCV_AGG` rule for + each input. Other `*args` and any `**kwargs` that are not already eaten by + implicit `backtesting.backtesting.Strategy.I` call are passed to `func` + unchanged. For example, if we have a typical moving average function `SMA(values, lookback_period)`, _hourly_ data source, and need to @@ -292,13 +295,29 @@ def func(x, *_, **__): 'or a `Strategy.data.*` array' series = series.s - if agg is None: - agg = OHLCV_AGG.get(getattr(series, 'name', ''), 'last') - if isinstance(series, pd.DataFrame): - agg = {column: OHLCV_AGG.get(column, 'last') - for column in series.columns} - - resampled = series.resample(rule, label='right').agg(agg).dropna() + def _default_agg(data): + if isinstance(data, pd.DataFrame): + return {column: OHLCV_AGG.get(column, 'last') + for column in data.columns} + return OHLCV_AGG.get(getattr(data, 'name', ''), 'last') + + resampled_args = list(args) + indexed_args = [] + for i, arg in enumerate(args): + data = arg.s if isinstance(arg, _Array) else arg + if (isinstance(data, (pd.Series, pd.DataFrame)) and + data.index.equals(series.index)): + indexed_args.append((i, data.resample(rule, label='right').agg( + _default_agg(data)))) + + resampled = series.resample(rule, label='right').agg( + _default_agg(series) if agg is None else agg) + valid_index = resampled.dropna().index + for _, data in indexed_args: + valid_index = valid_index[valid_index.isin(data.dropna().index)] + resampled = resampled.loc[valid_index] + for i, data in indexed_args: + resampled_args[i] = data.loc[valid_index] resampled.name = _as_str(series) + '[' + rule + ']' # Check first few stack frames if we are being called from @@ -331,7 +350,7 @@ def wrap_func(resampled, *args, **kwargs): wrap_func.__name__ = func.__name__ - array = strategy_I(wrap_func, resampled, *args, **kwargs) + array = strategy_I(wrap_func, resampled, *resampled_args, **kwargs) return array diff --git a/backtesting/test/_test.py b/backtesting/test/_test.py index d74fde9f..1b408705 100644 --- a/backtesting/test/_test.py +++ b/backtesting/test/_test.py @@ -976,6 +976,35 @@ def resets_index(*args): res3 = resample_apply('D', lambda df: (df.Close, df.Close), EURUSD) self.assertIsInstance(res3, pd.DataFrame) + def test_resample_apply_multiple_series(self): + data = EURUSD.iloc[:500] + + def atr(high, low, close, periods): + self.assertTrue(high.index.equals(low.index)) + self.assertTrue(high.index.equals(close.index)) + previous_close = close.shift(1) + true_range = pd.concat([ + high - low, + (high - previous_close).abs(), + (low - previous_close).abs(), + ], axis=1).max(axis=1) + return true_range.rolling(periods).mean() + + class MultiInputStrategy(Strategy): + def init(self): + self.atr = resample_apply( + 'D', atr, self.data.High, self.data.Low, self.data.Close, 3) + + def next(self): + pass + + strategy = Backtest(data, MultiInputStrategy).run()._strategy + daily = data.resample('D', label='right').agg(OHLCV_AGG).dropna() + expected = atr(daily.High, daily.Low, daily.Close, 3) + expected = expected.reindex( + data.index.union(daily.index), method='ffill').reindex(data.index) + np.testing.assert_allclose(strategy.atr, expected, equal_nan=True) + def test_plot_heatmaps(self): bt = Backtest(GOOG, SmaCross) stats, heatmap = bt.optimize(fast=range(2, 7, 2),