| @@ -629,7 +629,7 @@ class AwesomeStrategy(IStrategy): | ||||
|  | ||||
| The `position_adjustment_enable` strategy property enables the usage of `adjust_trade_position()` callback in the strategy. | ||||
| For performance reasons, it's disabled by default and freqtrade will show a warning message on startup if enabled. | ||||
| `adjust_trade_position()` can be used to perform additional orders, for example to manage risk with DCA (Dollar Cost Averaging). | ||||
| `adjust_trade_position()` can be used to perform additional orders, for example to manage risk with DCA (Dollar Cost Averaging) or to increase or decrease positions. | ||||
|  | ||||
| `max_entry_position_adjustment` property is used to limit the number of additional buys per trade (on top of the first buy) that the bot can execute. By default, the value is -1 which means the bot have no limit on number of adjustment buys. | ||||
|  | ||||
| @@ -637,10 +637,13 @@ The strategy is expected to return a stake_amount (in stake currency) between `m | ||||
| If there are not enough funds in the wallet (the return value is above `max_stake`) then the signal will be ignored. | ||||
| Additional orders also result in additional fees and those orders don't count towards `max_open_trades`. | ||||
|  | ||||
| This callback is **not** called when there is an open order (either buy or sell) waiting for execution, or when you have reached the maximum amount of extra buys that you have set on `max_entry_position_adjustment`. | ||||
| This callback is **not** called when there is an open order (either buy or sell) waiting for execution. | ||||
|  | ||||
| `adjust_trade_position()` is called very frequently for the duration of a trade, so you must keep your implementation as performant as possible. | ||||
|  | ||||
| Position adjustments will always be applied in the direction of the trade, so a positive value will always increase your position, no matter if it's a long or short trade. Modifications to leverage are not possible. | ||||
| Additional Buys are ignored once you have reached the maximum amount of extra buys that you have set on `max_entry_position_adjustment`, but the callback is called anyway looking for partial exits. | ||||
|  | ||||
| Position adjustments will always be applied in the direction of the trade, so a positive value will always increase your position (negative values will decrease your position), no matter if it's a long or short trade. Modifications to leverage are not possible. | ||||
|  | ||||
| !!! Note "About stake size" | ||||
|     Using fixed stake size means it will be the amount used for the first order, just like without position adjustment. | ||||
| @@ -649,12 +652,12 @@ Position adjustments will always be applied in the direction of the trade, so a | ||||
|  | ||||
| !!! Warning | ||||
|     Stoploss is still calculated from the initial opening price, not averaged price. | ||||
|     Regular stoploss rules still apply (cannot move down). | ||||
|  | ||||
| !!! Warning "/stopbuy" | ||||
|     While `/stopbuy` command stops the bot from entering new trades, the position adjustment feature will continue buying new orders on existing trades. | ||||
|  | ||||
| !!! Warning "Backtesting" | ||||
|     During backtesting this callback is called for each candle in `timeframe` or `timeframe_detail`, so performance will be affected. | ||||
|     During backtesting this callback is called for each candle in `timeframe` or `timeframe_detail`, so run-time performance will be affected. | ||||
|  | ||||
| ``` python | ||||
| from freqtrade.persistence import Trade | ||||
| @@ -675,7 +678,7 @@ class DigDeeperStrategy(IStrategy): | ||||
|     max_dca_multiplier = 5.5 | ||||
|  | ||||
|     # This is called when placing the initial order (opening trade) | ||||
| def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float, | ||||
|     def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float, | ||||
|                             proposed_stake: float, min_stake: Optional[float], max_stake: float, | ||||
|                             leverage: float, entry_tag: Optional[str], side: str, | ||||
|                             **kwargs) -> float: | ||||
| @@ -685,22 +688,41 @@ def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: f | ||||
|         return proposed_stake / self.max_dca_multiplier | ||||
|  | ||||
|     def adjust_trade_position(self, trade: Trade, current_time: datetime, | ||||
|                               current_rate: float, current_profit: float, min_stake: Optional[float], | ||||
|                               max_stake: float, **kwargs): | ||||
|                               current_rate: float, current_profit: float, | ||||
|                               min_stake: Optional[float], max_stake: float, | ||||
|                               current_entry_rate: float, current_exit_rate: float, | ||||
|                               current_entry_profit: float, current_exit_profit: float, | ||||
|                               **kwargs) -> Optional[float]: | ||||
|         """ | ||||
|         Custom trade adjustment logic, returning the stake amount that a trade should be increased. | ||||
|         This means extra buy orders with additional fees. | ||||
|         Custom trade adjustment logic, returning the stake amount that a trade should be | ||||
|         increased or decreased. | ||||
|         This means extra buy or sell orders with additional fees. | ||||
|         Only called when `position_adjustment_enable` is set to True. | ||||
|  | ||||
|         For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ | ||||
|  | ||||
|         When not implemented by a strategy, returns None | ||||
|  | ||||
|         :param trade: trade object. | ||||
|         :param current_time: datetime object, containing the current datetime | ||||
|         :param current_rate: Current buy rate. | ||||
|         :param current_profit: Current profit (as ratio), calculated based on current_rate. | ||||
|         :param min_stake: Minimal stake size allowed by exchange. | ||||
|         :param max_stake: Balance available for trading. | ||||
|         :param min_stake: Minimal stake size allowed by exchange (for both entries and exits) | ||||
|         :param max_stake: Maximum stake allowed (either through balance, or by exchange limits). | ||||
|         :param current_entry_rate: Current rate using entry pricing. | ||||
|         :param current_exit_rate: Current rate using exit pricing. | ||||
|         :param current_entry_profit: Current profit using entry pricing. | ||||
|         :param current_exit_profit: Current profit using exit pricing. | ||||
|         :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. | ||||
|         :return float: Stake amount to adjust your trade | ||||
|         :return float: Stake amount to adjust your trade, | ||||
|                        Positive values to increase position, Negative values to decrease position. | ||||
|                        Return None for no action. | ||||
|         """ | ||||
|  | ||||
|         if current_profit > 0.05 and trade.nr_of_successful_exits == 0: | ||||
|             # Take half of the profit at +5% | ||||
|             return -(trade.amount / 2) | ||||
|  | ||||
|         if current_profit > -0.05: | ||||
|             return None | ||||
|  | ||||
| @@ -735,6 +757,25 @@ def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: f | ||||
|  | ||||
| ``` | ||||
|  | ||||
| ### Position adjust calculations | ||||
|  | ||||
| * Entry rates are calculated using weighted averages. | ||||
| * Exits will not influence the average entry rate. | ||||
| * Partial exit relative profit is relative to the average entry price at this point. | ||||
| * Final exit relative profit is calculated based on the total invested capital. (See example below) | ||||
|  | ||||
| ??? example "Calculation example" | ||||
|     *This example assumes 0 fees for simplicity, and a long position on an imaginary coin.*   | ||||
|      | ||||
|     * Buy 100@8\$  | ||||
|     * Buy 100@9\$ -> Avg price: 8.5\$ | ||||
|     * Sell 100@10\$ -> Avg price: 8.5\$, realized profit 150\$, 17.65% | ||||
|     * Buy 150@11\$ -> Avg price: 10\$, realized profit 150\$, 17.65% | ||||
|     * Sell 100@12\$ -> Avg price: 10\$, total realized profit 350\$, 20% | ||||
|     * Sell 150@14\$ -> Avg price: 10\$, total realized profit 950\$, 40% | ||||
|  | ||||
|     The total profit for this trade was 950$ on a 3350$ investment (`100@8$ + 100@9$ + 150@11$`). As such - the final relative profit is 28.35% (`950 / 3350`). | ||||
|  | ||||
| ## Adjust Entry Price | ||||
|  | ||||
| The `adjust_entry_price()` callback may be used by strategy developer to refresh/replace limit orders upon arrival of new candles. | ||||
|   | ||||
| @@ -14,6 +14,7 @@ class ExitType(Enum): | ||||
|     FORCE_EXIT = "force_exit" | ||||
|     EMERGENCY_EXIT = "emergency_exit" | ||||
|     CUSTOM_EXIT = "custom_exit" | ||||
|     PARTIAL_EXIT = "partial_exit" | ||||
|     NONE = "" | ||||
|  | ||||
|     def __str__(self): | ||||
|   | ||||
| @@ -1507,7 +1507,8 @@ class Exchange: | ||||
|         return price_side | ||||
|  | ||||
|     def get_rate(self, pair: str, refresh: bool, | ||||
|                  side: EntryExit, is_short: bool) -> float: | ||||
|                  side: EntryExit, is_short: bool, | ||||
|                  order_book: Optional[dict] = None, ticker: Optional[dict] = None) -> float: | ||||
|         """ | ||||
|         Calculates bid/ask target | ||||
|         bid rate - between current ask price and last price | ||||
| @@ -1539,6 +1540,7 @@ class Exchange: | ||||
|         if conf_strategy.get('use_order_book', False): | ||||
|  | ||||
|             order_book_top = conf_strategy.get('order_book_top', 1) | ||||
|             if order_book is None: | ||||
|                 order_book = self.fetch_l2_order_book(pair, order_book_top) | ||||
|             logger.debug('order_book %s', order_book) | ||||
|             # top 1 = index 0 | ||||
| @@ -1546,14 +1548,15 @@ class Exchange: | ||||
|                 rate = order_book[f"{price_side}s"][order_book_top - 1][0] | ||||
|             except (IndexError, KeyError) as e: | ||||
|                 logger.warning( | ||||
|                     f"{name} Price at location {order_book_top} from orderbook could not be " | ||||
|                     f"determined. Orderbook: {order_book}" | ||||
|                     f"{pair} - {name} Price at location {order_book_top} from orderbook " | ||||
|                     f"could not be determined. Orderbook: {order_book}" | ||||
|                 ) | ||||
|                 raise PricingError from e | ||||
|             logger.debug(f"{name} price from orderbook {price_side_word}" | ||||
|             logger.debug(f"{pair} - {name} price from orderbook {price_side_word}" | ||||
|                          f"side - top {order_book_top} order book {side} rate {rate:.8f}") | ||||
|         else: | ||||
|             logger.debug(f"Using Last {price_side_word} / Last Price") | ||||
|             if ticker is None: | ||||
|                 ticker = self.fetch_ticker(pair) | ||||
|             ticker_rate = ticker[price_side] | ||||
|             if ticker['last'] and ticker_rate: | ||||
| @@ -1571,6 +1574,33 @@ class Exchange: | ||||
|  | ||||
|         return rate | ||||
|  | ||||
|     def get_rates(self, pair: str, refresh: bool, is_short: bool) -> Tuple[float, float]: | ||||
|         entry_rate = None | ||||
|         exit_rate = None | ||||
|         if not refresh: | ||||
|             entry_rate = self._entry_rate_cache.get(pair) | ||||
|             exit_rate = self._exit_rate_cache.get(pair) | ||||
|             if entry_rate: | ||||
|                 logger.debug(f"Using cached buy rate for {pair}.") | ||||
|             if exit_rate: | ||||
|                 logger.debug(f"Using cached sell rate for {pair}.") | ||||
|  | ||||
|         entry_pricing = self._config.get('entry_pricing', {}) | ||||
|         exit_pricing = self._config.get('exit_pricing', {}) | ||||
|         order_book = ticker = None | ||||
|         if not entry_rate and entry_pricing.get('use_order_book', False): | ||||
|             order_book_top = max(entry_pricing.get('order_book_top', 1), | ||||
|                                  exit_pricing.get('order_book_top', 1)) | ||||
|             order_book = self.fetch_l2_order_book(pair, order_book_top) | ||||
|             entry_rate = self.get_rate(pair, refresh, 'entry', is_short, order_book=order_book) | ||||
|         elif not entry_rate: | ||||
|             ticker = self.fetch_ticker(pair) | ||||
|             entry_rate = self.get_rate(pair, refresh, 'entry', is_short, ticker=ticker) | ||||
|         if not exit_rate: | ||||
|             exit_rate = self.get_rate(pair, refresh, 'exit', | ||||
|                                       is_short, order_book=order_book, ticker=ticker) | ||||
|         return entry_rate, exit_rate | ||||
|  | ||||
|     # Fee handling | ||||
|  | ||||
|     @retrier | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import copy | ||||
| import logging | ||||
| import traceback | ||||
| from datetime import datetime, time, timedelta, timezone | ||||
| from decimal import Decimal | ||||
| from math import isclose | ||||
| from threading import Lock | ||||
| from typing import Any, Dict, List, Optional, Tuple | ||||
| @@ -525,39 +526,61 @@ class FreqtradeBot(LoggingMixin): | ||||
|         If the strategy triggers the adjustment, a new order gets issued. | ||||
|         Once that completes, the existing trade is modified to match new data. | ||||
|         """ | ||||
|         if self.strategy.max_entry_position_adjustment > -1: | ||||
|             count_of_buys = trade.nr_of_successful_entries | ||||
|             if count_of_buys > self.strategy.max_entry_position_adjustment: | ||||
|                 logger.debug(f"Max adjustment entries for {trade.pair} has been reached.") | ||||
|                 return | ||||
|         else: | ||||
|             logger.debug("Max adjustment entries is set to unlimited.") | ||||
|         current_rate = self.exchange.get_rate( | ||||
|             trade.pair, side='entry', is_short=trade.is_short, refresh=True) | ||||
|         current_profit = trade.calc_profit_ratio(current_rate) | ||||
|         current_entry_rate, current_exit_rate = self.exchange.get_rates( | ||||
|             trade.pair, True, trade.is_short) | ||||
|  | ||||
|         min_stake_amount = self.exchange.get_min_pair_stake_amount(trade.pair, | ||||
|                                                                    current_rate, | ||||
|         current_entry_profit = trade.calc_profit_ratio(current_entry_rate) | ||||
|         current_exit_profit = trade.calc_profit_ratio(current_exit_rate) | ||||
|  | ||||
|         min_entry_stake = self.exchange.get_min_pair_stake_amount(trade.pair, | ||||
|                                                                   current_entry_rate, | ||||
|                                                                   self.strategy.stoploss) | ||||
|         max_stake_amount = self.exchange.get_max_pair_stake_amount(trade.pair, current_rate) | ||||
|         min_exit_stake = self.exchange.get_min_pair_stake_amount(trade.pair, | ||||
|                                                                  current_exit_rate, | ||||
|                                                                  self.strategy.stoploss) | ||||
|         max_entry_stake = self.exchange.get_max_pair_stake_amount(trade.pair, current_entry_rate) | ||||
|         stake_available = self.wallets.get_available_stake_amount() | ||||
|         logger.debug(f"Calling adjust_trade_position for pair {trade.pair}") | ||||
|         stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position, | ||||
|                                              default_retval=None)( | ||||
|             trade=trade, current_time=datetime.now(timezone.utc), current_rate=current_rate, | ||||
|             current_profit=current_profit, min_stake=min_stake_amount, | ||||
|             max_stake=min(max_stake_amount, stake_available)) | ||||
|             trade=trade, | ||||
|             current_time=datetime.now(timezone.utc), current_rate=current_entry_rate, | ||||
|             current_profit=current_entry_profit, min_stake=min_entry_stake, | ||||
|             max_stake=min(max_entry_stake, stake_available), | ||||
|             current_entry_rate=current_entry_rate, current_exit_rate=current_exit_rate, | ||||
|             current_entry_profit=current_entry_profit, current_exit_profit=current_exit_profit | ||||
|         ) | ||||
|  | ||||
|         if stake_amount is not None and stake_amount > 0.0: | ||||
|             # We should increase our position | ||||
|             self.execute_entry(trade.pair, stake_amount, price=current_rate, | ||||
|             if self.strategy.max_entry_position_adjustment > -1: | ||||
|                 count_of_entries = trade.nr_of_successful_entries | ||||
|                 if count_of_entries > self.strategy.max_entry_position_adjustment: | ||||
|                     logger.debug(f"Max adjustment entries for {trade.pair} has been reached.") | ||||
|                     return | ||||
|                 else: | ||||
|                     logger.debug("Max adjustment entries is set to unlimited.") | ||||
|             self.execute_entry(trade.pair, stake_amount, price=current_entry_rate, | ||||
|                                trade=trade, is_short=trade.is_short) | ||||
|  | ||||
|         if stake_amount is not None and stake_amount < 0.0: | ||||
|             # We should decrease our position | ||||
|             # TODO: Selling part of the trade not implemented yet. | ||||
|             logger.error(f"Unable to decrease trade position / sell partially" | ||||
|                          f" for pair {trade.pair}, feature not implemented.") | ||||
|             amount = abs(float(Decimal(stake_amount) / Decimal(current_exit_rate))) | ||||
|             if amount > trade.amount: | ||||
|                 # This is currently ineffective as remaining would become < min tradable | ||||
|                 # Fixing this would require checking for 0.0 there - | ||||
|                 # if we decide that this callback is allowed to "fully exit" | ||||
|                 logger.info( | ||||
|                     f"Adjusting amount to trade.amount as it is higher. {amount} > {trade.amount}") | ||||
|                 amount = trade.amount | ||||
|  | ||||
|             remaining = (trade.amount - amount) * current_exit_rate | ||||
|             if remaining < min_exit_stake: | ||||
|                 logger.info(f'Remaining amount of {remaining} would be too small.') | ||||
|                 return | ||||
|  | ||||
|             self.execute_trade_exit(trade, current_exit_rate, exit_check=ExitCheckTuple( | ||||
|                 exit_type=ExitType.PARTIAL_EXIT), sub_trade_amt=amount) | ||||
|  | ||||
|     def _check_depth_of_market(self, pair: str, conf: Dict, side: SignalDirection) -> bool: | ||||
|         """ | ||||
| @@ -731,7 +754,7 @@ class FreqtradeBot(LoggingMixin): | ||||
|         # Updating wallets | ||||
|         self.wallets.update() | ||||
|  | ||||
|         self._notify_enter(trade, order, order_type) | ||||
|         self._notify_enter(trade, order_obj, order_type, sub_trade=pos_adjust) | ||||
|  | ||||
|         if pos_adjust: | ||||
|             if order_status == 'closed': | ||||
| @@ -740,8 +763,8 @@ class FreqtradeBot(LoggingMixin): | ||||
|             else: | ||||
|                 logger.info(f"DCA order {order_status}, will wait for resolution: {trade}") | ||||
|  | ||||
|         # Update fees if order is closed | ||||
|         if order_status == 'closed': | ||||
|         # Update fees if order is non-opened | ||||
|         if order_status in constants.NON_OPEN_EXCHANGE_STATES: | ||||
|             self.update_trade_state(trade, order_id, order) | ||||
|  | ||||
|         return True | ||||
| @@ -830,13 +853,14 @@ class FreqtradeBot(LoggingMixin): | ||||
|  | ||||
|         return enter_limit_requested, stake_amount, leverage | ||||
|  | ||||
|     def _notify_enter(self, trade: Trade, order: Dict, order_type: Optional[str] = None, | ||||
|                       fill: bool = False) -> None: | ||||
|     def _notify_enter(self, trade: Trade, order: Order, order_type: Optional[str] = None, | ||||
|                       fill: bool = False, sub_trade: bool = False) -> None: | ||||
|         """ | ||||
|         Sends rpc notification when a entry order occurred. | ||||
|         """ | ||||
|         msg_type = RPCMessageType.ENTRY_FILL if fill else RPCMessageType.ENTRY | ||||
|         open_rate = safe_value_fallback(order, 'average', 'price') | ||||
|         open_rate = order.safe_price | ||||
|  | ||||
|         if open_rate is None: | ||||
|             open_rate = trade.open_rate | ||||
|  | ||||
| @@ -860,15 +884,17 @@ class FreqtradeBot(LoggingMixin): | ||||
|             'stake_amount': trade.stake_amount, | ||||
|             'stake_currency': self.config['stake_currency'], | ||||
|             'fiat_currency': self.config.get('fiat_display_currency', None), | ||||
|             'amount': safe_value_fallback(order, 'filled', 'amount') or trade.amount, | ||||
|             'amount': order.safe_amount_after_fee, | ||||
|             'open_date': trade.open_date or datetime.utcnow(), | ||||
|             'current_rate': current_rate, | ||||
|             'sub_trade': sub_trade, | ||||
|         } | ||||
|  | ||||
|         # Send the message | ||||
|         self.rpc.send_msg(msg) | ||||
|  | ||||
|     def _notify_enter_cancel(self, trade: Trade, order_type: str, reason: str) -> None: | ||||
|     def _notify_enter_cancel(self, trade: Trade, order_type: str, reason: str, | ||||
|                              sub_trade: bool = False) -> None: | ||||
|         """ | ||||
|         Sends rpc notification when a entry order cancel occurred. | ||||
|         """ | ||||
| @@ -893,6 +919,7 @@ class FreqtradeBot(LoggingMixin): | ||||
|             'open_date': trade.open_date, | ||||
|             'current_rate': current_rate, | ||||
|             'reason': reason, | ||||
|             'sub_trade': sub_trade, | ||||
|         } | ||||
|  | ||||
|         # Send the message | ||||
| @@ -1366,16 +1393,22 @@ class FreqtradeBot(LoggingMixin): | ||||
|             trade.open_order_id = None | ||||
|             trade.exit_reason = None | ||||
|             cancelled = True | ||||
|             self.wallets.update() | ||||
|         else: | ||||
|             # TODO: figure out how to handle partially complete sell orders | ||||
|             reason = constants.CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN'] | ||||
|             cancelled = False | ||||
|  | ||||
|         self.wallets.update() | ||||
|         order_obj = trade.select_order_by_order_id(order['id']) | ||||
|         if not order_obj: | ||||
|             raise DependencyException( | ||||
|                 f"Order_obj not found for {order['id']}. This should not have happened.") | ||||
|  | ||||
|         sub_trade = order_obj.amount != trade.amount | ||||
|         self._notify_exit_cancel( | ||||
|             trade, | ||||
|             order_type=self.strategy.order_types['exit'], | ||||
|             reason=reason | ||||
|             reason=reason, order=order_obj, sub_trade=sub_trade | ||||
|         ) | ||||
|         return cancelled | ||||
|  | ||||
| @@ -1416,6 +1449,7 @@ class FreqtradeBot(LoggingMixin): | ||||
|             *, | ||||
|             exit_tag: Optional[str] = None, | ||||
|             ordertype: Optional[str] = None, | ||||
|             sub_trade_amt: float = None, | ||||
|     ) -> bool: | ||||
|         """ | ||||
|         Executes a trade exit for the given trade and limit | ||||
| @@ -1462,10 +1496,12 @@ class FreqtradeBot(LoggingMixin): | ||||
|             # Emergency sells (default to market!) | ||||
|             order_type = self.strategy.order_types.get("emergency_exit", "market") | ||||
|  | ||||
|         amount = self._safe_exit_amount(trade.pair, trade.amount) | ||||
|         amount = self._safe_exit_amount(trade.pair, sub_trade_amt or trade.amount) | ||||
|         time_in_force = self.strategy.order_time_in_force['exit'] | ||||
|  | ||||
|         if (exit_check.exit_type != ExitType.LIQUIDATION and not strategy_safe_wrapper( | ||||
|         if (exit_check.exit_type != ExitType.LIQUIDATION | ||||
|                 and not sub_trade_amt | ||||
|                 and not strategy_safe_wrapper( | ||||
|                     self.strategy.confirm_trade_exit, default_retval=True)( | ||||
|                     pair=trade.pair, trade=trade, order_type=order_type, amount=amount, rate=limit, | ||||
|                     time_in_force=time_in_force, exit_reason=exit_reason, | ||||
| @@ -1504,7 +1540,7 @@ class FreqtradeBot(LoggingMixin): | ||||
|         self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc), | ||||
|                                 reason='Auto lock') | ||||
|  | ||||
|         self._notify_exit(trade, order_type) | ||||
|         self._notify_exit(trade, order_type, sub_trade=bool(sub_trade_amt), order=order_obj) | ||||
|         # In case of market sell orders the order can be closed immediately | ||||
|         if order.get('status', 'unknown') in ('closed', 'expired'): | ||||
|             self.update_trade_state(trade, trade.open_order_id, order) | ||||
| @@ -1512,16 +1548,27 @@ class FreqtradeBot(LoggingMixin): | ||||
|  | ||||
|         return True | ||||
|  | ||||
|     def _notify_exit(self, trade: Trade, order_type: str, fill: bool = False) -> None: | ||||
|     def _notify_exit(self, trade: Trade, order_type: str, fill: bool = False, | ||||
|                      sub_trade: bool = False, order: Order = None) -> None: | ||||
|         """ | ||||
|         Sends rpc notification when a sell occurred. | ||||
|         """ | ||||
|         profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested | ||||
|         profit_trade = trade.calc_profit(rate=profit_rate) | ||||
|         # Use cached rates here - it was updated seconds ago. | ||||
|         current_rate = self.exchange.get_rate( | ||||
|             trade.pair, side='exit', is_short=trade.is_short, refresh=False) if not fill else None | ||||
|  | ||||
|         # second condition is for mypy only; order will always be passed during sub trade | ||||
|         if sub_trade and order is not None: | ||||
|             amount = order.safe_filled if fill else order.amount | ||||
|             profit_rate = order.safe_price | ||||
|  | ||||
|             profit = trade.calc_profit(rate=profit_rate, amount=amount, open_rate=trade.open_rate) | ||||
|             profit_ratio = trade.calc_profit_ratio(profit_rate, amount, trade.open_rate) | ||||
|         else: | ||||
|             profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested | ||||
|             profit = trade.calc_profit(rate=profit_rate) + trade.realized_profit | ||||
|             profit_ratio = trade.calc_profit_ratio(profit_rate) | ||||
|             amount = trade.amount | ||||
|         gain = "profit" if profit_ratio > 0 else "loss" | ||||
|  | ||||
|         msg = { | ||||
| @@ -1535,11 +1582,11 @@ class FreqtradeBot(LoggingMixin): | ||||
|             'gain': gain, | ||||
|             'limit': profit_rate, | ||||
|             'order_type': order_type, | ||||
|             'amount': trade.amount, | ||||
|             'amount': amount, | ||||
|             'open_rate': trade.open_rate, | ||||
|             'close_rate': trade.close_rate, | ||||
|             'close_rate': profit_rate, | ||||
|             'current_rate': current_rate, | ||||
|             'profit_amount': profit_trade, | ||||
|             'profit_amount': profit, | ||||
|             'profit_ratio': profit_ratio, | ||||
|             'buy_tag': trade.enter_tag, | ||||
|             'enter_tag': trade.enter_tag, | ||||
| @@ -1547,19 +1594,18 @@ class FreqtradeBot(LoggingMixin): | ||||
|             'exit_reason': trade.exit_reason, | ||||
|             'open_date': trade.open_date, | ||||
|             'close_date': trade.close_date or datetime.utcnow(), | ||||
|             'stake_amount': trade.stake_amount, | ||||
|             'stake_currency': self.config['stake_currency'], | ||||
|             'fiat_currency': self.config.get('fiat_display_currency'), | ||||
|             'sub_trade': sub_trade, | ||||
|             'cumulative_profit': trade.realized_profit, | ||||
|         } | ||||
|  | ||||
|         if 'fiat_display_currency' in self.config: | ||||
|             msg.update({ | ||||
|                 'fiat_currency': self.config['fiat_display_currency'], | ||||
|             }) | ||||
|  | ||||
|         # Send the message | ||||
|         self.rpc.send_msg(msg) | ||||
|  | ||||
|     def _notify_exit_cancel(self, trade: Trade, order_type: str, reason: str) -> None: | ||||
|     def _notify_exit_cancel(self, trade: Trade, order_type: str, reason: str, | ||||
|                             order: Order, sub_trade: bool = False) -> None: | ||||
|         """ | ||||
|         Sends rpc notification when a sell cancel occurred. | ||||
|         """ | ||||
| @@ -1585,7 +1631,7 @@ class FreqtradeBot(LoggingMixin): | ||||
|             'gain': gain, | ||||
|             'limit': profit_rate or 0, | ||||
|             'order_type': order_type, | ||||
|             'amount': trade.amount, | ||||
|             'amount': order.safe_amount_after_fee, | ||||
|             'open_rate': trade.open_rate, | ||||
|             'current_rate': current_rate, | ||||
|             'profit_amount': profit_trade, | ||||
| @@ -1599,6 +1645,8 @@ class FreqtradeBot(LoggingMixin): | ||||
|             'stake_currency': self.config['stake_currency'], | ||||
|             'fiat_currency': self.config.get('fiat_display_currency', None), | ||||
|             'reason': reason, | ||||
|             'sub_trade': sub_trade, | ||||
|             'stake_amount': trade.stake_amount, | ||||
|         } | ||||
|  | ||||
|         if 'fiat_display_currency' in self.config: | ||||
| @@ -1653,14 +1701,18 @@ class FreqtradeBot(LoggingMixin): | ||||
|         self.handle_order_fee(trade, order_obj, order) | ||||
|  | ||||
|         trade.update_trade(order_obj) | ||||
|         # TODO: is the below necessary? it's already done in update_trade for filled buys | ||||
|         trade.recalc_trade_from_orders() | ||||
|         Trade.commit() | ||||
|  | ||||
|         if order['status'] in constants.NON_OPEN_EXCHANGE_STATES: | ||||
|         if order.get('status') in constants.NON_OPEN_EXCHANGE_STATES: | ||||
|             # If a entry order was closed, force update on stoploss on exchange | ||||
|             if order.get('side') == trade.entry_side: | ||||
|                 trade = self.cancel_stoploss_on_exchange(trade) | ||||
|                 if not self.edge: | ||||
|                     # TODO: should shorting/leverage be supported by Edge, | ||||
|                     # then this will need to be fixed. | ||||
|                     trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True) | ||||
|             if order.get('side') == trade.entry_side or trade.amount > 0: | ||||
|                 # Must also run for partial exits | ||||
|                 # TODO: Margin will need to use interest_rate as well. | ||||
|                 # interest_rate = self.exchange.get_interest_rate() | ||||
|                 trade.set_liquidation_price(self.exchange.get_liquidation_price( | ||||
| @@ -1670,23 +1722,29 @@ class FreqtradeBot(LoggingMixin): | ||||
|                     open_rate=trade.open_rate, | ||||
|                     is_short=trade.is_short | ||||
|                 )) | ||||
|                 if not self.edge: | ||||
|                     # TODO: should shorting/leverage be supported by Edge, | ||||
|                     # then this will need to be fixed. | ||||
|                     trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True) | ||||
|  | ||||
|             # Updating wallets when order is closed | ||||
|             self.wallets.update() | ||||
|  | ||||
|         if not trade.is_open: | ||||
|         self.order_close_notify(trade, order_obj, stoploss_order, send_msg) | ||||
|  | ||||
|         return False | ||||
|  | ||||
|     def order_close_notify( | ||||
|             self, trade: Trade, order: Order, stoploss_order: bool, send_msg: bool): | ||||
|         """send "fill" notifications""" | ||||
|  | ||||
|         sub_trade = not isclose(order.safe_amount_after_fee, | ||||
|                                 trade.amount, abs_tol=constants.MATH_CLOSE_PREC) | ||||
|         if order.ft_order_side == trade.exit_side: | ||||
|             # Exit notification | ||||
|             if send_msg and not stoploss_order and not trade.open_order_id: | ||||
|                 self._notify_exit(trade, '', True) | ||||
|                 self._notify_exit(trade, '', fill=True, sub_trade=sub_trade, order=order) | ||||
|             if not trade.is_open: | ||||
|                 self.handle_protections(trade.pair, trade.trade_direction) | ||||
|         elif send_msg and not trade.open_order_id and not stoploss_order: | ||||
|             # Enter fill | ||||
|             self._notify_enter(trade, order, fill=True) | ||||
|  | ||||
|         return False | ||||
|             self._notify_enter(trade, order, fill=True, sub_trade=sub_trade) | ||||
|  | ||||
|     def handle_protections(self, pair: str, side: LongShort) -> None: | ||||
|         prot_trig = self.protections.stop_per_pair(pair, side=side) | ||||
|   | ||||
							
								
								
									
										59
									
								
								freqtrade/optimize/backtesting.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										59
									
								
								freqtrade/optimize/backtesting.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -503,16 +503,20 @@ class Backtesting: | ||||
|  | ||||
|     def _get_adjust_trade_entry_for_candle(self, trade: LocalTrade, row: Tuple | ||||
|                                            ) -> LocalTrade: | ||||
|         current_profit = trade.calc_profit_ratio(row[OPEN_IDX]) | ||||
|         min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, row[OPEN_IDX], -0.1) | ||||
|         max_stake = self.exchange.get_max_pair_stake_amount(trade.pair, row[OPEN_IDX]) | ||||
|         current_rate = row[OPEN_IDX] | ||||
|         current_date = row[DATE_IDX].to_pydatetime() | ||||
|         current_profit = trade.calc_profit_ratio(current_rate) | ||||
|         min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, current_rate, -0.1) | ||||
|         max_stake = self.exchange.get_max_pair_stake_amount(trade.pair, current_rate) | ||||
|         stake_available = self.wallets.get_available_stake_amount() | ||||
|         stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position, | ||||
|                                              default_retval=None)( | ||||
|             trade=trade,  # type: ignore[arg-type] | ||||
|             current_time=row[DATE_IDX].to_pydatetime(), current_rate=row[OPEN_IDX], | ||||
|             current_time=current_date, current_rate=current_rate, | ||||
|             current_profit=current_profit, min_stake=min_stake, | ||||
|             max_stake=min(max_stake, stake_available)) | ||||
|             max_stake=min(max_stake, stake_available), | ||||
|             current_entry_rate=current_rate, current_exit_rate=current_rate, | ||||
|             current_entry_profit=current_profit, current_exit_profit=current_profit) | ||||
|  | ||||
|         # Check if we should increase our position | ||||
|         if stake_amount is not None and stake_amount > 0.0: | ||||
| @@ -523,6 +527,24 @@ class Backtesting: | ||||
|                 self.wallets.update() | ||||
|                 return pos_trade | ||||
|  | ||||
|         if stake_amount is not None and stake_amount < 0.0: | ||||
|             amount = abs(stake_amount) / current_rate | ||||
|             if amount > trade.amount: | ||||
|                 # This is currently ineffective as remaining would become < min tradable | ||||
|                 amount = trade.amount | ||||
|             remaining = (trade.amount - amount) * current_rate | ||||
|             if remaining < min_stake: | ||||
|                 # Remaining stake is too low to be sold. | ||||
|                 return trade | ||||
|             pos_trade = self._exit_trade(trade, row, current_rate, amount) | ||||
|             if pos_trade is not None: | ||||
|                 order = pos_trade.orders[-1] | ||||
|                 if self._get_order_filled(order.price, row): | ||||
|                     order.close_bt_order(current_date, trade) | ||||
|                     trade.recalc_trade_from_orders() | ||||
|                 self.wallets.update() | ||||
|                 return pos_trade | ||||
|  | ||||
|         return trade | ||||
|  | ||||
|     def _get_order_filled(self, rate: float, row: Tuple) -> bool: | ||||
| @@ -602,7 +624,7 @@ class Backtesting: | ||||
|                 self.strategy.confirm_trade_exit, default_retval=True)( | ||||
|                     pair=trade.pair, | ||||
|                     trade=trade,  # type: ignore[arg-type] | ||||
|                     order_type='limit', | ||||
|                     order_type=order_type, | ||||
|                     amount=trade.amount, | ||||
|                     rate=close_rate, | ||||
|                     time_in_force=time_in_force, | ||||
| @@ -613,7 +635,15 @@ class Backtesting: | ||||
|  | ||||
|             trade.exit_reason = exit_reason | ||||
|  | ||||
|             return self._exit_trade(trade, row, close_rate, trade.amount) | ||||
|         return None | ||||
|  | ||||
|     def _exit_trade(self, trade: LocalTrade, sell_row: Tuple, | ||||
|                     close_rate: float, amount: float = None) -> Optional[LocalTrade]: | ||||
|         self.order_id_counter += 1 | ||||
|         exit_candle_time = sell_row[DATE_IDX].to_pydatetime() | ||||
|         order_type = self.strategy.order_types['exit'] | ||||
|         amount = amount or trade.amount | ||||
|         order = Order( | ||||
|             id=self.order_id_counter, | ||||
|             ft_trade_id=trade.id, | ||||
| @@ -629,16 +659,14 @@ class Backtesting: | ||||
|             status="open", | ||||
|             price=close_rate, | ||||
|             average=close_rate, | ||||
|                 amount=trade.amount, | ||||
|             amount=amount, | ||||
|             filled=0, | ||||
|                 remaining=trade.amount, | ||||
|                 cost=trade.amount * close_rate, | ||||
|             remaining=amount, | ||||
|             cost=amount * close_rate, | ||||
|         ) | ||||
|         trade.orders.append(order) | ||||
|         return trade | ||||
|  | ||||
|         return None | ||||
|  | ||||
|     def _get_exit_trade_entry(self, trade: LocalTrade, row: Tuple) -> Optional[LocalTrade]: | ||||
|         exit_candle_time: datetime = row[DATE_IDX].to_pydatetime() | ||||
|  | ||||
| @@ -865,6 +893,8 @@ class Backtesting: | ||||
|                         # Ignore trade if entry-order did not fill yet | ||||
|                         continue | ||||
|                     exit_row = data[pair][-1] | ||||
|                     self._exit_trade(trade, exit_row, exit_row[OPEN_IDX], trade.amount) | ||||
|                     trade.orders[-1].close_bt_order(exit_row[DATE_IDX].to_pydatetime(), trade) | ||||
|  | ||||
|                     trade.close_date = exit_row[DATE_IDX].to_pydatetime() | ||||
|                     trade.exit_reason = ExitType.FORCE_EXIT.value | ||||
| @@ -1006,7 +1036,7 @@ class Backtesting: | ||||
|             return None | ||||
|         return row | ||||
|  | ||||
|     def backtest(self, processed: Dict, | ||||
|     def backtest(self, processed: Dict,  # noqa: max-complexity: 13 | ||||
|                  start_date: datetime, end_date: datetime, | ||||
|                  max_open_trades: int = 0, position_stacking: bool = False, | ||||
|                  enable_protections: bool = False) -> Dict[str, Any]: | ||||
| @@ -1108,6 +1138,11 @@ class Backtesting: | ||||
|                     if order and self._get_order_filled(order.price, row): | ||||
|                         order.close_bt_order(current_time, trade) | ||||
|                         trade.open_order_id = None | ||||
|                         sub_trade = order.safe_amount_after_fee != trade.amount | ||||
|                         if sub_trade: | ||||
|                             order.close_bt_order(current_time, trade) | ||||
|                             trade.recalc_trade_from_orders() | ||||
|                         else: | ||||
|                             trade.close_date = current_time | ||||
|                             trade.close(order.price, show_msg=False) | ||||
|  | ||||
|   | ||||
| @@ -95,6 +95,7 @@ def migrate_trades_and_orders_table( | ||||
|     exit_reason = get_column_def(cols, 'sell_reason', get_column_def(cols, 'exit_reason', 'null')) | ||||
|     strategy = get_column_def(cols, 'strategy', 'null') | ||||
|     enter_tag = get_column_def(cols, 'buy_tag', get_column_def(cols, 'enter_tag', 'null')) | ||||
|     realized_profit = get_column_def(cols, 'realized_profit', '0.0') | ||||
|  | ||||
|     trading_mode = get_column_def(cols, 'trading_mode', 'null') | ||||
|  | ||||
| @@ -155,7 +156,7 @@ def migrate_trades_and_orders_table( | ||||
|             max_rate, min_rate, exit_reason, exit_order_status, strategy, enter_tag, | ||||
|             timeframe, open_trade_value, close_profit_abs, | ||||
|             trading_mode, leverage, liquidation_price, is_short, | ||||
|             interest_rate, funding_fees | ||||
|             interest_rate, funding_fees, realized_profit | ||||
|             ) | ||||
|         select id, lower(exchange), pair, {base_currency} base_currency, | ||||
|             {stake_currency} stake_currency, | ||||
| @@ -181,7 +182,7 @@ def migrate_trades_and_orders_table( | ||||
|             {open_trade_value} open_trade_value, {close_profit_abs} close_profit_abs, | ||||
|             {trading_mode} trading_mode, {leverage} leverage, {liquidation_price} liquidation_price, | ||||
|             {is_short} is_short, {interest_rate} interest_rate, | ||||
|             {funding_fees} funding_fees | ||||
|             {funding_fees} funding_fees, {realized_profit} realized_profit | ||||
|             from {trade_back_name} | ||||
|             """)) | ||||
|  | ||||
| @@ -297,8 +298,9 @@ def check_migrate(engine, decl_base, previous_tables) -> None: | ||||
|  | ||||
|     # Check if migration necessary | ||||
|     # Migrates both trades and orders table! | ||||
|     if not has_column(cols_orders, 'stop_price'): | ||||
|         # if not has_column(cols_trades, 'base_currency'): | ||||
|     # if ('orders' not in previous_tables | ||||
|     # or not has_column(cols_orders, 'stop_price')): | ||||
|     if not has_column(cols_trades, 'realized_profit'): | ||||
|         logger.info(f"Running database migration for trades - " | ||||
|                     f"backup: {table_back_name}, {order_table_bak_name}") | ||||
|         migrate_trades_and_orders_table( | ||||
|   | ||||
| @@ -4,13 +4,15 @@ This module contains the class to persist trades into SQLite | ||||
| import logging | ||||
| from datetime import datetime, timedelta, timezone | ||||
| from decimal import Decimal | ||||
| from math import isclose | ||||
| from typing import Any, Dict, List, Optional | ||||
|  | ||||
| from sqlalchemy import (Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String, | ||||
|                         UniqueConstraint, desc, func) | ||||
| from sqlalchemy.orm import Query, lazyload, relationship | ||||
|  | ||||
| from freqtrade.constants import DATETIME_PRINT_FORMAT, NON_OPEN_EXCHANGE_STATES, BuySell, LongShort | ||||
| from freqtrade.constants import (DATETIME_PRINT_FORMAT, MATH_CLOSE_PREC, NON_OPEN_EXCHANGE_STATES, | ||||
|                                  BuySell, LongShort) | ||||
| from freqtrade.enums import ExitType, TradingMode | ||||
| from freqtrade.exceptions import DependencyException, OperationalException | ||||
| from freqtrade.leverage import interest | ||||
| @@ -176,10 +178,9 @@ class Order(_DECL_BASE): | ||||
|         self.remaining = 0 | ||||
|         self.status = 'closed' | ||||
|         self.ft_is_open = False | ||||
|         if (self.ft_order_side == trade.entry_side | ||||
|                 and len(trade.select_filled_orders(trade.entry_side)) == 1): | ||||
|         if (self.ft_order_side == trade.entry_side): | ||||
|             trade.open_rate = self.price | ||||
|             trade.recalc_open_trade_value() | ||||
|             trade.recalc_trade_from_orders() | ||||
|             trade.adjust_stop_loss(trade.open_rate, trade.stop_loss_pct, refresh=True) | ||||
|  | ||||
|     @staticmethod | ||||
| @@ -237,6 +238,7 @@ class LocalTrade(): | ||||
|     trades: List['LocalTrade'] = [] | ||||
|     trades_open: List['LocalTrade'] = [] | ||||
|     total_profit: float = 0 | ||||
|     realized_profit: float = 0 | ||||
|  | ||||
|     id: int = 0 | ||||
|  | ||||
| @@ -447,6 +449,7 @@ class LocalTrade(): | ||||
|                            if self.close_date else None), | ||||
|             'close_timestamp': int(self.close_date.replace( | ||||
|                 tzinfo=timezone.utc).timestamp() * 1000) if self.close_date else None, | ||||
|             'realized_profit': self.realized_profit or 0.0, | ||||
|             'close_rate': self.close_rate, | ||||
|             'close_rate_requested': self.close_rate_requested, | ||||
|             'close_profit': self.close_profit,  # Deprecated | ||||
| @@ -596,14 +599,28 @@ class LocalTrade(): | ||||
|             if self.is_open: | ||||
|                 payment = "SELL" if self.is_short else "BUY" | ||||
|                 logger.info(f'{order.order_type.upper()}_{payment} has been fulfilled for {self}.') | ||||
|             # condition to avoid reset value when updating fees | ||||
|             if self.open_order_id == order.order_id: | ||||
|                 self.open_order_id = None | ||||
|             else: | ||||
|                 logger.warning( | ||||
|                     f'Got different open_order_id {self.open_order_id} != {order.order_id}') | ||||
|             self.recalc_trade_from_orders() | ||||
|         elif order.ft_order_side == self.exit_side: | ||||
|             if self.is_open: | ||||
|                 payment = "BUY" if self.is_short else "SELL" | ||||
|                 # * On margin shorts, you buy a little bit more than the amount (amount + interest) | ||||
|                 logger.info(f'{order.order_type.upper()}_{payment} has been fulfilled for {self}.') | ||||
|             # condition to avoid reset value when updating fees | ||||
|             if self.open_order_id == order.order_id: | ||||
|                 self.open_order_id = None | ||||
|             else: | ||||
|                 logger.warning( | ||||
|                     f'Got different open_order_id {self.open_order_id} != {order.order_id}') | ||||
|             if isclose(order.safe_amount_after_fee, self.amount, abs_tol=MATH_CLOSE_PREC): | ||||
|                 self.close(order.safe_price) | ||||
|             else: | ||||
|                 self.recalc_trade_from_orders() | ||||
|         elif order.ft_order_side == 'stoploss': | ||||
|             self.stoploss_order_id = None | ||||
|             self.close_rate_requested = self.stop_loss | ||||
| @@ -622,11 +639,11 @@ class LocalTrade(): | ||||
|         """ | ||||
|         self.close_rate = rate | ||||
|         self.close_date = self.close_date or datetime.utcnow() | ||||
|         self.close_profit = self.calc_profit_ratio(rate) | ||||
|         self.close_profit_abs = self.calc_profit(rate) | ||||
|         self.close_profit_abs = self.calc_profit(rate) + self.realized_profit | ||||
|         self.is_open = False | ||||
|         self.exit_order_status = 'closed' | ||||
|         self.open_order_id = None | ||||
|         self.recalc_trade_from_orders(is_closing=True) | ||||
|         if show_msg: | ||||
|             logger.info( | ||||
|                 'Marking %s as closed as the trade is fulfilled and found no open orders for it.', | ||||
| @@ -672,12 +689,12 @@ class LocalTrade(): | ||||
|         """ | ||||
|         return len([o for o in self.orders if o.ft_order_side == self.exit_side]) | ||||
|  | ||||
|     def _calc_open_trade_value(self) -> float: | ||||
|     def _calc_open_trade_value(self, amount: float, open_rate: float) -> float: | ||||
|         """ | ||||
|         Calculate the open_rate including open_fee. | ||||
|         :return: Price in of the open trade incl. Fees | ||||
|         """ | ||||
|         open_trade = Decimal(self.amount) * Decimal(self.open_rate) | ||||
|         open_trade = Decimal(amount) * Decimal(open_rate) | ||||
|         fees = open_trade * Decimal(self.fee_open) | ||||
|         if self.is_short: | ||||
|             return float(open_trade - fees) | ||||
| @@ -689,7 +706,7 @@ class LocalTrade(): | ||||
|         Recalculate open_trade_value. | ||||
|         Must be called whenever open_rate, fee_open is changed. | ||||
|         """ | ||||
|         self.open_trade_value = self._calc_open_trade_value() | ||||
|         self.open_trade_value = self._calc_open_trade_value(self.amount, self.open_rate) | ||||
|  | ||||
|     def calculate_interest(self) -> Decimal: | ||||
|         """ | ||||
| @@ -721,7 +738,7 @@ class LocalTrade(): | ||||
|         else: | ||||
|             return close_trade - fees | ||||
|  | ||||
|     def calc_close_trade_value(self, rate: float) -> float: | ||||
|     def calc_close_trade_value(self, rate: float, amount: float = None) -> float: | ||||
|         """ | ||||
|         Calculate the Trade's close value including fees | ||||
|         :param rate: rate to compare with. | ||||
| @@ -730,96 +747,143 @@ class LocalTrade(): | ||||
|         if rate is None and not self.close_rate: | ||||
|             return 0.0 | ||||
|  | ||||
|         amount = Decimal(self.amount) | ||||
|         amount1 = Decimal(amount or self.amount) | ||||
|         trading_mode = self.trading_mode or TradingMode.SPOT | ||||
|  | ||||
|         if trading_mode == TradingMode.SPOT: | ||||
|             return float(self._calc_base_close(amount, rate, self.fee_close)) | ||||
|             return float(self._calc_base_close(amount1, rate, self.fee_close)) | ||||
|  | ||||
|         elif (trading_mode == TradingMode.MARGIN): | ||||
|  | ||||
|             total_interest = self.calculate_interest() | ||||
|  | ||||
|             if self.is_short: | ||||
|                 amount = amount + total_interest | ||||
|                 return float(self._calc_base_close(amount, rate, self.fee_close)) | ||||
|                 amount1 = amount1 + total_interest | ||||
|                 return float(self._calc_base_close(amount1, rate, self.fee_close)) | ||||
|             else: | ||||
|                 # Currency already owned for longs, no need to purchase | ||||
|                 return float(self._calc_base_close(amount, rate, self.fee_close) - total_interest) | ||||
|                 return float(self._calc_base_close(amount1, rate, self.fee_close) - total_interest) | ||||
|  | ||||
|         elif (trading_mode == TradingMode.FUTURES): | ||||
|             funding_fees = self.funding_fees or 0.0 | ||||
|             # Positive funding_fees -> Trade has gained from fees. | ||||
|             # Negative funding_fees -> Trade had to pay the fees. | ||||
|             if self.is_short: | ||||
|                 return float(self._calc_base_close(amount, rate, self.fee_close)) - funding_fees | ||||
|                 return float(self._calc_base_close(amount1, rate, self.fee_close)) - funding_fees | ||||
|             else: | ||||
|                 return float(self._calc_base_close(amount, rate, self.fee_close)) + funding_fees | ||||
|                 return float(self._calc_base_close(amount1, rate, self.fee_close)) + funding_fees | ||||
|         else: | ||||
|             raise OperationalException( | ||||
|                 f"{self.trading_mode.value} trading is not yet available using freqtrade") | ||||
|  | ||||
|     def calc_profit(self, rate: float) -> float: | ||||
|     def calc_profit(self, rate: float, amount: float = None, open_rate: float = None) -> float: | ||||
|         """ | ||||
|         Calculate the absolute profit in stake currency between Close and Open trade | ||||
|         :param rate: close rate to compare with. | ||||
|         :param amount: Amount to use for the calculation. Falls back to trade.amount if not set. | ||||
|         :param open_rate: open_rate to use. Defaults to self.open_rate if not provided. | ||||
|         :return: profit in stake currency as float | ||||
|         """ | ||||
|         close_trade_value = self.calc_close_trade_value(rate) | ||||
|         close_trade_value = self.calc_close_trade_value(rate, amount) | ||||
|         if amount is None or open_rate is None: | ||||
|             open_trade_value = self.open_trade_value | ||||
|         else: | ||||
|             open_trade_value = self._calc_open_trade_value(amount, open_rate) | ||||
|  | ||||
|         if self.is_short: | ||||
|             profit = self.open_trade_value - close_trade_value | ||||
|             profit = open_trade_value - close_trade_value | ||||
|         else: | ||||
|             profit = close_trade_value - self.open_trade_value | ||||
|             profit = close_trade_value - open_trade_value | ||||
|         return float(f"{profit:.8f}") | ||||
|  | ||||
|     def calc_profit_ratio(self, rate: float) -> float: | ||||
|     def calc_profit_ratio( | ||||
|             self, rate: float, amount: float = None, open_rate: float = None) -> float: | ||||
|         """ | ||||
|         Calculates the profit as ratio (including fee). | ||||
|         :param rate: rate to compare with. | ||||
|         :param amount: Amount to use for the calculation. Falls back to trade.amount if not set. | ||||
|         :param open_rate: open_rate to use. Defaults to self.open_rate if not provided. | ||||
|         :return: profit ratio as float | ||||
|         """ | ||||
|         close_trade_value = self.calc_close_trade_value(rate) | ||||
|         close_trade_value = self.calc_close_trade_value(rate, amount) | ||||
|  | ||||
|         if amount is None or open_rate is None: | ||||
|             open_trade_value = self.open_trade_value | ||||
|         else: | ||||
|             open_trade_value = self._calc_open_trade_value(amount, open_rate) | ||||
|  | ||||
|         short_close_zero = (self.is_short and close_trade_value == 0.0) | ||||
|         long_close_zero = (not self.is_short and self.open_trade_value == 0.0) | ||||
|         long_close_zero = (not self.is_short and open_trade_value == 0.0) | ||||
|         leverage = self.leverage or 1.0 | ||||
|  | ||||
|         if (short_close_zero or long_close_zero): | ||||
|             return 0.0 | ||||
|         else: | ||||
|             if self.is_short: | ||||
|                 profit_ratio = (1 - (close_trade_value / self.open_trade_value)) * leverage | ||||
|                 profit_ratio = (1 - (close_trade_value / open_trade_value)) * leverage | ||||
|             else: | ||||
|                 profit_ratio = ((close_trade_value / self.open_trade_value) - 1) * leverage | ||||
|                 profit_ratio = ((close_trade_value / open_trade_value) - 1) * leverage | ||||
|  | ||||
|         return float(f"{profit_ratio:.8f}") | ||||
|  | ||||
|     def recalc_trade_from_orders(self): | ||||
|     def recalc_trade_from_orders(self, is_closing: bool = False): | ||||
|  | ||||
|         current_amount = 0.0 | ||||
|         current_stake = 0.0 | ||||
|         total_stake = 0.0  # Total stake after all buy orders (does not subtract!) | ||||
|         avg_price = 0.0 | ||||
|         close_profit = 0.0 | ||||
|         close_profit_abs = 0.0 | ||||
|  | ||||
|         total_amount = 0.0 | ||||
|         total_stake = 0.0 | ||||
|         for o in self.orders: | ||||
|             if (o.ft_is_open or | ||||
|                     (o.ft_order_side != self.entry_side) or | ||||
|                     (o.status not in NON_OPEN_EXCHANGE_STATES)): | ||||
|             if o.ft_is_open or not o.filled: | ||||
|                 continue | ||||
|  | ||||
|             tmp_amount = o.safe_amount_after_fee | ||||
|             tmp_price = o.average or o.price | ||||
|             if tmp_amount > 0.0 and tmp_price is not None: | ||||
|                 total_amount += tmp_amount | ||||
|                 total_stake += tmp_price * tmp_amount | ||||
|             tmp_price = o.safe_price | ||||
|  | ||||
|         if total_amount > 0: | ||||
|             is_exit = o.ft_order_side != self.entry_side | ||||
|             side = -1 if is_exit else 1 | ||||
|             if tmp_amount > 0.0 and tmp_price is not None: | ||||
|                 current_amount += tmp_amount * side | ||||
|                 price = avg_price if is_exit else tmp_price | ||||
|                 current_stake += price * tmp_amount * side | ||||
|  | ||||
|                 if current_amount > 0: | ||||
|                     avg_price = current_stake / current_amount | ||||
|  | ||||
|             if is_exit: | ||||
|                 # Process partial exits | ||||
|                 exit_rate = o.safe_price | ||||
|                 exit_amount = o.safe_amount_after_fee | ||||
|                 profit = self.calc_profit(rate=exit_rate, amount=exit_amount, open_rate=avg_price) | ||||
|                 close_profit_abs += profit | ||||
|                 close_profit = self.calc_profit_ratio( | ||||
|                     exit_rate, amount=exit_amount, open_rate=avg_price) | ||||
|                 if current_amount <= 0: | ||||
|                     profit = close_profit_abs | ||||
|             else: | ||||
|                 total_stake = total_stake + self._calc_open_trade_value(tmp_amount, price) | ||||
|  | ||||
|         if close_profit: | ||||
|             self.close_profit = close_profit | ||||
|             self.realized_profit = close_profit_abs | ||||
|             self.close_profit_abs = profit | ||||
|  | ||||
|         if current_amount > 0: | ||||
|             # Trade is still open | ||||
|             # Leverage not updated, as we don't allow changing leverage through DCA at the moment. | ||||
|             self.open_rate = total_stake / total_amount | ||||
|             self.stake_amount = total_stake / (self.leverage or 1.0) | ||||
|             self.amount = total_amount | ||||
|             self.fee_open_cost = self.fee_open * total_stake | ||||
|             self.open_rate = current_stake / current_amount | ||||
|             self.stake_amount = current_stake / (self.leverage or 1.0) | ||||
|             self.amount = current_amount | ||||
|             self.fee_open_cost = self.fee_open * current_stake | ||||
|             self.recalc_open_trade_value() | ||||
|             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) | ||||
|         elif is_closing and total_stake > 0: | ||||
|             # Close profit abs / maximum owned | ||||
|             # Fees are considered as they are part of close_profit_abs | ||||
|             self.close_profit = (close_profit_abs / total_stake) * self.leverage | ||||
|  | ||||
|     def select_order_by_order_id(self, order_id: str) -> Optional[Order]: | ||||
|         """ | ||||
| @@ -841,7 +905,7 @@ class LocalTrade(): | ||||
|         """ | ||||
|         orders = self.orders | ||||
|         if order_side: | ||||
|             orders = [o for o in self.orders if o.ft_order_side == order_side] | ||||
|             orders = [o for o in orders if o.ft_order_side == order_side] | ||||
|         if is_open is not None: | ||||
|             orders = [o for o in orders if o.ft_is_open == is_open] | ||||
|         if len(orders) > 0: | ||||
| @@ -856,9 +920,9 @@ class LocalTrade(): | ||||
|         :return: array of Order objects | ||||
|         """ | ||||
|         return [o for o in self.orders if ((o.ft_order_side == order_side) or (order_side is None)) | ||||
|                 and o.ft_is_open is False and | ||||
|                 (o.filled or 0) > 0 and | ||||
|                 o.status in NON_OPEN_EXCHANGE_STATES] | ||||
|                 and o.ft_is_open is False | ||||
|                 and o.filled | ||||
|                 and o.status in NON_OPEN_EXCHANGE_STATES] | ||||
|  | ||||
|     def select_filled_or_open_orders(self) -> List['Order']: | ||||
|         """ | ||||
| @@ -1023,6 +1087,7 @@ class Trade(_DECL_BASE, LocalTrade): | ||||
|     open_trade_value = Column(Float) | ||||
|     close_rate: Optional[float] = Column(Float) | ||||
|     close_rate_requested = Column(Float) | ||||
|     realized_profit = Column(Float, default=0.0) | ||||
|     close_profit = Column(Float) | ||||
|     close_profit_abs = Column(Float) | ||||
|     stake_amount = Column(Float, nullable=False) | ||||
| @@ -1068,6 +1133,7 @@ class Trade(_DECL_BASE, LocalTrade): | ||||
|  | ||||
|     def __init__(self, **kwargs): | ||||
|         super().__init__(**kwargs) | ||||
|         self.realized_profit = 0 | ||||
|         self.recalc_open_trade_value() | ||||
|  | ||||
|     def delete(self) -> None: | ||||
|   | ||||
| @@ -201,7 +201,7 @@ class RPC: | ||||
|  | ||||
|                 trade_dict = trade.to_json() | ||||
|                 trade_dict.update(dict( | ||||
|                     close_profit=trade.close_profit if trade.close_profit is not None else None, | ||||
|                     close_profit=trade.close_profit if not trade.is_open else None, | ||||
|                     current_rate=current_rate, | ||||
|                     current_profit=current_profit,  # Deprecated | ||||
|                     current_profit_pct=round(current_profit * 100, 2),  # Deprecated | ||||
|   | ||||
| @@ -315,20 +315,36 @@ class Telegram(RPCHandler): | ||||
|             msg['profit_fiat'] = self._rpc._fiat_converter.convert_amount( | ||||
|                 msg['profit_amount'], msg['stake_currency'], msg['fiat_currency']) | ||||
|             msg['profit_extra'] = ( | ||||
|                 f" ({msg['gain']}: {msg['profit_amount']:.8f} {msg['stake_currency']}" | ||||
|                 f" / {msg['profit_fiat']:.3f} {msg['fiat_currency']})") | ||||
|                 f" / {msg['profit_fiat']:.3f} {msg['fiat_currency']}") | ||||
|         else: | ||||
|             msg['profit_extra'] = '' | ||||
|         msg['profit_extra'] = ( | ||||
|             f" ({msg['gain']}: {msg['profit_amount']:.8f} {msg['stake_currency']}" | ||||
|             f"{msg['profit_extra']})") | ||||
|         is_fill = msg['type'] == RPCMessageType.EXIT_FILL | ||||
|         is_sub_trade = msg.get('sub_trade') | ||||
|         is_sub_profit = msg['profit_amount'] != msg.get('cumulative_profit') | ||||
|         profit_prefix = ('Sub ' if is_sub_profit | ||||
|                          else 'Cumulative ') if is_sub_trade else '' | ||||
|         cp_extra = '' | ||||
|         if is_sub_profit and is_sub_trade: | ||||
|             if self._rpc._fiat_converter: | ||||
|                 cp_fiat = self._rpc._fiat_converter.convert_amount( | ||||
|                     msg['cumulative_profit'], msg['stake_currency'], msg['fiat_currency']) | ||||
|                 cp_extra = f" / {cp_fiat:.3f} {msg['fiat_currency']}" | ||||
|             else: | ||||
|                 cp_extra = '' | ||||
|             cp_extra = f"*Cumulative Profit:* (`{msg['cumulative_profit']:.8f} " \ | ||||
|                        f"{msg['stake_currency']}{cp_extra}`)\n" | ||||
|         message = ( | ||||
|             f"{msg['emoji']} *{self._exchange_from_msg(msg)}:* " | ||||
|             f"{'Exited' if is_fill else 'Exiting'} {msg['pair']} (#{msg['trade_id']})\n" | ||||
|             f"{self._add_analyzed_candle(msg['pair'])}" | ||||
|             f"*{'Profit' if is_fill else 'Unrealized Profit'}:* " | ||||
|             f"*{f'{profit_prefix}Profit' if is_fill else f'Unrealized {profit_prefix}Profit'}:* " | ||||
|             f"`{msg['profit_ratio']:.2%}{msg['profit_extra']}`\n" | ||||
|             f"{cp_extra}" | ||||
|             f"*Enter Tag:* `{msg['enter_tag']}`\n" | ||||
|             f"*Exit Reason:* `{msg['exit_reason']}`\n" | ||||
|             f"*Duration:* `{msg['duration']} ({msg['duration_min']:.1f} min)`\n" | ||||
|             f"*Direction:* `{msg['direction']}`\n" | ||||
|             f"{msg['leverage_text']}" | ||||
|             f"*Amount:* `{msg['amount']:.8f}`\n" | ||||
| @@ -336,11 +352,25 @@ class Telegram(RPCHandler): | ||||
|         ) | ||||
|         if msg['type'] == RPCMessageType.EXIT: | ||||
|             message += (f"*Current Rate:* `{msg['current_rate']:.8f}`\n" | ||||
|                         f"*Close Rate:* `{msg['limit']:.8f}`") | ||||
|                         f"*Exit Rate:* `{msg['limit']:.8f}`") | ||||
|  | ||||
|         elif msg['type'] == RPCMessageType.EXIT_FILL: | ||||
|             message += f"*Close Rate:* `{msg['close_rate']:.8f}`" | ||||
|             message += f"*Exit Rate:* `{msg['close_rate']:.8f}`" | ||||
|         if msg.get('sub_trade'): | ||||
|             if self._rpc._fiat_converter: | ||||
|                 msg['stake_amount_fiat'] = self._rpc._fiat_converter.convert_amount( | ||||
|                     msg['stake_amount'], msg['stake_currency'], msg['fiat_currency']) | ||||
|             else: | ||||
|                 msg['stake_amount_fiat'] = 0 | ||||
|             rem = round_coin_value(msg['stake_amount'], msg['stake_currency']) | ||||
|             message += f"\n*Remaining:* `({rem}" | ||||
|  | ||||
|             if msg.get('fiat_currency', None): | ||||
|                 message += f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}" | ||||
|  | ||||
|             message += ")`" | ||||
|         else: | ||||
|             message += f"\n*Duration:* `{msg['duration']} ({msg['duration_min']:.1f} min)`" | ||||
|         return message | ||||
|  | ||||
|     def compose_message(self, msg: Dict[str, Any], msg_type: RPCMessageType) -> str: | ||||
| @@ -353,7 +383,8 @@ class Telegram(RPCHandler): | ||||
|         elif msg_type in (RPCMessageType.ENTRY_CANCEL, RPCMessageType.EXIT_CANCEL): | ||||
|             msg['message_side'] = 'enter' if msg_type in [RPCMessageType.ENTRY_CANCEL] else 'exit' | ||||
|             message = (f"\N{WARNING SIGN} *{self._exchange_from_msg(msg)}:* " | ||||
|                        f"Cancelling {msg['message_side']} Order for {msg['pair']} " | ||||
|                        f"Cancelling {'partial ' if msg.get('sub_trade') else ''}" | ||||
|                        f"{msg['message_side']} Order for {msg['pair']} " | ||||
|                        f"(#{msg['trade_id']}). Reason: {msg['reason']}.") | ||||
|  | ||||
|         elif msg_type == RPCMessageType.PROTECTION_TRIGGER: | ||||
| @@ -424,7 +455,7 @@ class Telegram(RPCHandler): | ||||
|         else: | ||||
|             return "\N{CROSS MARK}" | ||||
|  | ||||
|     def _prepare_entry_details(self, filled_orders: List, quote_currency: str, is_open: bool): | ||||
|     def _prepare_order_details(self, filled_orders: List, quote_currency: str, is_open: bool): | ||||
|         """ | ||||
|         Prepare details of trade with entry adjustment enabled | ||||
|         """ | ||||
| @@ -433,44 +464,51 @@ class Telegram(RPCHandler): | ||||
|             first_avg = filled_orders[0]["safe_price"] | ||||
|  | ||||
|         for x, order in enumerate(filled_orders): | ||||
|             if not order['ft_is_entry'] or order['is_open'] is True: | ||||
|             if order['is_open'] is True: | ||||
|                 continue | ||||
|             wording = 'Entry' if order['ft_is_entry'] else 'Exit' | ||||
|  | ||||
|             cur_entry_datetime = arrow.get(order["order_filled_date"]) | ||||
|             cur_entry_amount = order["amount"] | ||||
|             cur_entry_amount = order["filled"] or order["amount"] | ||||
|             cur_entry_average = order["safe_price"] | ||||
|             lines.append("  ") | ||||
|             if x == 0: | ||||
|                 lines.append(f"*Entry #{x+1}:*") | ||||
|                 lines.append(f"*{wording} #{x+1}:*") | ||||
|                 lines.append( | ||||
|                     f"*Entry Amount:* {cur_entry_amount} ({order['cost']:.8f} {quote_currency})") | ||||
|                 lines.append(f"*Average Entry Price:* {cur_entry_average}") | ||||
|                     f"*Amount:* {cur_entry_amount} ({order['cost']:.8f} {quote_currency})") | ||||
|                 lines.append(f"*Average Price:* {cur_entry_average}") | ||||
|             else: | ||||
|                 sumA = 0 | ||||
|                 sumB = 0 | ||||
|                 for y in range(x): | ||||
|                     sumA += (filled_orders[y]["amount"] * filled_orders[y]["safe_price"]) | ||||
|                     sumB += filled_orders[y]["amount"] | ||||
|                     amount = filled_orders[y]["filled"] or filled_orders[y]["amount"] | ||||
|                     sumA += amount * filled_orders[y]["safe_price"] | ||||
|                     sumB += amount | ||||
|                 prev_avg_price = sumA / sumB | ||||
|                 # TODO: This calculation ignores fees. | ||||
|                 price_to_1st_entry = ((cur_entry_average - first_avg) / first_avg) | ||||
|                 minus_on_entry = 0 | ||||
|                 if prev_avg_price: | ||||
|                     minus_on_entry = (cur_entry_average - prev_avg_price) / prev_avg_price | ||||
|  | ||||
|                 dur_entry = cur_entry_datetime - arrow.get( | ||||
|                     filled_orders[x - 1]["order_filled_date"]) | ||||
|                 days = dur_entry.days | ||||
|                 hours, remainder = divmod(dur_entry.seconds, 3600) | ||||
|                 minutes, seconds = divmod(remainder, 60) | ||||
|                 lines.append(f"*Entry #{x+1}:* at {minus_on_entry:.2%} avg profit") | ||||
|                 lines.append(f"*{wording} #{x+1}:* at {minus_on_entry:.2%} avg profit") | ||||
|                 if is_open: | ||||
|                     lines.append("({})".format(cur_entry_datetime | ||||
|                                                .humanize(granularity=["day", "hour", "minute"]))) | ||||
|                 lines.append( | ||||
|                     f"*Entry Amount:* {cur_entry_amount} ({order['cost']:.8f} {quote_currency})") | ||||
|                 lines.append(f"*Average Entry Price:* {cur_entry_average} " | ||||
|                     f"*Amount:* {cur_entry_amount} ({order['cost']:.8f} {quote_currency})") | ||||
|                 lines.append(f"*Average {wording} Price:* {cur_entry_average} " | ||||
|                              f"({price_to_1st_entry:.2%} from 1st entry rate)") | ||||
|                 lines.append(f"*Order filled at:* {order['order_filled_date']}") | ||||
|                 lines.append(f"({days}d {hours}h {minutes}m {seconds}s from previous entry)") | ||||
|                 lines.append(f"*Order filled:* {order['order_filled_date']}") | ||||
|  | ||||
|                 # TODO: is this really useful? | ||||
|                 # dur_entry = cur_entry_datetime - arrow.get( | ||||
|                 #     filled_orders[x - 1]["order_filled_date"]) | ||||
|                 # days = dur_entry.days | ||||
|                 # hours, remainder = divmod(dur_entry.seconds, 3600) | ||||
|                 # minutes, seconds = divmod(remainder, 60) | ||||
|                 # lines.append( | ||||
|                 # f"({days}d {hours}h {minutes}m {seconds}s from previous {wording.lower()})") | ||||
|         return lines | ||||
|  | ||||
|     @authorized_only | ||||
| @@ -486,7 +524,14 @@ class Telegram(RPCHandler): | ||||
|         if context.args and 'table' in context.args: | ||||
|             self._status_table(update, context) | ||||
|             return | ||||
|         else: | ||||
|             self._status_msg(update, context) | ||||
|  | ||||
|     def _status_msg(self, update: Update, context: CallbackContext) -> None: | ||||
|         """ | ||||
|         handler for `/status` and `/status <id>`. | ||||
|  | ||||
|         """ | ||||
|         try: | ||||
|  | ||||
|             # Check if there's at least one numerical ID provided. | ||||
| @@ -529,6 +574,8 @@ class Telegram(RPCHandler): | ||||
|                 ]) | ||||
|  | ||||
|                 if r['is_open']: | ||||
|                     if r.get('realized_profit'): | ||||
|                         lines.append("*Realized Profit:* `{realized_profit:.8f}`") | ||||
|                     if (r['stop_loss_abs'] != r['initial_stop_loss_abs'] | ||||
|                             and r['initial_stop_loss_ratio'] is not None): | ||||
|                         # Adding initial stoploss only if it is different from stoploss | ||||
| @@ -546,7 +593,7 @@ class Telegram(RPCHandler): | ||||
|                         else: | ||||
|                             lines.append("*Open Order:* `{open_order}`") | ||||
|  | ||||
|                 lines_detail = self._prepare_entry_details( | ||||
|                 lines_detail = self._prepare_order_details( | ||||
|                     r['orders'], r['quote_currency'], r['is_open']) | ||||
|                 lines.extend(lines_detail if lines_detail else "") | ||||
|  | ||||
|   | ||||
| @@ -463,10 +463,13 @@ class IStrategy(ABC, HyperStrategyMixin): | ||||
|     def adjust_trade_position(self, trade: Trade, current_time: datetime, | ||||
|                               current_rate: float, current_profit: float, | ||||
|                               min_stake: Optional[float], max_stake: float, | ||||
|                               current_entry_rate: float, current_exit_rate: float, | ||||
|                               current_entry_profit: float, current_exit_profit: float, | ||||
|                               **kwargs) -> Optional[float]: | ||||
|         """ | ||||
|         Custom trade adjustment logic, returning the stake amount that a trade should be increased. | ||||
|         This means extra buy orders with additional fees. | ||||
|         Custom trade adjustment logic, returning the stake amount that a trade should be | ||||
|         increased or decreased. | ||||
|         This means extra buy or sell orders with additional fees. | ||||
|         Only called when `position_adjustment_enable` is set to True. | ||||
|  | ||||
|         For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ | ||||
| @@ -477,10 +480,16 @@ class IStrategy(ABC, HyperStrategyMixin): | ||||
|         :param current_time: datetime object, containing the current datetime | ||||
|         :param current_rate: Current buy rate. | ||||
|         :param current_profit: Current profit (as ratio), calculated based on current_rate. | ||||
|         :param min_stake: Minimal stake size allowed by exchange. | ||||
|         :param max_stake: Balance available for trading. | ||||
|         :param min_stake: Minimal stake size allowed by exchange (for both entries and exits) | ||||
|         :param max_stake: Maximum stake allowed (either through balance, or by exchange limits). | ||||
|         :param current_entry_rate: Current rate using entry pricing. | ||||
|         :param current_exit_rate: Current rate using exit pricing. | ||||
|         :param current_entry_profit: Current profit using entry pricing. | ||||
|         :param current_exit_profit: Current profit using exit pricing. | ||||
|         :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. | ||||
|         :return float: Stake amount to adjust your trade | ||||
|         :return float: Stake amount to adjust your trade, | ||||
|                        Positive values to increase position, Negative values to decrease position. | ||||
|                        Return None for no action. | ||||
|         """ | ||||
|         return None | ||||
|  | ||||
|   | ||||
| @@ -247,12 +247,16 @@ def check_exit_timeout(self, pair: str, trade: 'Trade', order: 'Order', | ||||
|     """ | ||||
|     return False | ||||
|  | ||||
| def adjust_trade_position(self, trade: 'Trade', current_time: 'datetime', | ||||
|                           current_rate: float, current_profit: float, min_stake: Optional[float], | ||||
|                           max_stake: float, **kwargs) -> 'Optional[float]': | ||||
| def adjust_trade_position(self, trade: 'Trade', current_time: datetime, | ||||
|                           current_rate: float, current_profit: float, | ||||
|                           min_stake: Optional[float], max_stake: float, | ||||
|                           current_entry_rate: float, current_exit_rate: float, | ||||
|                           current_entry_profit: float, current_exit_profit: float, | ||||
|                           **kwargs) -> Optional[float]: | ||||
|     """ | ||||
|     Custom trade adjustment logic, returning the stake amount that a trade should be increased. | ||||
|     This means extra buy orders with additional fees. | ||||
|     Custom trade adjustment logic, returning the stake amount that a trade should be | ||||
|     increased or decreased. | ||||
|     This means extra buy or sell orders with additional fees. | ||||
|     Only called when `position_adjustment_enable` is set to True. | ||||
|  | ||||
|     For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ | ||||
| @@ -263,10 +267,16 @@ def adjust_trade_position(self, trade: 'Trade', current_time: 'datetime', | ||||
|     :param current_time: datetime object, containing the current datetime | ||||
|     :param current_rate: Current buy rate. | ||||
|     :param current_profit: Current profit (as ratio), calculated based on current_rate. | ||||
|     :param min_stake: Minimal stake size allowed by exchange. | ||||
|     :param max_stake: Balance available for trading. | ||||
|     :param min_stake: Minimal stake size allowed by exchange (for both entries and exits) | ||||
|     :param max_stake: Maximum stake allowed (either through balance, or by exchange limits). | ||||
|     :param current_entry_rate: Current rate using entry pricing. | ||||
|     :param current_exit_rate: Current rate using exit pricing. | ||||
|     :param current_entry_profit: Current profit using entry pricing. | ||||
|     :param current_exit_profit: Current profit using exit pricing. | ||||
|     :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. | ||||
|     :return float: Stake amount to adjust your trade | ||||
|     :return float: Stake amount to adjust your trade, | ||||
|                     Positive values to increase position, Negative values to decrease position. | ||||
|                     Return None for no action. | ||||
|     """ | ||||
|     return None | ||||
|  | ||||
|   | ||||
| @@ -1627,8 +1627,8 @@ def limit_buy_order_open(): | ||||
|         'timestamp': arrow.utcnow().int_timestamp * 1000, | ||||
|         'datetime': arrow.utcnow().isoformat(), | ||||
|         'price': 0.00001099, | ||||
|         'average': 0.00001099, | ||||
|         'amount': 90.99181073, | ||||
|         'average': None, | ||||
|         'filled': 0.0, | ||||
|         'cost': 0.0009999, | ||||
|         'remaining': 90.99181073, | ||||
| @@ -2817,6 +2817,7 @@ def limit_buy_order_usdt_open(): | ||||
|         'datetime': arrow.utcnow().isoformat(), | ||||
|         'timestamp': arrow.utcnow().int_timestamp * 1000, | ||||
|         'price': 2.00, | ||||
|         'average': 2.00, | ||||
|         'amount': 30.0, | ||||
|         'filled': 0.0, | ||||
|         'cost': 60.0, | ||||
|   | ||||
| @@ -27,6 +27,57 @@ from tests.conftest import get_mock_coro, get_patched_exchange, log_has, log_has | ||||
| # Make sure to always keep one exchange here which is NOT subclassed!! | ||||
| EXCHANGES = ['bittrex', 'binance', 'kraken', 'ftx', 'gateio'] | ||||
|  | ||||
| get_entry_rate_data = [ | ||||
|     ('other', 20, 19, 10, 0.0, 20),  # Full ask side | ||||
|     ('ask', 20, 19, 10, 0.0, 20),  # Full ask side | ||||
|     ('ask', 20, 19, 10, 1.0, 10),  # Full last side | ||||
|     ('ask', 20, 19, 10, 0.5, 15),  # Between ask and last | ||||
|     ('ask', 20, 19, 10, 0.7, 13),  # Between ask and last | ||||
|     ('ask', 20, 19, 10, 0.3, 17),  # Between ask and last | ||||
|     ('ask', 5, 6, 10, 1.0, 5),  # last bigger than ask | ||||
|     ('ask', 5, 6, 10, 0.5, 5),  # last bigger than ask | ||||
|     ('ask', 20, 19, 10, None, 20),  # price_last_balance missing | ||||
|     ('ask', 10, 20, None, 0.5, 10),  # last not available - uses ask | ||||
|     ('ask', 4, 5, None, 0.5, 4),  # last not available - uses ask | ||||
|     ('ask', 4, 5, None, 1, 4),  # last not available - uses ask | ||||
|     ('ask', 4, 5, None, 0, 4),  # last not available - uses ask | ||||
|     ('same', 21, 20, 10, 0.0, 20),  # Full bid side | ||||
|     ('bid', 21, 20, 10, 0.0, 20),  # Full bid side | ||||
|     ('bid', 21, 20, 10, 1.0, 10),  # Full last side | ||||
|     ('bid', 21, 20, 10, 0.5, 15),  # Between bid and last | ||||
|     ('bid', 21, 20, 10, 0.7, 13),  # Between bid and last | ||||
|     ('bid', 21, 20, 10, 0.3, 17),  # Between bid and last | ||||
|     ('bid', 6, 5, 10, 1.0, 5),  # last bigger than bid | ||||
|     ('bid', 21, 20, 10, None, 20),  # price_last_balance missing | ||||
|     ('bid', 6, 5, 10, 0.5, 5),  # last bigger than bid | ||||
|     ('bid', 21, 20, None, 0.5, 20),  # last not available - uses bid | ||||
|     ('bid', 6, 5, None, 0.5, 5),  # last not available - uses bid | ||||
|     ('bid', 6, 5, None, 1, 5),  # last not available - uses bid | ||||
|     ('bid', 6, 5, None, 0, 5),  # last not available - uses bid | ||||
| ] | ||||
|  | ||||
| get_sell_rate_data = [ | ||||
|     ('bid', 12.0, 11.0, 11.5, 0.0, 11.0),  # full bid side | ||||
|     ('bid', 12.0, 11.0, 11.5, 1.0, 11.5),  # full last side | ||||
|     ('bid', 12.0, 11.0, 11.5, 0.5, 11.25),  # between bid and lat | ||||
|     ('bid', 12.0, 11.2, 10.5, 0.0, 11.2),  # Last smaller than bid | ||||
|     ('bid', 12.0, 11.2, 10.5, 1.0, 11.2),  # Last smaller than bid - uses bid | ||||
|     ('bid', 12.0, 11.2, 10.5, 0.5, 11.2),  # Last smaller than bid - uses bid | ||||
|     ('bid', 0.003, 0.002, 0.005, 0.0, 0.002), | ||||
|     ('bid', 0.003, 0.002, 0.005, None, 0.002), | ||||
|     ('ask', 12.0, 11.0, 12.5, 0.0, 12.0),  # full ask side | ||||
|     ('ask', 12.0, 11.0, 12.5, 1.0, 12.5),  # full last side | ||||
|     ('ask', 12.0, 11.0, 12.5, 0.5, 12.25),  # between bid and lat | ||||
|     ('ask', 12.2, 11.2, 10.5, 0.0, 12.2),  # Last smaller than ask | ||||
|     ('ask', 12.0, 11.0, 10.5, 1.0, 12.0),  # Last smaller than ask - uses ask | ||||
|     ('ask', 12.0, 11.2, 10.5, 0.5, 12.0),  # Last smaller than ask - uses ask | ||||
|     ('ask', 10.0, 11.0, 11.0, 0.0, 10.0), | ||||
|     ('ask', 10.11, 11.2, 11.0, 0.0, 10.11), | ||||
|     ('ask', 0.001, 0.002, 11.0, 0.0, 0.001), | ||||
|     ('ask', 0.006, 1.0, 11.0, 0.0, 0.006), | ||||
|     ('ask', 0.006, 1.0, 11.0, None, 0.006), | ||||
| ] | ||||
|  | ||||
|  | ||||
| def ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name, | ||||
|                            fun, mock_ccxt_fun, retries=API_RETRY_COUNT + 1, **kwargs): | ||||
| @@ -2360,34 +2411,7 @@ def test_fetch_l2_order_book_exception(default_conf, mocker, exchange_name): | ||||
|         exchange.fetch_l2_order_book(pair='ETH/BTC', limit=50) | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize("side,ask,bid,last,last_ab,expected", [ | ||||
|     ('other', 20, 19, 10, 0.0, 20),  # Full ask side | ||||
|     ('ask', 20, 19, 10, 0.0, 20),  # Full ask side | ||||
|     ('ask', 20, 19, 10, 1.0, 10),  # Full last side | ||||
|     ('ask', 20, 19, 10, 0.5, 15),  # Between ask and last | ||||
|     ('ask', 20, 19, 10, 0.7, 13),  # Between ask and last | ||||
|     ('ask', 20, 19, 10, 0.3, 17),  # Between ask and last | ||||
|     ('ask', 5, 6, 10, 1.0, 5),  # last bigger than ask | ||||
|     ('ask', 5, 6, 10, 0.5, 5),  # last bigger than ask | ||||
|     ('ask', 20, 19, 10, None, 20),  # price_last_balance missing | ||||
|     ('ask', 10, 20, None, 0.5, 10),  # last not available - uses ask | ||||
|     ('ask', 4, 5, None, 0.5, 4),  # last not available - uses ask | ||||
|     ('ask', 4, 5, None, 1, 4),  # last not available - uses ask | ||||
|     ('ask', 4, 5, None, 0, 4),  # last not available - uses ask | ||||
|     ('same', 21, 20, 10, 0.0, 20),  # Full bid side | ||||
|     ('bid', 21, 20, 10, 0.0, 20),  # Full bid side | ||||
|     ('bid', 21, 20, 10, 1.0, 10),  # Full last side | ||||
|     ('bid', 21, 20, 10, 0.5, 15),  # Between bid and last | ||||
|     ('bid', 21, 20, 10, 0.7, 13),  # Between bid and last | ||||
|     ('bid', 21, 20, 10, 0.3, 17),  # Between bid and last | ||||
|     ('bid', 6, 5, 10, 1.0, 5),  # last bigger than bid | ||||
|     ('bid', 21, 20, 10, None, 20),  # price_last_balance missing | ||||
|     ('bid', 6, 5, 10, 0.5, 5),  # last bigger than bid | ||||
|     ('bid', 21, 20, None, 0.5, 20),  # last not available - uses bid | ||||
|     ('bid', 6, 5, None, 0.5, 5),  # last not available - uses bid | ||||
|     ('bid', 6, 5, None, 1, 5),  # last not available - uses bid | ||||
|     ('bid', 6, 5, None, 0, 5),  # last not available - uses bid | ||||
| ]) | ||||
| @pytest.mark.parametrize("side,ask,bid,last,last_ab,expected", get_entry_rate_data) | ||||
| def test_get_entry_rate(mocker, default_conf, caplog, side, ask, bid, | ||||
|                         last, last_ab, expected) -> None: | ||||
|     caplog.set_level(logging.DEBUG) | ||||
| @@ -2411,27 +2435,7 @@ def test_get_entry_rate(mocker, default_conf, caplog, side, ask, bid, | ||||
|     assert not log_has("Using cached entry rate for ETH/BTC.", caplog) | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize('side,ask,bid,last,last_ab,expected', [ | ||||
|     ('bid', 12.0, 11.0, 11.5, 0.0, 11.0),  # full bid side | ||||
|     ('bid', 12.0, 11.0, 11.5, 1.0, 11.5),  # full last side | ||||
|     ('bid', 12.0, 11.0, 11.5, 0.5, 11.25),  # between bid and lat | ||||
|     ('bid', 12.0, 11.2, 10.5, 0.0, 11.2),  # Last smaller than bid | ||||
|     ('bid', 12.0, 11.2, 10.5, 1.0, 11.2),  # Last smaller than bid - uses bid | ||||
|     ('bid', 12.0, 11.2, 10.5, 0.5, 11.2),  # Last smaller than bid - uses bid | ||||
|     ('bid', 0.003, 0.002, 0.005, 0.0, 0.002), | ||||
|     ('bid', 0.003, 0.002, 0.005, None, 0.002), | ||||
|     ('ask', 12.0, 11.0, 12.5, 0.0, 12.0),  # full ask side | ||||
|     ('ask', 12.0, 11.0, 12.5, 1.0, 12.5),  # full last side | ||||
|     ('ask', 12.0, 11.0, 12.5, 0.5, 12.25),  # between bid and lat | ||||
|     ('ask', 12.2, 11.2, 10.5, 0.0, 12.2),  # Last smaller than ask | ||||
|     ('ask', 12.0, 11.0, 10.5, 1.0, 12.0),  # Last smaller than ask - uses ask | ||||
|     ('ask', 12.0, 11.2, 10.5, 0.5, 12.0),  # Last smaller than ask - uses ask | ||||
|     ('ask', 10.0, 11.0, 11.0, 0.0, 10.0), | ||||
|     ('ask', 10.11, 11.2, 11.0, 0.0, 10.11), | ||||
|     ('ask', 0.001, 0.002, 11.0, 0.0, 0.001), | ||||
|     ('ask', 0.006, 1.0, 11.0, 0.0, 0.006), | ||||
|     ('ask', 0.006, 1.0, 11.0, None, 0.006), | ||||
| ]) | ||||
| @pytest.mark.parametrize('side,ask,bid,last,last_ab,expected', get_sell_rate_data) | ||||
| def test_get_exit_rate(default_conf, mocker, caplog, side, bid, ask, | ||||
|                        last, last_ab, expected) -> None: | ||||
|     caplog.set_level(logging.DEBUG) | ||||
| @@ -2481,14 +2485,14 @@ def test_get_ticker_rate_error(mocker, entry, default_conf, caplog, side, is_sho | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize('is_short,side,expected', [ | ||||
|     (False, 'bid', 0.043936),  # Value from order_book_l2 fitxure - bids side | ||||
|     (False, 'ask', 0.043949),  # Value from order_book_l2 fitxure - asks side | ||||
|     (False, 'other', 0.043936),  # Value from order_book_l2 fitxure - bids side | ||||
|     (False, 'same', 0.043949),  # Value from order_book_l2 fitxure - asks side | ||||
|     (True, 'bid', 0.043936),  # Value from order_book_l2 fitxure - bids side | ||||
|     (True, 'ask', 0.043949),  # Value from order_book_l2 fitxure - asks side | ||||
|     (True, 'other', 0.043949),  # Value from order_book_l2 fitxure - asks side | ||||
|     (True, 'same', 0.043936),  # Value from order_book_l2 fitxure - bids side | ||||
|     (False, 'bid', 0.043936),  # Value from order_book_l2 fixture - bids side | ||||
|     (False, 'ask', 0.043949),  # Value from order_book_l2 fixture - asks side | ||||
|     (False, 'other', 0.043936),  # Value from order_book_l2 fixture - bids side | ||||
|     (False, 'same', 0.043949),  # Value from order_book_l2 fixture - asks side | ||||
|     (True, 'bid', 0.043936),  # Value from order_book_l2 fixture - bids side | ||||
|     (True, 'ask', 0.043949),  # Value from order_book_l2 fixture - asks side | ||||
|     (True, 'other', 0.043949),  # Value from order_book_l2 fixture - asks side | ||||
|     (True, 'same', 0.043936),  # Value from order_book_l2 fixture - bids side | ||||
| ]) | ||||
| def test_get_exit_rate_orderbook( | ||||
|         default_conf, mocker, caplog, is_short, side, expected, order_book_l2): | ||||
| @@ -2521,7 +2525,8 @@ def test_get_exit_rate_orderbook_exception(default_conf, mocker, caplog): | ||||
|     exchange = get_patched_exchange(mocker, default_conf) | ||||
|     with pytest.raises(PricingError): | ||||
|         exchange.get_rate(pair, refresh=True, side="exit", is_short=False) | ||||
|     assert log_has_re(r"Exit Price at location 1 from orderbook could not be determined\..*", | ||||
|     assert log_has_re(rf"{pair} - Exit Price at location 1 from orderbook " | ||||
|                       rf"could not be determined\..*", | ||||
|                       caplog) | ||||
|  | ||||
|  | ||||
| @@ -2548,6 +2553,84 @@ def test_get_exit_rate_exception(default_conf, mocker, is_short): | ||||
|     assert exchange.get_rate(pair, refresh=True, side="exit", is_short=is_short) == 0.13 | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize("side,ask,bid,last,last_ab,expected", get_entry_rate_data) | ||||
| @pytest.mark.parametrize("side2", ['bid', 'ask']) | ||||
| @pytest.mark.parametrize("use_order_book", [True, False]) | ||||
| def test_get_rates_testing_buy(mocker, default_conf, caplog, side, ask, bid, | ||||
|                                last, last_ab, expected, | ||||
|                                side2, use_order_book, order_book_l2) -> None: | ||||
|     caplog.set_level(logging.DEBUG) | ||||
|     if last_ab is None: | ||||
|         del default_conf['entry_pricing']['price_last_balance'] | ||||
|     else: | ||||
|         default_conf['entry_pricing']['price_last_balance'] = last_ab | ||||
|     default_conf['entry_pricing']['price_side'] = side | ||||
|     default_conf['exit_pricing']['price_side'] = side2 | ||||
|     default_conf['exit_pricing']['use_order_book'] = use_order_book | ||||
|     api_mock = MagicMock() | ||||
|     api_mock.fetch_l2_order_book = order_book_l2 | ||||
|     api_mock.fetch_ticker = MagicMock( | ||||
|         return_value={'ask': ask, 'last': last, 'bid': bid}) | ||||
|     exchange = get_patched_exchange(mocker, default_conf, api_mock) | ||||
|  | ||||
|     assert exchange.get_rates('ETH/BTC', refresh=True, is_short=False)[0] == expected | ||||
|     assert not log_has("Using cached buy rate for ETH/BTC.", caplog) | ||||
|  | ||||
|     api_mock.fetch_l2_order_book.reset_mock() | ||||
|     api_mock.fetch_ticker.reset_mock() | ||||
|     assert exchange.get_rates('ETH/BTC', refresh=False, is_short=False)[0] == expected | ||||
|     assert log_has("Using cached buy rate for ETH/BTC.", caplog) | ||||
|     assert api_mock.fetch_l2_order_book.call_count == 0 | ||||
|     assert api_mock.fetch_ticker.call_count == 0 | ||||
|     # Running a 2nd time with Refresh on! | ||||
|     caplog.clear() | ||||
|  | ||||
|     assert exchange.get_rates('ETH/BTC', refresh=True, is_short=False)[0] == expected | ||||
|     assert not log_has("Using cached buy rate for ETH/BTC.", caplog) | ||||
|  | ||||
|     assert api_mock.fetch_l2_order_book.call_count == int(use_order_book) | ||||
|     assert api_mock.fetch_ticker.call_count == 1 | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize('side,ask,bid,last,last_ab,expected', get_sell_rate_data) | ||||
| @pytest.mark.parametrize("side2", ['bid', 'ask']) | ||||
| @pytest.mark.parametrize("use_order_book", [True, False]) | ||||
| def test_get_rates_testing_sell(default_conf, mocker, caplog, side, bid, ask, | ||||
|                                 last, last_ab, expected, | ||||
|                                 side2, use_order_book, order_book_l2) -> None: | ||||
|     caplog.set_level(logging.DEBUG) | ||||
|  | ||||
|     default_conf['exit_pricing']['price_side'] = side | ||||
|     if last_ab is not None: | ||||
|         default_conf['exit_pricing']['price_last_balance'] = last_ab | ||||
|  | ||||
|     default_conf['entry_pricing']['price_side'] = side2 | ||||
|     default_conf['entry_pricing']['use_order_book'] = use_order_book | ||||
|     api_mock = MagicMock() | ||||
|     api_mock.fetch_l2_order_book = order_book_l2 | ||||
|     api_mock.fetch_ticker = MagicMock( | ||||
|                 return_value={'ask': ask, 'last': last, 'bid': bid}) | ||||
|     exchange = get_patched_exchange(mocker, default_conf, api_mock) | ||||
|  | ||||
|     pair = "ETH/BTC" | ||||
|  | ||||
|     # Test regular mode | ||||
|     rate = exchange.get_rates(pair, refresh=True, is_short=False)[1] | ||||
|     assert not log_has("Using cached sell rate for ETH/BTC.", caplog) | ||||
|     assert isinstance(rate, float) | ||||
|     assert rate == expected | ||||
|     # Use caching | ||||
|     api_mock.fetch_l2_order_book.reset_mock() | ||||
|     api_mock.fetch_ticker.reset_mock() | ||||
|  | ||||
|     rate = exchange.get_rates(pair, refresh=False, is_short=False)[1] | ||||
|     assert rate == expected | ||||
|     assert log_has("Using cached sell rate for ETH/BTC.", caplog) | ||||
|  | ||||
|     assert api_mock.fetch_l2_order_book.call_count == 0 | ||||
|     assert api_mock.fetch_ticker.call_count == 0 | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize("exchange_name", EXCHANGES) | ||||
| @pytest.mark.asyncio | ||||
| async def test___async_get_candle_history_sort(default_conf, mocker, exchange_name): | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| # pragma pylint: disable=missing-docstring, W0212, line-too-long, C0103, unused-argument | ||||
|  | ||||
| from copy import deepcopy | ||||
| from unittest.mock import MagicMock | ||||
|  | ||||
| import pandas as pd | ||||
| import pytest | ||||
| from arrow import Arrow | ||||
|  | ||||
| from freqtrade.configuration import TimeRange | ||||
| @@ -87,3 +89,87 @@ def test_backtest_position_adjustment(default_conf, fee, mocker, testdatadir) -> | ||||
|         assert (round(ln.iloc[0]["open"], 6) == round(t["close_rate"], 6) or | ||||
|                 round(ln.iloc[0]["low"], 6) < round( | ||||
|                 t["close_rate"], 6) < round(ln.iloc[0]["high"], 6)) | ||||
|  | ||||
|  | ||||
| def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> None: | ||||
|     default_conf['use_exit_signal'] = False | ||||
|     mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) | ||||
|     mocker.patch("freqtrade.exchange.Exchange.get_min_pair_stake_amount", return_value=10) | ||||
|     mocker.patch("freqtrade.exchange.Exchange.get_max_pair_stake_amount", return_value=float('inf')) | ||||
|     patch_exchange(mocker) | ||||
|     default_conf.update({ | ||||
|         "stake_amount": 100.0, | ||||
|         "dry_run_wallet": 1000.0, | ||||
|         "strategy": "StrategyTestV3" | ||||
|     }) | ||||
|     backtesting = Backtesting(default_conf) | ||||
|     backtesting._set_strategy(backtesting.strategylist[0]) | ||||
|     pair = 'XRP/USDT' | ||||
|     row = [ | ||||
|             pd.Timestamp(year=2020, month=1, day=1, hour=5, minute=0), | ||||
|             2.1,  # Open | ||||
|             2.2,  # High | ||||
|             1.9,  # Low | ||||
|             2.1,  # Close | ||||
|             1,  # enter_long | ||||
|             0,  # exit_long | ||||
|             0,  # enter_short | ||||
|             0,  # exit_short | ||||
|             '',  # enter_tag | ||||
|             '',  # exit_tag | ||||
|             ] | ||||
|     trade = backtesting._enter_trade(pair, row=row, direction='long') | ||||
|     trade.orders[0].close_bt_order(row[0], trade) | ||||
|     assert trade | ||||
|     assert pytest.approx(trade.stake_amount) == 100.0 | ||||
|     assert pytest.approx(trade.amount) == 47.61904762 | ||||
|     assert len(trade.orders) == 1 | ||||
|     backtesting.strategy.adjust_trade_position = MagicMock(return_value=None) | ||||
|  | ||||
|     trade = backtesting._get_adjust_trade_entry_for_candle(trade, row) | ||||
|     assert trade | ||||
|     assert pytest.approx(trade.stake_amount) == 100.0 | ||||
|     assert pytest.approx(trade.amount) == 47.61904762 | ||||
|     assert len(trade.orders) == 1 | ||||
|     # Increase position by 100 | ||||
|     backtesting.strategy.adjust_trade_position = MagicMock(return_value=100) | ||||
|  | ||||
|     trade = backtesting._get_adjust_trade_entry_for_candle(trade, row) | ||||
|  | ||||
|     assert trade | ||||
|     assert pytest.approx(trade.stake_amount) == 200.0 | ||||
|     assert pytest.approx(trade.amount) == 95.23809524 | ||||
|     assert len(trade.orders) == 2 | ||||
|  | ||||
|     # Reduce by more than amount - no change to trade. | ||||
|     backtesting.strategy.adjust_trade_position = MagicMock(return_value=-500) | ||||
|  | ||||
|     trade = backtesting._get_adjust_trade_entry_for_candle(trade, row) | ||||
|  | ||||
|     assert trade | ||||
|     assert pytest.approx(trade.stake_amount) == 200.0 | ||||
|     assert pytest.approx(trade.amount) == 95.23809524 | ||||
|     assert len(trade.orders) == 2 | ||||
|     assert trade.nr_of_successful_entries == 2 | ||||
|  | ||||
|     # Reduce position by 50 | ||||
|     backtesting.strategy.adjust_trade_position = MagicMock(return_value=-100) | ||||
|     trade = backtesting._get_adjust_trade_entry_for_candle(trade, row) | ||||
|  | ||||
|     assert trade | ||||
|     assert pytest.approx(trade.stake_amount) == 100.0 | ||||
|     assert pytest.approx(trade.amount) == 47.61904762 | ||||
|     assert len(trade.orders) == 3 | ||||
|     assert trade.nr_of_successful_entries == 2 | ||||
|     assert trade.nr_of_successful_exits == 1 | ||||
|  | ||||
|     # Adjust below minimum | ||||
|     backtesting.strategy.adjust_trade_position = MagicMock(return_value=-99) | ||||
|     trade = backtesting._get_adjust_trade_entry_for_candle(trade, row) | ||||
|  | ||||
|     assert trade | ||||
|     assert pytest.approx(trade.stake_amount) == 100.0 | ||||
|     assert pytest.approx(trade.amount) == 47.61904762 | ||||
|     assert len(trade.orders) == 3 | ||||
|     assert trade.nr_of_successful_entries == 2 | ||||
|     assert trade.nr_of_successful_exits == 1 | ||||
|   | ||||
| @@ -111,6 +111,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: | ||||
|         'stoploss_entry_dist': -0.00010475, | ||||
|         'stoploss_entry_dist_ratio': -0.10448878, | ||||
|         'open_order': None, | ||||
|         'realized_profit': 0.0, | ||||
|         'exchange': 'binance', | ||||
|         'leverage': 1.0, | ||||
|         'interest_rate': 0.0, | ||||
| @@ -196,6 +197,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: | ||||
|         'stoploss_entry_dist_ratio': -0.10448878, | ||||
|         'open_order': None, | ||||
|         'exchange': 'binance', | ||||
|         'realized_profit': 0.0, | ||||
|         'leverage': 1.0, | ||||
|         'interest_rate': 0.0, | ||||
|         'liquidation_price': None, | ||||
| @@ -841,7 +843,8 @@ def test_rpc_force_exit(default_conf, ticker, fee, mocker) -> None: | ||||
|             'side': 'sell', | ||||
|             'amount': amount, | ||||
|             'remaining': amount, | ||||
|             'filled': 0.0 | ||||
|             'filled': 0.0, | ||||
|             'id': trade.orders[0].order_id, | ||||
|         } | ||||
|     ) | ||||
|     msg = rpc._rpc_force_exit('3') | ||||
|   | ||||
| @@ -272,7 +272,7 @@ def test_telegram_status_multi_entry(default_conf, update, mocker, fee) -> None: | ||||
|     msg = msg_mock.call_args_list[0][0][0] | ||||
|     assert re.search(r'Number of Entries.*2', msg) | ||||
|     assert re.search(r'Average Entry Price', msg) | ||||
|     assert re.search(r'Order filled at', msg) | ||||
|     assert re.search(r'Order filled', msg) | ||||
|     assert re.search(r'Close Date:', msg) is None | ||||
|     assert re.search(r'Close Profit:', msg) is None | ||||
|  | ||||
| @@ -959,6 +959,9 @@ def test_telegram_forceexit_handle(default_conf, update, ticker, fee, | ||||
|         'open_date': ANY, | ||||
|         'close_date': ANY, | ||||
|         'close_rate': ANY, | ||||
|         'stake_amount': 0.0009999999999054, | ||||
|         'sub_trade': False, | ||||
|         'cumulative_profit': 0.0, | ||||
|     } == last_msg | ||||
|  | ||||
|  | ||||
| @@ -1028,6 +1031,9 @@ def test_telegram_force_exit_down_handle(default_conf, update, ticker, fee, | ||||
|         'open_date': ANY, | ||||
|         'close_date': ANY, | ||||
|         'close_rate': ANY, | ||||
|         'stake_amount': 0.0009999999999054, | ||||
|         'sub_trade': False, | ||||
|         'cumulative_profit': 0.0, | ||||
|     } == last_msg | ||||
|  | ||||
|  | ||||
| @@ -1087,6 +1093,9 @@ def test_forceexit_all_handle(default_conf, update, ticker, fee, mocker) -> None | ||||
|         'open_date': ANY, | ||||
|         'close_date': ANY, | ||||
|         'close_rate': ANY, | ||||
|         'stake_amount': 0.0009999999999054, | ||||
|         'sub_trade': False, | ||||
|         'cumulative_profit': 0.0, | ||||
|     } == msg | ||||
|  | ||||
|  | ||||
| @@ -1789,7 +1798,6 @@ def test_send_msg_entry_fill_notification(default_conf, mocker, message_type, en | ||||
|         'leverage': leverage, | ||||
|         'stake_amount': 0.01465333, | ||||
|         'direction': entered, | ||||
|         # 'stake_amount_fiat': 0.0, | ||||
|         'stake_currency': 'BTC', | ||||
|         'fiat_currency': 'USD', | ||||
|         'open_rate': 1.099e-05, | ||||
| @@ -1806,6 +1814,33 @@ def test_send_msg_entry_fill_notification(default_conf, mocker, message_type, en | ||||
|         '*Total:* `(0.01465333 BTC, 180.895 USD)`' | ||||
|     ) | ||||
|  | ||||
|     msg_mock.reset_mock() | ||||
|     telegram.send_msg({ | ||||
|         'type': message_type, | ||||
|         'trade_id': 1, | ||||
|         'enter_tag': enter_signal, | ||||
|         'exchange': 'Binance', | ||||
|         'pair': 'ETH/BTC', | ||||
|         'leverage': leverage, | ||||
|         'stake_amount': 0.01465333, | ||||
|         'sub_trade': True, | ||||
|         'direction': entered, | ||||
|         'stake_currency': 'BTC', | ||||
|         'fiat_currency': 'USD', | ||||
|         'open_rate': 1.099e-05, | ||||
|         'amount': 1333.3333333333335, | ||||
|         'open_date': arrow.utcnow().shift(hours=-1) | ||||
|     }) | ||||
|  | ||||
|     assert msg_mock.call_args[0][0] == ( | ||||
|         f'\N{CHECK MARK} *Binance (dry):* {entered}ed ETH/BTC (#1)\n' | ||||
|         f'*Enter Tag:* `{enter_signal}`\n' | ||||
|         '*Amount:* `1333.33333333`\n' | ||||
|         f"{leverage_text}" | ||||
|         '*Open Rate:* `0.00001099`\n' | ||||
|         '*Total:* `(0.01465333 BTC, 180.895 USD)`' | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def test_send_msg_sell_notification(default_conf, mocker) -> None: | ||||
|  | ||||
| @@ -1840,12 +1875,51 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: | ||||
|         '*Unrealized Profit:* `-57.41% (loss: -0.05746268 ETH / -24.812 USD)`\n' | ||||
|         '*Enter Tag:* `buy_signal1`\n' | ||||
|         '*Exit Reason:* `stop_loss`\n' | ||||
|         '*Duration:* `1:00:00 (60.0 min)`\n' | ||||
|         '*Direction:* `Long`\n' | ||||
|         '*Amount:* `1333.33333333`\n' | ||||
|         '*Open Rate:* `0.00007500`\n' | ||||
|         '*Current Rate:* `0.00003201`\n' | ||||
|         '*Close Rate:* `0.00003201`' | ||||
|         '*Exit Rate:* `0.00003201`\n' | ||||
|         '*Duration:* `1:00:00 (60.0 min)`' | ||||
|     ) | ||||
|  | ||||
|     msg_mock.reset_mock() | ||||
|     telegram.send_msg({ | ||||
|         'type': RPCMessageType.EXIT, | ||||
|         'trade_id': 1, | ||||
|         'exchange': 'Binance', | ||||
|         'pair': 'KEY/ETH', | ||||
|         'direction': 'Long', | ||||
|         'gain': 'loss', | ||||
|         'limit': 3.201e-05, | ||||
|         'amount': 1333.3333333333335, | ||||
|         'order_type': 'market', | ||||
|         'open_rate': 7.5e-05, | ||||
|         'current_rate': 3.201e-05, | ||||
|         'cumulative_profit': -0.15746268, | ||||
|         'profit_amount': -0.05746268, | ||||
|         'profit_ratio': -0.57405275, | ||||
|         'stake_currency': 'ETH', | ||||
|         'fiat_currency': 'USD', | ||||
|         'enter_tag': 'buy_signal1', | ||||
|         'exit_reason': ExitType.STOP_LOSS.value, | ||||
|         'open_date': arrow.utcnow().shift(days=-1, hours=-2, minutes=-30), | ||||
|         'close_date': arrow.utcnow(), | ||||
|         'stake_amount': 0.01, | ||||
|         'sub_trade': True, | ||||
|     }) | ||||
|     assert msg_mock.call_args[0][0] == ( | ||||
|         '\N{WARNING SIGN} *Binance (dry):* Exiting KEY/ETH (#1)\n' | ||||
|         '*Unrealized Sub Profit:* `-57.41% (loss: -0.05746268 ETH / -24.812 USD)`\n' | ||||
|         '*Cumulative Profit:* (`-0.15746268 ETH / -24.812 USD`)\n' | ||||
|         '*Enter Tag:* `buy_signal1`\n' | ||||
|         '*Exit Reason:* `stop_loss`\n' | ||||
|         '*Direction:* `Long`\n' | ||||
|         '*Amount:* `1333.33333333`\n' | ||||
|         '*Open Rate:* `0.00007500`\n' | ||||
|         '*Current Rate:* `0.00003201`\n' | ||||
|         '*Exit Rate:* `0.00003201`\n' | ||||
|         '*Remaining:* `(0.01 ETH, -24.812 USD)`' | ||||
|         ) | ||||
|  | ||||
|     msg_mock.reset_mock() | ||||
| @@ -1871,15 +1945,15 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: | ||||
|     }) | ||||
|     assert msg_mock.call_args[0][0] == ( | ||||
|         '\N{WARNING SIGN} *Binance (dry):* Exiting KEY/ETH (#1)\n' | ||||
|         '*Unrealized Profit:* `-57.41%`\n' | ||||
|         '*Unrealized Profit:* `-57.41% (loss: -0.05746268 ETH)`\n' | ||||
|         '*Enter Tag:* `buy_signal1`\n' | ||||
|         '*Exit Reason:* `stop_loss`\n' | ||||
|         '*Duration:* `1 day, 2:30:00 (1590.0 min)`\n' | ||||
|         '*Direction:* `Long`\n' | ||||
|         '*Amount:* `1333.33333333`\n' | ||||
|         '*Open Rate:* `0.00007500`\n' | ||||
|         '*Current Rate:* `0.00003201`\n' | ||||
|         '*Close Rate:* `0.00003201`' | ||||
|         '*Exit Rate:* `0.00003201`\n' | ||||
|         '*Duration:* `1 day, 2:30:00 (1590.0 min)`' | ||||
|     ) | ||||
|     # Reset singleton function to avoid random breaks | ||||
|     telegram._rpc._fiat_converter.convert_amount = old_convamount | ||||
| @@ -1954,15 +2028,15 @@ def test_send_msg_sell_fill_notification(default_conf, mocker, direction, | ||||
|     leverage_text = f'*Leverage:* `{leverage}`\n' if leverage and leverage != 1.0 else '' | ||||
|     assert msg_mock.call_args[0][0] == ( | ||||
|         '\N{WARNING SIGN} *Binance (dry):* Exited KEY/ETH (#1)\n' | ||||
|         '*Profit:* `-57.41%`\n' | ||||
|         '*Profit:* `-57.41% (loss: -0.05746268 ETH)`\n' | ||||
|         f'*Enter Tag:* `{enter_signal}`\n' | ||||
|         '*Exit Reason:* `stop_loss`\n' | ||||
|         '*Duration:* `1 day, 2:30:00 (1590.0 min)`\n' | ||||
|         f"*Direction:* `{direction}`\n" | ||||
|         f"{leverage_text}" | ||||
|         '*Amount:* `1333.33333333`\n' | ||||
|         '*Open Rate:* `0.00007500`\n' | ||||
|         '*Close Rate:* `0.00003201`' | ||||
|         '*Exit Rate:* `0.00003201`\n' | ||||
|         '*Duration:* `1 day, 2:30:00 (1590.0 min)`' | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @@ -2090,16 +2164,16 @@ def test_send_msg_sell_notification_no_fiat( | ||||
|     leverage_text = f'*Leverage:* `{leverage}`\n' if leverage and leverage != 1.0 else '' | ||||
|     assert msg_mock.call_args[0][0] == ( | ||||
|         '\N{WARNING SIGN} *Binance (dry):* Exiting KEY/ETH (#1)\n' | ||||
|         '*Unrealized Profit:* `-57.41%`\n' | ||||
|         '*Unrealized Profit:* `-57.41% (loss: -0.05746268 ETH)`\n' | ||||
|         f'*Enter Tag:* `{enter_signal}`\n' | ||||
|         '*Exit Reason:* `stop_loss`\n' | ||||
|         '*Duration:* `2:35:03 (155.1 min)`\n' | ||||
|         f'*Direction:* `{direction}`\n' | ||||
|         f'{leverage_text}' | ||||
|         '*Amount:* `1333.33333333`\n' | ||||
|         '*Open Rate:* `0.00007500`\n' | ||||
|         '*Current Rate:* `0.00003201`\n' | ||||
|         '*Close Rate:* `0.00003201`' | ||||
|         '*Exit Rate:* `0.00003201`\n' | ||||
|         '*Duration:* `2:35:03 (155.1 min)`' | ||||
|     ) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -185,9 +185,12 @@ class StrategyTestV3(IStrategy): | ||||
|  | ||||
|         return 3.0 | ||||
|  | ||||
|     def adjust_trade_position(self, trade: Trade, current_time: datetime, current_rate: float, | ||||
|                               current_profit: float, | ||||
|                               min_stake: Optional[float], max_stake: float, **kwargs): | ||||
|     def adjust_trade_position(self, trade: Trade, current_time: datetime, | ||||
|                               current_rate: float, current_profit: float, | ||||
|                               min_stake: Optional[float], max_stake: float, | ||||
|                               current_entry_rate: float, current_exit_rate: float, | ||||
|                               current_entry_profit: float, current_exit_profit: float, | ||||
|                               **kwargs) -> Optional[float]: | ||||
|  | ||||
|         if current_profit < -0.0075: | ||||
|             orders = trade.select_filled_orders(trade.entry_side) | ||||
|   | ||||
| @@ -843,8 +843,8 @@ def test_execute_entry(mocker, default_conf_usdt, fee, limit_order, | ||||
|  | ||||
|     # In case of closed order | ||||
|     order['status'] = 'closed' | ||||
|     order['price'] = 10 | ||||
|     order['cost'] = 100 | ||||
|     order['average'] = 10 | ||||
|     order['cost'] = 300 | ||||
|     order['id'] = '444' | ||||
|  | ||||
|     mocker.patch('freqtrade.exchange.Exchange.create_order', | ||||
| @@ -855,7 +855,7 @@ def test_execute_entry(mocker, default_conf_usdt, fee, limit_order, | ||||
|     assert trade | ||||
|     assert trade.open_order_id is None | ||||
|     assert trade.open_rate == 10 | ||||
|     assert trade.stake_amount == round(order['price'] * order['filled'] / leverage, 8) | ||||
|     assert trade.stake_amount == round(order['average'] * order['filled'] / leverage, 8) | ||||
|     assert pytest.approx(trade.liquidation_price) == liq_price | ||||
|  | ||||
|     # In case of rejected or expired order and partially filled | ||||
| @@ -863,8 +863,8 @@ def test_execute_entry(mocker, default_conf_usdt, fee, limit_order, | ||||
|     order['amount'] = 30.0 | ||||
|     order['filled'] = 20.0 | ||||
|     order['remaining'] = 10.00 | ||||
|     order['price'] = 0.5 | ||||
|     order['cost'] = 15.0 | ||||
|     order['average'] = 0.5 | ||||
|     order['cost'] = 10.0 | ||||
|     order['id'] = '555' | ||||
|     mocker.patch('freqtrade.exchange.Exchange.create_order', | ||||
|                  MagicMock(return_value=order)) | ||||
| @@ -872,9 +872,9 @@ def test_execute_entry(mocker, default_conf_usdt, fee, limit_order, | ||||
|     trade = Trade.query.all()[3] | ||||
|     trade.is_short = is_short | ||||
|     assert trade | ||||
|     assert trade.open_order_id == '555' | ||||
|     assert trade.open_order_id is None | ||||
|     assert trade.open_rate == 0.5 | ||||
|     assert trade.stake_amount == round(order['price'] * order['filled'] / leverage, 8) | ||||
|     assert trade.stake_amount == round(order['average'] * order['filled'] / leverage, 8) | ||||
|  | ||||
|     # Test with custom stake | ||||
|     order['status'] = 'open' | ||||
| @@ -901,7 +901,7 @@ def test_execute_entry(mocker, default_conf_usdt, fee, limit_order, | ||||
|     order['amount'] = 30.0 * leverage | ||||
|     order['filled'] = 0.0 | ||||
|     order['remaining'] = 30.0 | ||||
|     order['price'] = 0.5 | ||||
|     order['average'] = 0.5 | ||||
|     order['cost'] = 0.0 | ||||
|     order['id'] = '66' | ||||
|     mocker.patch('freqtrade.exchange.Exchange.create_order', | ||||
| @@ -1083,7 +1083,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_ | ||||
|             'last': 1.9 | ||||
|         }), | ||||
|         create_order=MagicMock(side_effect=[ | ||||
|             {'id': enter_order['id']}, | ||||
|             enter_order, | ||||
|             exit_order, | ||||
|         ]), | ||||
|         get_fee=fee, | ||||
| @@ -1109,20 +1109,20 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_ | ||||
|     # should do nothing and return false | ||||
|     trade.is_open = True | ||||
|     trade.open_order_id = None | ||||
|     trade.stoploss_order_id = 100 | ||||
|     trade.stoploss_order_id = "100" | ||||
|  | ||||
|     hanging_stoploss_order = MagicMock(return_value={'status': 'open'}) | ||||
|     mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', hanging_stoploss_order) | ||||
|  | ||||
|     assert freqtrade.handle_stoploss_on_exchange(trade) is False | ||||
|     assert trade.stoploss_order_id == 100 | ||||
|     assert trade.stoploss_order_id == "100" | ||||
|  | ||||
|     # Third case: when stoploss was set but it was canceled for some reason | ||||
|     # should set a stoploss immediately and return False | ||||
|     caplog.clear() | ||||
|     trade.is_open = True | ||||
|     trade.open_order_id = None | ||||
|     trade.stoploss_order_id = 100 | ||||
|     trade.stoploss_order_id = "100" | ||||
|  | ||||
|     canceled_stoploss_order = MagicMock(return_value={'status': 'canceled'}) | ||||
|     mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', canceled_stoploss_order) | ||||
| @@ -2039,6 +2039,7 @@ def test_update_trade_state_exception(mocker, default_conf_usdt, is_short, limit | ||||
|  | ||||
|     trade = MagicMock() | ||||
|     trade.open_order_id = '123' | ||||
|     trade.amount = 123 | ||||
|  | ||||
|     # Test raise of OperationalException exception | ||||
|     mocker.patch( | ||||
| @@ -2352,9 +2353,9 @@ def test_close_trade( | ||||
|     trade.is_short = is_short | ||||
|     assert trade | ||||
|  | ||||
|     oobj = Order.parse_from_ccxt_object(enter_order, enter_order['symbol'], 'buy') | ||||
|     oobj = Order.parse_from_ccxt_object(enter_order, enter_order['symbol'], trade.enter_side) | ||||
|     trade.update_trade(oobj) | ||||
|     oobj = Order.parse_from_ccxt_object(exit_order, exit_order['symbol'], 'sell') | ||||
|     oobj = Order.parse_from_ccxt_object(exit_order, exit_order['symbol'], trade.exit_side) | ||||
|     trade.update_trade(oobj) | ||||
|     assert trade.is_open is False | ||||
|  | ||||
| @@ -2397,8 +2398,8 @@ def test_manage_open_orders_entry_usercustom( | ||||
|         'freqtrade.exchange.Exchange', | ||||
|         fetch_ticker=ticker_usdt, | ||||
|         fetch_order=MagicMock(return_value=old_order), | ||||
|         cancel_order_with_result=cancel_order_wr_mock, | ||||
|         cancel_order=cancel_order_mock, | ||||
|         cancel_order_with_result=cancel_order_wr_mock, | ||||
|         get_fee=fee | ||||
|     ) | ||||
|     freqtrade = FreqtradeBot(default_conf_usdt) | ||||
| @@ -2446,7 +2447,9 @@ def test_manage_open_orders_entry( | ||||
| ) -> None: | ||||
|     old_order = limit_sell_order_old if is_short else limit_buy_order_old | ||||
|     rpc_mock = patch_RPCManager(mocker) | ||||
|     old_order['id'] = open_trade.open_order_id | ||||
|     open_trade.open_order_id = old_order['id'] | ||||
|     order = Order.parse_from_ccxt_object(old_order, 'mocked', 'buy') | ||||
|     open_trade.orders[0] = order | ||||
|     limit_buy_cancel = deepcopy(old_order) | ||||
|     limit_buy_cancel['status'] = 'canceled' | ||||
|     cancel_order_mock = MagicMock(return_value=limit_buy_cancel) | ||||
| @@ -2637,7 +2640,9 @@ def test_manage_open_orders_exit_usercustom( | ||||
|     is_short, open_trade_usdt, caplog | ||||
| ) -> None: | ||||
|     default_conf_usdt["unfilledtimeout"] = {"entry": 1440, "exit": 1440, "exit_timeout_count": 1} | ||||
|     limit_sell_order_old['id'] = open_trade_usdt.open_order_id | ||||
|     open_trade_usdt.open_order_id = limit_sell_order_old['id'] | ||||
|     order = Order.parse_from_ccxt_object(limit_sell_order_old, 'mocked', 'sell') | ||||
|     open_trade_usdt.orders[0] = order | ||||
|     if is_short: | ||||
|         limit_sell_order_old['side'] = 'buy' | ||||
|         open_trade_usdt.is_short = is_short | ||||
| @@ -3250,6 +3255,9 @@ def test_execute_trade_exit_up(default_conf_usdt, ticker_usdt, fee, ticker_usdt_ | ||||
|         'open_date': ANY, | ||||
|         'close_date': ANY, | ||||
|         'close_rate': ANY, | ||||
|         'sub_trade': False, | ||||
|         'cumulative_profit': 0.0, | ||||
|         'stake_amount': pytest.approx(60), | ||||
|     } == last_msg | ||||
|  | ||||
|  | ||||
| @@ -3310,6 +3318,9 @@ def test_execute_trade_exit_down(default_conf_usdt, ticker_usdt, fee, ticker_usd | ||||
|         'open_date': ANY, | ||||
|         'close_date': ANY, | ||||
|         'close_rate': ANY, | ||||
|         'sub_trade': False, | ||||
|         'cumulative_profit': 0.0, | ||||
|         'stake_amount': pytest.approx(60), | ||||
|     } == last_msg | ||||
|  | ||||
|  | ||||
| @@ -3391,6 +3402,9 @@ def test_execute_trade_exit_custom_exit_price( | ||||
|         'open_date': ANY, | ||||
|         'close_date': ANY, | ||||
|         'close_rate': ANY, | ||||
|         'sub_trade': False, | ||||
|         'cumulative_profit': 0.0, | ||||
|         'stake_amount': pytest.approx(60), | ||||
|     } == last_msg | ||||
|  | ||||
|  | ||||
| @@ -3459,6 +3473,9 @@ def test_execute_trade_exit_down_stoploss_on_exchange_dry_run( | ||||
|         'open_date': ANY, | ||||
|         'close_date': ANY, | ||||
|         'close_rate': ANY, | ||||
|         'sub_trade': False, | ||||
|         'cumulative_profit': 0.0, | ||||
|         'stake_amount': pytest.approx(60), | ||||
|     } == last_msg | ||||
|  | ||||
|  | ||||
| @@ -3690,7 +3707,7 @@ def test_execute_trade_exit_market_order( | ||||
|     ) | ||||
|  | ||||
|     assert not trade.is_open | ||||
|     assert trade.close_profit == profit_ratio | ||||
|     assert pytest.approx(trade.close_profit) == profit_ratio | ||||
|  | ||||
|     assert rpc_mock.call_count == 4 | ||||
|     last_msg = rpc_mock.call_args_list[-2][0][0] | ||||
| @@ -3718,6 +3735,9 @@ def test_execute_trade_exit_market_order( | ||||
|         'open_date': ANY, | ||||
|         'close_date': ANY, | ||||
|         'close_rate': ANY, | ||||
|         'sub_trade': False, | ||||
|         'cumulative_profit': 0.0, | ||||
|         'stake_amount': pytest.approx(60), | ||||
|  | ||||
|     } == last_msg | ||||
|  | ||||
| @@ -3789,7 +3809,7 @@ def test_exit_profit_only( | ||||
|             'last': bid | ||||
|         }), | ||||
|         create_order=MagicMock(side_effect=[ | ||||
|             limit_order_open[eside], | ||||
|             limit_order[eside], | ||||
|             {'id': 1234553382}, | ||||
|         ]), | ||||
|         get_fee=fee, | ||||
| @@ -4081,7 +4101,7 @@ def test_trailing_stop_loss_positive( | ||||
|             'last': enter_price - (-0.01 if is_short else 0.01), | ||||
|         }), | ||||
|         create_order=MagicMock(side_effect=[ | ||||
|             limit_order_open[eside], | ||||
|             limit_order[eside], | ||||
|             {'id': 1234553382}, | ||||
|         ]), | ||||
|         get_fee=fee, | ||||
| @@ -4632,7 +4652,7 @@ def test_order_book_entry_pricing1(mocker, default_conf_usdt, order_book_l2, exc | ||||
|         with pytest.raises(PricingError): | ||||
|             freqtrade.exchange.get_rate('ETH/USDT', side="entry", is_short=False, refresh=True) | ||||
|         assert log_has_re( | ||||
|             r'Entry Price at location 1 from orderbook could not be determined.', caplog) | ||||
|             r'ETH/USDT - Entry Price at location 1 from orderbook could not be determined.', caplog) | ||||
|     else: | ||||
|         assert freqtrade.exchange.get_rate( | ||||
|             'ETH/USDT', side="entry", is_short=False, refresh=True) == 0.043935 | ||||
| @@ -4711,7 +4731,8 @@ def test_order_book_exit_pricing( | ||||
|                  return_value={'bids': [[]], 'asks': [[]]}) | ||||
|     with pytest.raises(PricingError): | ||||
|         freqtrade.handle_trade(trade) | ||||
|     assert log_has_re(r'Exit Price at location 1 from orderbook could not be determined\..*', | ||||
|     assert log_has_re( | ||||
|         r"ETH/USDT - Exit Price at location 1 from orderbook could not be determined\..*", | ||||
|         caplog) | ||||
|  | ||||
|  | ||||
| @@ -5385,7 +5406,7 @@ def test_position_adjust(mocker, default_conf_usdt, fee) -> None: | ||||
|         'status': None, | ||||
|         'price': 9, | ||||
|         'amount': 12, | ||||
|         'cost': 100, | ||||
|         'cost': 108, | ||||
|         'ft_is_open': True, | ||||
|         'id': '651', | ||||
|         'order_id': '651' | ||||
| @@ -5480,7 +5501,7 @@ def test_position_adjust(mocker, default_conf_usdt, fee) -> None: | ||||
|     assert trade.open_order_id is None | ||||
|     assert pytest.approx(trade.open_rate) == 9.90909090909 | ||||
|     assert trade.amount == 22 | ||||
|     assert trade.stake_amount == 218 | ||||
|     assert pytest.approx(trade.stake_amount) == 218 | ||||
|  | ||||
|     orders = Order.query.all() | ||||
|     assert orders | ||||
| @@ -5533,6 +5554,329 @@ def test_position_adjust(mocker, default_conf_usdt, fee) -> None: | ||||
|     # Make sure the closed order is found as the second order. | ||||
|     order = trade.select_order('buy', False) | ||||
|     assert order.order_id == '652' | ||||
|     closed_sell_dca_order_1 = { | ||||
|         'ft_pair': pair, | ||||
|         'status': 'closed', | ||||
|         'ft_order_side': 'sell', | ||||
|         'side': 'sell', | ||||
|         'type': 'limit', | ||||
|         'price': 8, | ||||
|         'average': 8, | ||||
|         'amount': 15, | ||||
|         'filled': 15, | ||||
|         'cost': 120, | ||||
|         'ft_is_open': False, | ||||
|         'id': '653', | ||||
|         'order_id': '653' | ||||
|     } | ||||
|     mocker.patch('freqtrade.exchange.Exchange.create_order', | ||||
|                  MagicMock(return_value=closed_sell_dca_order_1)) | ||||
|     mocker.patch('freqtrade.exchange.Exchange.fetch_order', | ||||
|                  MagicMock(return_value=closed_sell_dca_order_1)) | ||||
|     mocker.patch('freqtrade.exchange.Exchange.fetch_order_or_stoploss_order', | ||||
|                  MagicMock(return_value=closed_sell_dca_order_1)) | ||||
|     assert freqtrade.execute_trade_exit(trade=trade, limit=8, | ||||
|                                         exit_check=ExitCheckTuple(exit_type=ExitType.PARTIAL_EXIT), | ||||
|                                         sub_trade_amt=15) | ||||
|  | ||||
|     # Assert trade is as expected (averaged dca) | ||||
|     trade = Trade.query.first() | ||||
|     assert trade | ||||
|     assert trade.open_order_id is None | ||||
|     assert trade.is_open | ||||
|     assert trade.amount == 22 | ||||
|     assert trade.stake_amount == 192.05405405405406 | ||||
|     assert pytest.approx(trade.open_rate) == 8.729729729729 | ||||
|  | ||||
|     orders = Order.query.all() | ||||
|     assert orders | ||||
|     assert len(orders) == 4 | ||||
|  | ||||
|     # Make sure the closed order is found as the second order. | ||||
|     order = trade.select_order('sell', False) | ||||
|     assert order.order_id == '653' | ||||
|  | ||||
|  | ||||
| def test_position_adjust2(mocker, default_conf_usdt, fee) -> None: | ||||
|     """ | ||||
|     TODO: Should be adjusted to test both long and short | ||||
|     buy 100 @ 11 | ||||
|     sell 50 @ 8 | ||||
|     sell 50 @ 16 | ||||
|     """ | ||||
|     patch_RPCManager(mocker) | ||||
|     patch_exchange(mocker) | ||||
|     patch_wallet(mocker, free=10000) | ||||
|     default_conf_usdt.update({ | ||||
|         "position_adjustment_enable": True, | ||||
|         "dry_run": False, | ||||
|         "stake_amount": 200.0, | ||||
|         "dry_run_wallet": 1000.0, | ||||
|     }) | ||||
|     freqtrade = FreqtradeBot(default_conf_usdt) | ||||
|     freqtrade.strategy.confirm_trade_entry = MagicMock(return_value=True) | ||||
|     bid = 11 | ||||
|     amount = 100 | ||||
|     buy_rate_mock = MagicMock(return_value=bid) | ||||
|     mocker.patch.multiple( | ||||
|         'freqtrade.exchange.Exchange', | ||||
|         get_rate=buy_rate_mock, | ||||
|         fetch_ticker=MagicMock(return_value={ | ||||
|             'bid': 10, | ||||
|             'ask': 12, | ||||
|             'last': 11 | ||||
|         }), | ||||
|         get_min_pair_stake_amount=MagicMock(return_value=1), | ||||
|         get_fee=fee, | ||||
|     ) | ||||
|     pair = 'ETH/USDT' | ||||
|     # Initial buy | ||||
|     closed_successful_buy_order = { | ||||
|         'pair': pair, | ||||
|         'ft_pair': pair, | ||||
|         'ft_order_side': 'buy', | ||||
|         'side': 'buy', | ||||
|         'type': 'limit', | ||||
|         'status': 'closed', | ||||
|         'price': bid, | ||||
|         'average': bid, | ||||
|         'cost': bid * amount, | ||||
|         'amount': amount, | ||||
|         'filled': amount, | ||||
|         'ft_is_open': False, | ||||
|         'id': '600', | ||||
|         'order_id': '600' | ||||
|     } | ||||
|     mocker.patch('freqtrade.exchange.Exchange.create_order', | ||||
|                  MagicMock(return_value=closed_successful_buy_order)) | ||||
|     mocker.patch('freqtrade.exchange.Exchange.fetch_order_or_stoploss_order', | ||||
|                  MagicMock(return_value=closed_successful_buy_order)) | ||||
|     assert freqtrade.execute_entry(pair, amount) | ||||
|     # Should create an closed trade with an no open order id | ||||
|     # Order is filled and trade is open | ||||
|     orders = Order.query.all() | ||||
|     assert orders | ||||
|     assert len(orders) == 1 | ||||
|     trade = Trade.query.first() | ||||
|     assert trade | ||||
|     assert trade.is_open is True | ||||
|     assert trade.open_order_id is None | ||||
|     assert trade.open_rate == bid | ||||
|     assert trade.stake_amount == bid * amount | ||||
|  | ||||
|     # Assume it does nothing since order is closed and trade is open | ||||
|     freqtrade.update_closed_trades_without_assigned_fees() | ||||
|  | ||||
|     trade = Trade.query.first() | ||||
|     assert trade | ||||
|     assert trade.is_open is True | ||||
|     assert trade.open_order_id is None | ||||
|     assert trade.open_rate == bid | ||||
|     assert trade.stake_amount == bid * amount | ||||
|     assert not trade.fee_updated(trade.entry_side) | ||||
|  | ||||
|     freqtrade.manage_open_orders() | ||||
|  | ||||
|     trade = Trade.query.first() | ||||
|     assert trade | ||||
|     assert trade.is_open is True | ||||
|     assert trade.open_order_id is None | ||||
|     assert trade.open_rate == bid | ||||
|     assert trade.stake_amount == bid * amount | ||||
|     assert not trade.fee_updated(trade.entry_side) | ||||
|  | ||||
|     amount = 50 | ||||
|     ask = 8 | ||||
|     closed_sell_dca_order_1 = { | ||||
|         'ft_pair': pair, | ||||
|         'status': 'closed', | ||||
|         'ft_order_side': 'sell', | ||||
|         'side': 'sell', | ||||
|         'type': 'limit', | ||||
|         'price': ask, | ||||
|         'average': ask, | ||||
|         'amount': amount, | ||||
|         'filled': amount, | ||||
|         'cost': amount * ask, | ||||
|         'ft_is_open': False, | ||||
|         'id': '601', | ||||
|         'order_id': '601' | ||||
|     } | ||||
|     mocker.patch('freqtrade.exchange.Exchange.create_order', | ||||
|                  MagicMock(return_value=closed_sell_dca_order_1)) | ||||
|     mocker.patch('freqtrade.exchange.Exchange.fetch_order', | ||||
|                  MagicMock(return_value=closed_sell_dca_order_1)) | ||||
|     mocker.patch('freqtrade.exchange.Exchange.fetch_order_or_stoploss_order', | ||||
|                  MagicMock(return_value=closed_sell_dca_order_1)) | ||||
|     assert freqtrade.execute_trade_exit(trade=trade, limit=ask, | ||||
|                                         exit_check=ExitCheckTuple(exit_type=ExitType.PARTIAL_EXIT), | ||||
|                                         sub_trade_amt=amount) | ||||
|     trades: List[Trade] = trade.get_open_trades_without_assigned_fees() | ||||
|     assert len(trades) == 1 | ||||
|     # Assert trade is as expected (averaged dca) | ||||
|  | ||||
|     trade = Trade.query.first() | ||||
|     assert trade | ||||
|     assert trade.open_order_id is None | ||||
|     assert trade.amount == 50 | ||||
|     assert trade.open_rate == 11 | ||||
|     assert trade.stake_amount == 550 | ||||
|     assert pytest.approx(trade.realized_profit) == -152.375 | ||||
|     assert pytest.approx(trade.close_profit_abs) == -152.375 | ||||
|  | ||||
|     orders = Order.query.all() | ||||
|     assert orders | ||||
|     assert len(orders) == 2 | ||||
|     # Make sure the closed order is found as the second order. | ||||
|     order = trade.select_order('sell', False) | ||||
|     assert order.order_id == '601' | ||||
|  | ||||
|     amount = 50 | ||||
|     ask = 16 | ||||
|     closed_sell_dca_order_2 = { | ||||
|         'ft_pair': pair, | ||||
|         'status': 'closed', | ||||
|         'ft_order_side': 'sell', | ||||
|         'side': 'sell', | ||||
|         'type': 'limit', | ||||
|         'price': ask, | ||||
|         'average': ask, | ||||
|         'amount': amount, | ||||
|         'filled': amount, | ||||
|         'cost': amount * ask, | ||||
|         'ft_is_open': False, | ||||
|         'id': '602', | ||||
|         'order_id': '602' | ||||
|     } | ||||
|     mocker.patch('freqtrade.exchange.Exchange.create_order', | ||||
|                  MagicMock(return_value=closed_sell_dca_order_2)) | ||||
|     mocker.patch('freqtrade.exchange.Exchange.fetch_order', | ||||
|                  MagicMock(return_value=closed_sell_dca_order_2)) | ||||
|     mocker.patch('freqtrade.exchange.Exchange.fetch_order_or_stoploss_order', | ||||
|                  MagicMock(return_value=closed_sell_dca_order_2)) | ||||
|     assert freqtrade.execute_trade_exit(trade=trade, limit=ask, | ||||
|                                         exit_check=ExitCheckTuple(exit_type=ExitType.PARTIAL_EXIT), | ||||
|                                         sub_trade_amt=amount) | ||||
|     # Assert trade is as expected (averaged dca) | ||||
|  | ||||
|     trade = Trade.query.first() | ||||
|     assert trade | ||||
|     assert trade.open_order_id is None | ||||
|     assert trade.amount == 50 | ||||
|     assert trade.open_rate == 11 | ||||
|     assert trade.stake_amount == 550 | ||||
|     # Trade fully realized | ||||
|     assert pytest.approx(trade.realized_profit) == 94.25 | ||||
|     assert pytest.approx(trade.close_profit_abs) == 94.25 | ||||
|     orders = Order.query.all() | ||||
|     assert orders | ||||
|     assert len(orders) == 3 | ||||
|  | ||||
|     # Make sure the closed order is found as the second order. | ||||
|     order = trade.select_order('sell', False) | ||||
|     assert order.order_id == '602' | ||||
|     assert trade.is_open is False | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize('data', [ | ||||
|     ( | ||||
|         # tuple 1 - side amount, price | ||||
|         # tuple 2 - amount, open_rate, stake_amount, cumulative_profit, realized_profit, rel_profit | ||||
|         (('buy', 100, 10), (100.0, 10.0, 1000.0, 0.0, None, None)), | ||||
|         (('buy', 100, 15), (200.0, 12.5, 2500.0, 0.0, None, None)), | ||||
|         (('sell', 50, 12), (150.0, 12.5, 1875.0, -28.0625, -28.0625, -0.044788)), | ||||
|         (('sell', 100, 20), (50.0, 12.5, 625.0, 713.8125, 741.875, 0.59201995)), | ||||
|         (('sell', 50, 5), (50.0, 12.5, 625.0, 336.625, 336.625, 0.1343142)),  # final profit (sum) | ||||
|     ), | ||||
|     ( | ||||
|         (('buy', 100, 3), (100.0, 3.0, 300.0, 0.0, None, None)), | ||||
|         (('buy', 100, 7), (200.0, 5.0, 1000.0, 0.0, None, None)), | ||||
|         (('sell', 100, 11), (100.0, 5.0, 500.0, 596.0, 596.0, 1.189027)), | ||||
|         (('buy', 150, 15), (250.0, 11.0, 2750.0, 596.0, 596.0, 1.189027)), | ||||
|         (('sell', 100, 19), (150.0, 11.0, 1650.0, 1388.5, 792.5, 0.7186579)), | ||||
|         (('sell', 150, 23), (150.0, 11.0, 1650.0, 3175.75, 3175.75, 0.9747170)),  # final profit | ||||
|     ) | ||||
| ]) | ||||
| def test_position_adjust3(mocker, default_conf_usdt, fee, data) -> None: | ||||
|     default_conf_usdt.update({ | ||||
|         "position_adjustment_enable": True, | ||||
|         "dry_run": False, | ||||
|         "stake_amount": 200.0, | ||||
|         "dry_run_wallet": 1000.0, | ||||
|     }) | ||||
|     patch_RPCManager(mocker) | ||||
|     patch_exchange(mocker) | ||||
|     patch_wallet(mocker, free=10000) | ||||
|     freqtrade = FreqtradeBot(default_conf_usdt) | ||||
|     trade = None | ||||
|     freqtrade.strategy.confirm_trade_entry = MagicMock(return_value=True) | ||||
|     for idx, (order, result) in enumerate(data): | ||||
|         amount = order[1] | ||||
|         price = order[2] | ||||
|         price_mock = MagicMock(return_value=price) | ||||
|         mocker.patch.multiple( | ||||
|             'freqtrade.exchange.Exchange', | ||||
|             get_rate=price_mock, | ||||
|             fetch_ticker=MagicMock(return_value={ | ||||
|                 'bid': 10, | ||||
|                 'ask': 12, | ||||
|                 'last': 11 | ||||
|             }), | ||||
|             get_min_pair_stake_amount=MagicMock(return_value=1), | ||||
|             get_fee=fee, | ||||
|         ) | ||||
|         pair = 'ETH/USDT' | ||||
|         closed_successful_order = { | ||||
|             'pair': pair, | ||||
|             'ft_pair': pair, | ||||
|             'ft_order_side': order[0], | ||||
|             'side': order[0], | ||||
|             'type': 'limit', | ||||
|             'status': 'closed', | ||||
|             'price': price, | ||||
|             'average': price, | ||||
|             'cost': price * amount, | ||||
|             'amount': amount, | ||||
|             'filled': amount, | ||||
|             'ft_is_open': False, | ||||
|             'id': f'60{idx}', | ||||
|             'order_id': f'60{idx}' | ||||
|         } | ||||
|         mocker.patch('freqtrade.exchange.Exchange.create_order', | ||||
|                      MagicMock(return_value=closed_successful_order)) | ||||
|         mocker.patch('freqtrade.exchange.Exchange.fetch_order_or_stoploss_order', | ||||
|                      MagicMock(return_value=closed_successful_order)) | ||||
|         if order[0] == 'buy': | ||||
|             assert freqtrade.execute_entry(pair, amount, trade=trade) | ||||
|         else: | ||||
|             assert freqtrade.execute_trade_exit( | ||||
|                 trade=trade, limit=price, | ||||
|                 exit_check=ExitCheckTuple(exit_type=ExitType.PARTIAL_EXIT), | ||||
|                 sub_trade_amt=amount) | ||||
|  | ||||
|         orders1 = Order.query.all() | ||||
|         assert orders1 | ||||
|         assert len(orders1) == idx + 1 | ||||
|  | ||||
|         trade = Trade.query.first() | ||||
|         assert trade | ||||
|         if idx < len(data) - 1: | ||||
|             assert trade.is_open is True | ||||
|         assert trade.open_order_id is None | ||||
|         assert trade.amount == result[0] | ||||
|         assert trade.open_rate == result[1] | ||||
|         assert trade.stake_amount == result[2] | ||||
|         assert pytest.approx(trade.realized_profit) == result[3] | ||||
|         assert pytest.approx(trade.close_profit_abs) == result[4] | ||||
|         assert pytest.approx(trade.close_profit) == result[5] | ||||
|  | ||||
|         order_obj = trade.select_order(order[0], False) | ||||
|         assert order_obj.order_id == f'60{idx}' | ||||
|  | ||||
|     trade = Trade.query.first() | ||||
|     assert trade | ||||
|     assert trade.open_order_id is None | ||||
|     assert trade.is_open is False | ||||
|  | ||||
|  | ||||
| def test_process_open_trade_positions_exception(mocker, default_conf_usdt, fee, caplog) -> None: | ||||
| @@ -5556,9 +5900,25 @@ def test_check_and_call_adjust_trade_position(mocker, default_conf_usdt, fee, ca | ||||
|         "max_entry_position_adjustment": 0, | ||||
|     }) | ||||
|     freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) | ||||
|  | ||||
|     buy_rate_mock = MagicMock(return_value=10) | ||||
|     mocker.patch.multiple( | ||||
|         'freqtrade.exchange.Exchange', | ||||
|         get_rate=buy_rate_mock, | ||||
|         fetch_ticker=MagicMock(return_value={ | ||||
|             'bid': 10, | ||||
|             'ask': 12, | ||||
|             'last': 11 | ||||
|         }), | ||||
|         get_min_pair_stake_amount=MagicMock(return_value=1), | ||||
|         get_fee=fee, | ||||
|     ) | ||||
|     create_mock_trades(fee) | ||||
|     caplog.set_level(logging.DEBUG) | ||||
|  | ||||
|     freqtrade.strategy.adjust_trade_position = MagicMock(return_value=10) | ||||
|     freqtrade.process_open_trade_positions() | ||||
|     assert log_has_re(r"Max adjustment entries for .* has been reached\.", caplog) | ||||
|  | ||||
|     caplog.clear() | ||||
|     freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-10) | ||||
|     freqtrade.process_open_trade_positions() | ||||
|     assert log_has_re(r"LIMIT_SELL has been fulfilled.*", caplog) | ||||
|   | ||||
| @@ -6,7 +6,7 @@ from freqtrade.enums import ExitCheckTuple, ExitType | ||||
| from freqtrade.persistence import Trade | ||||
| from freqtrade.persistence.models import Order | ||||
| from freqtrade.rpc.rpc import RPC | ||||
| from tests.conftest import get_patched_freqtradebot, patch_get_signal | ||||
| from tests.conftest import get_patched_freqtradebot, log_has_re, patch_get_signal | ||||
|  | ||||
|  | ||||
| def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee, | ||||
| @@ -455,3 +455,60 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, fee, mocker) -> None: | ||||
|     # Check the 2 filled orders equal the above amount | ||||
|     assert pytest.approx(trade.orders[1].amount) == 30.150753768 | ||||
|     assert pytest.approx(trade.orders[-1].amount) == 61.538461232 | ||||
|  | ||||
|  | ||||
| def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog) -> None: | ||||
|     default_conf_usdt['position_adjustment_enable'] = True | ||||
|  | ||||
|     freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) | ||||
|     mocker.patch.multiple( | ||||
|         'freqtrade.exchange.Exchange', | ||||
|         fetch_ticker=ticker_usdt, | ||||
|         get_fee=fee, | ||||
|         amount_to_precision=lambda s, x, y: y, | ||||
|         price_to_precision=lambda s, x, y: y, | ||||
|         get_min_pair_stake_amount=MagicMock(return_value=10), | ||||
|     ) | ||||
|  | ||||
|     patch_get_signal(freqtrade) | ||||
|     freqtrade.enter_positions() | ||||
|  | ||||
|     assert len(Trade.get_trades().all()) == 1 | ||||
|     trade = Trade.get_trades().first() | ||||
|     assert len(trade.orders) == 1 | ||||
|     assert pytest.approx(trade.stake_amount) == 60 | ||||
|     assert pytest.approx(trade.amount) == 30.0 | ||||
|     assert trade.open_rate == 2.0 | ||||
|  | ||||
|     # Too small size | ||||
|     freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-59) | ||||
|     freqtrade.process() | ||||
|     trade = Trade.get_trades().first() | ||||
|     assert len(trade.orders) == 1 | ||||
|     assert pytest.approx(trade.stake_amount) == 60 | ||||
|     assert pytest.approx(trade.amount) == 30.0 | ||||
|     assert log_has_re("Remaining amount of 1.6.* would be too small.", caplog) | ||||
|  | ||||
|     freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-20) | ||||
|  | ||||
|     freqtrade.process() | ||||
|     trade = Trade.get_trades().first() | ||||
|     assert len(trade.orders) == 2 | ||||
|     assert trade.orders[-1].ft_order_side == 'sell' | ||||
|     assert pytest.approx(trade.stake_amount) == 40.198 | ||||
|     assert pytest.approx(trade.amount) == 20.099 | ||||
|     assert trade.open_rate == 2.0 | ||||
|     assert trade.is_open | ||||
|     caplog.clear() | ||||
|  | ||||
|     # Sell more than what we got (we got ~20 coins left) | ||||
|     # First adjusts the amount to 20 - then rejects. | ||||
|     freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-50) | ||||
|     freqtrade.process() | ||||
|     assert log_has_re("Adjusting amount to trade.amount as it is higher.*", caplog) | ||||
|     assert log_has_re("Remaining amount of 0.0 would be too small.", caplog) | ||||
|     trade = Trade.get_trades().first() | ||||
|     assert len(trade.orders) == 2 | ||||
|     assert trade.orders[-1].ft_order_side == 'sell' | ||||
|     assert pytest.approx(trade.stake_amount) == 40.198 | ||||
|     assert trade.is_open | ||||
|   | ||||
| @@ -500,7 +500,7 @@ def test_update_limit_order(fee, caplog, limit_buy_order_usdt, limit_sell_order_ | ||||
|     assert trade.close_profit is None | ||||
|     assert trade.close_date is None | ||||
|  | ||||
|     trade.open_order_id = 'something' | ||||
|     trade.open_order_id = enter_order['id'] | ||||
|     oobj = Order.parse_from_ccxt_object(enter_order, 'ADA/USDT', entry_side) | ||||
|     trade.orders.append(oobj) | ||||
|     trade.update_trade(oobj) | ||||
| @@ -515,7 +515,7 @@ def test_update_limit_order(fee, caplog, limit_buy_order_usdt, limit_sell_order_ | ||||
|                       caplog) | ||||
|  | ||||
|     caplog.clear() | ||||
|     trade.open_order_id = 'something' | ||||
|     trade.open_order_id = enter_order['id'] | ||||
|     time_machine.move_to("2022-03-31 21:45:05 +00:00") | ||||
|     oobj = Order.parse_from_ccxt_object(exit_order, 'ADA/USDT', exit_side) | ||||
|     trade.orders.append(oobj) | ||||
| @@ -550,7 +550,7 @@ def test_update_market_order(market_buy_order_usdt, market_sell_order_usdt, fee, | ||||
|         leverage=1.0, | ||||
|     ) | ||||
|  | ||||
|     trade.open_order_id = 'something' | ||||
|     trade.open_order_id = 'mocked_market_buy' | ||||
|     oobj = Order.parse_from_ccxt_object(market_buy_order_usdt, 'ADA/USDT', 'buy') | ||||
|     trade.orders.append(oobj) | ||||
|     trade.update_trade(oobj) | ||||
| @@ -565,7 +565,7 @@ def test_update_market_order(market_buy_order_usdt, market_sell_order_usdt, fee, | ||||
|  | ||||
|     caplog.clear() | ||||
|     trade.is_open = True | ||||
|     trade.open_order_id = 'something' | ||||
|     trade.open_order_id = 'mocked_market_sell' | ||||
|     oobj = Order.parse_from_ccxt_object(market_sell_order_usdt, 'ADA/USDT', 'sell') | ||||
|     trade.orders.append(oobj) | ||||
|     trade.update_trade(oobj) | ||||
| @@ -630,14 +630,14 @@ def test_calc_open_close_trade_price( | ||||
|     trade.open_rate = 2.0 | ||||
|     trade.close_rate = 2.2 | ||||
|     trade.recalc_open_trade_value() | ||||
|     assert isclose(trade._calc_open_trade_value(), open_value) | ||||
|     assert isclose(trade._calc_open_trade_value(trade.amount, trade.open_rate), open_value) | ||||
|     assert isclose(trade.calc_close_trade_value(trade.close_rate), close_value) | ||||
|     assert isclose(trade.calc_profit(trade.close_rate), round(profit, 8)) | ||||
|     assert pytest.approx(trade.calc_profit_ratio(trade.close_rate)) == profit_ratio | ||||
|  | ||||
|  | ||||
| @pytest.mark.usefixtures("init_persistence") | ||||
| def test_trade_close(limit_buy_order_usdt, limit_sell_order_usdt, fee): | ||||
| def test_trade_close(fee): | ||||
|     trade = Trade( | ||||
|         pair='ADA/USDT', | ||||
|         stake_amount=60.0, | ||||
| @@ -815,7 +815,7 @@ def test_calc_open_trade_value( | ||||
|     trade.update_trade(oobj)  # Buy @ 2.0 | ||||
|  | ||||
|     # Get the open rate price with the standard fee rate | ||||
|     assert trade._calc_open_trade_value() == result | ||||
|     assert trade._calc_open_trade_value(trade.amount, trade.open_rate) == result | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize( | ||||
| @@ -905,7 +905,7 @@ def test_calc_close_trade_price( | ||||
|         ('binance', False, 1, 1.9, 0.003, -3.3209999, -0.055211970, spot, 0), | ||||
|         ('binance', False, 1, 2.2, 0.003, 5.6520000, 0.093965087, spot, 0), | ||||
|  | ||||
|         # # FUTURES, funding_fee=1 | ||||
|         # FUTURES, funding_fee=1 | ||||
|         ('binance', False, 1, 2.1, 0.0025, 3.6925, 0.06138819, futures, 1), | ||||
|         ('binance', False, 3, 2.1, 0.0025, 3.6925, 0.18416458, futures, 1), | ||||
|         ('binance', True, 1, 2.1, 0.0025, -2.3074999, -0.03855472, futures, 1), | ||||
| @@ -1191,6 +1191,11 @@ def test_calc_profit( | ||||
|     assert pytest.approx(trade.calc_profit(rate=close_rate)) == round(profit, 8) | ||||
|     assert pytest.approx(trade.calc_profit_ratio(rate=close_rate)) == round(profit_ratio, 8) | ||||
|  | ||||
|     assert pytest.approx(trade.calc_profit(close_rate, trade.amount, | ||||
|                          trade.open_rate)) == round(profit, 8) | ||||
|     assert pytest.approx(trade.calc_profit_ratio(close_rate, trade.amount, | ||||
|                          trade.open_rate)) == round(profit_ratio, 8) | ||||
|  | ||||
|  | ||||
| def test_migrate_new(mocker, default_conf, fee, caplog): | ||||
|     """ | ||||
| @@ -1382,7 +1387,7 @@ def test_migrate_new(mocker, default_conf, fee, caplog): | ||||
|     assert log_has("trying trades_bak2", caplog) | ||||
|     assert log_has("Running database migration for trades - backup: trades_bak2, orders_bak0", | ||||
|                    caplog) | ||||
|     assert trade.open_trade_value == trade._calc_open_trade_value() | ||||
|     assert trade.open_trade_value == trade._calc_open_trade_value(trade.amount, trade.open_rate) | ||||
|     assert trade.close_profit_abs is None | ||||
|  | ||||
|     orders = trade.orders | ||||
| @@ -1744,6 +1749,7 @@ def test_to_json(fee): | ||||
|                       'stake_amount': 0.001, | ||||
|                       'trade_duration': None, | ||||
|                       'trade_duration_s': None, | ||||
|                       'realized_profit': 0.0, | ||||
|                       'close_profit': None, | ||||
|                       'close_profit_pct': None, | ||||
|                       'close_profit_abs': None, | ||||
| @@ -1820,6 +1826,7 @@ def test_to_json(fee): | ||||
|                       'initial_stop_loss_abs': None, | ||||
|                       'initial_stop_loss_pct': None, | ||||
|                       'initial_stop_loss_ratio': None, | ||||
|                       'realized_profit': 0.0, | ||||
|                       'close_profit': None, | ||||
|                       'close_profit_pct': None, | ||||
|                       'close_profit_abs': None, | ||||
| @@ -2421,7 +2428,7 @@ def test_recalc_trade_from_orders(fee): | ||||
|     ) | ||||
|  | ||||
|     assert fee.return_value == 0.0025 | ||||
|     assert trade._calc_open_trade_value() == o1_trade_val | ||||
|     assert trade._calc_open_trade_value(trade.amount, trade.open_rate) == o1_trade_val | ||||
|     assert trade.amount == o1_amount | ||||
|     assert trade.stake_amount == o1_cost | ||||
|     assert trade.open_rate == o1_rate | ||||
| @@ -2533,7 +2540,8 @@ def test_recalc_trade_from_orders(fee): | ||||
|     assert pytest.approx(trade.fee_open_cost) == o1_fee_cost + o2_fee_cost + o3_fee_cost | ||||
|     assert pytest.approx(trade.open_trade_value) == o1_trade_val + o2_trade_val + o3_trade_val | ||||
|  | ||||
|     # Just to make sure sell orders are ignored, let's calculate one more time. | ||||
|     # Just to make sure full sell orders are ignored, let's calculate one more time. | ||||
|  | ||||
|     sell1 = Order( | ||||
|         ft_order_side='sell', | ||||
|         ft_pair=trade.pair, | ||||
| @@ -2695,7 +2703,7 @@ def test_recalc_trade_from_orders_ignores_bad_orders(fee, is_short): | ||||
|     assert trade.open_trade_value == 2 * o1_trade_val | ||||
|     assert trade.nr_of_successful_entries == 2 | ||||
|  | ||||
|     # Just to make sure exit orders are ignored, let's calculate one more time. | ||||
|     # Reduce position - this will reduce amount again. | ||||
|     sell1 = Order( | ||||
|         ft_order_side=exit_side, | ||||
|         ft_pair=trade.pair, | ||||
| @@ -2706,7 +2714,7 @@ def test_recalc_trade_from_orders_ignores_bad_orders(fee, is_short): | ||||
|         side=exit_side, | ||||
|         price=4, | ||||
|         average=3, | ||||
|         filled=2, | ||||
|         filled=o1_amount, | ||||
|         remaining=1, | ||||
|         cost=5, | ||||
|         order_date=trade.open_date, | ||||
| @@ -2715,11 +2723,11 @@ def test_recalc_trade_from_orders_ignores_bad_orders(fee, is_short): | ||||
|     trade.orders.append(sell1) | ||||
|     trade.recalc_trade_from_orders() | ||||
|  | ||||
|     assert trade.amount == 2 * o1_amount | ||||
|     assert trade.stake_amount == 2 * o1_amount | ||||
|     assert trade.amount == o1_amount | ||||
|     assert trade.stake_amount == o1_amount | ||||
|     assert trade.open_rate == o1_rate | ||||
|     assert trade.fee_open_cost == 2 * o1_fee_cost | ||||
|     assert trade.open_trade_value == 2 * o1_trade_val | ||||
|     assert trade.fee_open_cost == o1_fee_cost | ||||
|     assert trade.open_trade_value == o1_trade_val | ||||
|     assert trade.nr_of_successful_entries == 2 | ||||
|  | ||||
|     # Check with 1 order | ||||
| @@ -2743,11 +2751,11 @@ def test_recalc_trade_from_orders_ignores_bad_orders(fee, is_short): | ||||
|     trade.recalc_trade_from_orders() | ||||
|  | ||||
|     # Calling recalc with single initial order should not change anything | ||||
|     assert trade.amount == 3 * o1_amount | ||||
|     assert trade.stake_amount == 3 * o1_amount | ||||
|     assert trade.amount == 2 * o1_amount | ||||
|     assert trade.stake_amount == 2 * o1_amount | ||||
|     assert trade.open_rate == o1_rate | ||||
|     assert trade.fee_open_cost == 3 * o1_fee_cost | ||||
|     assert trade.open_trade_value == 3 * o1_trade_val | ||||
|     assert trade.fee_open_cost == 2 * o1_fee_cost | ||||
|     assert trade.open_trade_value == 2 * o1_trade_val | ||||
|     assert trade.nr_of_successful_entries == 3 | ||||
|  | ||||
|  | ||||
| @@ -2815,3 +2823,144 @@ def test_order_to_ccxt(limit_buy_order_open): | ||||
|     del raw_order['stopPrice'] | ||||
|     del limit_buy_order_open['datetime'] | ||||
|     assert raw_order == limit_buy_order_open | ||||
|  | ||||
|  | ||||
| @pytest.mark.usefixtures("init_persistence") | ||||
| @pytest.mark.parametrize('data', [ | ||||
|     { | ||||
|         # tuple 1 - side, amount, price | ||||
|         # tuple 2 - amount, open_rate, stake_amount, cumulative_profit, realized_profit, rel_profit | ||||
|         'orders': [ | ||||
|             (('buy', 100, 10), (100.0, 10.0, 1000.0, 0.0, None, None)), | ||||
|             (('buy', 100, 15), (200.0, 12.5, 2500.0, 0.0, None, None)), | ||||
|             (('sell', 50, 12), (150.0, 12.5, 1875.0, -25.0, -25.0, -0.04)), | ||||
|             (('sell', 100, 20), (50.0, 12.5, 625.0, 725.0, 750.0, 0.60)), | ||||
|             (('sell', 50, 5), (50.0, 12.5, 625.0, 350.0, -375.0, -0.60)), | ||||
|         ], | ||||
|         'end_profit': 350.0, | ||||
|         'end_profit_ratio': 0.14, | ||||
|         'fee': 0.0, | ||||
|     }, | ||||
|     { | ||||
|         'orders': [ | ||||
|             (('buy', 100, 10), (100.0, 10.0, 1000.0, 0.0, None, None)), | ||||
|             (('buy', 100, 15), (200.0, 12.5, 2500.0, 0.0, None, None)), | ||||
|             (('sell', 50, 12), (150.0, 12.5, 1875.0, -28.0625, -28.0625, -0.044788)), | ||||
|             (('sell', 100, 20), (50.0, 12.5, 625.0, 713.8125, 741.875, 0.59201995)), | ||||
|             (('sell', 50, 5), (50.0, 12.5, 625.0, 336.625, -377.1875, -0.60199501)), | ||||
|         ], | ||||
|         'end_profit': 336.625, | ||||
|         'end_profit_ratio': 0.1343142, | ||||
|         'fee': 0.0025, | ||||
|     }, | ||||
|     { | ||||
|         'orders': [ | ||||
|             (('buy', 100, 3), (100.0, 3.0, 300.0, 0.0, None, None)), | ||||
|             (('buy', 100, 7), (200.0, 5.0, 1000.0, 0.0, None, None)), | ||||
|             (('sell', 100, 11), (100.0, 5.0, 500.0, 596.0, 596.0, 1.189027)), | ||||
|             (('buy', 150, 15), (250.0, 11.0, 2750.0, 596.0, 596.0, 1.189027)), | ||||
|             (('sell', 100, 19), (150.0, 11.0, 1650.0, 1388.5, 792.5, 0.7186579)), | ||||
|             (('sell', 150, 23), (150.0, 11.0, 1650.0, 3175.75, 1787.25, 1.08048062)), | ||||
|         ], | ||||
|         'end_profit': 3175.75, | ||||
|         'end_profit_ratio': 0.9747170, | ||||
|         'fee': 0.0025, | ||||
|     }, | ||||
|     { | ||||
|         # Test above without fees | ||||
|         'orders': [ | ||||
|             (('buy', 100, 3), (100.0, 3.0, 300.0, 0.0, None, None)), | ||||
|             (('buy', 100, 7), (200.0, 5.0, 1000.0, 0.0, None, None)), | ||||
|             (('sell', 100, 11), (100.0, 5.0, 500.0, 600.0, 600.0, 1.2)), | ||||
|             (('buy', 150, 15), (250.0, 11.0, 2750.0, 600.0, 600.0, 1.2)), | ||||
|             (('sell', 100, 19), (150.0, 11.0, 1650.0, 1400.0, 800.0, 0.72727273)), | ||||
|             (('sell', 150, 23), (150.0, 11.0, 1650.0, 3200.0, 1800.0, 1.09090909)), | ||||
|         ], | ||||
|         'end_profit': 3200.0, | ||||
|         'end_profit_ratio': 0.98461538, | ||||
|         'fee': 0.0, | ||||
|     }, | ||||
|     { | ||||
|         'orders': [ | ||||
|             (('buy', 100, 8), (100.0, 8.0, 800.0, 0.0, None, None)), | ||||
|             (('buy', 100, 9), (200.0, 8.5, 1700.0, 0.0, None, None)), | ||||
|             (('sell', 100, 10), (100.0, 8.5, 850.0, 150.0, 150.0, 0.17647059)), | ||||
|             (('buy', 150, 11), (250.0, 10, 2500.0, 150.0, 150.0, 0.17647059)), | ||||
|             (('sell', 100, 12), (150.0, 10.0, 1500.0, 350.0, 350.0, 0.2)), | ||||
|             (('sell', 150, 14), (150.0, 10.0, 1500.0, 950.0, 950.0, 0.40)), | ||||
|         ], | ||||
|         'end_profit': 950.0, | ||||
|         'end_profit_ratio': 0.283582, | ||||
|         'fee': 0.0, | ||||
|     }, | ||||
| ]) | ||||
| def test_recalc_trade_from_orders_dca(data) -> None: | ||||
|  | ||||
|     pair = 'ETH/USDT' | ||||
|     trade = Trade( | ||||
|         id=2, | ||||
|         pair=pair, | ||||
|         stake_amount=1000, | ||||
|         open_rate=data['orders'][0][0][2], | ||||
|         amount=data['orders'][0][0][1], | ||||
|         is_open=True, | ||||
|         open_date=arrow.utcnow().datetime, | ||||
|         fee_open=data['fee'], | ||||
|         fee_close=data['fee'], | ||||
|         exchange='binance', | ||||
|         is_short=False, | ||||
|         leverage=1.0, | ||||
|         trading_mode=TradingMode.SPOT | ||||
|     ) | ||||
|     Trade.query.session.add(trade) | ||||
|  | ||||
|     for idx, (order, result) in enumerate(data['orders']): | ||||
|         amount = order[1] | ||||
|         price = order[2] | ||||
|  | ||||
|         order_obj = Order( | ||||
|             ft_order_side=order[0], | ||||
|             ft_pair=trade.pair, | ||||
|             order_id=f"order_{order[0]}_{idx}", | ||||
|             ft_is_open=False, | ||||
|             status="closed", | ||||
|             symbol=trade.pair, | ||||
|             order_type="market", | ||||
|             side=order[0], | ||||
|             price=price, | ||||
|             average=price, | ||||
|             filled=amount, | ||||
|             remaining=0, | ||||
|             cost=amount * price, | ||||
|             order_date=arrow.utcnow().shift(hours=-10 + idx).datetime, | ||||
|             order_filled_date=arrow.utcnow().shift(hours=-10 + idx).datetime, | ||||
|         ) | ||||
|         trade.orders.append(order_obj) | ||||
|         trade.recalc_trade_from_orders() | ||||
|         Trade.commit() | ||||
|  | ||||
|         orders1 = Order.query.all() | ||||
|         assert orders1 | ||||
|         assert len(orders1) == idx + 1 | ||||
|  | ||||
|         trade = Trade.query.first() | ||||
|         assert trade | ||||
|         assert len(trade.orders) == idx + 1 | ||||
|         if idx < len(data) - 1: | ||||
|             assert trade.is_open is True | ||||
|         assert trade.open_order_id is None | ||||
|         assert trade.amount == result[0] | ||||
|         assert trade.open_rate == result[1] | ||||
|         assert trade.stake_amount == result[2] | ||||
|         # TODO: enable the below. | ||||
|         assert pytest.approx(trade.realized_profit) == result[3] | ||||
|         # assert pytest.approx(trade.close_profit_abs) == result[4] | ||||
|         assert pytest.approx(trade.close_profit) == result[5] | ||||
|  | ||||
|     trade.close(price) | ||||
|     assert pytest.approx(trade.close_profit_abs) == data['end_profit'] | ||||
|     assert pytest.approx(trade.close_profit) == data['end_profit_ratio'] | ||||
|     assert not trade.is_open | ||||
|     trade = Trade.query.first() | ||||
|     assert trade | ||||
|     assert trade.open_order_id is None | ||||
|   | ||||
		Reference in New Issue
	
	Block a user