From 4e22454d2d7ffbbf02fb95232cd8771609368952 Mon Sep 17 00:00:00 2001 From: Kernc Date: Tue, 13 Dec 2022 14:50:40 +0100 Subject: [PATCH 1/2] ENH: Add Backtest(spread=), change Backtest(commission=) `commission=` is now applied twice as common with brokers. `spread=` takes the role `commission=` had previously. --- backtesting/backtesting.py | 80 ++++++++++++++++++++++++++++---------- backtesting/test/_test.py | 37 ++++++++++++++++-- 2 files changed, 94 insertions(+), 23 deletions(-) diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index 9c168703..1c6f7817 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -698,16 +698,27 @@ def __set_contingent(self, type, price): class _Broker: - def __init__(self, *, data, cash, commission, margin, + def __init__(self, *, data, cash, spread, commission, margin, trade_on_close, hedging, exclusive_orders, index): assert 0 < cash, f"cash should be >0, is {cash}" - assert -.1 <= commission < .1, \ - ("commission should be between -10% " - f"(e.g. market-maker's rebates) and 10% (fees), is {commission}") assert 0 < margin <= 1, f"margin should be between 0 and 1, is {margin}" self._data: _Data = data self._cash = cash - self._commission = commission + + if callable(commission): + self._commission = commission + else: + try: + self._commission_fixed, self._commission_relative = commission + except TypeError: + self._commission_fixed, self._commission_relative = 0, commission + assert self._commission_fixed >= 0, 'Need fixed cash commission in $ >= 0' + assert -.1 <= self._commission_relative < .1, \ + ("commission should be between -10% " + f"(e.g. market-maker's rebates) and 10% (fees), is {self._commission_relative}") + self._commission = self._commission_func + + self._spread = spread self._leverage = 1 / margin self._trade_on_close = trade_on_close self._hedging = hedging @@ -719,6 +730,9 @@ def __init__(self, *, data, cash, commission, margin, self.position = Position(self) self.closed_trades: List[Trade] = [] + def _commission_func(self, order_size, price): + return self._commission_fixed + abs(order_size) * price * self._commission_relative + def __repr__(self): return f'' @@ -780,10 +794,10 @@ def last_price(self) -> float: def _adjusted_price(self, size=None, price=None) -> float: """ - Long/short `price`, adjusted for commisions. + Long/short `price`, adjusted for spread. In long positions, the adjusted price is a fraction higher, and vice versa. """ - return (price or self.last_price) * (1 + copysign(self._commission, size)) + return (price or self.last_price) * (1 + copysign(self._spread, size)) @property def equity(self) -> float: @@ -890,15 +904,17 @@ def _process_orders(self): # Adjust price to include commission (or bid-ask spread). # In long positions, the adjusted price is a fraction higher, and vice versa. adjusted_price = self._adjusted_price(order.size, price) + adjusted_price_plus_commission = adjusted_price + self._commission(order.size, price) # If order size was specified proportionally, # precompute true size in units, accounting for margin and spread/commissions size = order.size if -1 < size < 1: size = copysign(int((self.margin_available * self._leverage * abs(size)) - // adjusted_price), size) + // adjusted_price_plus_commission), size) # Not enough cash/margin even for a single unit if not size: + # XXX: The order is canceled by the broker? self.orders.remove(order) continue assert size == round(size) @@ -927,8 +943,9 @@ def _process_orders(self): if not need_size: break - # If we don't have enough liquidity to cover for the order, cancel it - if abs(need_size) * adjusted_price > self.margin_available * self._leverage: + # If we don't have enough liquidity to cover for the order, the broker CANCELS it + if abs(need_size) * adjusted_price_plus_commission > \ + self.margin_available * self._leverage: self.orders.remove(order) continue @@ -995,12 +1012,15 @@ def _close_trade(self, trade: Trade, price: float, time_index: int): self.orders.remove(trade._tp_order) self.closed_trades.append(trade._replace(exit_price=price, exit_bar=time_index)) - self._cash += trade.pl + # Apply commission one more time at trade exit + self._cash += trade.pl - self._commission(trade.size, price) def _open_trade(self, price: float, size: int, sl: Optional[float], tp: Optional[float], time_index: int, tag): trade = Trade(self, size, price, time_index, tag) self.trades.append(trade) + # Apply broker commission at trade open + self._cash -= self._commission(size, price) # Create SL/TP (bracket) orders. # Make sure SL order is created first so it gets adversarially processed before TP order # in case of an ambiguous tie (both hit within a single bar). @@ -1026,7 +1046,8 @@ def __init__(self, strategy: Type[Strategy], *, cash: float = 10_000, - commission: float = .0, + spread: float = .0, + commission: Union[float, Tuple[float, float]] = .0, margin: float = 1., trade_on_close=False, hedging=False, @@ -1052,11 +1073,25 @@ def __init__(self, `cash` is the initial cash to start with. - `commission` is the commission ratio. E.g. if your broker's commission - is 1% of trade value, set commission to `0.01`. Note, if you wish to - account for bid-ask spread, you can approximate doing so by increasing - the commission, e.g. set it to `0.0002` for commission-less forex - trading where the average spread is roughly 0.2‰ of asking price. + `spread` is the the constant bid-ask spread rate (relative to the price). + E.g. set it to `0.0002` for commission-less forex + trading where the average spread is roughly 0.2‰ of the asking price. + + `commission` is the commission rate. E.g. if your broker's commission + is 1% of order value, set commission to `0.01`. + The commission is applied twice: at trade entry and at trade exit. + Besides one single floating value, `commission` can also be a tuple of floating + values `(fixed, relative)`. E.g. set it to `(100, .01)` + if your broker charges minimum $100 + 1%. + Additionally, `commission` can be a callable + `func(order_size: int, price: float) -> float` + (note, order size is negative for short orders), + which can be used to model more complex commission structures. + Negative commission values are interpreted as market-maker's rebates. + + .. note:: + Before v0.4.0, the commission was only applied once, like `spread` is now. + If you want to keep the old behavior, simply set `spread` instead. `margin` is the required margin (ratio) of a leveraged account. No difference is made between initial and maintenance margins. @@ -1082,9 +1117,14 @@ def __init__(self, raise TypeError('`strategy` must be a Strategy sub-type') if not isinstance(data, pd.DataFrame): raise TypeError("`data` must be a pandas.DataFrame with columns") - if not isinstance(commission, Number): - raise TypeError('`commission` must be a float value, percent of ' + if not isinstance(spread, Number): + raise TypeError('`spread` must be a float value, percent of ' 'entry order price') + if not isinstance(commission, (Number, tuple)) and not callable(commission): + raise TypeError('`commission` must be a float percent of order value, ' + 'a tuple of `(fixed, relative)` commission, ' + 'or a function that takes `(order_size, price)`' + 'and returns commission dollar value') data = data.copy(deep=False) @@ -1127,7 +1167,7 @@ def __init__(self, self._data: pd.DataFrame = data self._broker = partial( - _Broker, cash=cash, commission=commission, margin=margin, + _Broker, cash=cash, spread=spread, commission=commission, margin=margin, trade_on_close=trade_on_close, hedging=hedging, exclusive_orders=exclusive_orders, index=data.index, ) diff --git a/backtesting/test/_test.py b/backtesting/test/_test.py index d8d87814..c0f056e5 100644 --- a/backtesting/test/_test.py +++ b/backtesting/test/_test.py @@ -222,9 +222,40 @@ def next(self, _FEW_DAYS=pd.Timedelta('3 days')): # noqa: N803 def test_broker_params(self): bt = Backtest(GOOG.iloc[:100], SmaCross, - cash=1000, commission=.01, margin=.1, trade_on_close=True) + cash=1000, spread=.01, margin=.1, trade_on_close=True) bt.run() + def test_spread_commission(self): + class S(Strategy): + def init(self): + self.done = False + + def next(self): + if not self.position: + self.buy() + else: + self.position.close() + self.next = lambda: None # Done + + SPREAD = .01 + COMMISSION = .01 + CASH = 10_000 + ORDER_BAR = 2 + stats = Backtest(SHORT_DATA, S, cash=CASH, spread=SPREAD, commission=COMMISSION).run() + trade_open_price = SHORT_DATA['Open'].iloc[ORDER_BAR] + self.assertEqual(stats['_trades']['EntryPrice'].iloc[0], trade_open_price * (1 + SPREAD)) + self.assertEqual(stats['_equity_curve']['Equity'].iloc[2:4].round(2).tolist(), + [9685.31, 9749.33]) + + stats = Backtest(SHORT_DATA, S, cash=CASH, commission=(100, COMMISSION)).run() + self.assertEqual(stats['_equity_curve']['Equity'].iloc[2:4].round(2).tolist(), + [9784.50, 9718.69]) + + commission_func = lambda size, price: size * price * COMMISSION # noqa: E731 + stats = Backtest(SHORT_DATA, S, cash=CASH, commission=commission_func).run() + self.assertEqual(stats['_equity_curve']['Equity'].iloc[2:4].round(2).tolist(), + [9781.28, 9846.04]) + def test_dont_overwrite_data(self): df = EURUSD.copy() bt = Backtest(df, SmaCross) @@ -388,7 +419,7 @@ def next(self): if self.position and crossover(self.sma2, self.sma1): self.position.close(portion=.5) - bt = Backtest(GOOG, SmaCross, commission=.002) + bt = Backtest(GOOG, SmaCross, spread=.002) bt.run() def test_close_orders_from_last_strategy_iteration(self): @@ -410,7 +441,7 @@ def init(self): pass def next(self): self.buy(tp=self.data.Close * 1.01) - self.assertRaises(ValueError, Backtest(SHORT_DATA, S, commission=.02).run) + self.assertRaises(ValueError, Backtest(SHORT_DATA, S, spread=.02).run) class TestStrategy(TestCase): From cd5ae83135b2644bf611134e6c8218ca7e845da0 Mon Sep 17 00:00:00 2001 From: Kernc Date: Tue, 13 Dec 2022 15:13:54 +0100 Subject: [PATCH 2/2] ENH: Show paid 'Commissions [$]' in stats --- backtesting/_stats.py | 4 ++++ backtesting/backtesting.py | 12 ++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index f2bb4f7c..fa32f92d 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -53,6 +53,7 @@ def compute_stats( if isinstance(trades, pd.DataFrame): trades_df: pd.DataFrame = trades + commissions = None # Not shown else: # Came straight from Backtest.run() trades_df = pd.DataFrame({ @@ -68,6 +69,7 @@ def compute_stats( 'Tag': [t.tag for t in trades], }) trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime'] + commissions = sum(t._commissions for t in trades) del trades pl = trades_df['PnL'] @@ -92,6 +94,8 @@ def _round_timedelta(value, _period=_data_period(index)): 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() + if commissions: + s.loc['Commissions [$]'] = commissions s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100 c = ohlc_data.Close.values s.loc['Buy & Hold Return [%]'] = (c[-1] - c[0]) / c[0] * 100 # long-only return diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index 1c6f7817..42ecd68b 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -538,6 +538,7 @@ def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar, self.__sl_order: Optional[Order] = None self.__tp_order: Optional[Order] = None self.__tag = tag + self._commissions = 0 def __repr__(self): return f'