perf: vectorize trade resampling in plot() for ~9x speedup#1350
Conversation
Replace per-bucket Python callbacks (_weighted_returns, _group_trades) in _maybe_resample_data with vectorized pre-computation: - Pre-compute _abs_size and _weighted_ret columns, aggregate with sum, then divide to get weighted return (mathematically equivalent) - Aggregate EntryTime/ExitTime with mean, then use get_indexer once for all buckets instead of per-bucket nearest-bar lookup Reduces 34M function calls to a handful of vectorized operations. Benchmarked ~9x speedup (6.1s -> 0.69s) on 50k bars / 1069 trades. Refs kernc#35 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Benchmark resultsRan both the original (per-bucket callbacks) and the new (vectorized) implementation side-by-side. Timing uses
Speedup scales with the number of resample buckets, since the old code calls Python closures once per bucket while the new code runs a fixed number of vectorized pandas operations. Correctness validationCompared all output columns across 3 datasets x 2 strategies x 5 frequencies = 30 combinations. All 9 numerical columns match exactly:
Benchmark script (test_perf.py)"""
Performance benchmark: original callbacks vs vectorized resampling.
"""
import time
import warnings
import numpy as np
import pandas as pd
from backtesting import Backtest, Strategy
from backtesting.lib import OHLCV_AGG, TRADES_AGG, crossover
from backtesting.test import SMA
import backtesting._plotting as _plotting
class AggressiveSma(Strategy):
fast = 3
slow = 8
def init(self):
self.sma1 = self.I(SMA, self.data.Close, self.fast)
self.sma2 = self.I(SMA, self.data.Close, self.slow)
def next(self):
if crossover(self.sma1, self.sma2):
self.position.close()
self.buy()
elif crossover(self.sma2, self.sma1):
self.position.close()
self.sell()
def resample_trades_original(df_resampled, trades, freq):
"""Exact copy of the OLD callback-based logic."""
def _weighted_returns(s, _trades=trades):
d = _trades.loc[s.index]
return ((d["Size"].abs() * d["ReturnPct"])
/ d["Size"].abs().sum()).sum()
def _group_trades(column):
def f(s, new_index=pd.Index(df_resampled.index.astype(np.int64)),
bars=trades[column]):
if s.size:
mean_time = int(
bars.loc[s.index].astype(np.int64).mean())
return new_index.get_indexer(
[mean_time], method="nearest")[0]
return f
return trades.assign(count=1).resample(
freq, on="ExitTime", label="right",
).agg(dict(
TRADES_AGG,
ReturnPct=_weighted_returns,
count="sum",
EntryBar=_group_trades("EntryTime"),
ExitBar=_group_trades("ExitTime"),
)).dropna()
def time_original(df_ohlcv, trades, freq, n_runs=3):
times = []
for _ in range(n_runs):
df_res = df_ohlcv.resample(freq, label="right").agg(OHLCV_AGG).dropna()
t0 = time.perf_counter()
resample_trades_original(df_res, trades.copy(), freq)
times.append(time.perf_counter() - t0)
return min(times)
def time_vectorized(df_ohlcv, equity_data, trades, freq, n_runs=3):
times = []
for _ in range(n_runs):
t0 = time.perf_counter()
_plotting._maybe_resample_data(
freq, df_ohlcv.copy(), [], equity_data.copy(), trades.copy(),
)
times.append(time.perf_counter() - t0)
return min(times)
if __name__ == "__main__":
warnings.filterwarnings("ignore")
from backtesting.test import GOOG, EURUSD
configs = []
for data, name in [(GOOG, "GOOG"), (EURUSD, "EURUSD")]:
bt = Backtest(data, AggressiveSma)
results = bt.run()
trades = results["_trades"]
if not trades.empty:
configs.append(dict(name=name, bars=len(data), trades=len(trades),
df_ohlcv=bt._data.copy(),
equity=results["_equity_curve"].copy(deep=False),
trades_df=trades, freqs=["1ME", "1W"]))
for n_copies, label in [(5, "synth-5x"), (20, "synth-20x"), (50, "synth-50x")]:
pieces = []
base = EURUSD.copy()
for i in range(n_copies):
chunk = base.copy()
chunk.index = base.index + pd.Timedelta(hours=len(base) * i)
pieces.append(chunk)
data = pd.concat(pieces)
data = data[~data.index.duplicated(keep="first")]
bt = Backtest(data, AggressiveSma)
results = bt.run()
trades = results["_trades"]
if not trades.empty:
configs.append(dict(name=f"{label}({len(data)})", bars=len(data),
trades=len(trades), df_ohlcv=bt._data.copy(),
equity=results["_equity_curve"].copy(deep=False),
trades_df=trades, freqs=["1ME", "1W", "1D", "8h"]))
print(f"{'Dataset':<14} {'Freq':>5} {'Bars':>7} {'Trades':>7} "
f"{'Buckets':>8} {'Old (s)':>9} {'New (s)':>9} {'Speedup':>8}")
print("-" * 80)
for cfg in configs:
for freq in cfg["freqs"]:
df_res = cfg["df_ohlcv"].resample(freq, label="right").agg(OHLCV_AGG).dropna()
ref = resample_trades_original(df_res, cfg["trades_df"].copy(), freq)
if not len(ref):
continue
t_old = time_original(cfg["df_ohlcv"], cfg["trades_df"], freq)
t_new = time_vectorized(cfg["df_ohlcv"], cfg["equity"], cfg["trades_df"], freq)
speedup = t_old / t_new if t_new > 0 else float("inf")
print(f"{cfg['name']:<14} {freq:>5} {cfg['bars']:>7} {cfg['trades']:>7} "
f"{len(ref):>8} {t_old:>9.4f} {t_new:>9.4f} {speedup:>7.1f}x")Correctness script (test_before_after.py)"""
Correctness comparison: original callbacks vs vectorized resampling.
Tests 3 datasets x 2 strategies x 5 frequencies = 30 combinations.
"""
import warnings
import numpy as np
import pandas as pd
from backtesting import Backtest, Strategy
from backtesting.lib import OHLCV_AGG, TRADES_AGG, crossover
from backtesting.test import BTCUSD, EURUSD, GOOG, SMA
import backtesting._plotting as _plotting
class SmaCross(Strategy):
fast, slow = 10, 30
def init(self):
self.sma1 = self.I(SMA, self.data.Close, self.fast)
self.sma2 = self.I(SMA, self.data.Close, self.slow)
def next(self):
if crossover(self.sma1, self.sma2):
self.position.close(); self.buy()
elif crossover(self.sma2, self.sma1):
self.position.close(); self.sell()
class AggressiveSma(Strategy):
fast, slow = 3, 8
def init(self):
self.sma1 = self.I(SMA, self.data.Close, self.fast)
self.sma2 = self.I(SMA, self.data.Close, self.slow)
def next(self):
if crossover(self.sma1, self.sma2):
self.position.close(); self.buy()
elif crossover(self.sma2, self.sma1):
self.position.close(); self.sell()
def resample_trades_original(df_resampled, trades, freq):
def _weighted_returns(s, _trades=trades):
d = _trades.loc[s.index]
return ((d["Size"].abs() * d["ReturnPct"]) / d["Size"].abs().sum()).sum()
def _group_trades(column):
def f(s, new_index=pd.Index(df_resampled.index.astype(np.int64)),
bars=trades[column]):
if s.size:
mean_time = int(bars.loc[s.index].astype(np.int64).mean())
return new_index.get_indexer([mean_time], method="nearest")[0]
return f
return trades.assign(count=1).resample(freq, on="ExitTime", label="right").agg(dict(
TRADES_AGG, ReturnPct=_weighted_returns, count="sum",
EntryBar=_group_trades("EntryTime"), ExitBar=_group_trades("ExitTime"),
)).dropna()
def run_comparison(data, strategy_cls, freq, label):
with warnings.catch_warnings():
warnings.simplefilter("ignore")
bt = Backtest(data, strategy_cls)
results = bt.run()
trades = results["_trades"]
if trades.empty:
return True
df_ohlcv = bt._data.copy()
equity_data = results["_equity_curve"].copy(deep=False)
ref = resample_trades_original(
df_ohlcv.resample(freq, label="right").agg(OHLCV_AGG).dropna(),
trades.copy(), freq)
_, _, _, vec = _plotting._maybe_resample_data(
freq, df_ohlcv.copy(), [], equity_data.copy(), trades.copy())
assert len(ref) == len(vec), f"Row count: {len(ref)} vs {len(vec)}"
for col in ["Size", "EntryBar", "ExitBar", "PnL", "count"]:
assert np.allclose(ref[col].values.astype(float),
vec[col].values.astype(float), equal_nan=True), col
for col in ["EntryPrice", "ExitPrice", "ReturnPct"]:
assert np.allclose(ref[col].values.astype(float),
vec[col].values.astype(float), atol=1e-10), col
assert ref["Duration"].reset_index(drop=True).equals(
vec["Duration"].reset_index(drop=True))
return True
if __name__ == "__main__":
passed = total = 0
for data, dn in [(GOOG, "GOOG"), (EURUSD, "EURUSD"), (BTCUSD, "BTCUSD")]:
for strat, sn in [(SmaCross, "SmaCross"), (AggressiveSma, "Aggressive")]:
for freq in ["1ME", "1W", "1D", "8h", "4h"]:
total += 1
try:
run_comparison(data, strat, freq, f"{dn}/{sn}")
passed += 1
except Exception as e:
print(f"FAIL {dn}/{sn} @ {freq}: {e}")
print(f"{passed}/{total} passed") |
atharvajoshi01
left a comment
There was a problem hiding this comment.
Nice optimization. The vectorized weighted return calculation (pre-computing abs_size and weighted_ret columns then aggregating) is much cleaner than the per-group lambda. Using get_indexer in bulk instead of calling it per-trade in _group_trades is where most of the 9x comes from.
One minor thing: the mean aggregation on EntryTime/ExitTime changes behavior slightly from the original _group_trades which computed the mean bar index differently (mean of int64 timestamps then nearest index lookup). Should give the same result in most cases but worth noting.
Let's risk it. Thanks for the effort, all. |
Summary
_weighted_returns,_group_trades) in_maybe_resample_data()with vectorized pre-computation_abs_sizeand_weighted_retcolumns, aggregate withsum, then divide — mathematically equivalent to the weighted mean (sum(|size|*ret) / sum(|size|))EntryTime/ExitTimewithmean, then callget_indexer(..., method='nearest')once for all buckets instead of per-bucket_weighted_returnsand_group_tradesclosure definitions entirelyBenchmark
_maybe_resample_datatimeTested on 50k bars / 1069 trades with
resample='8h'.Mathematical equivalence
Weighted returns: The original computes
sum(|size_i| * ret_i) / sum(|size_i|)per bucket via a callback. The vectorized version pre-computes_weighted_ret = |size| * retand_abs_size = |size|, sums both per bucket, then divides — identical result.Bar indices: The original computes
mean(time_column)per bucket then finds the nearest bar viaget_indexer. The vectorized version aggregates times withmean, then callsget_indexeronce on the full column — identical result.Test plan
test_resample_trades_vectorizedcomparing vectorized output against original callback implementationpython -m backtesting.test)flake8 backtesting— no new warningsRefs #35
🤖 Generated with Claude Code