From ce6fc3c9887a21fee96583a6c174a807cec19f97 Mon Sep 17 00:00:00 2001 From: Nill Rezende Date: Fri, 24 Oct 2025 20:32:30 -0300 Subject: [PATCH 01/10] Doc: Move Gemini to Obsolete projects list The Gemini backtesting framework appears to be deprecated. This moves it to the list of obsolete projects. --- doc/alternatives.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/alternatives.md b/doc/alternatives.md index b3f292bd9..6bd0e83f8 100644 --- a/doc/alternatives.md +++ b/doc/alternatives.md @@ -38,8 +38,6 @@ kindly have a look at some similar alternative Python backtesting frameworks: A unique new method for designing trading and investing systems_. - [QTPyLib](https://github.com/ranaroussi/qtpylib) - a versatile, event-driven algorithmic trading library. -- [Gemini](https://github.com/anfederico/Gemini) - - a backtester namely focusing on cryptocurrency markets. - [Quantdom](https://github.com/constverum/Quantdom) - a Qt-based framework that lets you focus on modeling financial strategies, portfolio management, and analyzing backtests. @@ -103,3 +101,5 @@ abandoned, and here for posterity reference only: a library to construct, backtest, analyze, and evaluate portfolios and their benchmarks, with comprehensive documentation illustrating all underlying methodologies and statistics. +- [Gemini](https://github.com/anfederico/Gemini) - + a backtester namely focusing on cryptocurrency markets. From 27d85575250254491b6f39058374fdf2941a3033 Mon Sep 17 00:00:00 2001 From: ved1beta Date: Sun, 5 Oct 2025 10:06:17 +0530 Subject: [PATCH 02/10] [Doc] clarity on trade pct calculation --- backtesting/backtesting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index 39fb80f1e..5a6335ec8 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -681,7 +681,7 @@ def pl(self): @property def pl_pct(self): - """Trade profit (positive) or loss (negative) in percent.""" + """Trade profit (positive) or loss (negative) in percent relative to trade entry price.""" price = self.__exit_price or self.__broker.last_price gross_pl_pct = copysign(1, self.__size) * (price / self.__entry_price - 1) From de7914c04fc3e444e9a959136c314d691e2865c1 Mon Sep 17 00:00:00 2001 From: ChadThackray Date: Fri, 31 Oct 2025 22:25:19 +0000 Subject: [PATCH 03/10] Added a function to specifically get the most recent value of ohlc, rather than generating the full slice first. ~12% speed improvement --- backtesting/_util.py | 5 ++++ backtesting/backtesting.py | 52 +++++++++++++++++++++++++++++--------- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/backtesting/_util.py b/backtesting/_util.py index 123abe4ea..9391a20a8 100644 --- a/backtesting/_util.py +++ b/backtesting/_util.py @@ -216,6 +216,11 @@ def __get_array(self, key) -> _Array: arr = self.__cache[key] = cast(_Array, self.__arrays[key][:self.__len]) return arr + def current_value(self, key: str): + if self.__len <= 0: + raise IndexError("No data available") + return self.__arrays[key][self.__len - 1] + @property def Open(self) -> _Array: return self.__get_array('Open') diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index 5a6335ec8..aa42c4bd8 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -870,7 +870,9 @@ def next(self): def _process_orders(self): data = self._data - open, high, low = data.Open[-1], data.High[-1], data.Low[-1] + open_price = data.current_value("Open") + high_price = data.current_value("High") + low_price = data.current_value("Low") reprocess_orders = False # Process orders @@ -883,7 +885,9 @@ def _process_orders(self): # Check if stop condition was hit stop_price = order.stop if stop_price: - is_stop_hit = ((high >= stop_price) if order.is_long else (low <= stop_price)) + is_stop_hit = ( + high_price >= stop_price if order.is_long else low_price <= stop_price + ) if not is_stop_hit: continue @@ -894,7 +898,9 @@ def _process_orders(self): # Determine purchase price. # Check if limit order can be filled. if order.limit: - is_limit_hit = low <= order.limit if order.is_long else high >= order.limit + is_limit_hit = ( + low_price <= order.limit if order.is_long else high_price >= order.limit + ) # When stop and limit are hit within the same bar, we pessimistically # assume limit was hit before the stop (i.e. "before it counts") is_limit_hit_before_stop = (is_limit_hit and @@ -905,14 +911,20 @@ def _process_orders(self): continue # stop_price, if set, was hit within this bar - price = (min(stop_price or open, order.limit) - if order.is_long else - max(stop_price or open, order.limit)) + price = ( + min(stop_price or open_price, order.limit) + if order.is_long + else max(stop_price or open_price, order.limit) + ) else: # Market-if-touched / market order # Contingent orders always on next open prev_close = data.Close[-2] - price = prev_close if self._trade_on_close and not order.is_contingent else open + price = ( + prev_close + if self._trade_on_close and not order.is_contingent + else open_price + ) if stop_price: price = max(price, stop_price) if order.is_long else min(price, stop_price) @@ -1018,12 +1030,28 @@ def _process_orders(self): reprocess_orders = True # Order.stop and TP hit within the same bar, but SL wasn't. This case # is not ambiguous, because stop and TP go in the same price direction. - elif stop_price and not order.limit and order.tp and ( - (order.is_long and order.tp <= high and (order.sl or -np.inf) < low) or - (order.is_short and order.tp >= low and (order.sl or np.inf) > high)): + elif ( + stop_price + and not order.limit + and order.tp + and ( + ( + order.is_long + and order.tp <= high_price + and (order.sl or -np.inf) < low_price + ) + or ( + order.is_short + and order.tp >= low_price + and (order.sl or np.inf) > high_price + ) + ) + ): reprocess_orders = True - elif (low <= (order.sl or -np.inf) <= high or - low <= (order.tp or -np.inf) <= high): + elif ( + low_price <= (order.sl or -np.inf) <= high_price + or low_price <= (order.tp or -np.inf) <= high_price + ): warnings.warn( f"({data.index[-1]}) A contingent SL/TP order would execute in the " "same bar its parent stop/limit order was turned into a trade. " From 028d1eb39007ce663f9b59629db6836a6fb264d2 Mon Sep 17 00:00:00 2001 From: ChadThackray Date: Fri, 31 Oct 2025 23:18:11 +0000 Subject: [PATCH 04/10] Cache calculations for pnl, total position size etc. when we are not changing positions at all. ~13% time saving --- backtesting/backtesting.py | 41 ++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index aa42c4bd8..b12ab074a 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -362,17 +362,21 @@ def __bool__(self): @property def size(self) -> float: """Position size in units of asset. Negative if position is short.""" - return sum(trade.size for trade in self.__broker.trades) + if self.__broker._trade_sums_dirty: + self.__broker._recalculate_trade_sums() + return self.__broker._open_trade_size_sum @property def pl(self) -> float: """Profit (positive) or loss (negative) of the current position in cash units.""" - return sum(trade.pl for trade in self.__broker.trades) + return self.__broker.unrealized_pl @property def pl_pct(self) -> float: """Profit (positive) or loss (negative) of the current position in percent.""" - total_invested = sum(trade.entry_price * abs(trade.size) for trade in self.__broker.trades) + if self.__broker._trade_sums_dirty: + self.__broker._recalculate_trade_sums() + total_invested = self.__broker._open_trade_entry_abs_value_sum return (self.pl / total_invested) * 100 if total_invested else 0 @property @@ -772,6 +776,10 @@ def __init__(self, *, data, cash, spread, commission, margin, self.trades: List[Trade] = [] self.position = Position(self) self.closed_trades: List[Trade] = [] + self._trade_sums_dirty = True + self._open_trade_size_sum = 0 + self._open_trade_entry_value_sum = 0.0 + self._open_trade_entry_abs_value_sum = 0.0 def _commission_func(self, order_size, price): return self._commission_fixed + abs(order_size) * price * self._commission_relative @@ -829,6 +837,28 @@ def new_order(self, return order + def _mark_trade_sums_dirty(self) -> None: + self._trade_sums_dirty = True + + def _recalculate_trade_sums(self) -> None: + self._open_trade_size_sum = sum(int(trade.size) for trade in self.trades) + self._open_trade_entry_value_sum = sum( + trade.size * trade.entry_price for trade in self.trades + ) + self._open_trade_entry_abs_value_sum = sum( + abs(trade.size) * trade.entry_price for trade in self.trades + ) + self._trade_sums_dirty = False + + @property + def unrealized_pl(self) -> float: + if self._trade_sums_dirty: + self._recalculate_trade_sums() + if not self.trades: + return 0.0 + current_price = float(self._data.current_value("Close")) + return current_price * self._open_trade_size_sum - self._open_trade_entry_value_sum + @property def last_price(self) -> float: """ Price at the last (current) close. """ @@ -843,7 +873,7 @@ def _adjusted_price(self, size=None, price=None) -> float: @property def equity(self) -> float: - return self._cash + sum(trade.pl for trade in self.trades) + return self._cash + self.unrealized_pl @property def margin_available(self) -> float: @@ -1071,6 +1101,7 @@ def _process_orders(self): def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int): assert trade.size * size < 0 assert abs(trade.size) >= abs(size) + self._mark_trade_sums_dirty() size_left = trade.size + size assert size_left * trade.size >= 0 @@ -1091,6 +1122,7 @@ def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int self._close_trade(close_trade, price, time_index) def _close_trade(self, trade: Trade, price: float, time_index: int): + self._mark_trade_sums_dirty() self.trades.remove(trade) if trade._sl_order: self.orders.remove(trade._sl_order) @@ -1114,6 +1146,7 @@ def _open_trade(self, price: float, size: int, self.trades.append(trade) # Apply broker commission at trade open self._cash -= self._commission(size, price) + self._mark_trade_sums_dirty() # Create SL/TP (bracket) orders. if tp: trade.tp = tp From ce0e21a9e9a8669a5e5a6a5a459ee8e7f6b41a71 Mon Sep 17 00:00:00 2001 From: ChadThackray Date: Thu, 6 Nov 2025 18:18:09 +0000 Subject: [PATCH 05/10] Build stats series all at once at the end rather than incrementally using `s.loc` --- backtesting/_stats.py | 104 +++++++++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 38 deletions(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 6905d62cd..84a2a5dcd 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -97,24 +97,34 @@ def _round_timedelta(value, _period=_data_period(index)): resolution = getattr(_period, 'resolution_string', None) or _period.resolution return value.ceil(resolution) - s = pd.Series(dtype=object) - s.loc['Start'] = index[0] - s.loc['End'] = index[-1] - s.loc['Duration'] = s.End - s.Start + stat_items: list[tuple[str, object]] = [] + start = index[0] + end = index[-1] + duration = end - start + stat_items.extend([ + ('Start', start), + ('End', end), + ('Duration', duration), + ]) have_position = np.repeat(0, len(index)) for t in trades_df.itertuples(index=False): have_position[t.EntryBar:t.ExitBar + 1] = 1 - s.loc['Exposure Time [%]'] = have_position.mean() * 100 # In "n bars" time, not index time - s.loc['Equity Final [$]'] = equity[-1] - s.loc['Equity Peak [$]'] = equity.max() + exposure_time_pct = have_position.mean() * 100 # In "n bars" time, not index time + stat_items.append(('Exposure Time [%]', exposure_time_pct)) + equity_final = equity[-1] + equity_peak = equity.max() + stat_items.append(('Equity Final [$]', equity_final)) + stat_items.append(('Equity Peak [$]', equity_peak)) if commissions: - s.loc['Commissions [$]'] = commissions - s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100 + stat_items.append(('Commissions [$]', commissions)) + return_pct = (equity_final - equity[0]) / equity[0] * 100 + stat_items.append(('Return [%]', return_pct)) first_trading_bar = _indicator_warmup_nbars(strategy_instance) c = ohlc_data.Close.values - s.loc['Buy & Hold Return [%]'] = (c[-1] - c[first_trading_bar]) / c[first_trading_bar] * 100 # long-only return + buy_hold_return_pct = (c[-1] - c[first_trading_bar]) / c[first_trading_bar] * 100 + stat_items.append(('Buy & Hold Return [%]', buy_hold_return_pct)) # long-only return gmean_day_return: float = 0 day_returns = np.array(np.nan) @@ -137,22 +147,29 @@ def _round_timedelta(value, _period=_data_period(index)): # Our annualized return matches `empyrical.annual_return(day_returns)` whereas # our risk doesn't; they use the simpler approach below. annualized_return = (1 + gmean_day_return)**annual_trading_days - 1 - s.loc['Return (Ann.) [%]'] = annualized_return * 100 - s.loc['Volatility (Ann.) [%]'] = np.sqrt((day_returns.var(ddof=int(bool(day_returns.shape))) + (1 + gmean_day_return)**2)**annual_trading_days - (1 + gmean_day_return)**(2 * annual_trading_days)) * 100 # noqa: E501 + return_ann_pct = annualized_return * 100 + volatility_ann_pct = np.sqrt((day_returns.var(ddof=int(bool(day_returns.shape))) + (1 + gmean_day_return)**2)**annual_trading_days - (1 + gmean_day_return)**(2 * annual_trading_days)) * 100 # noqa: E501 + stat_items.append(('Return (Ann.) [%]', return_ann_pct)) + stat_items.append(('Volatility (Ann.) [%]', volatility_ann_pct)) # s.loc['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100 # s.loc['Risk (Ann.) [%]'] = day_returns.std(ddof=1) * np.sqrt(annual_trading_days) * 100 if is_datetime_index: - time_in_years = (s.loc['Duration'].days + s.loc['Duration'].seconds / 86400) / annual_trading_days - s.loc['CAGR [%]'] = ((s.loc['Equity Final [$]'] / equity[0])**(1 / time_in_years) - 1) * 100 if time_in_years else np.nan # noqa: E501 + time_in_years = (duration.days + duration.seconds / 86400) / annual_trading_days + cagr_pct = ((equity_final / equity[0])**(1 / time_in_years) - 1) * 100 if time_in_years else np.nan # noqa: E501 + stat_items.append(('CAGR [%]', cagr_pct)) # Our Sharpe mismatches `empyrical.sharpe_ratio()` because they use arithmetic mean return # and simple standard deviation - s.loc['Sharpe Ratio'] = (s.loc['Return (Ann.) [%]'] - risk_free_rate * 100) / (s.loc['Volatility (Ann.) [%]'] or np.nan) # noqa: E501 + sharpe_denom = volatility_ann_pct or np.nan + sharpe_ratio = (return_ann_pct - risk_free_rate * 100) / sharpe_denom + stat_items.append(('Sharpe Ratio', sharpe_ratio)) # noqa: E501 # Our Sortino mismatches `empyrical.sortino_ratio()` because they use arithmetic mean return with np.errstate(divide='ignore'): - s.loc['Sortino Ratio'] = (annualized_return - risk_free_rate) / (np.sqrt(np.mean(day_returns.clip(-np.inf, 0)**2)) * np.sqrt(annual_trading_days)) # noqa: E501 + sortino_ratio = (annualized_return - risk_free_rate) / (np.sqrt(np.mean(day_returns.clip(-np.inf, 0)**2)) * np.sqrt(annual_trading_days)) # noqa: E501 + stat_items.append(('Sortino Ratio', sortino_ratio)) max_dd = -np.nan_to_num(dd.max()) - s.loc['Calmar Ratio'] = annualized_return / (-max_dd or np.nan) + calmar_ratio = annualized_return / (-max_dd or np.nan) + stat_items.append(('Calmar Ratio', calmar_ratio)) equity_log_returns = np.log(equity[1:] / equity[:-1]) market_log_returns = np.log(c[1:] / c[:-1]) beta = np.nan @@ -161,29 +178,40 @@ def _round_timedelta(value, _period=_data_period(index)): cov_matrix = np.cov(equity_log_returns, market_log_returns) beta = cov_matrix[0, 1] / cov_matrix[1, 1] # Jensen CAPM Alpha: can be strongly positive when beta is negative and B&H Return is large - s.loc['Alpha [%]'] = s.loc['Return [%]'] - risk_free_rate * 100 - beta * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) # noqa: E501 - s.loc['Beta'] = beta - s.loc['Max. Drawdown [%]'] = max_dd * 100 - s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100 - s.loc['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max()) - s.loc['Avg. Drawdown Duration'] = _round_timedelta(dd_dur.mean()) - s.loc['# Trades'] = n_trades = len(trades_df) + alpha_pct = return_pct - risk_free_rate * 100 - beta * (buy_hold_return_pct - risk_free_rate * 100) # noqa: E501 + stat_items.append(('Alpha [%]', alpha_pct)) + stat_items.append(('Beta', beta)) + stat_items.append(('Max. Drawdown [%]', max_dd * 100)) + stat_items.append(('Avg. Drawdown [%]', -dd_peaks.mean() * 100)) + stat_items.append(('Max. Drawdown Duration', _round_timedelta(dd_dur.max()))) + stat_items.append(('Avg. Drawdown Duration', _round_timedelta(dd_dur.mean()))) + n_trades = len(trades_df) + stat_items.append(('# Trades', n_trades)) win_rate = np.nan if not n_trades else (pl > 0).mean() - s.loc['Win Rate [%]'] = win_rate * 100 - s.loc['Best Trade [%]'] = returns.max() * 100 - s.loc['Worst Trade [%]'] = returns.min() * 100 + stat_items.append(('Win Rate [%]', win_rate * 100)) + stat_items.append(('Best Trade [%]', returns.max() * 100)) + stat_items.append(('Worst Trade [%]', returns.min() * 100)) mean_return = geometric_mean(returns) - s.loc['Avg. Trade [%]'] = mean_return * 100 - s.loc['Max. Trade Duration'] = _round_timedelta(durations.max()) - s.loc['Avg. Trade Duration'] = _round_timedelta(durations.mean()) - s.loc['Profit Factor'] = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan) # noqa: E501 - s.loc['Expectancy [%]'] = returns.mean() * 100 - s.loc['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan) - s.loc['Kelly Criterion'] = win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean()) - - s.loc['_strategy'] = strategy_instance - s.loc['_equity_curve'] = equity_df - s.loc['_trades'] = trades_df + stat_items.append(('Avg. Trade [%]', mean_return * 100)) + stat_items.append(('Max. Trade Duration', _round_timedelta(durations.max()))) + stat_items.append(('Avg. Trade Duration', _round_timedelta(durations.mean()))) + profit_factor = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan) # noqa: E501 + stat_items.append(('Profit Factor', profit_factor)) + expectancy = returns.mean() * 100 + stat_items.append(('Expectancy [%]', expectancy)) + sqn = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan) + stat_items.append(('SQN', sqn)) + kelly = win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean()) + stat_items.append(('Kelly Criterion', kelly)) + + stat_items.extend([ + ('_strategy', strategy_instance), + ('_equity_curve', equity_df), + ('_trades', trades_df), + ]) + + labels, values = zip(*stat_items) + s = pd.Series(values, index=labels, dtype=object) s = _Stats(s) return s From d7a7812d5b653813dbc68aa7d7265d207742999f Mon Sep 17 00:00:00 2001 From: Kernc Date: Sat, 20 Dec 2025 13:55:22 +0100 Subject: [PATCH 06/10] DEPR: Remove old deprecation --- backtesting/backtesting.py | 23 +---------------------- backtesting/test/_test.py | 1 - 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index cbc21e337..9ed77d6b8 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -312,7 +312,7 @@ def position(self) -> 'Position': @property def orders(self) -> 'Tuple[Order, ...]': """List of orders (see `Order`) waiting for execution.""" - return _Orders(self._broker.orders) + return tuple(self._broker.orders) @property def trades(self) -> 'Tuple[Trade, ...]': @@ -325,27 +325,6 @@ def closed_trades(self) -> 'Tuple[Trade, ...]': return tuple(self._broker.closed_trades) -class _Orders(tuple): - """ - TODO: remove this class. Only for deprecation. - """ - def cancel(self): - """Cancel all non-contingent (i.e. SL/TP) orders.""" - for order in self: - if not order.is_contingent: - order.cancel() - - def __getattr__(self, item): - # TODO: Warn on deprecations from the previous version. Remove in the next. - removed_attrs = ('entry', 'set_entry', 'is_long', 'is_short', - 'sl', 'tp', 'set_sl', 'set_tp') - if item in removed_attrs: - raise AttributeError(f'Strategy.orders.{"/.".join(removed_attrs)} were removed in' - 'Backtesting 0.2.0. ' - 'Use `Order` API instead. See docs.') - raise AttributeError(f"'tuple' object has no attribute {item!r}") - - class Position: """ Currently held asset position, available as diff --git a/backtesting/test/_test.py b/backtesting/test/_test.py index 2a187a799..3fe3ebf2e 100644 --- a/backtesting/test/_test.py +++ b/backtesting/test/_test.py @@ -173,7 +173,6 @@ def next(self, _FEW_DAYS=pd.Timedelta('3 days')): # noqa: N803 self.position.is_long if crossover(self.sma, self.data.Close): - self.orders.cancel() # cancels only non-contingent price = self.data.Close[-1] sl, tp = 1.05 * price, .9 * price From 431ec3272e726e6f8a34ce96428d89912e88650c Mon Sep 17 00:00:00 2001 From: Kernc Date: Sat, 20 Dec 2025 13:53:26 +0100 Subject: [PATCH 07/10] PERF: df.itertuples over a subset of columns --- backtesting/_stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 6905d62cd..1f01c5a3d 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -103,7 +103,7 @@ def _round_timedelta(value, _period=_data_period(index)): s.loc['Duration'] = s.End - s.Start have_position = np.repeat(0, len(index)) - for t in trades_df.itertuples(index=False): + for t in trades_df[['EntryBar', 'ExitBar']].itertuples(index=False): have_position[t.EntryBar:t.ExitBar + 1] = 1 s.loc['Exposure Time [%]'] = have_position.mean() * 100 # In "n bars" time, not index time From 3485f0cac0bc705ca8af8ce0f0827893deec0ba7 Mon Sep 17 00:00:00 2001 From: ChadThackray Date: Sat, 20 Dec 2025 16:30:32 +0000 Subject: [PATCH 08/10] Update the `current_values` function to be private: `_current_values` --- backtesting/_util.py | 2 +- backtesting/backtesting.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backtesting/_util.py b/backtesting/_util.py index 9391a20a8..1edd007a4 100644 --- a/backtesting/_util.py +++ b/backtesting/_util.py @@ -216,7 +216,7 @@ def __get_array(self, key) -> _Array: arr = self.__cache[key] = cast(_Array, self.__arrays[key][:self.__len]) return arr - def current_value(self, key: str): + def _current_value(self, key: str): if self.__len <= 0: raise IndexError("No data available") return self.__arrays[key][self.__len - 1] diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index b12ab074a..2c2b587be 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -856,7 +856,7 @@ def unrealized_pl(self) -> float: self._recalculate_trade_sums() if not self.trades: return 0.0 - current_price = float(self._data.current_value("Close")) + current_price = float(self._data._current_value("Close")) return current_price * self._open_trade_size_sum - self._open_trade_entry_value_sum @property @@ -900,9 +900,9 @@ def next(self): def _process_orders(self): data = self._data - open_price = data.current_value("Open") - high_price = data.current_value("High") - low_price = data.current_value("Low") + open_price = data._current_value("Open") + high_price = data._current_value("High") + low_price = data._current_value("Low") reprocess_orders = False # Process orders From 4099372ca2526ec9496a9374d92cc54c5cba2d0f Mon Sep 17 00:00:00 2001 From: ChadThackray Date: Sat, 20 Dec 2025 16:37:24 +0000 Subject: [PATCH 09/10] Revert name changes --- backtesting/backtesting.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index 2c2b587be..19cfa65c3 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -900,9 +900,7 @@ def next(self): def _process_orders(self): data = self._data - open_price = data._current_value("Open") - high_price = data._current_value("High") - low_price = data._current_value("Low") + open, high, low = data._current_value("Open"), data._current_value("High"), data._current_value("Low") reprocess_orders = False # Process orders @@ -916,7 +914,7 @@ def _process_orders(self): stop_price = order.stop if stop_price: is_stop_hit = ( - high_price >= stop_price if order.is_long else low_price <= stop_price + high >= stop_price if order.is_long else low <= stop_price ) if not is_stop_hit: continue @@ -929,7 +927,7 @@ def _process_orders(self): # Check if limit order can be filled. if order.limit: is_limit_hit = ( - low_price <= order.limit if order.is_long else high_price >= order.limit + low <= order.limit if order.is_long else high >= order.limit ) # When stop and limit are hit within the same bar, we pessimistically # assume limit was hit before the stop (i.e. "before it counts") @@ -942,9 +940,9 @@ def _process_orders(self): # stop_price, if set, was hit within this bar price = ( - min(stop_price or open_price, order.limit) + min(stop_price or open, order.limit) if order.is_long - else max(stop_price or open_price, order.limit) + else max(stop_price or open, order.limit) ) else: # Market-if-touched / market order @@ -953,7 +951,7 @@ def _process_orders(self): price = ( prev_close if self._trade_on_close and not order.is_contingent - else open_price + else open ) if stop_price: price = max(price, stop_price) if order.is_long else min(price, stop_price) @@ -1067,20 +1065,20 @@ def _process_orders(self): and ( ( order.is_long - and order.tp <= high_price - and (order.sl or -np.inf) < low_price + and order.tp <= high + and (order.sl or -np.inf) < low ) or ( order.is_short - and order.tp >= low_price - and (order.sl or np.inf) > high_price + and order.tp >= low + and (order.sl or np.inf) > high ) ) ): reprocess_orders = True elif ( - low_price <= (order.sl or -np.inf) <= high_price - or low_price <= (order.tp or -np.inf) <= high_price + low <= (order.sl or -np.inf) <= high + or low <= (order.tp or -np.inf) <= high ): warnings.warn( f"({data.index[-1]}) A contingent SL/TP order would execute in the " From 28eca40a7b728f9ef1210342d634a258a421bdf1 Mon Sep 17 00:00:00 2001 From: ChadThackray Date: Sat, 20 Dec 2025 17:04:53 +0000 Subject: [PATCH 10/10] Replace hand-rolled cache with @cached_property --- backtesting/backtesting.py | 44 ++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index 19cfa65c3..ab5f5f318 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -12,7 +12,7 @@ import warnings from abc import ABCMeta, abstractmethod from copy import copy -from functools import lru_cache, partial +from functools import cached_property, lru_cache, partial from itertools import chain, product, repeat from math import copysign from numbers import Number @@ -362,8 +362,6 @@ def __bool__(self): @property def size(self) -> float: """Position size in units of asset. Negative if position is short.""" - if self.__broker._trade_sums_dirty: - self.__broker._recalculate_trade_sums() return self.__broker._open_trade_size_sum @property @@ -374,8 +372,6 @@ def pl(self) -> float: @property def pl_pct(self) -> float: """Profit (positive) or loss (negative) of the current position in percent.""" - if self.__broker._trade_sums_dirty: - self.__broker._recalculate_trade_sums() total_invested = self.__broker._open_trade_entry_abs_value_sum return (self.pl / total_invested) * 100 if total_invested else 0 @@ -776,10 +772,6 @@ def __init__(self, *, data, cash, spread, commission, margin, self.trades: List[Trade] = [] self.position = Position(self) self.closed_trades: List[Trade] = [] - self._trade_sums_dirty = True - self._open_trade_size_sum = 0 - self._open_trade_entry_value_sum = 0.0 - self._open_trade_entry_abs_value_sum = 0.0 def _commission_func(self, order_size, price): return self._commission_fixed + abs(order_size) * price * self._commission_relative @@ -837,23 +829,25 @@ def new_order(self, return order - def _mark_trade_sums_dirty(self) -> None: - self._trade_sums_dirty = True + @cached_property + def _open_trade_size_sum(self) -> int: + return sum(int(trade.size) for trade in self.trades) - def _recalculate_trade_sums(self) -> None: - self._open_trade_size_sum = sum(int(trade.size) for trade in self.trades) - self._open_trade_entry_value_sum = sum( - trade.size * trade.entry_price for trade in self.trades - ) - self._open_trade_entry_abs_value_sum = sum( - abs(trade.size) * trade.entry_price for trade in self.trades - ) - self._trade_sums_dirty = False + @cached_property + def _open_trade_entry_value_sum(self) -> float: + return sum(trade.size * trade.entry_price for trade in self.trades) + + @cached_property + def _open_trade_entry_abs_value_sum(self) -> float: + return sum(abs(trade.size) * trade.entry_price for trade in self.trades) + + def _clear_trade_caches(self) -> None: + self.__dict__.pop('_open_trade_size_sum', None) + self.__dict__.pop('_open_trade_entry_value_sum', None) + self.__dict__.pop('_open_trade_entry_abs_value_sum', None) @property def unrealized_pl(self) -> float: - if self._trade_sums_dirty: - self._recalculate_trade_sums() if not self.trades: return 0.0 current_price = float(self._data._current_value("Close")) @@ -1099,7 +1093,7 @@ def _process_orders(self): def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int): assert trade.size * size < 0 assert abs(trade.size) >= abs(size) - self._mark_trade_sums_dirty() + self._clear_trade_caches() size_left = trade.size + size assert size_left * trade.size >= 0 @@ -1120,7 +1114,7 @@ def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int self._close_trade(close_trade, price, time_index) def _close_trade(self, trade: Trade, price: float, time_index: int): - self._mark_trade_sums_dirty() + self._clear_trade_caches() self.trades.remove(trade) if trade._sl_order: self.orders.remove(trade._sl_order) @@ -1144,7 +1138,7 @@ def _open_trade(self, price: float, size: int, self.trades.append(trade) # Apply broker commission at trade open self._cash -= self._commission(size, price) - self._mark_trade_sums_dirty() + self._clear_trade_caches() # Create SL/TP (bracket) orders. if tp: trade.tp = tp