Backtesting should not double-loop for sell signals
This commit is contained in:
parent
72337a0ab7
commit
52502193c4
@ -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:
|
||||||
|
Loading…
Reference in New Issue
Block a user