diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 5f2b72e1e..0d8fbbb46 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -478,9 +478,8 @@ class FreqtradeBot(LoggingMixin): 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.") + self.execute_trade_exit(trade, current_rate, sell_reason=SellCheckTuple( + sell_type=SellType.CUSTOM_SELL), sub_trade_amt=-stake_amount) def _check_depth_of_market_buy(self, pair: str, conf: Dict) -> bool: """ @@ -620,7 +619,7 @@ class FreqtradeBot(LoggingMixin): # Updating wallets self.wallets.update() - self._notify_enter(trade, order, order_type) + self._notify_enter(trade, order, order_type, sub_trade=pos_adjust) if pos_adjust: if order_status == 'closed': @@ -677,7 +676,7 @@ class FreqtradeBot(LoggingMixin): return enter_limit_requested, stake_amount def _notify_enter(self, trade: Trade, order: Dict, order_type: Optional[str] = None, - fill: bool = False) -> None: + fill: bool = False, sub_trade: bool = False) -> None: """ Sends rpc notification when a buy occurred. """ @@ -693,7 +692,7 @@ class FreqtradeBot(LoggingMixin): 'trade_id': trade.id, 'type': RPCMessageType.BUY_FILL if fill else RPCMessageType.BUY, 'buy_tag': trade.buy_tag, - 'exchange': self.exchange.name.capitalize(), + 'exchange': trade.exchange.capitalize(), 'pair': trade.pair, 'limit': open_rate, # Deprecated (?) 'open_rate': open_rate, @@ -701,15 +700,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.get('filled') or order.get('amount'), '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 buy cancel occurred. """ @@ -730,6 +731,7 @@ class FreqtradeBot(LoggingMixin): 'open_date': trade.open_date, 'current_rate': current_rate, 'reason': reason, + 'sub_trade': sub_trade, } # Send the message @@ -1153,6 +1155,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 @@ -1190,7 +1193,7 @@ class FreqtradeBot(LoggingMixin): # Emergency sells (default to market!) order_type = self.strategy.order_types.get("emergencysell", "market") - amount = self._safe_exit_amount(trade.pair, trade.amount) + amount = sub_trade_amt or self._safe_exit_amount(trade.pair, trade.amount) time_in_force = self.strategy.order_time_in_force['sell'] if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)( @@ -1225,15 +1228,17 @@ 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)) # 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) + self.update_trade_state(trade, trade.open_order_id, order, + sub_trade=bool(sub_trade_amt)) Trade.commit() 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) -> None: """ Sends rpc notification when a sell occurred. """ @@ -1264,9 +1269,11 @@ class FreqtradeBot(LoggingMixin): 'sell_reason': trade.sell_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', None), - } + 'sub_trade': sub_trade, + } if 'fiat_display_currency' in self.config: msg.update({ @@ -1276,7 +1283,8 @@ class FreqtradeBot(LoggingMixin): # 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, + sub_trade: bool = False) -> None: """ Sends rpc notification when a sell cancel occurred. """ @@ -1311,6 +1319,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: @@ -1326,7 +1336,8 @@ class FreqtradeBot(LoggingMixin): # def update_trade_state(self, trade: Trade, order_id: str, action_order: Dict[str, Any] = None, - stoploss_order: bool = False, send_msg: bool = True) -> bool: + stoploss_order: bool = False, send_msg: bool = True, + sub_trade: bool = False) -> bool: """ Checks trades with open orders and updates the amount if necessary Handles closing both buy and sell orders. @@ -1359,7 +1370,7 @@ class FreqtradeBot(LoggingMixin): order = self.handle_order_fee(trade, order) - trade.update(order) + trade.update(order, sub_trade=sub_trade) trade.recalc_trade_from_orders() Trade.commit() @@ -1372,11 +1383,11 @@ class FreqtradeBot(LoggingMixin): if not trade.is_open: if send_msg and not stoploss_order and not trade.open_order_id: - self._notify_exit(trade, '', True) + self._notify_exit(trade, '', True, sub_trade=sub_trade) self.handle_protections(trade.pair) elif send_msg and not trade.open_order_id: # Buy fill - self._notify_enter(trade, order, fill=True) + self._notify_enter(trade, order, fill=True, sub_trade=sub_trade) return False diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 5eddb7b3d..acdaa4e98 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -119,6 +119,7 @@ class Order(_DECL_BASE): ft_order_side = Column(String(25), nullable=False) ft_pair = Column(String(25), nullable=False) ft_is_open = Column(Boolean, nullable=False, default=True, index=True) + is_fully_realized = Column(Boolean, nullable=True, default=False) order_id = Column(String(255), nullable=False, index=True) status = Column(String(255), nullable=True) @@ -455,7 +456,7 @@ class LocalTrade(): f"Trailing stoploss saved us: " f"{float(self.stop_loss) - float(self.initial_stop_loss):.8f}.") - def update(self, order: Dict) -> None: + def update(self, order: Dict, sub_trade: bool = False) -> None: """ Updates this entity with amount and actual open/close rates. :param order: order retrieved by exchange.fetch_order() @@ -479,7 +480,13 @@ class LocalTrade(): elif order_type in ('market', 'limit') and order['side'] == 'sell': if self.is_open: logger.info(f'{order_type.upper()}_SELL has been fulfilled for {self}.') - self.close(safe_value_fallback(order, 'average', 'price')) + self.open_order_id = None + if sub_trade or 1: + logger.info(f'debug1:{sub_trade}') + self.process_sell_sub_trade(order) + return + # else: + # self.close(safe_value_fallback(order, 'average', 'price')) elif order_type in ('stop_loss_limit', 'stop-loss', 'stop-loss-limit', 'stop'): self.stoploss_order_id = None self.close_rate_requested = self.stop_loss @@ -491,6 +498,44 @@ class LocalTrade(): raise ValueError(f'Unknown order type: {order_type}') Trade.commit() + def process_sell_sub_trade(self, order: Dict) -> None: + orders = (self.select_filled_orders('buy')) + sell_rate = float(safe_value_fallback(order, 'average', 'price')) + sell_amount = float(safe_value_fallback(order, 'filled', 'amount')) + profit = 0.0 + idx = -1 + while sell_amount: + b_order = orders[idx] + buy_amount = b_order.filled or b_order.amount + buy_rate = b_order.average or b_order.price + if sell_amount < buy_amount: + amount = sell_amount + b_order.filled -= amount + else: + if sell_amount == self.amount: + self.close(safe_value_fallback(order, 'average', 'price')) + Trade.commit() + return + b_order.is_fully_realized = True + self.update_order(b_order) + idx -= 1 + amount = buy_amount + sell_amount -= amount + profit += self.calc_profit2(buy_rate, sell_rate, amount) + b_order2 = orders[idx] + amount2 = b_order2.filled or b_order2.amount + b_order2.average = (b_order2.average * amount2 - profit) / amount2 + self.update_order(b_order2) + Order.query.session.commit() + self.recalc_trade_from_orders() + Trade.commit() + + def calc_profit2(self, open_rate: float, close_rate: float, + amount: float) -> float: + return float(Decimal(amount) * + (Decimal(1 - self.fee_close) * Decimal(close_rate) - + Decimal(1 + self.fee_open) * Decimal(open_rate))) + def close(self, rate: float, *, show_msg: bool = True) -> None: """ Sets close_rate to the given rate, calculates total profit @@ -628,6 +673,7 @@ class LocalTrade(): for o in self.orders: if (o.ft_is_open or (o.ft_order_side != 'buy') or + o.is_fully_realized or (o.status not in NON_OPEN_EXCHANGE_STATES)): continue @@ -685,6 +731,7 @@ class LocalTrade(): 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 + not o.is_fully_realized and o.status in NON_OPEN_EXCHANGE_STATES] @property diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index da613fab8..dd122ef89 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -235,17 +235,31 @@ class Telegram(RPCHandler): if msg['type'] == RPCMessageType.BUY_FILL: message += f"*Open Rate:* `{msg['open_rate']:.8f}`\n" + total = msg['amount'] * msg['open_rate'] elif msg['type'] == RPCMessageType.BUY: message += f"*Open Rate:* `{msg['limit']:.8f}`\n"\ f"*Current Rate:* `{msg['current_rate']:.8f}`\n" - - message += f"*Total:* `({round_coin_value(msg['stake_amount'], msg['stake_currency'])}" + total = msg['amount'] * msg['limit'] + if self._rpc._fiat_converter: + total_fiat = self._rpc._fiat_converter.convert_amount( + total, msg['stake_currency'], msg['fiat_currency']) + else: + total_fiat = 0 + message += f"*Total:* `({round_coin_value(total, msg['stake_currency'])}" if msg.get('fiat_currency', None): - message += f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}" + message += f", {round_coin_value(total_fiat, msg['fiat_currency'])}" message += ")`" + if msg.get('sub_trade'): + bal = round_coin_value(msg['stake_amount'], msg['stake_currency']) + message += f"\n*Balance:* `({bal}" + + if msg.get('fiat_currency', None): + message += f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}" + + message += ")`" return message def _format_sell_msg(self, msg: Dict[str, Any]) -> str: @@ -287,7 +301,19 @@ class Telegram(RPCHandler): elif msg['type'] == RPCMessageType.SELL_FILL: message += f"*Close 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 + bal = round_coin_value(msg['stake_amount'], msg['stake_currency']) + message += f"\n*Balance:* `({bal}" + if msg.get('fiat_currency', None): + message += f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}" + + message += ")`" return message def compose_message(self, msg: Dict[str, Any], msg_type: RPCMessageType) -> str: @@ -388,12 +414,14 @@ class Telegram(RPCHandler): else: sumA = 0 sumB = 0 + first_order_price = filled_orders[0]["average"] or filled_orders[0]["price"] for y in range(x): - sumA += (filled_orders[y]["amount"] * filled_orders[y]["average"]) + sumA += (filled_orders[y]["amount"] * (filled_orders[y]["average"] + or filled_orders[y]["price"])) sumB += filled_orders[y]["amount"] prev_avg_price = sumA/sumB - price_to_1st_entry = ((cur_entry_average - filled_orders[0]["average"]) - / filled_orders[0]["average"]) + price_to_1st_entry = ((cur_entry_average - first_order_price) + / first_order_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 @@ -535,7 +563,9 @@ class Telegram(RPCHandler): reload_able=True, callback_path="update_status_table", query=update.callback_query) except RPCException as e: - self._send_msg(str(e)) + self._send_msg(str(e), reload_able=True, + callback_path="update_status_table", + query=update.callback_query) @authorized_only def _daily(self, update: Update, context: CallbackContext) -> None: diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 353aa959f..05b30ee9e 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -1037,6 +1037,8 @@ def test_telegram_forcesell_handle(default_conf, update, ticker, fee, 'open_date': ANY, 'close_date': ANY, 'close_rate': ANY, + 'stake_amount': 0.0009999999999054, + 'sub_trade': False } == last_msg @@ -1102,6 +1104,8 @@ def test_telegram_forcesell_down_handle(default_conf, update, ticker, fee, 'open_date': ANY, 'close_date': ANY, 'close_rate': ANY, + 'stake_amount': 0.0009999999999054, + 'sub_trade': False } == last_msg @@ -1157,6 +1161,8 @@ def test_forcesell_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 } == msg diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 08d98b42d..5cbc7c489 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -2622,7 +2622,9 @@ def test_execute_trade_exit_up(default_conf_usdt, ticker_usdt, fee, ticker_usdt_ 'open_date': ANY, 'close_date': ANY, 'close_rate': ANY, - } == last_msg + 'sub_trade': False, + 'stake_amount': 60.0, + } == last_msg def test_execute_trade_exit_down(default_conf_usdt, ticker_usdt, fee, ticker_usdt_sell_down, @@ -2676,7 +2678,9 @@ def test_execute_trade_exit_down(default_conf_usdt, ticker_usdt, fee, ticker_usd 'open_date': ANY, 'close_date': ANY, 'close_rate': ANY, - } == last_msg + 'sub_trade': False, + 'stake_amount': 60.0, + } == last_msg def test_execute_trade_exit_custom_exit_price(default_conf_usdt, ticker_usdt, fee, @@ -2744,7 +2748,9 @@ def test_execute_trade_exit_custom_exit_price(default_conf_usdt, ticker_usdt, fe 'open_date': ANY, 'close_date': ANY, 'close_rate': ANY, - } == last_msg + 'sub_trade': False, + 'stake_amount': 60.0, + } == last_msg def test_execute_trade_exit_down_stoploss_on_exchange_dry_run( @@ -2804,6 +2810,8 @@ def test_execute_trade_exit_down_stoploss_on_exchange_dry_run( 'open_date': ANY, 'close_date': ANY, 'close_rate': ANY, + 'sub_trade': False, + 'stake_amount': 60.0, } == last_msg @@ -3022,6 +3030,8 @@ def test_execute_trade_exit_market_order(default_conf_usdt, ticker_usdt, fee, 'open_date': ANY, 'close_date': ANY, 'close_rate': ANY, + 'sub_trade': False, + 'stake_amount': 60.0, } == last_msg @@ -4312,7 +4322,6 @@ def test_position_adjust(mocker, default_conf_usdt, fee) -> None: get_fee=fee, ) pair = 'ETH/USDT' - # Initial buy closed_successful_buy_order = { 'pair': pair, @@ -4377,7 +4386,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' @@ -4524,6 +4533,46 @@ 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, + sell_reason=SellCheckTuple(sell_type=SellType.STOP_LOSS), + 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.amount == 22 + assert trade.stake_amount == 203.5625 + assert pytest.approx(trade.open_rate) == 9.252840909090908 + + 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_process_open_trade_positions_exception(mocker, default_conf_usdt, fee, caplog) -> None: