Source code for unicorn_binance_trailing_stop_loss.manager

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# File: unicorn_binance_trailing_stop_loss/manager.py
#
# Part of ‘UNICORN Binance Trailing Stop Loss’
# Project website: https://www.lucit.tech/unicorn-binance-trailing-stop-loss.html
# Github: https://github.com/LUCIT-Systems-and-Development/unicorn-binance-trailing-stop-loss
# Documentation: https://unicorn-binance-trailing-stop-loss.docs.lucit.tech
# PyPI: https://pypi.org/project/unicorn-binance-trailing-stop-loss
# LUCIT Online Shop: https://shop.lucit.services/software
#
# License: LSOSL - LUCIT Synergetic Open Source License
# https://github.com/LUCIT-Systems-and-Development/unicorn-binance-websocket-api/blob/master/LICENSE
#
# Author: LUCIT Systems and Development
#
# Copyright (c) 2022-2023, LUCIT Systems and Development (https://www.lucit.tech)
# All rights reserved.

from .licensing_manager import LucitLicensingManager, NoValidatedLucitLicense
from unicorn_binance_rest_api import BinanceRestApiManager, BinanceAPIException
from unicorn_binance_websocket_api import BinanceWebSocketApiManager, UnknownExchange
from typing import Optional, Union
import cython
import datetime
import logging
import math
import platform
import smtplib
import requests
import socket
import ssl
import sys
import threading
import time

__app_name__ = "unicorn-binance-trailing-stop-loss"
__version__ = "1.1.0.dev"
__logger__ = logging.getLogger("unicorn_binance_trailing_stop_loss")


[docs] class BinanceTrailingStopLossManager(threading.Thread): """ After starting the engine, a stop/loss order is placed and trailed until it is completely fulfilled. If desired, a notification can be sent via email and Telegram afterward. Then it calls the function passed with the `callback_finished` parameter or on error it calls the function passed to `callback_error`. Partially filled orders are currently not handled by the engine. If you want to react individually to this event, you can use the function provided to `callback_partially_filled`. In addition, there is a smart entry option called `jump-in-and-trail`. This offers the possibility to buy spot, future and margin assets with a limit or market order and then to trail a stop/loss order until sold. Supported exchanges: binance.com, binance.com-testnet, binance.com-futures, binance.com-margin, binance.com-isolated_margin :param api_key: Provide the Binance API key. :type api_key: str :param api_secret: Provide the Binance API secret. :type api_secret: str :param borrow_threshold: Provide the private Binance key. :type borrow_threshold: str :param callback_error: Callback function used if an error occurs. :type callback_error: function or None :param callback_finished: Callback function used if stop_loss gets filled. :type callback_finished: function or None :param callback_partially_filled: Callback function used if stop_loss gets partially filled filled. :type callback_partially_filled: function or None :param disable_colorama: set to True to disable the use of `colorama <https://pypi.org/project/colorama/>`_ :type disable_colorama: bool :param engine: Option `trail` (default) for standard trailing stop/loss or `jump-in-and-trail` to activate smart entry function. :type engine: str :param exchange: Choose the exchange endpoint: binance.com, binance.com-futures, binance.com-margin, binance.com-isolated_margin :type exchange: str :param keep_threshold: If empty we sell the full balance, use integer or percent values. :type keep_threshold: str :param market: The market to enforce stop/loss. :type market: str :param print_notifications: If True the lib is printing user friendly information to terminal. :type print_notifications: bool :param reset_stop_loss_price: Reset an existing stop_loss_price and calculate a new one. Only True is True, anything else is False! :type reset_stop_loss_price: bool :param send_to_email_address: Email address of receiver :type send_to_email_address: str :param send_from_email_address: Email address of sender :type send_from_email_address: str :param send_from_email_password: Password for SMTP auth :type send_from_email_password: str :param send_from_email_server: Hostname or IP of SMTP server :type send_from_email_server: str :param send_from_email_port: Port of SMTP server :type send_from_email_port: int :param start_engine: Start the trailing stop loss engine. Default is True :type start_engine: bool :param stop_loss_limit: The limit is used to calculate the `stop_loss_price` of the highest given price, use integer or percent values. :type stop_loss_limit: str :param stop_loss_order_type: Can be `limit` or `market` - default is None which leads to a stop of the algorithm. :type stop_loss_order_type: str :param stop_loss_price: Set a price to use for stop/loss, this is valid till it get overwritten with a higher price. :type stop_loss_price: float :param stop_loss_start_limit: The trailing stop/loss order is trailed with the distance defined in `stop_loss_limit`. If you want to use a different value at the start, you can specify it with `stop_loss_start_limit`. This value will be used instead of the `stop_loss_limit` value until this value is caught up and then trailed. :type stop_loss_start_limit: str :param stop_loss_trigger_gap: Gap between stopPrice and limit order price, use integer or percent values. :type stop_loss_trigger_gap: str :param test: Use this to test specific systems like "notification", "binance-connectivity" and "streams". The streams test needs a valid exchange and market. If test is not None the engine will NOT start! It only tests! :type test: str :param telegram_bot_token: Token to connect with Telegram API. :type telegram_bot_token: str :param telegram_send_to: Receiver of the message sent via Telegram. :type telegram_send_to: str :param trading_fee_use_bnb: Default is False. Set to True to use BNB for a discount on trading fees: https://www.binance.com/en/support/faq/115000583311. :type trading_fee_use_bnb: bool :param warn_on_update: set to `False` to disable the update warning :type warn_on_update: bool :param lucit_api_secret: The `api_secret` of your UNICORN Binance Suite license from https://shop.lucit.services/software/unicorn-binance-suite :type lucit_api_secret: str :param lucit_license_ini: Specify the path including filename to the config file (ex: `~/license_a.ini`). If not provided lucitlicmgr tries to load a `lucit_license.ini` from `/home/oliver/.lucit/`. :type lucit_license_ini: str :param lucit_license_profile: The license profile to use. Default is 'LUCIT'. :type lucit_license_profile: str :param lucit_license_token: The `license_token` of your UNICORN Binance Suite license from https://shop.lucit.services/software/unicorn-binance-suite :type lucit_license_token: str :param ubra_manager: Provide a shared unicorn_binance_rest_api.manager instance :type ubra_manager: BinanceRestApiManager :param ubwa_manager: Provide a shared unicorn_binance_websocket_api.manager instance. :type ubwa_manager: BinanceWebSocketApiManager """ def __init__(self, api_key: str = None, api_secret: str = None, borrow_threshold: str = None, callback_error: Optional[type(abs)] = None, callback_finished: Optional[type(abs)] = None, callback_partially_filled: Optional[type(abs)] = None, disable_colorama: bool = False, engine: str = "trail", exchange: str = "binance.com", keep_threshold: str = None, market: str = None, print_notifications: bool = False, reset_stop_loss_price: bool = False, send_to_email_address: str = None, send_from_email_address: str = None, send_from_email_password: str = None, send_from_email_server: str = None, send_from_email_port: int = None, start_engine: bool = True, stop_loss_limit: str = None, stop_loss_order_type: str = None, stop_loss_price: float = None, stop_loss_start_limit: str = None, stop_loss_trigger_gap: str = "0.01", telegram_bot_token: str = None, telegram_send_to: str = None, test: str = None, trading_fee_discount_futures_percent: float = 10.0, trading_fee_discount_margin_percent: float = 25.0, trading_fee_discount_spot_percent: float = 25.0, trading_fee_percent: float = 0.1, trading_fee_use_bnb: bool = False, lucit_api_secret: str = None, lucit_license_ini: str = None, lucit_license_profile: str = None, lucit_license_token: str = None, ubra_manager: Optional[Union[BinanceRestApiManager]] = None, ubwa_manager: Optional[Union[BinanceWebSocketApiManager]] = None, warn_on_update=True): threading.Thread.__init__(self) self.name = __app_name__ self.logger = __logger__ self.version = __version__ self.logger.info(f"New instance of {self.get_user_agent()}-{'compiled' if cython.compiled else 'source'} on " f"{str(platform.system())} {str(platform.release())} for exchange {exchange} started") self.api_key = api_key self.api_secret = api_secret self.borrow_threshold = borrow_threshold self.callback_error = callback_error self.callback_finished = callback_finished self.callback_partially_filled = callback_partially_filled self.current_price: float = 0.0 self.engine = engine self.exchange = exchange self.exchange_info: dict = {} self.keep_threshold = keep_threshold self.last_update_check_github = {'timestamp': time.time(), 'status': {'tag_name': None}} self.lock_create_stop_loss_order = threading.Lock() self.precision_price: int = 8 self.precision_quantity: int = 8 self.print_notifications = print_notifications self.reset_stop_loss_price = True if reset_stop_loss_price is True else False self.send_to_email_address = send_to_email_address self.send_from_email_address = send_from_email_address self.send_from_email_password = send_from_email_password self.send_from_email_server = send_from_email_server self.send_from_email_port = send_from_email_port self.start_engine = start_engine self.stop_loss_asset_name: str = "" self.stop_loss_asset_amount: float = 0.0 self.stop_loss_asset_amount_free: float = 0.0 self.stop_loss_limit = stop_loss_limit self.market = market self.stop_loss_order_id: int = 0 self.stop_loss_order_type = stop_loss_order_type self.stop_loss_price: float = None if stop_loss_price is None else float(stop_loss_price) self.stop_loss_start_limit = stop_loss_start_limit self.stop_loss_quantity: float = 0.0 self.stop_loss_trigger_gap = stop_loss_trigger_gap self.stop_loss_request: bool = False self.stop_manager_request: bool = False self.symbol_info: dict = {} self.telegram_bot_token = telegram_bot_token self.telegram_send_to = telegram_send_to self.test = test self.trade_stream_id = None self.trading_fee_discount_futures_percent = trading_fee_discount_futures_percent self.trading_fee_discount_margin_percent = trading_fee_discount_margin_percent self.trading_fee_discount_spot_percent = trading_fee_discount_spot_percent self.trading_fee_percent = trading_fee_percent self.trading_fee_use_bnb = trading_fee_use_bnb self.user_stream_id = None self.lucit_api_secret = lucit_api_secret self.lucit_license_ini = lucit_license_ini self.lucit_license_profile = lucit_license_profile self.lucit_license_token = lucit_license_token self.ubra = ubra_manager self.ubwa = ubwa_manager self.llm = LucitLicensingManager(api_secret=self.lucit_api_secret, license_ini=self.lucit_license_ini, license_profile=self.lucit_license_profile, license_token=self.lucit_license_token, parent_shutdown_function=self.stop_manager, program_used=self.name, needed_license_type="UNICORN-BINANCE-SUITE", start=True) licensing_exception = self.llm.get_license_exception() if licensing_exception is not None: raise NoValidatedLucitLicense(licensing_exception) self.ubra: BinanceRestApiManager = ubra_manager or BinanceRestApiManager(api_key=self.api_key, api_secret=self.api_secret, exchange=self.exchange, disable_colorama=disable_colorama, warn_on_update=warn_on_update, lucit_api_secret=self.lucit_api_secret, lucit_license_ini=self.lucit_license_ini, lucit_license_profile=self.lucit_license_profile, lucit_license_token=self.lucit_license_token) if warn_on_update and self.is_update_available(): update_msg = f"Release {self.name}_{self.get_latest_version()} is available, please consider updating! " \ f"(Changelog: https://unicorn-binance-trailing-stop-loss.docs.lucit.tech/changelog.html)" print(update_msg) self.logger.warning(update_msg) try: self.ubwa: BinanceWebSocketApiManager = ubwa_manager or \ BinanceWebSocketApiManager(exchange=self.exchange, output_default="UnicornFy", disable_colorama=disable_colorama, high_performance=True, warn_on_update=warn_on_update, lucit_api_secret=self.lucit_api_secret, lucit_license_ini=self.lucit_license_ini, lucit_license_profile=self.lucit_license_profile, lucit_license_token=self.lucit_license_token, ubra_manager=self.ubra, show_secrets_in_logs=True) except UnknownExchange: self.logger.critical("BinanceTrailingStopLossManager() - Please use a valid exchange!") if test is None or "streams" in str(test): if self.print_notifications: print(f"Please use a valid exchange!") sys.exit(1) if test is None and start_engine is True: msg = f"Starting the ubtsl engine" self.logger.info(msg) if self.print_notifications: print(msg) self.start() elif test == "notification": msg = f"Starting notification test" self.logger.info(msg) if self.print_notifications: print(msg) notification_text = f"Subject: unicorn-binance-trailing-stop-loss notificaton test\n\nTest notification" if self.send_email_notification(notification_text): msg = f"E-Mail sent, please check for incoming messages!" self.logger.info(msg) if self.print_notifications: print(msg) if self.send_telegram_notification(notification_text): msg = f"Telegram sent, please check for incoming messages!" self.logger.info(msg) if self.print_notifications: print(msg) elif test == "binance-connectivity": msg = f"Starting connectivity test to Binance API" self.logger.info(msg) if self.print_notifications: print(msg) try: response = self.ubra.get_account() if response['makerCommission']: if self.print_notifications: msg = f"Connection to Binance API successfully established!" self.logger.error(msg) if self.print_notifications: print(msg) except BinanceAPIException as error_msg: self.logger.error(error_msg) if self.print_notifications: print(error_msg) elif "streams" in str(test): msg = f"Starting streams test" test_time_in_seconds = str(test).replace("streams", "") if test_time_in_seconds == "": test_time_in_seconds = 0 else: test_time_in_seconds = int(test_time_in_seconds) self.logger.info(msg) if self.print_notifications: print(msg) self.start_streams() try: i = 0 while self.is_manager_stopping() is False: i += 1 self.ubwa.print_summary(title=f"UNICORN Binance Trailing Stop Loss {self.version} - " f"Testing streams") print(f"Press CTRL+C to leave this test!\r\n") if test_time_in_seconds == 0 or test_time_in_seconds > i: time.sleep(1) else: break except KeyboardInterrupt: print("\nStopping ... just wait a few seconds!") self.stop_manager() sys.exit(0) else: if test is not None: msg = f"Stopping, test `{test}` is an invalid option!" self.logger.error(msg) if self.print_notifications: print(msg) def __enter__(self): self.logger.debug(f"Entering 'with-context' ...") return self def __exit__(self, exc_type, exc_value, error_traceback): self.logger.debug(f"Leaving 'with-context' ...") self.stop_manager() if exc_type: self.logger.critical(f"An exception occurred: {exc_type} - {exc_value} - {error_traceback}")
[docs] def calculate_stop_loss_amount(self, amount: float ) -> Optional[float]: """ Calculate the tradeable stop/loss asset amount (= owning and free - trading fee) :param amount: The full owning asset amount. :type amount: float :return: float or None """ self.logger.debug(f"BinanceTrailingStopLossManager.calculate_stop_loss_amount() - Calculation stop/loss " f"amount without trading fee") fee = self.trading_fee_percent final_fee = 0 if self.exchange == "binance.com": final_fee = fee elif self.exchange == "binance.com-futures": final_fee = fee elif self.exchange == "binance.com-margin": final_fee = fee / 100 * (100-self.trading_fee_discount_margin_percent) elif self.exchange == "binance.com-isolated_margin": final_fee = fee / 100 * (100-self.trading_fee_discount_margin_percent) amount_without_fee = amount/100*(100-final_fee) return amount_without_fee
[docs] @staticmethod def calculate_stop_loss_price(price: Union[str, float] = None, limit: Union[str, float] = None ) -> Optional[float]: """ Calculate the stop/loss price. :param price: Base price used for the calculation :type price: float, str :param limit: Stop loss limit in percent or as fixed float value :type limit: float, str :return: float or None """ __logger__.debug(f"BinanceTrailingStopLossManager.calculate_stop_loss_price() - Calculation stop/loss price " f"of base price: {price}, limit: {limit}") price = float(price) if "%" in str(limit): limit_percent = float(limit.rstrip("%")) sl_price = float(price / 100) * float(100.0 - limit_percent) else: sl_price = price - float(limit) return BinanceTrailingStopLossManager.round_decimals_down(sl_price, 2)
[docs] def cancel_open_stop_loss_order(self) -> bool: """ Cancel all open stop/loss orders. :return: bool """ open_orders = self.get_open_orders(market=self.market) if open_orders: for open_order in open_orders: if open_order['type'] == "STOP_LOSS_LIMIT": self.logger.info(f"BinanceTrailingStopLossManager.cancel_open_stop_loss_order() - Cancelling " f"open STOP_LOSS_LIMIT order (orderID={open_order['orderId']}) " f"with stop_loss_price={open_order['price']}.") try: if self.exchange == "binance.com" or self.exchange == "binance.com-testnet": canceled_order = self.ubra.cancel_order(symbol=self.market, orderId=open_order['orderId']) elif self.exchange == "binance.com-isolated_margin": canceled_order = self.ubra.cancel_margin_order(symbol=self.market, isIsolated="TRUE", orderId=open_order['orderId']) elif self.exchange == "binance.com-margin": canceled_order = self.ubra.cancel_margin_order(symbol=self.market, orderId=open_order['orderId']) elif self.exchange == "binance.com-futures": canceled_order = self.ubra.futures_cancel_order(symbol=self.market, orderId=open_order['orderId']) else: self.logger.info( f"BinanceTrailingStopLossManager.create_stop_loss_order() - Invalid exchange " f"`{self.exchange}`") if self.print_notifications: print(f"Invalid exchange `{self.exchange}`") return False except BinanceAPIException as error_msg: self.logger.error(f"BinanceTrailingStopLossManager.cancel_open_stop_loss_order() - " f"error_msg: {error_msg}") return False self.logger.info(f"BinanceTrailingStopLossManager.cancel_open_stop_loss_order() - New " f"order_status of orderID={canceled_order['orderId']} is" f" {canceled_order['status']}.") return True self.logger.info(f"BinanceTrailingStopLossManager.cancel_open_stop_loss_order() - No open order for " f"cancellation found!") return False
[docs] def create_stop_loss_order(self, stop_loss_price: float = None, current_price: float = None) -> bool: """ Create a stop/loss order! :param stop_loss_price: Price to set for the SL order. :type stop_loss_price: float :param current_price: Current price is optional and only used for logging. :type current_price: float :return: bool """ order_is_placed = False with self.lock_create_stop_loss_order: if self.stop_loss_price is None and stop_loss_price is not None: self.set_stop_loss_price(stop_loss_price) elif self.stop_loss_price is not None and stop_loss_price is None: self.set_stop_loss_price(self.stop_loss_price) elif self.stop_loss_price is not None and stop_loss_price is not None: if stop_loss_price > self.stop_loss_price: self.set_stop_loss_price(stop_loss_price) else: stop_loss_price = self.stop_loss_price if self.cancel_open_stop_loss_order(): return True total, free = self.update_stop_loss_asset_amount() if self.keep_threshold is not None: stop_loss_quantity = self.round_decimals_down(self.update_stop_loss_quantity(total=total, free=free), self.precision_quantity) else: stop_loss_quantity = self.calculate_stop_loss_amount(free) free = free - stop_loss_quantity self.stop_loss_asset_amount_free = free if current_price is not None: current_price_str = f"current_price={current_price}, " else: current_price_str = "" self.logger.info(f"BinanceTrailingStopLossManager.create_stop_loss_order() - Creating stop/loss " f"order: {current_price_str}" f"stop_price={self.get_stop_loss_trigger_price(stop_loss_price)}, " f"sell_price={stop_loss_price}, " f"owning_amount={total}, " f"owning_amount_free={free}, " f"stop_loss_quantity={stop_loss_quantity}") if stop_loss_quantity == 0: msg = f"Empty stop_loss_quantity in create_stop_loss_order()" self.logger.error(f"BinanceTrailingStopLossManager.create_stop_loss_order() - {msg}") if self.print_notifications: print(f"Stopping because stop_loss_quantity is zero!") self.send_email_notification(msg) self.send_telegram_notification(msg) self.stop_manager() if self.callback_error is not None: self.callback_error(msg) return False while order_is_placed is False: try: if self.exchange == "binance.com" or self.exchange == "binance.com-testnet": new_order = self.ubra.create_order(symbol=self.market, side="SELL", type="STOP_LOSS_LIMIT", price=self.stop_loss_price, stopPrice=self.get_stop_loss_trigger_price(stop_loss_price), quantity=str(round(stop_loss_quantity, self.precision_quantity)), timeInForce="GTC") elif self.exchange == "binance.com-isolated_margin": new_order = self.ubra.create_margin_order(symbol=self.market, isIsolated="TRUE", side="SELL", type="STOP_LOSS_LIMIT", price=self.stop_loss_price, stopPrice=self.get_stop_loss_trigger_price(stop_loss_price), quantity=str(round(stop_loss_quantity, self.precision_quantity)), timeInForce="GTC") elif self.exchange == "binance.com-margin": new_order = self.ubra.create_margin_order(symbol=self.market, side="SELL", type="STOP_LOSS_LIMIT", price=self.stop_loss_price, stopPrice=self.get_stop_loss_trigger_price(stop_loss_price), quantity=str(round(stop_loss_quantity, self.precision_quantity)), timeInForce="GTC") elif self.exchange == "binance.com-futures": new_order = self.ubra.futures_create_order(symbol=self.market, side="SELL", type="STOP_LOSS_LIMIT", price=self.stop_loss_price, stopPrice=self.get_stop_loss_trigger_price(stop_loss_price), quantity=str(round(stop_loss_quantity, self.precision_quantity)), timeInForce="GTC") else: self.logger.info(f"BinanceTrailingStopLossManager.create_stop_loss_order() - Invalid exchange " f"`{self.exchange}`") if self.print_notifications: print(f"Invalid exchange `{self.exchange}`") return False self.stop_loss_order_id = new_order['orderId'] self.logger.info(f"BinanceTrailingStopLossManager.create_stop_loss_order() - Created stop/loss " f"order for market {new_order['symbol']} - Response: {new_order}.") if self.print_notifications: print(f"Created stop/loss order for market {new_order['symbol']}: " f"stop_loss_price={self.stop_loss_price} and " f"stop_loss_quantity={self.stop_loss_quantity}") order_is_placed = True except BinanceAPIException as error_msg: if "code=-2010" in str(error_msg): waiting_time = 5 self.logger.info(f"BinanceTrailingStopLossManager.create_stop_loss_order() - Retrying in " f"{waiting_time} seconds") if self.print_notifications: print(f"Retrying in {waiting_time} seconds") time.sleep(waiting_time) else: self.logger.error(f"BinanceTrailingStopLossManager.create_stop_loss_order() - {error_msg}") if self.print_notifications: print(f"Can not create stop/loss order! error: {error_msg}") return False return True
[docs] @staticmethod def get_latest_release_info(): """ Get infos about the latest available release :return: dict or False """ try: respond = requests.get('https://api.github.com/repos/LUCIT-Systems-and-Development/unicorn-binance-trailing' '-stop-loss/releases/latest') latest_release_info = respond.json() return latest_release_info except KeyError as error_msg: __logger__.error(f"BinanceTrailingStopLossManager.get_latest_release_info() - {error_msg}") return False
[docs] def get_latest_version(self) -> Optional[str]: """ Get the version of the latest available release (cache time 1 hour) :return: str or None """ # Do a fresh request if status is None or last timestamp is older 1 hour if self.last_update_check_github['status']['tag_name'] is None or \ (self.last_update_check_github['timestamp']+(60*60) < time.time()): latest_release = self.get_latest_release_info() try: self.last_update_check_github['status']['tag_name'] = latest_release['tag_name'] except KeyError as error_msg: self.logger.error(f"BinanceTrailingStopLossManager.get_latest_version() - KeyError: {error_msg}") return None return self.last_update_check_github['status']['tag_name']
[docs] def get_exchange_info(self) -> Union[dict, bool]: """ Get the exchange info. :return: dict or bool """ if self.exchange == "binance.com" or self.exchange == "binance.com-testnet" or \ self.exchange == "binance.com-margin" or self.exchange == "binance.com-isolated_margin": self.exchange_info = self.ubra.get_exchange_info() elif self.exchange == "binance.com-futures": self.exchange_info = self.ubra.futures_exchange_info() else: self.logger.error(f"BinanceTrailingStopLossManager.get_exchange_info() - Invalid exchange " f"`{self.exchange}`") if self.print_notifications: print(f"Invalid exchange `{self.exchange}`") return False for item in self.exchange_info['symbols']: if item['symbol'] == self.market: return item return False
[docs] def get_open_orders(self, market: str = None) -> Optional[dict]: """ Get the owning amount of the stop/loss asset. :return: dict or None """ try: if self.exchange == "binance.com" or self.exchange == "binance.com-testnet": open_orders = self.ubra.get_open_orders(symbol=market) elif self.exchange == "binance.com-futures": open_orders = self.ubra.futures_get_open_orders(symbol=market) elif self.exchange == "binance.com-margin": open_orders = self.ubra.get_open_margin_orders(symbol=market) elif self.exchange == "binance.com-isolated_margin": open_orders = self.ubra.get_open_margin_orders(symbol=market, isIsolated="TRUE") else: return None return open_orders except BinanceAPIException as error_msg: self.logger.error(f"BinanceTrailingStopLossManager.get_open_orders() - {error_msg}") return None
[docs] def get_owning_amount(self, base_asset: str = None) -> Optional[tuple]: """ Get the owning amount of the stop/loss asset. :return: tuple (total, free) or None """ try: if self.exchange == "binance.com" or self.exchange == "binance.com-testnet": account_info = self.ubra.get_account() elif self.exchange == "binance.com-futures": account_info = self.ubra.futures_account() elif self.exchange == "binance.com-margin": account_info = self.ubra.get_margin_account() elif self.exchange == "binance.com-isolated_margin": account_info = self.ubra.get_isolated_margin_account() else: self.logger.error(f"BinanceTrailingStopLossManager.get_owning_amount() - Invalid exchange " f"`{self.exchange}`") if self.print_notifications: print(f"Invalid exchange `{self.exchange}`") return None except BinanceAPIException as error_msg: self.logger.error(f"BinanceTrailingStopLossManager.get_owning_amount() - {error_msg}") return None if self.exchange == "binance.com": for item in account_info['balances']: base_asset_pool = item if base_asset_pool['asset'] == base_asset: self.logger.info(f"BinanceTrailingStopLossManager.get_owning_amount() - Owning " f"{base_asset_pool['asset']}: free={base_asset_pool['free']}, " f"total={base_asset_pool['free']})") return float(base_asset_pool['free']), float(base_asset_pool['free']) else: for item in account_info['assets']: base_asset_pool = item['baseAsset'] if base_asset_pool['asset'] == base_asset: self.logger.info(f"BinanceTrailingStopLossManager.get_owning_amount() - Owning " f"{base_asset_pool['asset']}: free={base_asset_pool['free']}, " f"total={base_asset_pool['totalAsset']} " f"(interest={base_asset_pool['interest']})") return float(base_asset_pool['totalAsset']), float(base_asset_pool['free']) return None
[docs] @staticmethod def get_precision(step_size=None): if step_size is None: return None parts = str(step_size).split('.') if len(parts) == 2: count = 0 for char in parts[1]: count += 1 if char == '1': return count return count else: return 0
[docs] def get_stop_loss_asset_amount(self) -> Optional[float]: """ Get the current stop/loss asset amount. :return: float """ return self.round_decimals_down(self.stop_loss_asset_amount, self.precision_quantity)
[docs] def get_stop_loss_asset_amount_free(self) -> Optional[float]: """ Get the free current stop/loss asset amount. :return: float """ return self.round_decimals_down(self.stop_loss_asset_amount_free, self.precision_quantity)
[docs] def get_stop_loss_price(self) -> Optional[float]: """ Get the current stop loss price. :return: float """ if self.exchange == "binance.com": if self.symbol_info['quoteAsset'] == "USDT": return self.round_decimals_down(self.stop_loss_price, 2) else: return self.stop_loss_price else: if self.symbol_info['quote'] == "USDT": return self.round_decimals_down(self.stop_loss_price, 2) else: return self.stop_loss_price
[docs] def get_stop_loss_trigger_price(self, stop_loss_price: float = 0.0) -> Optional[float]: """ Get the current stop/loss trigger price - if this price gets touched the limit order will get placed in the orderbook. :return: float """ if "%" in self.stop_loss_trigger_gap: gap_percent = float(self.stop_loss_trigger_gap.rstrip("%")) trigger_gap = float(self.get_stop_loss_price()/100)*float(100.0-gap_percent) else: trigger_gap = float(self.stop_loss_trigger_gap) trigger_gap = float(self.round_decimals_down(trigger_gap, self.precision_quantity)) precision = self.precision_quantity if stop_loss_price == 0: stop_loss_price = self.stop_loss_price trigger_price = round(stop_loss_price + trigger_gap, 2) if len(str(trigger_price).split(".")[1]) <= precision: return trigger_price else: return self.round_decimals_down(trigger_price, precision)
[docs] def get_symbol_info(self, symbol: str = None) -> Optional[dict]: """ Get the symbol info of the stop/loss asset. :return: dict """ try: if self.exchange == "binance.com": symbol_info = self.ubra.get_symbol_info(symbol=symbol) elif self.exchange == "binance.com-futures": symbol_info = self.ubra.get_symbol_info(symbol=symbol) elif self.exchange == "binance.com-margin": symbol_info = self.ubra.get_margin_symbol(symbol=symbol) elif self.exchange == "binance.com-isolated_margin": symbol_info = self.ubra.get_isolated_margin_symbol(symbol=symbol) else: symbol_info = None return symbol_info except BinanceAPIException as error_msg: self.logger.error(f"BinanceTrailingStopLossManager.get_symbol_info() - {error_msg}") if "APIError(code=-2008): Invalid Api-Key ID" in error_msg: if self.print_notifications: print(f"ERROR: Not able to fetch `symbol_info`. {error_msg}") sys.exit(1) return None
[docs] def get_user_agent(self): """ Get the user_agent string "lib name + lib version + python version" :return: """ user_agent = f"{self.name}_{str(self.get_version())}-python_{str(platform.python_version())}" return user_agent
[docs] @staticmethod def get_version() -> str: """ Get the package/module version :return: str """ return __version__
[docs] def is_manager_stopping(self): """ Returns `True` if the manager has a stop request, 'False' if not. :return: bool """ if self.stop_manager_request is False: return False else: return True
[docs] def is_update_available(self) -> bool: """ Is a new release of this package available? :return: bool """ self.logger.debug(f"BinanceTrailingStopLossManager.is_update_available() - Starting the request") installed_version = self.get_version() if ".dev" in installed_version: installed_version = installed_version[:-4] if self.get_latest_version() == installed_version: return False elif self.get_latest_version() is None: return False else: return True
[docs] def process_userdata_stream(self, stream_data: dict = None, stream_buffer_name=False): """ Process the received data of the userData stream. :return: bool """ self.logger.debug(f"BinanceTrailingStopLossManager.process_userdata_stream(stream_data={stream_data}, " f"stream_buffer_name={stream_buffer_name}) started") if self.is_manager_stopping() is False: if stream_data['event_type'] == "executionReport": if stream_data['order_id'] == self.stop_loss_order_id: if stream_data['current_order_status'] == "FILLED": msg = f"Subject: unicorn-binance-trailing-stop-loss '{self.market}'\n\n" \ f"STOP LOSS FILLED at price {stream_data['order_price']} (order_id={stream_data['order_id']})" msg_short = f"STOP LOSS FILLED at price {stream_data['order_price']} " \ f"(order_id={stream_data['order_id']})" log_msg_short = " ".join(msg_short.strip()) self.logger.info(f"BinanceTrailingStopLossManager.process_userdata_stream() - {log_msg_short}") if self.print_notifications: print(msg_short) self.send_telegram_notification(msg) self.send_email_notification(msg) self.stop_manager() if self.callback_finished is not None: self.callback_finished(stream_data) return True elif stream_data['current_order_status'] == "CANCELED": self.logger.info(f"BinanceTrailingStopLossManager.process_userdata_stream() - " f"Received CANCELED event, trigger creation of new order") if self.print_notifications: print("Received CANCELED event, creating a new order") self.create_stop_loss_order(self.stop_loss_price, current_price=self.current_price) return False elif stream_data['current_order_status'] == "PARTIALLY_FILLED": self.logger.warning(f"BinanceTrailingStopLossManager.process_userdata_stream() - " f"Received PARTIALLY_FILLED event") if self.print_notifications: print("Received PARTIALLY_FILLED event") if self.callback_partially_filled is not None: self.callback_partially_filled(stream_data) return False elif stream_data['current_order_status'] == "NEW": self.logger.debug(f"BinanceTrailingStopLossManager.process_userdata_stream() - Received event: " f"{str(stream_data)}") else: self.logger.critical(f"BinanceTrailingStopLossManager.process_userdata_stream() - Received unknown" f" event: {str(stream_data)}") if self.print_notifications: print("Unknown, please report:", str(stream_data)) elif stream_data['current_order_status'] == "NEW": self.logger.debug(f"BinanceTrailingStopLossManager.process_userdata_stream() - Received event: " f"{str(stream_data)}") elif stream_data['current_order_status'] == "CANCELED": self.logger.debug(f"BinanceTrailingStopLossManager.process_userdata_stream() - Received event: " f"{str(stream_data)}") else: self.logger.debug(f"BinanceTrailingStopLossManager.process_userdata_stream() - " f"Received stream_data: {stream_data}") if self.print_notifications: print("Unknown, please report:", str(stream_data)) elif stream_data['event_type'] == "outboundAccountPosition": self.logger.debug(f"BinanceTrailingStopLossManager.process_userdata_stream() - Received: {stream_data}") else: self.logger.debug(f"BinanceTrailingStopLossManager.process_userdata_stream() - " f"Received unkown stream_data: {stream_data}") if self.print_notifications: print("Unknown, please report:", str(stream_data))
[docs] def process_price_feed_stream(self, stream_data: dict = None, stream_buffer_name=False) -> bool: """ Process the price feed data: Control current price and update `stop_loss_price` or trigger stop/loss if needed. :return: bool """ if "streams" in str(self.test): self.logger.debug(f"BinanceTrailingStopLossManager.process_price_feed_stream() - Not processing in test " f"mode") return True self.logger.debug(f"BinanceTrailingStopLossManager.process_price_feed_stream(stream_data={stream_data}, " f"stream_buffer_name={stream_buffer_name}) started") if self.is_manager_stopping() is False: if stream_data.get('price'): self.current_price = stream_data.get('price') sl_price = self.calculate_stop_loss_price(stream_data.get('price'), self.stop_loss_limit) if self.stop_loss_price is None: self.logger.info(f"BinanceTrailingStopLossManager.process_price_feed_stream() - Setting " f"stop_loss_price from None to {sl_price}!") if self.print_notifications: print(f"Setting stop_loss_price from None to {sl_price}!") self.create_stop_loss_order(sl_price, current_price=stream_data.get('price')) elif self.stop_loss_price < sl_price: self.logger.info(f"BinanceTrailingStopLossManager.process_price_feed_stream() - Setting " f"stop_loss_price from {self.stop_loss_price} to {sl_price}!") if self.print_notifications: print(f"Setting stop_loss_price from {self.stop_loss_price} to {sl_price}!") self.create_stop_loss_order(sl_price, current_price=stream_data.get('price'))
[docs] @staticmethod def round_decimals_down(number: float, decimals: int = 2) -> float: """ Returns a value rounded down to a specific number of decimal places. :param number: The decimal number to round down. :type number: float :param decimals: How many decimals you want to keep. :type decimals: int :return: float """ if not isinstance(decimals, int): raise TypeError("BinanceTrailingStopLossManager.round_decimals_down() - Decimal places must be an integer") elif decimals < 0: raise ValueError("BinanceTrailingStopLossManager.round_decimals_down() - Decimal places has to be 0 or " "more") elif decimals == 0: return math.floor(number) else: factor = 10 ** decimals return math.floor(number * factor) / factor
[docs] def start_streams(self) -> bool: """ Procedure to start the web streams :return: bool """ if self.exchange == "binance.com-isolated_margin": symbol = self.market else: symbol = False self.user_stream_id = self.ubwa.create_stream("arr", "!userData", api_key=self.api_key, api_secret=self.api_secret, process_stream_data=self.process_userdata_stream, symbols=symbol, stream_label="UserData") self.trade_stream_id = self.ubwa.create_stream(channels="aggTrade", markets=self.market, process_stream_data=self.process_price_feed_stream, stream_label="PriceFeed") return True
[docs] def run(self) -> None: """ Start Stop/Loss with provided settings! :return: None """ self.start_streams() if self.stop_loss_start_limit: limit = self.stop_loss_start_limit else: limit = self.stop_loss_limit if self.engine == "jump-in-and-trail": self.logger.info(f"Starting jump-in-and-trail engine") if self.print_notifications: print(f"Starting `jump-in-and-trail` engine") buy_order = None buy_price = None if self.exchange == "binance.com-isolated_margin": isolated_margin_account = self.ubra.get_isolated_margin_account() for item in isolated_margin_account['assets']: if item['symbol'] == self.market: if self.borrow_threshold: loan_details = self.ubra.get_margin_loan_details() print(f"Loan details: {loan_details}") # Todo: Take loan -> gain free quote asset amount_to_buy = isolated_margin_account['assets'][0]['quoteAsset']['free'] try: buy_order = self.ubra.create_margin_order(symbol=self.market, isIsolated="TRUE", side="BUY", type="MARKET", quoteOrderQty=amount_to_buy, sideEffectType="MARGIN_BUY") print(f"Buy order: {buy_order}") # Todo: Calc real buy price (average) buy_price = buy_order['fills'][0]['price'] self.stop_loss_price = self.calculate_stop_loss_price(price=buy_price, limit=limit) except BinanceAPIException as error_msg: msg = f"Stopping because of Binance API exception: {error_msg}" logging.critical(msg) if self.print_notifications: print(msg) if self.callback_error is not None: self.callback_error(msg) return None # We expect only one match, so we leave if we found one break else: msg = f"Option `jump-in-and-trail` in parameter `engine` is not supported for exchange " \ f"'{self.exchange}'!" self.logger.critical(msg) if self.print_notifications: print(msg) sys.exit(1) self.logger.info(f"Jumped in with buy order: {buy_order}") if self.print_notifications: print(f"Jumped in with buy price: {buy_price}") elif self.engine == "trail": msg = f"Starting `trail` engine" self.logger.info(msg) if self.print_notifications: print(msg) else: msg = f"Engine `{self.engine}` is not supported!" self.logger.critical(msg) if self.print_notifications: print(msg) sys.exit(1) self.logger.info(f"BinanceTrailingStopLossManager.run() - Starting trailing stop/loss on {self.exchange} " f"for the market {self.market}") if self.print_notifications: print(f"Starting trailing stop/loss on {self.exchange} for the market {self.market}") self.logger.debug(f"BinanceTrailingStopLossManager.run() - reset_stop_loss_price=" f"{self.reset_stop_loss_price}") self.symbol_info = self.get_symbol_info(symbol=self.market) symbol_info_symbols = self.ubra.get_exchange_info(**{'symbol': self.market})['symbols'] for symbols in symbol_info_symbols: if symbols.get('filters') is not None: for filters in symbols.get('filters'): if filters.get('filterType') == "LOT_SIZE": self.precision_quantity = self.get_precision(filters['stepSize']) self.logger.info(f"BinanceTrailingStopLossManager.run() - used_weight: {self.ubra.get_used_weight()}") if self.symbol_info is None: self.logger.critical(f"BinanceTrailingStopLossManager.run() - `symbol_info` is None") if self.print_notifications: print(f"ERROR: `symbol_info` is None -> Stopping!") self.stop_manager() sys.exit(1) if self.exchange == "binance.com": self.stop_loss_asset_name = self.symbol_info['baseAsset'] else: self.stop_loss_asset_name = self.symbol_info['base'] self.exchange_info = self.get_exchange_info() self.update_stop_loss_asset_amount() self.logger.info(f"BinanceTrailingStopLossManager.start() - Waiting till streams are running") if self.ubwa.wait_till_stream_has_started(self.user_stream_id) and \ self.ubwa.wait_till_stream_has_started(self.trade_stream_id): time.sleep(5) self.logger.info(f"BinanceTrailingStopLossManager.start() - UserData and Trade streams are running!") if self.stop_loss_price is None or self.stop_loss_price == 0.0: if self.reset_stop_loss_price is not True: open_orders = self.get_open_orders(market=self.market) if open_orders: for open_order in open_orders: if open_order['type'] == "STOP_LOSS_LIMIT": self.logger.info(f"BinanceTrailingStopLossManager.start() - Found open STOP_LOSS_LIMIT " f"order with stop_loss_price={open_order['price']}.") self.create_stop_loss_order(float(open_order['price'])) else: self.logger.info(f"BinanceTrailingStopLossManager.start() - No open STOP_LOSS_LIMIT orders found!") else: self.logger.info(f"BinanceTrailingStopLossManager.start() - Resetting old stop_loss_price!") else: self.logger.info(f"BinanceTrailingStopLossManager.start() - Using provided stop_loss_price=" f"{self.stop_loss_price}") self.create_stop_loss_order(self.stop_loss_price)
[docs] def send_email_notification(self, message: str = None) -> bool: """ Send a notification via email! :param message: Text to send via email. :type message: str :return: """ self.logger.debug(f"BinanceTrailingStopLossManager.send_email_notification() - msg: {message}") if self.send_to_email_address \ and self.send_from_email_address \ and self.send_from_email_server \ and self.send_from_email_port: context = ssl.create_default_context() try: with smtplib.SMTP_SSL(self.send_from_email_server, self.send_from_email_port, context=context) as server: server.login(self.send_from_email_address, self.send_from_email_password) server.sendmail(self.send_from_email_address, self.send_to_email_address, message) self.logger.info(f"BinanceTrailingStopLossManager.send_email_notification() - Email sent!") if self.print_notifications: print("Email sent!") return True except socket.gaierror as error_msg: self.logger.info(f"BinanceTrailingStopLossManager.send_email_notification() - {error_msg}") if self.print_notifications: print(f"ERROR: Email not sent! {error_msg}") else: self.logger.debug(f"BinanceTrailingStopLossManager.send_email_notification() - Data for email dispatch not " f"available") return False
[docs] def send_telegram_notification(self, message: str = None) -> bool: """ Send a notification via telegram! :param message: Text to send via Telegram. :type message: str :return: """ self.logger.debug(f"BinanceTrailingStopLossManager.send_telegram_message() - msg: {message}") if self.telegram_send_to \ and self.telegram_bot_token: date = datetime.datetime.now().strftime("%H:%M:%S") msg = message.replace("%25", "%") logging.info(" ".join([msg, "at", date])) request_url = f"https://api.telegram.org/bot{self.telegram_bot_token}/sendMessage?chat_id=" \ f"{self.telegram_send_to}&parse_mode=HTML&text={message}" response = requests.get(request_url) self.logger.info(f"BinanceTrailingStopLossManager.send_telegram_message() - response: {response}") return True else: self.logger.debug(f"BinanceTrailingStopLossManager.send_telegram_message() - Data for Telegram dispatch " f"not available") return False
[docs] def stop(self) -> bool: """ Stop stop_loss! :) :return: bool """ return self.stop_manager()
[docs] def stop_manager(self, close_api_session: bool = True) -> bool: """ Stop stop_loss! :) :return: bool """ self.logger.info(f"BinanceTrailingStopLossManager.stop_manager() - Gracefully stopping " f"unicorn-binance-trailing-stop-loss engine") self.stop_manager_request = True if self.ubwa is not None: self.ubwa.stop_manager() if self.ubra is not None: self.ubra.stop_manager() # close lucit license manger and the api session if close_api_session is True: self.llm.close() return True
[docs] def set_stop_loss_price(self, stop_loss_price: float = None) -> bool: """ Set the stop/loss price. :param stop_loss_price: Price to set for the SL order. :type stop_loss_price: float :return: bool """ self.logger.debug(f"BinanceTrailingStopLossManager.set_stop_loss_price() - " f"Setting new stop_loss_price={stop_loss_price}") self.stop_loss_price = stop_loss_price return True
[docs] def update_stop_loss_quantity(self, total: float = 0.0, free: float = 0.0) -> float: """ Calculate and update the stop_loss_quantity! :param total: Total asset amount :type total: float :param free: Free asset amount :type free: float :return: float """ self.logger.info(f"BinanceTrailingStopLossManager.update_stop_loss_quantity() - Calculating the " f"stop_loss_quantity amount.") if "%" in self.keep_threshold: keep_threshold_percent = float(self.keep_threshold.rstrip("%")) keep_threshold_float = total/100*keep_threshold_percent else: keep_threshold_float = float(self.keep_threshold) if keep_threshold_float > free: msg = f"BinanceTrailingStopLossManager.update_stop_loss_quantity() - Nothing to do - `keep_threshold` " \ f"is greater then `stop_loss_asset_amount_free`!" self.logger.critical(msg) self.send_telegram_notification(msg) self.send_email_notification(msg) self.stop_manager() if self.callback_error is not None: self.callback_error(msg) return False stop_loss_quantity = free - keep_threshold_float self.stop_loss_quantity = stop_loss_quantity return stop_loss_quantity
[docs] def update_stop_loss_asset_amount(self, total: float = None, free: float = None) -> tuple: """ Update the owning asset amount (total, free)! :param total: Total amount of the stop_loss_asset! :type total: float :param free: Free amount of the stop_loss_asset! :type free: float :return: tuple """ if total is None or free is None: total, free = self.get_owning_amount(base_asset=self.stop_loss_asset_name) self.stop_loss_asset_amount = float(total) self.stop_loss_asset_amount_free = float(free) return total, free