Backtesting should not double-loop for sell signals

This commit is contained in:
Matthias 2020-10-07 20:59:05 +02:00
parent 72337a0ab7
commit 52502193c4

View File

@ -4,6 +4,7 @@
This module contains the backtesting logic This module contains the backtesting logic
""" """
import logging import logging
from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict, List, NamedTuple, Optional, Tuple from typing import Any, Dict, List, NamedTuple, Optional, Tuple
@ -215,72 +216,29 @@ class Backtesting:
else: else:
return sell_row.open return sell_row.open
def _get_sell_trade_entry( def _get_sell_trade_entry(self, trade: Trade, sell_row: DataFrame) -> Optional[BacktestResult]:
self, pair: str, buy_row: DataFrame,
partial_ohlcv: List, trade_count_lock: Dict,
stake_amount: float, max_open_trades: int) -> Optional[BacktestResult]:
trade = Trade( sell = self.strategy.should_sell(trade, sell_row.open, sell_row.date, sell_row.buy,
pair=pair, sell_row.sell, low=sell_row.low, high=sell_row.high)
open_rate=buy_row.open, if sell.sell_flag:
open_date=buy_row.date, logger.debug(f"Fund sell signal {sell.sell_flag}")
stake_amount=stake_amount, trade_dur = int((sell_row.date - trade.open_date).total_seconds() // 60)
amount=round(stake_amount / buy_row.open, 8), closerate = self._get_close_rate(sell_row, trade, sell, trade_dur)
fee_open=self.fee,
fee_close=self.fee,
is_open=True,
)
logger.debug(f"{pair} - Backtesting emulates creation of new trade: {trade}.")
# calculate win/lose forwards from buy point
for sell_row in partial_ohlcv:
if max_open_trades > 0:
# Increase trade_count_lock for every iteration
trade_count_lock[sell_row.date] = trade_count_lock.get(sell_row.date, 0) + 1
sell = self.strategy.should_sell(trade, sell_row.open, sell_row.date, sell_row.buy, return BacktestResult(pair=trade.pair,
sell_row.sell, low=sell_row.low, high=sell_row.high) profit_percent=trade.calc_profit_ratio(rate=closerate),
if sell.sell_flag: profit_abs=trade.calc_profit(rate=closerate),
trade_dur = int((sell_row.date - buy_row.date).total_seconds() // 60) open_date=trade.open_date,
closerate = self._get_close_rate(sell_row, trade, sell, trade_dur) open_rate=trade.open_rate,
open_fee=self.fee,
return BacktestResult(pair=pair, close_date=sell_row.date,
profit_percent=trade.calc_profit_ratio(rate=closerate), close_rate=closerate,
profit_abs=trade.calc_profit(rate=closerate), close_fee=self.fee,
open_date=buy_row.date, amount=trade.amount,
open_rate=buy_row.open, trade_duration=trade_dur,
open_fee=self.fee, open_at_end=False,
close_date=sell_row.date, sell_reason=sell.sell_type
close_rate=closerate, )
close_fee=self.fee,
amount=trade.amount,
trade_duration=trade_dur,
open_at_end=False,
sell_reason=sell.sell_type
)
if partial_ohlcv:
# no sell condition found - trade stil open at end of backtest period
sell_row = partial_ohlcv[-1]
bt_res = BacktestResult(pair=pair,
profit_percent=trade.calc_profit_ratio(rate=sell_row.open),
profit_abs=trade.calc_profit(rate=sell_row.open),
open_date=buy_row.date,
open_rate=buy_row.open,
open_fee=self.fee,
close_date=sell_row.date,
close_rate=sell_row.open,
close_fee=self.fee,
amount=trade.amount,
trade_duration=int((
sell_row.date - buy_row.date).total_seconds() // 60),
open_at_end=True,
sell_reason=SellType.FORCE_SELL
)
logger.debug(f"{pair} - Force selling still open trade, "
f"profit percent: {bt_res.profit_percent}, "
f"profit abs: {bt_res.profit_abs}")
return bt_res
return None
def backtest(self, processed: Dict, stake_amount: float, def backtest(self, processed: Dict, stake_amount: float,
start_date: arrow.Arrow, end_date: arrow.Arrow, start_date: arrow.Arrow, end_date: arrow.Arrow,
@ -305,19 +263,21 @@ class Backtesting:
f"max_open_trades: {max_open_trades}, position_stacking: {position_stacking}" f"max_open_trades: {max_open_trades}, position_stacking: {position_stacking}"
) )
trades = [] trades = []
trade_count_lock: Dict = {}
# Use dict of lists with data for performance # Use dict of lists with data for performance
# (looping lists is a lot faster than pandas DataFrames) # (looping lists is a lot faster than pandas DataFrames)
data: Dict = self._get_ohlcv_as_lists(processed) data: Dict = self._get_ohlcv_as_lists(processed)
lock_pair_until: Dict = {}
# Indexes per pair, so some pairs are allowed to have a missing start. # Indexes per pair, so some pairs are allowed to have a missing start.
indexes: Dict = {} indexes: Dict = {}
tmp = start_date + timedelta(minutes=self.timeframe_min) tmp = start_date + timedelta(minutes=self.timeframe_min)
open_trades: Dict[str, List] = defaultdict(list)
open_trade_count = 0
# Loop timerange and get candle for each pair at that point in time # Loop timerange and get candle for each pair at that point in time
while tmp < end_date: while tmp <= end_date:
open_trade_count_start = open_trade_count
for i, pair in enumerate(data): for i, pair in enumerate(data):
if pair not in indexes: if pair not in indexes:
@ -336,37 +296,73 @@ class Backtesting:
indexes[pair] += 1 indexes[pair] += 1
if row.buy == 0 or row.sell == 1: # without positionstacking, we can only have one open trade per pair.
continue # skip rows where no buy signal or that would immediately sell off # max_open_trades must be respected
# don't open on the last row
if ((position_stacking or len(open_trades[pair]) == 0)
and max_open_trades > 0 and open_trade_count_start < max_open_trades
and tmp != end_date
and row.buy == 1 and row.sell != 1):
# Enter trade
trade = Trade(
pair=pair,
open_rate=row.open,
open_date=row.date,
stake_amount=stake_amount,
amount=round(stake_amount / row.open, 8),
fee_open=self.fee,
fee_close=self.fee,
is_open=True,
)
# TODO: hacky workaround to avoid opening > max_open_trades
# This emulates previous behaviour - not sure if this is correct
# Prevents buying if the trade-slot was freed in this candle
open_trade_count_start += 1
open_trade_count += 1
logger.debug(f"{pair} - Backtesting emulates creation of new trade: {trade}.")
open_trades[pair].append(trade)
if (not position_stacking and pair in lock_pair_until for trade in open_trades[pair]:
and row.date <= lock_pair_until[pair]): # logger.debug(f"{pair} - Checking for sells for {trade} at {row.date}")
# without positionstacking, we can only have one open trade per pair.
continue
if max_open_trades > 0: # since indexes has been incremented before, we need to go one step back to
# Check if max_open_trades has already been reached for the given date # also check the buying candle for sell conditions.
if not trade_count_lock.get(row.date, 0) < max_open_trades: trade_entry = self._get_sell_trade_entry(trade, row)
continue # Sell occured
trade_count_lock[row.date] = trade_count_lock.get(row.date, 0) + 1 if trade_entry:
logger.debug(f"{pair} - Backtesting sell {trade}")
# since indexes has been incremented before, we need to go one step back to open_trade_count -= 1
# also check the buying candle for sell conditions. open_trades[pair].remove(trade)
trade_entry = self._get_sell_trade_entry(pair, row, data[pair][indexes[pair]-1:], trades.append(trade_entry)
trade_count_lock, stake_amount,
max_open_trades)
if trade_entry:
logger.debug(f"{pair} - Locking pair till "
f"close_date={trade_entry.close_date}")
lock_pair_until[pair] = trade_entry.close_date
trades.append(trade_entry)
else:
# Set lock_pair_until to end of testing period if trade could not be closed
lock_pair_until[pair] = end_date.datetime
# Move time one configured time_interval ahead. # Move time one configured time_interval ahead.
tmp += timedelta(minutes=self.timeframe_min) tmp += timedelta(minutes=self.timeframe_min)
# Handle trades that were left open
for pair in open_trades.keys():
if len(open_trades[pair]) == 0:
continue
else:
for trade in open_trades[pair]:
sell_row = data[pair][-1]
trade_entry = BacktestResult(pair=trade.pair,
profit_percent=trade.calc_profit_ratio(
rate=sell_row.open),
profit_abs=trade.calc_profit(rate=sell_row.open),
open_date=trade.open_date,
open_rate=trade.open_rate,
open_fee=self.fee,
close_date=sell_row.date,
close_rate=sell_row.open,
close_fee=self.fee,
amount=trade.amount,
trade_duration=int((
sell_row.date - trade.open_date).total_seconds() // 60),
open_at_end=True,
sell_reason=SellType.FORCE_SELL
)
trades.append(trade_entry)
return DataFrame.from_records(trades, columns=BacktestResult._fields) return DataFrame.from_records(trades, columns=BacktestResult._fields)
def start(self) -> None: def start(self) -> None: