diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index d356b211..eb76d650 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -17,7 +17,7 @@ from itertools import chain, product, repeat from math import copysign from numbers import Number -from typing import Callable, List, Optional, Sequence, Tuple, Type, Union +from typing import Callable, List, Literal, Optional, Sequence, Tuple, Type, Union import numpy as np import pandas as pd @@ -355,7 +355,9 @@ def pl(self) -> float: def pl_pct(self) -> float: """Profit (positive) or loss (negative) of the current position in percent.""" total_invested = self.__broker._position_initial_value - return (self.pl / total_invested) * 100 if total_invested else 0 + if not np.isfinite(total_invested) or total_invested == 0: + return 0 + return (self.pl / total_invested) * 100 @property def is_long(self) -> bool: @@ -430,9 +432,9 @@ def __repr__(self): ('tp', self.__tp_price), ('contingent', self.is_contingent), ('tag', self.__tag), - ) if value is not None)) # noqa: E126 + ) if value is not None and value is not False)) # noqa: E126 - def cancel(self): + def cancel(self) -> None: """Cancel the order.""" self.__broker.orders.remove(self) trade = self.__parent_trade @@ -498,11 +500,11 @@ def tp(self) -> Optional[float]: return self.__tp_price @property - def parent_trade(self): + def parent_trade(self) -> Optional['Trade']: return self.__parent_trade @property - def tag(self): + def tag(self) -> object: """ Arbitrary value (such as a string) which, if set, enables tracking of this order and the associated `Trade` (see `Trade.tag`). @@ -514,17 +516,17 @@ def tag(self): # Extra properties @property - def is_long(self): + def is_long(self) -> bool: """True if the order is long (order size is positive).""" return self.__size > 0 @property - def is_short(self): + def is_short(self) -> bool: """True if the order is short (order size is negative).""" return self.__size < 0 @property - def is_contingent(self): + def is_contingent(self) -> bool: """ True for [contingent] orders, i.e. [OCO] stop-loss and take-profit bracket orders placed upon an active trade. Remaining contingent orders are canceled when @@ -545,7 +547,7 @@ class Trade: When an `Order` is filled, it results in an active `Trade`. Find active trades in `Strategy.trades` and closed, settled trades in `Strategy.closed_trades`. """ - def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar, tag): + def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar: int, tag: object): self.__broker = broker self.__size = size self.__entry_price = entry_price @@ -557,7 +559,7 @@ def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar, self.__tag = tag self._commissions = 0 - def __repr__(self): + def __repr__(self) -> str: return f'' @@ -570,7 +572,7 @@ def _replace(self, **kwargs): def _copy(self, **kwargs): return copy(self)._replace(**kwargs) - def close(self, portion: float = 1.): + def close(self, portion: float = 1.) -> None: """Place new `Order` to close `portion` of the trade at next market price.""" assert 0 < portion <= 1, "portion must be a fraction between 0 and 1" # Ensure size is an int to avoid rounding errors on 32-bit OS @@ -581,7 +583,7 @@ def close(self, portion: float = 1.): # Fields getters @property - def size(self): + def size(self) -> int: """Trade size (volume; negative for short trades).""" return self.__size @@ -609,7 +611,7 @@ def exit_bar(self) -> Optional[int]: return self.__exit_bar @property - def tag(self): + def tag(self) -> object: """ A tag value inherited from the `Order` that opened this trade. @@ -622,11 +624,11 @@ def tag(self): return self.__tag @property - def _sl_order(self): + def _sl_order(self) -> Optional[Order]: return self.__sl_order @property - def _tp_order(self): + def _tp_order(self) -> Optional[Order]: return self.__tp_order # Extra properties @@ -644,17 +646,17 @@ def exit_time(self) -> Optional[Union[pd.Timestamp, int]]: return self.__broker._data.index[self.__exit_bar] @property - def is_long(self): + def is_long(self) -> bool: """True if the trade is long (trade size is positive).""" return self.__size > 0 @property - def is_short(self): + def is_short(self) -> bool: """True if the trade is short (trade size is negative).""" return not self.is_long @property - def pl(self): + def pl(self) -> float: """ Trade profit (positive) or loss (negative) in cash units. Commissions are reflected only after the Trade is closed. @@ -663,17 +665,20 @@ def pl(self): return (self.__size * (price - self.__entry_price)) - self._commissions @property - def pl_pct(self): + def pl_pct(self) -> float: """Trade profit (positive) or loss (negative) in percent relative to trade entry price.""" price = self.__exit_price or self.__broker.last_price + total_invested = abs(self.__size) * self.__entry_price + if not np.isfinite(total_invested) or total_invested == 0: + return 0 gross_pl_pct = copysign(1, self.__size) * (price / self.__entry_price - 1) # Total commission across the entire trade size to individual units - commission_pct = self._commissions / (abs(self.__size) * self.__entry_price) + commission_pct = self._commissions / total_invested return gross_pl_pct - commission_pct @property - def value(self): + def value(self) -> float: """Trade total value in cash (volume × price).""" price = self.__exit_price or self.__broker.last_price return abs(self.__size) * price @@ -681,7 +686,7 @@ def value(self): # SL/TP management API @property - def sl(self): + def sl(self) -> Optional[float]: """ Stop-loss price at which to close the trade. @@ -689,14 +694,14 @@ def sl(self): you create or modify the existing SL order. By assigning it `None`, you cancel it. """ - return self.__sl_order and self.__sl_order.stop + return self.__sl_order.stop if self.__sl_order else None @sl.setter - def sl(self, price: float): + def sl(self, price: Optional[float]): self.__set_contingent('sl', price) @property - def tp(self): + def tp(self) -> Optional[float]: """ Take-profit price at which to close the trade. @@ -704,13 +709,13 @@ def tp(self): you create or modify the existing TP order. By assigning it `None`, you cancel it. """ - return self.__tp_order and self.__tp_order.limit + return self.__tp_order.limit if self.__tp_order else None @tp.setter - def tp(self, price: float): + def tp(self, price: Optional[float]): self.__set_contingent('tp', price) - def __set_contingent(self, type, price): + def __set_contingent(self, type: str, price: Optional[float]) -> None: assert type in ('sl', 'tp') assert price is None or 0 < price < np.inf, f'Make sure 0 < price < inf! price: {price}' attr = f'_{self.__class__.__qualname__}__{type}_order' @@ -870,6 +875,7 @@ def next(self): self._close_trade(trade, self.last_price, i) self._cash = 0 self._equity[i:] = 0 + self.orders.clear() # Clear any remaining (unfilled) orders; account is bankrupt raise _OutOfMoneyError def _process_orders(self): @@ -1040,11 +1046,11 @@ def _process_orders(self): 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. " "Since we can't assert the precise intra-candle " - "price movement, the affected SL/TP order will instead be executed on " - "the next (matching) price/bar, making the result (of this trade) " - "somewhat dubious. " + "price movement, the affected SL/TP order will be executed " + "pessimistically within the same bar. " "See https://github.com/kernc/backtesting.py/issues/119", UserWarning) + reprocess_orders = True # Order processed self.orders.remove(order) @@ -1385,7 +1391,7 @@ def run(self, **kwargs) -> pd.Series: def optimize(self, *, maximize: Union[str, Callable[[pd.Series], float]] = 'SQN', - method: str = 'grid', + method: Literal['grid', 'sambo'] = 'grid', max_tries: Optional[Union[int, float]] = None, constraint: Optional[Callable[[dict], bool]] = None, return_heatmap: bool = False, diff --git a/backtesting/test/_test.py b/backtesting/test/_test.py index 7a6e5d1d..47d9f698 100644 --- a/backtesting/test/_test.py +++ b/backtesting/test/_test.py @@ -439,7 +439,7 @@ def next(self): self.buy(stop=758, sl=720) with self.assertWarns(UserWarning): - self.assertEqual(Backtest(GOOG, S).run()._trades.iloc[0].ExitPrice, 705.58) + self.assertEqual(Backtest(GOOG, S).run()._trades.iloc[0].ExitPrice, 720) def test_stop_price_between_sl_tp(self): class S(_S): @@ -519,6 +519,46 @@ def coroutine(self): self._Backtest(coroutine).run() + def test_order_repr_omits_falsy_contingent(self): + def coroutine(self): + self.buy(size=1, sl=1) + order = self.orders[-1] + # Plain (non-contingent) order: falsy `contingent=False` is omitted (gh-1318) + assert 'contingent' not in repr(order), repr(order) + yield + + sl_order = self.trades[0]._sl_order + # Contingent SL order: truthy `contingent` is still shown + assert 'contingent' in repr(sl_order), repr(sl_order) + yield + + self._Backtest(coroutine).run() + + def test_pl_pct_with_zero_entry_price(self): + data = pd.DataFrame({ + 'Open': [1, 1, 0, 1], + 'High': [1, 1, 1, 1], + 'Low': [0, 0, 0, 0], + 'Close': [1, 1, 0, 1], + }) + + class S(Strategy): + trade_pl_pct = position_pl_pct = None + + def init(self): + pass + + def next(self): + if len(self.data) == 2: + self.buy(size=1) + elif len(self.data) == 3: + type(self).trade_pl_pct = self.trades[0].pl_pct + type(self).position_pl_pct = self.position.pl_pct + + Backtest(data, S).run() + self.assertEqual(S.trade_pl_pct, 0) + self.assertEqual(S.position_pl_pct, 0) + def test_broker_hedging(self): def coroutine(self): yield self.buy(size=2) @@ -1185,6 +1225,24 @@ def next(self): with self.assertWarnsRegex(UserWarning, 'margin'): self.assertEqual(bt.run()._trades['ExitPrice'][0], 50) + def test_gh_1318_bankruptcy_clears_orders(self): + # On bankruptcy (equity <= 0), any remaining unfilled orders should be + # cleared, not left dangling in `Strategy.orders` / `_Broker.orders`. + class S(_S): + def next(self): + if not self.position and not self.trades: + self.buy(size=.9) + # This one never fills, so it'd otherwise remain pending forever + self.buy(size=.05, limit=1e-6, tag='never-fills') + + arr = np.r_[100., 100., 1.] # Price craters 99% in one bar + 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, margin=1 / 50, trade_on_close=True) + stats = bt.run() + self.assertEqual(stats['Equity Final [$]'], 0) + self.assertEqual(stats._strategy.orders, ()) + def test_stats_annualized(self): stats = Backtest(GOOG.resample('W').agg(OHLCV_AGG), SmaCross).run() self.assertFalse(np.isnan(stats['Return (Ann.) [%]']))