Merge branch 'develop' into feature/proceed-exit-while-open-order

This commit is contained in:
Axel-CH
2024-12-01 10:51:41 -04:00
219 changed files with 18735 additions and 7501 deletions

View File

@@ -8,7 +8,7 @@ import logging
from collections import defaultdict
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from typing import Any
from numpy import nan
from pandas import DataFrame
@@ -110,7 +110,7 @@ class Backtesting:
backtesting.start()
"""
def __init__(self, config: Config, exchange: Optional[Exchange] = None) -> None:
def __init__(self, config: Config, exchange: Exchange | None = None) -> None:
LoggingMixin.show_output = False
self.config = config
self.results: BacktestResultType = get_BacktestResultType_default()
@@ -685,7 +685,7 @@ class Backtesting:
)
def _try_close_open_order(
self, order: Optional[Order], trade: LocalTrade, current_date: datetime, row: tuple
self, order: Order | None, trade: LocalTrade, current_date: datetime, row: tuple
) -> bool:
"""
Check if an order is open and if it should've filled.
@@ -732,7 +732,6 @@ class Backtesting:
trade.close_date = current_time
trade.close(order.ft_price, show_msg=False)
# logger.debug(f"{pair} - Backtesting exit {trade}")
LocalTrade.close_bt_trade(trade)
self.wallets.update()
self.run_protections(pair, current_time, trade.trade_direction)
@@ -743,8 +742,8 @@ class Backtesting:
row: tuple,
exit_: ExitCheckTuple,
current_time: datetime,
amount: Optional[float] = None,
) -> Optional[LocalTrade]:
amount: float | None = None,
) -> LocalTrade | None:
if exit_.exit_flag:
trade.close_date = current_time
exit_reason = exit_.exit_reason
@@ -823,8 +822,8 @@ class Backtesting:
sell_row: tuple,
close_rate: float,
amount: float,
exit_reason: Optional[str],
) -> Optional[LocalTrade]:
exit_reason: str | None,
) -> LocalTrade | None:
self.order_id_counter += 1
exit_candle_time = sell_row[DATE_IDX].to_pydatetime()
order_type = self.strategy.order_types["exit"]
@@ -860,7 +859,7 @@ class Backtesting:
def _check_trade_exit(
self, trade: LocalTrade, row: tuple, current_time: datetime
) -> Optional[LocalTrade]:
) -> LocalTrade | None:
self._run_funding_fees(trade, current_time)
# Check if we need to adjust our current positions
@@ -910,10 +909,10 @@ class Backtesting:
stake_amount: float,
direction: LongShort,
current_time: datetime,
entry_tag: Optional[str],
trade: Optional[LocalTrade],
entry_tag: str | None,
trade: LocalTrade | None,
order_type: str,
price_precision: Optional[float],
price_precision: float | None,
) -> tuple[float, float, float, float]:
if order_type == "limit":
new_rate = strategy_safe_wrapper(
@@ -1005,12 +1004,12 @@ class Backtesting:
pair: str,
row: tuple,
direction: LongShort,
stake_amount: Optional[float] = None,
trade: Optional[LocalTrade] = None,
requested_rate: Optional[float] = None,
requested_stake: Optional[float] = None,
entry_tag1: Optional[str] = None,
) -> Optional[LocalTrade]:
stake_amount: float | None = None,
trade: LocalTrade | None = None,
requested_rate: float | None = None,
requested_stake: float | None = None,
entry_tag1: str | None = None,
) -> LocalTrade | None:
"""
:param trade: Trade to adjust - initial entry if None
:param requested_rate: Adjusted entry rate
@@ -1103,6 +1102,7 @@ class Backtesting:
fee_close=self.fee,
is_open=True,
enter_tag=entry_tag,
timeframe=self.timeframe_min,
exchange=self._exchange_name,
is_short=is_short,
trading_mode=self.trading_mode,
@@ -1139,7 +1139,7 @@ class Backtesting:
amount=amount,
filled=0,
remaining=amount,
cost=amount * propose_rate + trade.fee_open,
cost=amount * propose_rate * (1 + self.fee),
ft_order_tag=entry_tag,
)
order._trade_bt = trade
@@ -1178,7 +1178,7 @@ class Backtesting:
self.rejected_trades += 1
return False
def check_for_trade_entry(self, row) -> Optional[LongShort]:
def check_for_trade_entry(self, row) -> LongShort | None:
enter_long = row[LONG_IDX] == 1
exit_long = row[ELONG_IDX] == 1
enter_short = self._can_short and row[SHORT_IDX] == 1
@@ -1216,7 +1216,7 @@ class Backtesting:
def check_order_cancel(
self, trade: LocalTrade, order: Order, current_time: datetime
) -> Optional[bool]:
) -> bool | None:
"""
Check if current analyzed order has to be canceled.
Returns True if the trade should be Deleted (initial order was canceled),
@@ -1298,7 +1298,7 @@ class Backtesting:
def validate_row(
self, data: dict, pair: str, row_index: int, current_time: datetime
) -> Optional[tuple]:
) -> tuple | None:
try:
# Row is treated as "current incomplete candle".
# entry / exit signals are shifted by 1 to compensate for this.
@@ -1332,19 +1332,45 @@ class Backtesting:
row: tuple,
pair: str,
current_time: datetime,
end_date: datetime,
trade_dir: Optional[LongShort],
is_first: bool = True,
trade_dir: LongShort | None,
can_enter: bool,
) -> None:
"""
Conditionally call backtest_loop_inner a 2nd time if shorting is enabled,
a position closed and a new signal in the other direction is available.
"""
if not self._can_short or trade_dir is None:
# No need to reverse position if shorting is disabled or there's no new signal
self.backtest_loop_inner(row, pair, current_time, trade_dir, can_enter)
else:
for _ in (0, 1):
a = self.backtest_loop_inner(row, pair, current_time, trade_dir, can_enter)
if not a or a == trade_dir:
# the trade didn't close or position change is in the same direction
break
def backtest_loop_inner(
self,
row: tuple,
pair: str,
current_time: datetime,
trade_dir: LongShort | None,
can_enter: bool,
) -> LongShort | None:
"""
NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized.
Backtesting processing for one candle/pair.
"""
exiting_dir: LongShort | None = None
if not self._position_stacking and len(LocalTrade.bt_trades_open_pp[pair]) > 0:
# position_stacking not supported for now.
exiting_dir = "short" if LocalTrade.bt_trades_open_pp[pair][0].is_short else "long"
for t in list(LocalTrade.bt_trades_open_pp[pair]):
# 1. Manage currently open orders of active trades
if self.manage_open_orders(t, current_time, row):
# Close trade
# Remove trade (initial open order never filled)
LocalTrade.remove_bt_trade(t)
self.wallets.update()
@@ -1354,13 +1380,12 @@ class Backtesting:
# don't open on the last row
# We only open trades on the main candle, not on detail candles
if (
(self._position_stacking or len(LocalTrade.bt_trades_open_pp[pair]) == 0)
and is_first
and current_time != end_date
can_enter
and trade_dir is not None
and (self._position_stacking or len(LocalTrade.bt_trades_open_pp[pair]) == 0)
and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir)
):
if self.trade_slot_available(LocalTrade.bt_open_open_trade_count):
if self.trade_slot_available(LocalTrade.bt_open_open_trade_count_candle):
trade = self._enter_trade(pair, row, trade_dir)
if trade:
self.wallets.update()
@@ -1382,6 +1407,10 @@ class Backtesting:
if order:
self._process_exit_order(order, trade, current_time, row, pair)
if exiting_dir and len(LocalTrade.bt_trades_open_pp[pair]) == 0:
return exiting_dir
return None
def time_pair_generator(
self, start_date: datetime, end_date: datetime, increment: timedelta, pairs: list[str]
):
@@ -1429,11 +1458,15 @@ class Backtesting:
indexes: dict = defaultdict(int)
# Loop timerange and get candle for each pair at that point in time
for current_time, pair, is_first in self.time_pair_generator(
for current_time, pair, is_first_call in self.time_pair_generator(
start_date, end_date, self.timeframe_td, list(data.keys())
):
if is_first:
if is_first_call:
self.check_abort()
# Reset open trade count for this candle
# Critical to avoid exceeding max_open_trades in backtesting
# when timeframe-detail is used and trades close within the opening candle.
LocalTrade.bt_open_open_trade_count_candle = LocalTrade.bt_open_open_trade_count
strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)(
current_time=current_time
)
@@ -1444,10 +1477,11 @@ class Backtesting:
row_index += 1
indexes[pair] = row_index
is_last_row = current_time == end_date
self.dataprovider._set_dataframe_max_index(self.required_startup + row_index)
self.dataprovider._set_dataframe_max_date(current_time)
current_detail_time: datetime = row[DATE_IDX].to_pydatetime()
trade_dir: Optional[LongShort] = self.check_for_trade_entry(row)
trade_dir: LongShort | None = self.check_for_trade_entry(row)
if (
(trade_dir is not None or len(LocalTrade.bt_trades_open_pp[pair]) > 0)
@@ -1466,7 +1500,8 @@ class Backtesting:
].copy()
if len(detail_data) == 0:
# Fall back to "regular" data if no detail data was found for this candle
self.backtest_loop(row, pair, current_time, end_date, trade_dir)
self.dataprovider._set_dataframe_max_date(current_time)
self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row)
continue
detail_data.loc[:, "enter_long"] = row[LONG_IDX]
detail_data.loc[:, "exit_long"] = row[ELONG_IDX]
@@ -1482,15 +1517,14 @@ class Backtesting:
det_row,
pair,
current_time_det,
end_date,
trade_dir,
is_first,
is_first and not is_last_row,
)
current_time_det += self.timeframe_detail_td
is_first = False
else:
self.dataprovider._set_dataframe_max_date(current_time)
self.backtest_loop(row, pair, current_time, end_date, trade_dir)
self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row)
self.handle_left_open(LocalTrade.bt_trades_open_pp, data=data)
self.wallets.update()
@@ -1518,12 +1552,6 @@ class Backtesting:
backtest_start_time = datetime.now(timezone.utc)
self._set_strategy(strat)
# Use max_open_trades in backtesting, except --disable-max-market-positions is set
if not self.config.get("use_max_market_positions", True):
logger.info("Ignoring max_open_trades (--disable-max-market-positions was used) ...")
self.strategy.max_open_trades = float("inf")
self.config.update({"max_open_trades": self.strategy.max_open_trades})
# need to reprocess data every time to populate signals
preprocessed = self.strategy.advise_all_indicators(data)