Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions backtesting/backtesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,9 @@ def sell(self, *,
Keep in mind that `self.sell(size=.1)` doesn't close existing `self.buy(size=.1)`
trade unless:

* the backtest was run with `exclusive_orders=True`,
* the underlying asset price is equal in both cases and
the backtest was run with `spread = commission = 0`.
* the backtest was run with `exclusive_orders=True`, or
* the underlying asset price is equal in both cases,
_and_ the backtest was run with `spread = commission = 0`.

Use `Trade.close()` or `Position.close()` to explicitly exit trades.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -571,7 +573,14 @@ def _copy(self, **kwargs):
return copy(self)._replace(**kwargs)

def close(self, portion: float = 1.):
"""Place new `Order` to close `portion` of the trade at next market price."""
"""
Place new `Order` to close `portion` of the trade at next market price.

Since order size is quantized to a whole number of units, `portion`
of a small trade may round up to _at least one unit_, so it can end
up closing more of the trade than requested (in the extreme, the
entire trade for `portion` values that would otherwise round to zero units).
"""
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
size = copysign(max(1, int(round(abs(self.__size) * portion))), -self.__size)
Expand Down Expand Up @@ -666,10 +675,13 @@ def pl(self):
def pl_pct(self):
"""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
Expand Down Expand Up @@ -1040,11 +1052,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)
Expand Down
27 changes: 26 additions & 1 deletion backtesting/test/_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -519,6 +519,31 @@ def coroutine(self):

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)
Expand Down