Merge pull request #6235 from freqtrade/backtest_order_timeout

Backtest order timeout
This commit is contained in:
Matthias 2022-02-09 07:12:35 +01:00 committed by GitHub
commit 9f47853661
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 368 additions and 79 deletions

View File

@ -313,6 +313,7 @@ A backtesting result will look like that:
| Avg. Duration Winners | 4:23:00 |
| Avg. Duration Loser | 6:55:00 |
| Rejected Buy signals | 3089 |
| Entry/Exit Timeouts | 0 / 0 |
| | |
| Min balance | 0.00945123 BTC |
| Max balance | 0.01846651 BTC |
@ -400,6 +401,7 @@ It contains some useful key metrics about performance of your strategy on backte
| Avg. Duration Winners | 4:23:00 |
| Avg. Duration Loser | 6:55:00 |
| Rejected Buy signals | 3089 |
| Entry/Exit Timeouts | 0 / 0 |
| | |
| Min balance | 0.00945123 BTC |
| Max balance | 0.01846651 BTC |
@ -429,6 +431,7 @@ It contains some useful key metrics about performance of your strategy on backte
- `Days win/draw/lose`: Winning / Losing days (draws are usually days without closed trade).
- `Avg. Duration Winners` / `Avg. Duration Loser`: Average durations for winning and losing trades.
- `Rejected Buy signals`: Buy signals that could not be acted upon due to max_open_trades being reached.
- `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used).
- `Min balance` / `Max balance`: Lowest and Highest Wallet balance during the backtest period.
- `Drawdown (Account)`: Maximum Account Drawdown experienced. Calculated as $(Absolute Drawdown) / (DrawdownHigh + startingBalance)$.
- `Drawdown`: Maximum, absolute drawdown experienced. Difference between Drawdown High and Subsequent Low point.

View File

@ -62,6 +62,7 @@ This loop will be repeated again and again until the bot is stopped.
* Check position adjustments for open trades if enabled and call `adjust_trade_position()` to determine if an additional order is requested.
* Call `custom_stoploss()` and `custom_sell()` to find custom exit points.
* For sells based on sell-signal and custom-sell: Call `custom_exit_price()` to determine exit price (Prices are moved to be within the closing candle).
* Check for Order timeouts, either via the `unfilledtimeout` configuration, or via `check_buy_timeout()` / `check_sell_timeout()` strategy callbacks.
* Generate backtest report output
!!! Note

View File

@ -389,8 +389,8 @@ class AwesomeStrategy(IStrategy):
If the new_entryprice is 97, the proposed_rate is 100 and the `custom_price_max_distance_ratio` is set to 2%, The retained valid custom entry price will be 98, which is 2% below the current (proposed) rate.
!!! Warning "Backtesting"
While Custom prices are supported in backtesting (starting with 2021.12), prices will be moved to within the candle's high/low prices.
This behavior is currently being tested, and might be changed at a later point.
Custom prices are supported in backtesting (starting with 2021.12), and orders will fill if the price falls within the candle's low/high range.
Orders that don't fill immediately are subject to regular timeout handling, which happens once per (detail) candle.
`custom_exit_price()` is only called for sells of type Sell_signal and Custom sell. All other sell-types will use regular backtesting prices.
## Custom order timeout rules
@ -400,7 +400,8 @@ Simple, time-based order-timeouts can be configured either via strategy or in th
However, freqtrade also offers a custom callback for both order types, which allows you to decide based on custom criteria if an order did time out or not.
!!! Note
Unfilled order timeouts are not relevant during backtesting or hyperopt, and are only relevant during real (live) trading. Therefore these methods are only called in these circumstances.
Backtesting fills orders if their price falls within the candle's low/high range.
The below callbacks will be called once per (detail) candle for orders that don't fill immediately (which use custom pricing).
### Custom order timeout example
@ -467,7 +468,8 @@ class AwesomeStrategy(IStrategy):
'sell': 60 * 25
}
def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool:
def check_buy_timeout(self, pair: str, trade: Trade, order: dict,
current_time: datetime, **kwargs) -> bool:
ob = self.dp.orderbook(pair, 1)
current_price = ob['bids'][0][0]
# Cancel buy order if price is more than 2% above the order.
@ -476,7 +478,8 @@ class AwesomeStrategy(IStrategy):
return False
def check_sell_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool:
def check_sell_timeout(self, pair: str, trade: Trade, order: dict,
current_time: datetime, **kwargs) -> bool:
ob = self.dp.orderbook(pair, 1)
current_price = ob['asks'][0][0]
# Cancel sell order if price is more than 2% below the order.

View File

@ -456,6 +456,7 @@ SCHEMA_BACKTEST_REQUIRED = [
'dry_run_wallet',
'dataformat_ohlcv',
'dataformat_trades',
'unfilledtimeout',
]
SCHEMA_MINIMAL_REQUIRED = [

View File

@ -987,18 +987,20 @@ class FreqtradeBot(LoggingMixin):
fully_cancelled = self.update_trade_state(trade, trade.open_order_id, order)
order_obj = trade.select_order_by_order_id(trade.open_order_id)
if (order['side'] == 'buy' and (order['status'] == 'open' or fully_cancelled) and (
fully_cancelled
or self.strategy.ft_check_timed_out(
'buy', trade, order, datetime.now(timezone.utc))
)):
or (order_obj and self.strategy.ft_check_timed_out(
'buy', trade, order_obj, datetime.now(timezone.utc))
))):
self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT'])
elif (order['side'] == 'sell' and (order['status'] == 'open' or fully_cancelled) and (
fully_cancelled
or self.strategy.ft_check_timed_out(
'sell', trade, order, datetime.now(timezone.utc)))
):
or (order_obj and self.strategy.ft_check_timed_out(
'sell', trade, order_obj, datetime.now(timezone.utc))
))):
self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['TIMEOUT'])
canceled_count = trade.get_exit_order_count()
max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0)

View File

@ -63,6 +63,8 @@ class Backtesting:
LoggingMixin.show_output = False
self.config = config
self.results: Dict[str, Any] = {}
self.trade_id_counter: int = 0
self.order_id_counter: int = 0
config['dry_run'] = True
self.run_ids: Dict[str, str] = {}
@ -231,6 +233,8 @@ class Backtesting:
PairLocks.reset_locks()
Trade.reset_trades()
self.rejected_trades = 0
self.timedout_entry_orders = 0
self.timedout_exit_orders = 0
self.dataprovider.clear_cache()
if enable_protections:
self._load_protections(self.strategy)
@ -353,7 +357,10 @@ class Backtesting:
# use Open rate if open_rate > calculated sell rate
return sell_row[OPEN_IDX]
return close_rate
# Use the maximum between close_rate and low as we
# cannot sell outside of a candle.
# Applies when a new ROI setting comes in place and the whole candle is above that.
return min(max(close_rate, sell_row[LOW_IDX]), sell_row[HIGH_IDX])
else:
# This should not be reached...
@ -376,10 +383,15 @@ class Backtesting:
if stake_amount is not None and stake_amount > 0.0:
pos_trade = self._enter_trade(trade.pair, row, stake_amount, trade)
if pos_trade is not None:
self.wallets.update()
return pos_trade
return trade
def _get_order_filled(self, rate: float, row: Tuple) -> bool:
""" Rate is within candle, therefore filled"""
return row[LOW_IDX] <= rate <= row[HIGH_IDX]
def _get_sell_trade_entry_for_candle(self, trade: LocalTrade,
sell_row: Tuple) -> Optional[LocalTrade]:
@ -405,18 +417,21 @@ class Backtesting:
closerate = self._get_close_rate(sell_row, trade, sell, trade_dur)
# call the custom exit price,with default value as previous closerate
current_profit = trade.calc_profit_ratio(closerate)
order_type = self.strategy.order_types['sell']
if sell.sell_type in (SellType.SELL_SIGNAL, SellType.CUSTOM_SELL):
# Custom exit pricing only for sell-signals
if order_type == 'limit':
closerate = strategy_safe_wrapper(self.strategy.custom_exit_price,
default_retval=closerate)(
pair=trade.pair, trade=trade,
current_time=sell_row[DATE_IDX],
current_time=sell_candle_time,
proposed_rate=closerate, current_profit=current_profit)
# Use the maximum between close_rate and low as we cannot sell outside of a candle.
closerate = min(max(closerate, sell_row[LOW_IDX]), sell_row[HIGH_IDX])
# We can't place orders lower than current low.
# freqtrade does not support this in live, and the order would fill immediately
closerate = max(closerate, sell_row[LOW_IDX])
# Confirm trade exit:
time_in_force = self.strategy.order_time_in_force['sell']
if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)(
pair=trade.pair, trade=trade, order_type='limit', amount=trade.amount,
rate=closerate,
@ -436,7 +451,28 @@ class Backtesting:
):
trade.sell_reason = sell_row[EXIT_TAG_IDX]
trade.close(closerate, show_msg=False)
self.order_id_counter += 1
order = Order(
id=self.order_id_counter,
ft_trade_id=trade.id,
order_date=sell_candle_time,
order_update_date=sell_candle_time,
ft_is_open=True,
ft_pair=trade.pair,
order_id=str(self.order_id_counter),
symbol=trade.pair,
ft_order_side="sell",
side="sell",
order_type=order_type,
status="open",
price=closerate,
average=closerate,
amount=trade.amount,
filled=0,
remaining=trade.amount,
cost=trade.amount * closerate,
)
trade.orders.append(order)
return trade
return None
@ -475,13 +511,16 @@ class Backtesting:
current_time = row[DATE_IDX].to_pydatetime()
entry_tag = row[BUY_TAG_IDX] if len(row) >= BUY_TAG_IDX + 1 else None
# let's call the custom entry price, using the open price as default price
order_type = self.strategy.order_types['buy']
propose_rate = row[OPEN_IDX]
if order_type == 'limit':
propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price,
default_retval=row[OPEN_IDX])(
pair=pair, current_time=current_time,
proposed_rate=row[OPEN_IDX], entry_tag=entry_tag) # default value is the open rate
# Move rate to within the candle's low/high rate
propose_rate = min(max(propose_rate, row[LOW_IDX]), row[HIGH_IDX])
proposed_rate=propose_rate, entry_tag=entry_tag) # default value is the open rate
# We can't place orders higher than current high (otherwise it'd be a stop limit buy)
# which freqtrade does not support in live.
propose_rate = min(propose_rate, row[HIGH_IDX])
min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, propose_rate, -0.05) or 0
max_stake_amount = self.wallets.get_available_stake_amount()
@ -489,9 +528,9 @@ class Backtesting:
pos_adjust = trade is not None
if not pos_adjust:
try:
stake_amount = self.wallets.get_trade_stake_amount(pair, None)
stake_amount = self.wallets.get_trade_stake_amount(pair, None, update=False)
except DependencyException:
return trade
return None
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
default_retval=stake_amount)(
@ -506,8 +545,7 @@ class Backtesting:
# If not pos adjust, trade is None
return trade
order_type = self.strategy.order_types['buy']
time_in_force = self.strategy.order_time_in_force['sell']
time_in_force = self.strategy.order_time_in_force['buy']
# Confirm trade entry:
if not pos_adjust:
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
@ -517,15 +555,21 @@ class Backtesting:
return None
if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
self.order_id_counter += 1
amount = round(stake_amount / propose_rate, 8)
if trade is None:
# Enter trade
self.trade_id_counter += 1
trade = LocalTrade(
id=self.trade_id_counter,
open_order_id=self.order_id_counter,
pair=pair,
open_rate=propose_rate,
open_rate_requested=propose_rate,
open_date=current_time,
stake_amount=stake_amount,
amount=amount,
amount_requested=amount,
fee_open=self.fee,
fee_close=self.fee,
is_open=True,
@ -533,27 +577,35 @@ class Backtesting:
exchange='backtesting',
orders=[]
)
trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
order = Order(
ft_is_open=False,
id=self.order_id_counter,
ft_trade_id=trade.id,
ft_is_open=True,
ft_pair=trade.pair,
order_id=str(self.order_id_counter),
symbol=trade.pair,
ft_order_side="buy",
side="buy",
order_type="market",
status="closed",
order_type=order_type,
status="open",
order_date=current_time,
order_filled_date=current_time,
order_update_date=current_time,
price=propose_rate,
average=propose_rate,
amount=amount,
filled=amount,
cost=stake_amount + trade.fee_open
filled=0,
remaining=amount,
cost=stake_amount + trade.fee_open,
)
if pos_adjust and self._get_order_filled(order.price, row):
order.close_bt_order(current_time)
else:
trade.open_order_id = str(self.order_id_counter)
trade.orders.append(order)
if pos_adjust:
trade.recalc_trade_from_orders()
return trade
@ -567,6 +619,9 @@ class Backtesting:
for pair in open_trades.keys():
if len(open_trades[pair]) > 0:
for trade in open_trades[pair]:
if trade.open_order_id and trade.nr_of_successful_buys == 0:
# Ignore trade if buy-order did not fill yet
continue
sell_row = data[pair][-1]
trade.close_date = sell_row[DATE_IDX].to_pydatetime()
@ -587,6 +642,51 @@ class Backtesting:
self.rejected_trades += 1
return False
def run_protections(self, enable_protections, pair: str, current_time: datetime):
if enable_protections:
self.protections.stop_per_pair(pair, current_time)
self.protections.global_stop(current_time)
def check_order_cancel(self, trade: LocalTrade, current_time) -> bool:
"""
Check if an order has been canceled.
Returns True if the trade should be Deleted (initial order was canceled).
"""
for order in [o for o in trade.orders if o.ft_is_open]:
timedout = self.strategy.ft_check_timed_out(order.side, trade, order, current_time)
if timedout:
if order.side == 'buy':
self.timedout_entry_orders += 1
if trade.nr_of_successful_buys == 0:
# Remove trade due to buy timeout expiration.
return True
else:
# Close additional buy order
del trade.orders[trade.orders.index(order)]
if order.side == 'sell':
self.timedout_exit_orders += 1
# Close sell order and retry selling on next signal.
del trade.orders[trade.orders.index(order)]
return False
def validate_row(
self, data: Dict, pair: str, row_index: int, current_time: datetime) -> Optional[Tuple]:
try:
# Row is treated as "current incomplete candle".
# Buy / sell signals are shifted by 1 to compensate for this.
row = data[pair][row_index]
except IndexError:
# missing Data for one pair at the end.
# Warnings for this are shown during data loading
return None
# Waits until the time-counter reaches the start of the data for this pair.
if row[DATE_IDX] > current_time:
return None
return row
def backtest(self, processed: Dict,
start_date: datetime, end_date: datetime,
max_open_trades: int = 0, position_stacking: bool = False,
@ -616,7 +716,7 @@ class Backtesting:
# Indexes per pair, so some pairs are allowed to have a missing start.
indexes: Dict = defaultdict(int)
tmp = start_date + timedelta(minutes=self.timeframe_min)
current_time = start_date + timedelta(minutes=self.timeframe_min)
open_trades: Dict[str, List[LocalTrade]] = defaultdict(list)
open_trade_count = 0
@ -625,35 +725,27 @@ class Backtesting:
(end_date - start_date) / timedelta(minutes=self.timeframe_min)))
# Loop timerange and get candle for each pair at that point in time
while tmp <= end_date:
while current_time <= end_date:
open_trade_count_start = open_trade_count
self.check_abort()
for i, pair in enumerate(data):
row_index = indexes[pair]
try:
# Row is treated as "current incomplete candle".
# Buy / sell signals are shifted by 1 to compensate for this.
row = data[pair][row_index]
except IndexError:
# missing Data for one pair at the end.
# Warnings for this are shown during data loading
continue
# Waits until the time-counter reaches the start of the data for this pair.
if row[DATE_IDX] > tmp:
row = self.validate_row(data, pair, row_index, current_time)
if not row:
continue
row_index += 1
indexes[pair] = row_index
self.dataprovider._set_dataframe_max_index(row_index)
# 1. Process buys.
# without positionstacking, we can only have one open trade per pair.
# max_open_trades must be respected
# don't open on the last row
if (
(position_stacking or len(open_trades[pair]) == 0)
and self.trade_slot_available(max_open_trades, open_trade_count_start)
and tmp != end_date
and current_time != end_date
and row[BUY_IDX] == 1
and row[SELL_IDX] != 1
and not PairLocks.is_pair_locked(pair, row[DATE_IDX])
@ -661,32 +753,51 @@ class Backtesting:
trade = self._enter_trade(pair, row)
if trade:
# TODO: hacky workaround to avoid opening > max_open_trades
# This emulates previous behaviour - not sure if this is correct
# This emulates previous behavior - 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} - Emulate creation of new trade: {trade}.")
open_trades[pair].append(trade)
LocalTrade.add_bt_trade(trade)
for trade in list(open_trades[pair]):
# also check the buying candle for sell conditions.
trade_entry = self._get_sell_trade_entry(trade, row)
# Sell occurred
if trade_entry:
# 2. Process buy orders.
order = trade.select_order('buy', is_open=True)
if order and self._get_order_filled(order.price, row):
order.close_bt_order(current_time)
trade.open_order_id = None
LocalTrade.add_bt_trade(trade)
self.wallets.update()
# 3. Create sell orders (if any)
if not trade.open_order_id:
self._get_sell_trade_entry(trade, row) # Place sell order if necessary
# 4. Process sell orders.
order = trade.select_order('sell', is_open=True)
if order and self._get_order_filled(order.price, row):
trade.open_order_id = None
trade.close_date = current_time
trade.close(order.price, show_msg=False)
# logger.debug(f"{pair} - Backtesting sell {trade}")
open_trade_count -= 1
open_trades[pair].remove(trade)
LocalTrade.close_bt_trade(trade)
trades.append(trade_entry)
if enable_protections:
self.protections.stop_per_pair(pair, row[DATE_IDX])
self.protections.global_stop(tmp)
trades.append(trade)
self.wallets.update()
self.run_protections(enable_protections, pair, current_time)
# 5. Cancel expired buy/sell orders.
if self.check_order_cancel(trade, current_time):
# Close trade due to buy timeout expiration.
open_trade_count -= 1
open_trades[pair].remove(trade)
self.wallets.update()
# Move time one configured time_interval ahead.
self.progress.increment()
tmp += timedelta(minutes=self.timeframe_min)
current_time += timedelta(minutes=self.timeframe_min)
trades += self.handle_left_open(open_trades, data=data)
self.wallets.update()
@ -697,6 +808,8 @@ class Backtesting:
'config': self.strategy.config,
'locks': PairLocks.get_all_locks(),
'rejected_signals': self.rejected_trades,
'timedout_entry_orders': self.timedout_entry_orders,
'timedout_exit_orders': self.timedout_exit_orders,
'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']),
}

View File

@ -436,6 +436,8 @@ def generate_strategy_stats(pairlist: List[str],
'dry_run_wallet': starting_balance,
'final_balance': content['final_balance'],
'rejected_signals': content['rejected_signals'],
'timedout_entry_orders': content['timedout_entry_orders'],
'timedout_exit_orders': content['timedout_exit_orders'],
'max_open_trades': max_open_trades,
'max_open_trades_setting': (config['max_open_trades']
if config['max_open_trades'] != float('inf') else -1),
@ -726,6 +728,9 @@ def text_table_add_metrics(strat_results: Dict) -> str:
('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"),
('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"),
('Rejected Buy signals', strat_results.get('rejected_signals', 'N/A')),
('Entry/Exit Timeouts',
f"{strat_results.get('timedout_entry_orders', 'N/A')} / "
f"{strat_results.get('timedout_exit_orders', 'N/A')}"),
('', ''), # Empty line to improve readability
('Min balance', round_coin_value(strat_results['csum_min'],

View File

@ -132,6 +132,10 @@ class Order(_DECL_BASE):
order_filled_date = Column(DateTime, nullable=True)
order_update_date = Column(DateTime, nullable=True)
@property
def order_date_utc(self):
return self.order_date.replace(tzinfo=timezone.utc)
def __repr__(self):
return (f'Order(id={self.id}, order_id={self.order_id}, trade_id={self.ft_trade_id}, '
@ -188,6 +192,12 @@ class Order(_DECL_BASE):
'status': self.status,
}
def close_bt_order(self, close_date: datetime):
self.order_filled_date = close_date
self.filled = self.amount
self.status = 'closed'
self.ft_is_open = False
@staticmethod
def update_orders(orders: List['Order'], order: Dict[str, Any]):
"""
@ -635,13 +645,26 @@ class LocalTrade():
if self.stop_loss_pct is not None and self.open_rate is not None:
self.adjust_stop_loss(self.open_rate, self.stop_loss_pct)
def select_order(self, order_side: str, is_open: Optional[bool]) -> Optional[Order]:
def select_order_by_order_id(self, order_id: str) -> Optional[Order]:
"""
Finds order object by Order id.
:param order_id: Exchange order id
"""
for o in self.orders:
if o.order_id == order_id:
return o
return None
def select_order(
self, order_side: str = None, is_open: Optional[bool] = None) -> Optional[Order]:
"""
Finds latest order for this orderside and status
:param order_side: Side of the order (either 'buy' or 'sell')
:param is_open: Only search for open orders?
:return: latest Order object if it exists, else None
"""
orders = self.orders
if order_side:
orders = [o for o in self.orders if o.side == order_side]
if is_open is not None:
orders = [o for o in orders if o.ft_is_open == is_open]

View File

@ -18,6 +18,7 @@ from freqtrade.exceptions import OperationalException, StrategyError
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
from freqtrade.exchange.exchange import timeframe_to_next_date
from freqtrade.persistence import PairLocks, Trade
from freqtrade.persistence.models import LocalTrade, Order
from freqtrade.strategy.hyper import HyperStrategyMixin
from freqtrade.strategy.informative_decorator import (InformativeData, PopulateIndicators,
_create_and_merge_informative_pair,
@ -862,23 +863,22 @@ class IStrategy(ABC, HyperStrategyMixin):
else:
return current_profit > roi
def ft_check_timed_out(self, side: str, trade: Trade, order: Dict,
def ft_check_timed_out(self, side: str, trade: LocalTrade, order: Order,
current_time: datetime) -> bool:
"""
FT Internal method.
Check if timeout is active, and if the order is still open and timed out
"""
timeout = self.config.get('unfilledtimeout', {}).get(side)
ordertime = arrow.get(order['datetime']).datetime
if timeout is not None:
timeout_unit = self.config.get('unfilledtimeout', {}).get('unit', 'minutes')
timeout_kwargs = {timeout_unit: -timeout}
timeout_threshold = current_time + timedelta(**timeout_kwargs)
timedout = (order['status'] == 'open' and order['side'] == side
and ordertime < timeout_threshold)
timedout = (order.status == 'open' and order.side == side
and order.order_date_utc < timeout_threshold)
if timedout:
return True
time_method = self.check_sell_timeout if order['side'] == 'sell' else self.check_buy_timeout
time_method = self.check_sell_timeout if order.side == 'sell' else self.check_buy_timeout
return strategy_safe_wrapper(time_method,
default_retval=False)(

View File

@ -211,7 +211,7 @@ class Wallets:
return stake_amount
def get_trade_stake_amount(self, pair: str, edge=None) -> float:
def get_trade_stake_amount(self, pair: str, edge=None, update: bool = True) -> float:
"""
Calculate stake amount for the trade
:return: float: Stake amount
@ -219,6 +219,7 @@ class Wallets:
"""
stake_amount: float
# Ensure wallets are uptodate.
if update:
self.update()
val_tied_up = Trade.total_open_trades_stakes()
available_amount = self.get_available_stake_amount()

View File

@ -19,7 +19,7 @@ from freqtrade.edge import PairInfo
from freqtrade.enums import RunMode
from freqtrade.exchange import Exchange
from freqtrade.freqtradebot import FreqtradeBot
from freqtrade.persistence import LocalTrade, Trade, init_db
from freqtrade.persistence import LocalTrade, Order, Trade, init_db
from freqtrade.resolvers import ExchangeResolver
from freqtrade.worker import Worker
from tests.conftest_trades import (mock_trade_1, mock_trade_2, mock_trade_3, mock_trade_4,
@ -1985,7 +1985,7 @@ def import_fails() -> None:
@pytest.fixture(scope="function")
def open_trade():
return Trade(
trade = Trade(
pair='ETH/BTC',
open_rate=0.00001099,
exchange='binance',
@ -1997,6 +1997,26 @@ def open_trade():
open_date=arrow.utcnow().shift(minutes=-601).datetime,
is_open=True
)
trade.orders = [
Order(
ft_order_side='buy',
ft_pair=trade.pair,
ft_is_open=False,
order_id='123456789',
status="closed",
symbol=trade.pair,
order_type="market",
side="buy",
price=trade.open_rate,
average=trade.open_rate,
filled=trade.amount,
remaining=0,
cost=trade.open_rate * trade.amount,
order_date=trade.open_date,
order_filled_date=trade.open_date,
)
]
return trade
@pytest.fixture(scope="function")

View File

@ -36,6 +36,8 @@ class BTContainer(NamedTuple):
trailing_stop_positive_offset: float = 0.0
use_sell_signal: bool = False
use_custom_stoploss: bool = False
custom_entry_price: Optional[float] = None
custom_exit_price: Optional[float] = None
def _get_frame_time_from_offset(offset):

View File

@ -1,5 +1,6 @@
# pragma pylint: disable=missing-docstring, W0212, line-too-long, C0103, C0330, unused-argument
import logging
from unittest.mock import MagicMock
import pytest
@ -534,6 +535,80 @@ tc33 = BTContainer(data=[
)]
)
# Test 34: Custom-entry-price below all candles should timeout - so no trade happens.
tc34 = BTContainer(data=[
# D O H L C V B S
[0, 5000, 5050, 4950, 5000, 6172, 1, 0],
[1, 5000, 5500, 4951, 5000, 6172, 0, 0], # timeout
[2, 4900, 5250, 4500, 5100, 6172, 0, 0],
[3, 5100, 5100, 4650, 4750, 6172, 0, 0],
[4, 4750, 4950, 4350, 4750, 6172, 0, 0]],
stop_loss=-0.01, roi={"0": 0.10}, profit_perc=0.0,
custom_entry_price=4200, trades=[]
)
# Test 35: Custom-entry-price above all candles should have rate adjusted to "entry candle high"
tc35 = BTContainer(data=[
# D O H L C V B S
[0, 5000, 5050, 4950, 5000, 6172, 1, 0],
[1, 5000, 5500, 4951, 5000, 6172, 0, 0], # Timeout
[2, 4900, 5250, 4500, 5100, 6172, 0, 0],
[3, 5100, 5100, 4650, 4750, 6172, 0, 0],
[4, 4750, 4950, 4350, 4750, 6172, 0, 0]],
stop_loss=-0.01, roi={"0": 0.10}, profit_perc=-0.01,
custom_entry_price=7200, trades=[
BTrade(sell_reason=SellType.STOP_LOSS, open_tick=1, close_tick=1)
]
)
# Test 36: Custom-entry-price around candle low
# Causes immediate ROI exit. This is currently expected behavior (#6261)
# https://github.com/freqtrade/freqtrade/issues/6261
# But may change at a later point.
tc36 = BTContainer(data=[
# D O H L C V B S BT
[0, 5000, 5050, 4950, 5000, 6172, 1, 0],
[1, 5000, 5500, 4951, 5000, 6172, 0, 0], # Enter and immediate ROI
[2, 4900, 5250, 4500, 5100, 6172, 0, 0],
[3, 5100, 5100, 4650, 4750, 6172, 0, 0],
[4, 4750, 4950, 4350, 4750, 6172, 0, 0]],
stop_loss=-0.01, roi={"0": 0.10}, profit_perc=0.1,
custom_entry_price=4952,
trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=1)]
)
# Test 37: Custom exit price below all candles
# Price adjusted to candle Low.
tc37 = BTContainer(data=[
# D O H L C V B S BT
[0, 5000, 5050, 4950, 5000, 6172, 1, 0],
[1, 5000, 5500, 4951, 5000, 6172, 0, 0],
[2, 4900, 5250, 4900, 5100, 6172, 0, 1], # exit - but timeout
[3, 5100, 5100, 4950, 4950, 6172, 0, 0],
[4, 5000, 5100, 4950, 4950, 6172, 0, 0]],
stop_loss=-0.10, roi={"0": 0.10}, profit_perc=-0.01,
use_sell_signal=True,
custom_exit_price=4552,
trades=[BTrade(sell_reason=SellType.SELL_SIGNAL, open_tick=1, close_tick=3)]
)
# Test 38: Custom exit price above all candles
# causes sell signal timeout
tc38 = BTContainer(data=[
# D O H L C V B S BT
[0, 5000, 5050, 4950, 5000, 6172, 1, 0],
[1, 5000, 5500, 4951, 5000, 6172, 0, 0],
[2, 4900, 5250, 4900, 5100, 6172, 0, 1], # exit - but timeout
[3, 5100, 5100, 4950, 4950, 6172, 0, 0],
[4, 5000, 5100, 4950, 4950, 6172, 0, 0]],
stop_loss=-0.10, roi={"0": 0.10}, profit_perc=0.0,
use_sell_signal=True,
custom_exit_price=6052,
trades=[BTrade(sell_reason=SellType.FORCE_SELL, open_tick=1, close_tick=4)]
)
TESTS = [
tc0,
tc1,
@ -569,6 +644,11 @@ TESTS = [
tc31,
tc32,
tc33,
tc34,
tc35,
tc36,
tc37,
tc38,
]
@ -597,6 +677,10 @@ def test_backtest_results(default_conf, fee, mocker, caplog, data) -> None:
backtesting.required_startup = 0
backtesting.strategy.advise_buy = lambda a, m: frame
backtesting.strategy.advise_sell = lambda a, m: frame
if data.custom_entry_price:
backtesting.strategy.custom_entry_price = MagicMock(return_value=data.custom_entry_price)
if data.custom_exit_price:
backtesting.strategy.custom_exit_price = MagicMock(return_value=data.custom_exit_price)
backtesting.strategy.use_custom_stoploss = data.use_custom_stoploss
caplog.set_level(logging.DEBUG)

View File

@ -521,6 +521,7 @@ def test_backtest__enter_trade(default_conf, fee, mocker) -> None:
# Fake 2 trades, so there's not enough amount for the next trade left.
LocalTrade.trades_open.append(trade)
LocalTrade.trades_open.append(trade)
backtesting.wallets.update()
trade = backtesting._enter_trade(pair, row=row)
assert trade is None
LocalTrade.trades_open.pop()
@ -528,6 +529,7 @@ def test_backtest__enter_trade(default_conf, fee, mocker) -> None:
assert trade is not None
backtesting.strategy.custom_stake_amount = lambda **kwargs: 123.5
backtesting.wallets.update()
trade = backtesting._enter_trade(pair, row=row)
assert trade
assert trade.stake_amount == 123.5
@ -635,7 +637,8 @@ def test_backtest__get_sell_trade_entry(default_conf, fee, mocker) -> None:
assert res.sell_reason == SellType.ROI.value
# Sell at minute 3 (not available above!)
assert res.close_date_utc == datetime(2020, 1, 1, 5, 3, tzinfo=timezone.utc)
assert round(res.close_rate, 3) == round(209.0225, 3)
sell_order = res.select_order('sell', True)
assert sell_order is not None
def test_backtest_one(default_conf, fee, mocker, testdatadir) -> None:
@ -1020,6 +1023,8 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir):
'config': default_conf,
'locks': [],
'rejected_signals': 20,
'timedout_entry_orders': 0,
'timedout_exit_orders': 0,
'final_balance': 1000,
})
mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist',
@ -1128,6 +1133,8 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat
'config': default_conf,
'locks': [],
'rejected_signals': 20,
'timedout_entry_orders': 0,
'timedout_exit_orders': 0,
'final_balance': 1000,
},
{
@ -1135,6 +1142,8 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat
'config': default_conf,
'locks': [],
'rejected_signals': 20,
'timedout_entry_orders': 0,
'timedout_exit_orders': 0,
'final_balance': 1000,
}
])
@ -1237,6 +1246,8 @@ def test_backtest_start_multi_strat_nomock_detail(default_conf, mocker,
'config': default_conf,
'locks': [],
'rejected_signals': 20,
'timedout_entry_orders': 0,
'timedout_exit_orders': 0,
'final_balance': 1000,
},
{
@ -1244,6 +1255,8 @@ def test_backtest_start_multi_strat_nomock_detail(default_conf, mocker,
'config': default_conf,
'locks': [],
'rejected_signals': 20,
'timedout_entry_orders': 0,
'timedout_exit_orders': 0,
'final_balance': 1000,
}
])
@ -1305,6 +1318,8 @@ def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testda
'config': default_conf,
'locks': [],
'rejected_signals': 20,
'timedout_entry_orders': 0,
'timedout_exit_orders': 0,
'final_balance': 1000,
})
mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist',

View File

@ -364,6 +364,8 @@ def test_hyperopt_format_results(hyperopt):
'locks': [],
'final_balance': 0.02,
'rejected_signals': 2,
'timedout_entry_orders': 0,
'timedout_exit_orders': 0,
'backtest_start_time': 1619718665,
'backtest_end_time': 1619718665,
}
@ -431,6 +433,8 @@ def test_generate_optimizer(mocker, hyperopt_conf) -> None:
'config': hyperopt_conf,
'locks': [],
'rejected_signals': 20,
'timedout_entry_orders': 0,
'timedout_exit_orders': 0,
'final_balance': 1000,
}

View File

@ -82,6 +82,8 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir):
'locks': [],
'final_balance': 1000.02,
'rejected_signals': 20,
'timedout_entry_orders': 0,
'timedout_exit_orders': 0,
'backtest_start_time': Arrow.utcnow().int_timestamp,
'backtest_end_time': Arrow.utcnow().int_timestamp,
'run_id': '123',
@ -131,6 +133,8 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir):
'locks': [],
'final_balance': 1000.02,
'rejected_signals': 20,
'timedout_entry_orders': 0,
'timedout_exit_orders': 0,
'backtest_start_time': Arrow.utcnow().int_timestamp,
'backtest_end_time': Arrow.utcnow().int_timestamp,
'run_id': '124',

View File

@ -2042,6 +2042,7 @@ def test_check_handle_timedout_buy_usercustom(default_conf_usdt, ticker_usdt, li
def test_check_handle_timedout_buy(default_conf_usdt, ticker_usdt, limit_buy_order_old, open_trade,
fee, mocker) -> None:
rpc_mock = patch_RPCManager(mocker)
limit_buy_order_old['id'] = open_trade.open_order_id
limit_buy_cancel = deepcopy(limit_buy_order_old)
limit_buy_cancel['status'] = 'canceled'
cancel_order_mock = MagicMock(return_value=limit_buy_cancel)
@ -2126,6 +2127,8 @@ def test_check_handle_timedout_buy_exception(default_conf_usdt, ticker_usdt,
def test_check_handle_timedout_sell_usercustom(default_conf_usdt, ticker_usdt, limit_sell_order_old,
mocker, open_trade, caplog) -> None:
default_conf_usdt["unfilledtimeout"] = {"buy": 1440, "sell": 1440, "exit_timeout_count": 1}
limit_sell_order_old['id'] = open_trade.open_order_id
rpc_mock = patch_RPCManager(mocker)
cancel_order_mock = MagicMock()
patch_exchange(mocker)
@ -2174,7 +2177,7 @@ def test_check_handle_timedout_sell_usercustom(default_conf_usdt, ticker_usdt, l
# 2nd canceled trade - Fail execute sell
caplog.clear()
open_trade.open_order_id = 'order_id_2'
open_trade.open_order_id = limit_sell_order_old['id']
mocker.patch('freqtrade.persistence.Trade.get_exit_order_count', return_value=1)
mocker.patch('freqtrade.freqtradebot.FreqtradeBot.execute_trade_exit',
side_effect=DependencyException)
@ -2185,7 +2188,7 @@ def test_check_handle_timedout_sell_usercustom(default_conf_usdt, ticker_usdt, l
caplog.clear()
# 2nd canceled trade ...
open_trade.open_order_id = 'order_id_2'
open_trade.open_order_id = limit_sell_order_old['id']
freqtrade.check_handle_timedout()
assert log_has_re('Emergencyselling trade.*', caplog)
assert et_mock.call_count == 1
@ -2195,6 +2198,7 @@ def test_check_handle_timedout_sell(default_conf_usdt, ticker_usdt, limit_sell_o
open_trade) -> None:
rpc_mock = patch_RPCManager(mocker)
cancel_order_mock = MagicMock()
limit_sell_order_old['id'] = open_trade.open_order_id
patch_exchange(mocker)
mocker.patch.multiple(
'freqtrade.exchange.Exchange',
@ -2253,6 +2257,7 @@ def test_check_handle_cancelled_sell(default_conf_usdt, ticker_usdt, limit_sell_
def test_check_handle_timedout_partial(default_conf_usdt, ticker_usdt, limit_buy_order_old_partial,
open_trade, mocker) -> None:
rpc_mock = patch_RPCManager(mocker)
limit_buy_order_old_partial['id'] = open_trade.open_order_id
limit_buy_canceled = deepcopy(limit_buy_order_old_partial)
limit_buy_canceled['status'] = 'canceled'
@ -2283,6 +2288,7 @@ def test_check_handle_timedout_partial_fee(default_conf_usdt, ticker_usdt, open_
limit_buy_order_old_partial, trades_for_order,
limit_buy_order_old_partial_canceled, mocker) -> None:
rpc_mock = patch_RPCManager(mocker)
limit_buy_order_old_partial['id'] = open_trade.open_order_id
cancel_order_mock = MagicMock(return_value=limit_buy_order_old_partial_canceled)
mocker.patch('freqtrade.wallets.Wallets.get_free', MagicMock(return_value=0))
patch_exchange(mocker)
@ -2322,6 +2328,8 @@ def test_check_handle_timedout_partial_except(default_conf_usdt, ticker_usdt, op
fee, limit_buy_order_old_partial, trades_for_order,
limit_buy_order_old_partial_canceled, mocker) -> None:
rpc_mock = patch_RPCManager(mocker)
limit_buy_order_old_partial_canceled['id'] = open_trade.open_order_id
limit_buy_order_old_partial['id'] = open_trade.open_order_id
cancel_order_mock = MagicMock(return_value=limit_buy_order_old_partial_canceled)
patch_exchange(mocker)
mocker.patch.multiple(