diff --git a/api/deals/base.py b/api/deals/base.py index f15ebfbe0..63ae459d9 100644 --- a/api/deals/base.py +++ b/api/deals/base.py @@ -1,4 +1,3 @@ -from typing import List import uuid import requests import numpy @@ -11,21 +10,17 @@ from pymongo import ReturnDocument from tools.round_numbers import round_numbers, supress_notation from tools.handle_error import handle_binance_errors, encode_json -from tools.exceptions import BinanceErrors, MarginLoanNotFound +from tools.exceptions import BinanceErrors, DealCreationError, MarginLoanNotFound from scipy.stats import linregress from tools.round_numbers import round_numbers_ceiling from tools.enum_definitions import Status, Strategy +from bson.objectid import ObjectId +from deals.schema import DealSchema, OrderSchema # To be removed one day when commission endpoint found that provides this value ESTIMATED_COMMISSIONS_RATE = 0.0075 -class DealCreationError(Exception): - pass - -class StreamingSaveError(Exception): - pass - class BaseDeal(OrderController): """ Base Deal class to share with CreateDealController and MarginDeal @@ -61,9 +56,7 @@ def compute_qty(self, pair): qty = round_numbers(balance, self.qty_precision) return qty - def compute_margin_buy_back( - self, pair: str - ): + def compute_margin_buy_back(self, pair: str): """ Same as compute_qty but with isolated margin balance @@ -78,7 +71,12 @@ def compute_margin_buy_back( ): return None - qty = float(self.isolated_balance[0]["baseAsset"]["borrowed"]) + float(self.isolated_balance[0]["baseAsset"]["interest"]) + float(self.isolated_balance[0]["baseAsset"]["borrowed"]) * ESTIMATED_COMMISSIONS_RATE + qty = ( + float(self.isolated_balance[0]["baseAsset"]["borrowed"]) + + float(self.isolated_balance[0]["baseAsset"]["interest"]) + + float(self.isolated_balance[0]["baseAsset"]["borrowed"]) + * ESTIMATED_COMMISSIONS_RATE + ) qty = round_numbers_ceiling(qty, self.qty_precision) free = float(self.isolated_balance[0]["baseAsset"]["free"]) @@ -128,7 +126,6 @@ def update_deal_logs(self, msg): {"id": self.active_bot.id}, {"$push": {"errors": msg}}, ) - self.save_bot_streaming() return response def replace_order(self, cancel_order_id): @@ -155,7 +152,11 @@ def close_open_orders(self, symbol): open_orders = self.signed_request(self.open_orders, payload={"symbol": symbol}) for order in open_orders: if order["status"] == "NEW": - self.signed_request(self.order_url, method="DELETE", payload={"symbol": symbol, "orderId": order["orderId"]}) + self.signed_request( + self.order_url, + method="DELETE", + payload={"symbol": symbol, "orderId": order["orderId"]}, + ) return True return False @@ -200,26 +201,109 @@ def save_bot_streaming(self): def create_new_bot_streaming(self): """ - MongoDB query to save bot using Pydantic + Resets bot to initial state and saves it to DB - This function differs from usual save query in that - it returns the saved bot, thus called streaming, it's - specifically for streaming saves + This function differs from usual create_bot in that + it needs to set strategy first (reversal) + clear orders, deal and errors, + which are not required in new bots, + as they initialize with empty values + """ + self.active_bot.id = str(ObjectId()) + self.active_bot.orders = [] + self.active_bot.errors = [] + self.active_bot.created_at = time() * 1000 + self.active_bot.updated_at = time() * 1000 + self.active_bot.status = Status.inactive + self.active_bot.deal = DealSchema() + + bot = encode_json(self.active_bot) + self.db_collection.insert_one(bot) + new_bot = self.db_collection.find_one({"id": bot["id"]}) + bot_class = BotSchema.parse_obj(new_bot) + + # notify the system to update streaming as usual bot actions + self.db.research_controller.update_one({"_id": "settings"}, {"$set": {"update_required": time()}}) + + return bot_class + + def base_order(self): """ + Required initial order to trigger long strategy bot. + Other orders require this to execute, + therefore should fail if not successful + + 1. Initial base purchase + 2. Set take_profit + """ + + pair = self.active_bot.pair + + # Long position does not need qty in take_profit + # initial price with 1 qty should return first match + price = float(self.matching_engine(pair, True)) + qty = round_numbers( + (float(self.active_bot.base_order_size) / float(price)), + self.qty_precision, + ) + # setup stop_loss_price + stop_loss_price = 0 + if float(self.active_bot.stop_loss) > 0: + stop_loss_price = price - (price * (float(self.active_bot.stop_loss) / 100)) + + if self.db_collection.name == "paper_trading": + res = self.simulate_order( + pair, supress_notation(price, self.price_precision), qty, "BUY" + ) + else: + res = self.buy_order( + symbol=pair, + qty=qty, + price=supress_notation(price, self.price_precision), + ) + + order_data = OrderSchema( + timestamp=res["transactTime"], + order_id=res["orderId"], + deal_type="base_order", + pair=res["symbol"], + order_side=res["side"], + order_type=res["type"], + price=res["price"], + qty=res["origQty"], + fills=res["fills"], + time_in_force=res["timeInForce"], + status=res["status"], + ) + + self.active_bot.orders.append(order_data) + tp_price = float(res["price"]) * 1 + (float(self.active_bot.take_profit) / 100) + + self.active_bot.deal = DealSchema( + buy_timestamp=res["transactTime"], + buy_price=res["price"], + buy_total_qty=res["origQty"], + current_price=res["price"], + take_profit_price=tp_price, + stop_loss_price=stop_loss_price, + ) + + # Activate bot + self.active_bot.status = Status.active bot = encode_json(self.active_bot) - bot["id"] = self.generate_id() if "_id" in bot: - bot.pop("_id") + bot.pop("_id") # _id is what causes conflict not id - bot_response = self.db_collection.insert_one( - bot, + document = self.db_collection.find_one_and_update( + {"id": self.active_bot.id}, + {"$set": bot}, + return_document=ReturnDocument.AFTER, ) - return bot_response.inserted_id + return document def dynamic_take_profit(self, current_bot, close_price): - self.active_bot = BotSchema.parse_obj(current_bot) params = { @@ -245,8 +329,7 @@ def dynamic_take_profit(self, current_bot, close_price): self.active_bot.deal.trailling_stop_loss_price > 0 and self.active_bot.deal.trailling_stop_loss_price > self.active_bot.deal.base_order_price - and float(close_price) - > self.active_bot.deal.trailling_stop_loss_price + and float(close_price) > self.active_bot.deal.trailling_stop_loss_price and ( (self.active_bot.strategy == "long" and slope > 0) or (self.active_bot.strategy == "margin_short" and slope < 0) @@ -267,9 +350,11 @@ def dynamic_take_profit(self, current_bot, close_price): ) # deal.sd comparison will prevent it from making trailling_stop_loss too big # and thus losing all the gains - if new_trailling_stop_loss_price > float( - self.active_bot.deal.buy_price - ) and sd < self.active_bot.deal.sd: + if ( + new_trailling_stop_loss_price + > float(self.active_bot.deal.buy_price) + and sd < self.active_bot.deal.sd + ): self.active_bot.trailling_deviation = volatility * 100 self.active_bot.deal.trailling_stop_loss_price = float( close_price @@ -327,7 +412,7 @@ def margin_liquidation(self, pair: str, qty_precision=None): # transfer from wallet transfer_diff_qty = round_numbers_ceiling(repay_amount - free) available_balance = self.get_one_balance(quote) - amount_to_transfer = 15 # Min amount + amount_to_transfer = 15 # Min amount if available_balance < 15: amount_to_transfer = available_balance self.transfer_spot_to_isolated_margin( @@ -335,7 +420,9 @@ def margin_liquidation(self, pair: str, qty_precision=None): symbol=pair, amount=amount_to_transfer, ) - buy_margin_response = self.buy_margin_order(pair, supress_notation(transfer_diff_qty, qty_precision)) + buy_margin_response = self.buy_margin_order( + pair, supress_notation(transfer_diff_qty, qty_precision) + ) repay_amount, free = self.compute_margin_buy_back(pair) pass if error.code == -2010 or error.code == -1013: @@ -346,7 +433,9 @@ def margin_liquidation(self, pair: str, qty_precision=None): if usdt_notional < 15: qty = round_numbers_ceiling(15 / price) - buy_margin_response = self.buy_margin_order(pair, supress_notation(qty, qty_precision)) + buy_margin_response = self.buy_margin_order( + pair, supress_notation(qty, qty_precision) + ) repay_amount, free = self.compute_margin_buy_back(pair) pass @@ -373,7 +462,7 @@ def margin_liquidation(self, pair: str, qty_precision=None): symbol=pair, amount=self.isolated_balance[0]["baseAsset"]["free"], ) - + if borrowed_amount == 0: # Funds are transferred back by now, # disabling pair should be done by cronjob, @@ -384,4 +473,3 @@ def margin_liquidation(self, pair: str, qty_precision=None): raise MarginLoanNotFound("Isolated margin loan already liquidated") return buy_margin_response - diff --git a/api/deals/controllers.py b/api/deals/controllers.py index 8ee380a5a..b7c658dbd 100644 --- a/api/deals/controllers.py +++ b/api/deals/controllers.py @@ -4,7 +4,6 @@ from deals.base import BaseDeal from deals.margin import MarginDeal from deals.models import BinanceOrderModel -from deals.schema import DealSchema, OrderSchema from pymongo import ReturnDocument from tools.enum_definitions import Status from tools.exceptions import ( @@ -59,82 +58,6 @@ def compute_qty(self, pair): qty = round_numbers(balance, self.qty_precision) return qty - def base_order(self): - """ - Required initial order to trigger bot. - Other orders require this to execute, - therefore should fail if not successful - - 1. Initial base purchase - 2. Set take_profit - """ - - pair = self.active_bot.pair - - # Long position does not need qty in take_profit - # initial price with 1 qty should return first match - price = float(self.matching_engine(pair, True)) - qty = round_numbers( - (float(self.active_bot.base_order_size) / float(price)), - self.qty_precision, - ) - # setup stop_loss_price - stop_loss_price = 0 - if float(self.active_bot.stop_loss) > 0: - stop_loss_price = price - (price * (float(self.active_bot.stop_loss) / 100)) - - if self.db_collection.name == "paper_trading": - res = self.simulate_order( - pair, supress_notation(price, self.price_precision), qty, "BUY" - ) - else: - res = self.buy_order( - symbol=pair, - qty=qty, - price=supress_notation(price, self.price_precision), - ) - - order_data = OrderSchema( - timestamp=res["transactTime"], - order_id=res["orderId"], - deal_type="base_order", - pair=res["symbol"], - order_side=res["side"], - order_type=res["type"], - price=res["price"], - qty=res["origQty"], - fills=res["fills"], - time_in_force=res["timeInForce"], - status=res["status"], - ) - - self.active_bot.orders.append(order_data) - tp_price = float(res["price"]) * 1 + (float(self.active_bot.take_profit) / 100) - - self.active_bot.deal = DealSchema( - buy_timestamp=res["transactTime"], - buy_price=res["price"], - buy_total_qty=res["origQty"], - current_price=res["price"], - take_profit_price=tp_price, - stop_loss_price=stop_loss_price, - ) - - # Activate bot - self.active_bot.status = Status.active - - bot = encode_json(self.active_bot) - if "_id" in bot: - bot.pop("_id") # _id is what causes conflict not id - - document = self.db_collection.find_one_and_update( - {"id": self.active_bot.id}, - {"$set": bot}, - return_document=ReturnDocument.AFTER, - ) - - return document - def take_profit_order(self) -> BotSchema: """ take profit order (Binance take_profit) diff --git a/api/deals/margin.py b/api/deals/margin.py index 621a3b2d6..baf7daf67 100644 --- a/api/deals/margin.py +++ b/api/deals/margin.py @@ -683,38 +683,11 @@ def switch_to_long_bot(self, new_base_order_price): 3. Create deal """ self.update_deal_logs("Resetting bot for long strategy...") - new_id = self.create_new_bot_streaming() - self.active_bot.id = new_id - - # Reset bot to prepare for new activation - base_order = next( - ( - bo_deal - for bo_deal in self.active_bot.orders - if bo_deal.deal_type == "base_order" - ), - None, - ) - # start from current stop_loss_price which is where the bot switched to long strategy - tp_price = new_base_order_price * ( - 1 + (float(self.active_bot.take_profit) / 100) - ) - if float(self.active_bot.stop_loss) > 0: - stop_loss_price = new_base_order_price - ( - new_base_order_price * (float(self.active_bot.stop_loss) / 100) - ) - else: - stop_loss_price = 0 - - self.active_bot.deal = DealSchema( - buy_timestamp=base_order.timestamp, - buy_price=new_base_order_price, - buy_total_qty=base_order.qty, - take_profit_price=tp_price, - stop_loss_price=stop_loss_price, - ) self.active_bot.strategy = Strategy.long - self.active_bot.status = Status.active + self.active_bot = self.create_new_bot_streaming() + + bot = self.base_order() + self.active_bot = BotSchema.parse_obj(bot) # Keep bot up to date in the DB # this avoid unsyched bots when errors ocurr in other functions diff --git a/api/deals/spot.py b/api/deals/spot.py index 46e1ef876..7f504f28f 100644 --- a/api/deals/spot.py +++ b/api/deals/spot.py @@ -9,7 +9,6 @@ from tools.exceptions import ( TraillingProfitError, NotEnoughFunds, - TerminateStreaming ) from tools.enum_definitions import Status, Strategy from tools.round_numbers import round_numbers, supress_notation @@ -41,38 +40,12 @@ def switch_margin_short(self, new_base_order_price: float): 3. Create deal """ self.update_deal_logs("Resetting bot for margin_short strategy...") - new_id = self.create_new_bot_streaming() - self.active_bot.id = new_id - - # Reset bot to prepare for new activation - base_order = next( - ( - bo_deal - for bo_deal in self.active_bot.orders - if bo_deal.deal_type == "base_order" - ), - None, - ) - # start from current stop_loss_price which is where the bot switched to long strategy - tp_price = float(new_base_order_price) * ( - 1 + (float(self.active_bot.take_profit) / 100) - ) - if float(self.active_bot.stop_loss) > 0: - stop_loss_price = float(new_base_order_price) - ( - float(new_base_order_price) * (float(self.active_bot.stop_loss) / 100) - ) - else: - stop_loss_price = 0 - - self.active_bot.deal = DealSchema( - buy_timestamp=base_order.timestamp, - buy_price=float(new_base_order_price), - buy_total_qty=base_order.qty, - take_profit_price=tp_price, - stop_loss_price=stop_loss_price, - ) self.active_bot.strategy = Strategy.margin_short - self.active_bot.status = Status.active + self.active_bot = self.create_new_bot_streaming() + + self.active_bot = MarginDeal( + bot=self.active_bot, db_collection_name=self.db_collection.name + ).margin_short_base_order() self.save_bot_streaming() return self.active_bot diff --git a/api/tools/exceptions.py b/api/tools/exceptions.py index 71007cb15..777dcf4f5 100644 --- a/api/tools/exceptions.py +++ b/api/tools/exceptions.py @@ -71,6 +71,9 @@ class ShortStrategyError(OpenDealError): pass +class DealCreationError(Exception): + pass + class TerminateStreaming(Exception): """ This is required sometimes