DCAv2/trader.py

1720 lines
93 KiB
Python
Executable File

import time
from os import path, remove
from json import dumps, load
from config_handler import ConfigHandler
from status_handler import StatusHandler
class trader:
def __init__(self, broker, pair: str, is_import: bool = False):
#Flags
self.pause = True
self.quit = False
self.restart = False
self.trader_restart_errors = {1: "start_trader returned error #1. Trader will be restarted",
2: "start_trader returned error #2: Initial order never got filled. Trader will be restarted",
3: "start_trader returned error #3: Slippage threshold exceeded. Trader will be restarted"}
#Status string caches
self.low_price_cache = None
self.mid_price_cache = None
self.high_price_cache = None
self.concurrent_so_amount_cache = None
self.broker = broker
self.config = ConfigHandler(pair,broker)
base_quote = self.config.get_pair()
self.base,self.quote = base_quote.split("/")
self.status = StatusHandler(broker, self.base, self.quote)
self.market = self.broker.fetch_market(base_quote)
self.market_load_time = int(time.time())
self.market_reload_period = 86400 #Market reload period in seconds
self.status.set_start_time(int(time.time()))
self.last_time_seen = time.time()
if self.config.get_is_short():
#Check if there is an old_long file. If so, load it.
try:
with open(f"status/{self.base}{self.quote}.oldlong") as ol:
self.status.set_old_long(load(ol))
except Exception as e:
self.broker.logger.log_this(f"Exception: No old_long file. {e}",1,base_quote)
self.profit_filename = f"profits/{self.base}{self.quote}.profits"
self.log_filename = f"logs/{self.base}{self.quote}.log"
self.deals_timestamps = self.broker.get_trades_timestamps(base_quote,self.config.get_boosted_time_range())
self.status.set_pause_reason("Initialization")
if is_import:
self.load_imported_trader()
return None
else:
#Only reloads the value from config file if the trader wasn't running previously.
self.status.set_no_of_safety_orders(self.config.get_no_of_safety_orders())
# An alternative would be to set up a variable like self.is_initalized to false and finish the initialization here.
# Then, in the main loop, check if self.is_initalized is false. If it is, run start_trader.
start_result = self.start_trader()
if start_result==0:
return None #Everything is OK
elif start_result==1: #If initialization fails
self.quit = True
elif start_result==2: #Retries exceeded
if self.config.get_force_restart_if_retries_exhausted():
self.set_pause(False)
self.restart = True
else:
self.quit = True
elif start_result==3: #Not enough liquidity
self.set_pause(False)
self.restart = True
def __str__(self):
return self.status.get_status_string()
def get_color(self, color):
'''
Returns white if color does not exist
'''
colors = {"yellow": "\033[0;33;40m",
"green": "\033[0;32;40m",
"red": "\033[0;31;40m",
"blue": "\033[0;34;40m",
"cyan": "\033[0;36;40m",
"bright_white": "\033[0;97;40m",
"bright_green": "\033[0;92;40m",
"white": "\033[0;37;40m"}
return colors[color] if color in colors else "\033[0;37;40m"
def get_status_dict(self):
return self.status.get_status()
def set_market_load_time(self, period: float) -> int:
self.market_load_time = period
return 0
def get_market_reload_period(self) -> float:
return self.market_reload_period
def start_trader(self) -> int:
'''
Initializes the trader.
'''
#Perhaps we should search for open buy orders from a crashed trader and cancel them?
#Resets some variables
self.status.set_so_amount(0)
self.status.clear_deal_order_history()
self.status.set_take_profit_order(self.broker.get_empty_order())
self.status.set_safety_orders([])
self.status.set_safety_orders_filled(0)
self.status.set_no_of_safety_orders(self.config.get_no_of_safety_orders())
#Reloads the market
new_market_data = self.broker.fetch_market(self.status.get_pair())
if new_market_data is not None:
self.market = new_market_data
self.set_pause(True, "start_trader")
if self.config.get_is_short():
self.broker.logger.log_this("Calculating optimal order size...",2,self.status.get_pair())
#Get minimum order size from exchange
self.broker.logger.log_this("Fetching minimum order size...",2,self.status.get_pair())
min_base_size = self.broker.get_min_base_size(self.status.get_pair())
if min_base_size is None:
self.broker.logger.log_this("Can't fetch the minimum order size",1,self.status.get_pair())
return 1
#Fetch the amount of free base available on the exchange
self.broker.logger.log_this("Fetching free base currency on the exchange...",2,self.status.get_pair())
free_base = self.fetch_free_base()
if free_base is None:
self.broker.logger.log_this("Can't fetch the amount of base at the exchange",1,self.status.get_pair())
return 1
#Buy missing base sold because of errors (unaccounted filled orders, crashes, etc)
if self.status.get_old_long()!={}:
#diff = self.broker.amount_to_precision(self.status.get_pair(), self.status.get_old_long()["tp_amount"] - free_base)
if self.status.get_old_long()["tp_amount"]-free_base>min_base_size:
amount_to_buy = self.broker.amount_to_precision(self.status.get_pair(),self.status.get_old_long()["tp_amount"]-free_base)
self.broker.logger.log_this(f"Buying missing {amount_to_buy} {self.base}",1,self.status.get_pair())
self.broker.new_market_order(self.status.get_pair(),amount_to_buy,"buy",amount_in_base=True)
time.sleep(self.broker.get_wait_time()*2)
#Re-querying for the amount of base currency on the exchange
free_base = self.fetch_free_base()
if free_base is None:
self.broker.logger.log_this("Can't fetch the amount of base at the exchange",1,self.status.get_pair())
return 1
#Calculate order size and amount of safety orders
self.broker.logger.log_this("Calculating the order size...",2,self.status.get_pair())
order_size,no_of_safety_orders = self.calculate_order_size(free_base,min_base_size,self.config.get_max_short_safety_orders())
if order_size is None or no_of_safety_orders is None:
self.broker.logger.log_this("Can't calculate optimal size",1,self.status.get_pair())
return 1
self.status.set_order_size(order_size)
self.status.set_no_of_safety_orders(no_of_safety_orders)
self.broker.logger.log_this(f"Order size: {self.broker.amount_to_precision(self.status.get_pair(),order_size)}. Amount of safety orders: {no_of_safety_orders}",2,self.status.get_pair())
#Write the changes to the config file
self.config.save_to_file()
else:
#Check order size
self.status.set_pause_reason("start_trader - checking order size")
self.broker.logger.log_this("Checking for order size",2,self.status.get_pair())
minimum_order_size_allowed = self.broker.get_min_quote_size(self.status.get_pair())
if minimum_order_size_allowed is not None and minimum_order_size_allowed>self.config.get_order_size():
self.broker.logger.log_this(f"Order size too small. Minimum order size is {minimum_order_size_allowed} {self.quote}",1,self.status.get_pair())
if minimum_order_size_allowed<self.config.get_order_size()*2:
#int(n)+1 is treated here as a simplified ceil function, since minimum_order_size_allowed will always be positive.
self.broker.logger.log_this(f"Due to exchange limits, trader initial order size will be {float(int(minimum_order_size_allowed)+1)} {self.quote}",1,self.status.get_pair())
self.config.set_order_size(float(int(minimum_order_size_allowed)+1))
else:
self.broker.logger.log_this("Limit difference is more than 2x the configured order size. Please adjust the order size in the trader config file and restart the trader.",1,self.status.get_pair())
return 1
#check slippage
if self.config.get_check_slippage():
self.broker.logger.log_this("Checking slippage...",2,self.status.get_pair())
self.status.set_pause_reason("start_trader - checking slippage")
if self.check_orderbook_depth(self.broker.get_slippage_default_threshold(),self.config.get_order_size()):
#Slippage threshold exceeded
self.broker.logger.log_this("Slippage threshold exceeded",1,self.status.get_pair())
return 3
self.status.set_order_size(self.config.get_order_size())
self.status.set_pause_reason("start_trader - after slippage")
#Sending initial order
#
# Here, if the amount of the initial order is already available in the account, don't send a market order; just pull the current price and simulate that the order was sent and filled.
# Cleanup probably would have to be disabled for this to make sense.
#
self.status.set_pause_reason("start_trader - sending first order")
self.broker.logger.log_this("Sending first order...",2,self.status.get_pair())
action = "sell" if self.config.get_is_short() else "buy"
first_order = self.broker.new_market_order(self.status.get_pair(),self.status.get_order_size(),action)
#self.broker.logger.log_this(f"First order id: {first_order}",1,self.status.get_pair())
if first_order in [None,self.broker.get_empty_order()]:
self.broker.logger.log_this(f"Error sending the first order. Market order returned {first_order}",1,self.status.get_pair())
return 1
tries = self.broker.get_retries()*2 #This is really necessary, don't change it. Don't. DON'T.
#Wait until the first order gets filled
self.status.set_pause_reason("start_trader - waiting for the first order to get filled")
while True:
#Wait a bit longer, sometimes a recently filled market order is not updated quickly enough.
# When that happens, the amount of base taken into account by the trader is lower than the amount bought,
# which ends up misrepresenting the trade cost per unit of base, which causes the take profit price to skyrocket.
time.sleep(self.broker.get_wait_after_initial_market_order())
returned_order = self.broker.get_order(first_order["id"],self.status.get_pair())
if returned_order==self.broker.get_empty_order():
self.broker.logger.log_this("Problems with the initial order",1,self.status.get_pair())
return 1
elif returned_order["status"]=="closed":
break
elif returned_order["status"]=="expired":
self.broker.logger.log_this(f"First order expired. Id: {returned_order['id']}",1,self.status.get_pair())
return 1
else:
tries-=1
self.broker.logger.log_this("Waiting for initial order to get filled...",2,self.status.get_pair())
self.broker.logger.log_this(f"Order ID: {returned_order['id']}",2,self.status.get_pair())
if tries==0:
self.broker.logger.log_this("Restart retries exhausted.",0,self.status.get_pair())
self.broker.cancel_order(returned_order["id"],self.status.get_pair())
#self.restart = True #This restart is tricky, it can end up in an endless loop of retries
#At this point, both the take_profit_routine initialization AND the subsequent restart attempt failed.
#Since it only reaches this point very unfrequently, let we'll the trader get stuck in a pause state.
return 2
#Save the order
if self.broker.follow_order_history:
self.status.set_pause_reason("start_trader - saving the order in deal_order_history")
self.status.update_deal_order_history(returned_order, "init")
# Reset the fee count and sum fees from the first order
self.status.set_fees_paid_in_base(self.parse_fees(returned_order)[0])
self.status.set_fees_paid_in_quote(self.parse_fees(returned_order)[1])
self.broker.logger.log_this(f"Fees paid: {self.status.get_fees_paid_in_base()} {self.base}, {self.status.get_fees_paid_in_quote()} {self.quote}",2,self.status.get_pair())
self.broker.logger.log_this(f"Take profit order ID: {returned_order['id']}",2,self.status.get_pair())
# Sum total amount of quote and base
if returned_order["filled"]!=None:
self.status.set_base_bought(returned_order["filled"])
if not self.config.get_is_short():
self.status.set_base_bought(self.status.get_base_bought()-self.status.get_fees_paid_in_base())
self.status.set_quote_spent(returned_order["cost"])
else:
self.broker.logger.log_this("Error starting trader. Aborting.",1,self.status.get_pair())
return 1
# Send the take profit order
self.status.set_pause_reason("start_trader - sending tp order")
self.broker.logger.log_this("Sending take profit order...",2,self.status.get_pair())
if self.send_new_tp_order()==0:
self.broker.logger.log_this("Take profit order sent",2,self.status.get_pair())
else:
self.broker.logger.log_this("Error sending take profit order. Aborting.",1,self.status.get_pair())
return 1
# Generate the safety prices table
self.status.set_start_price(self.broker.price_to_precision(self.status.get_pair(),self.status.get_quote_spent()/self.status.get_base_bought()))
self.status.set_safety_price_table(self.calculate_safety_prices(self.status.get_start_price(),self.status.get_no_of_safety_orders(),self.config.get_safety_order_deviance()))
# Send the initial batch of safety orders
self.status.set_pause_reason("start_trader - sending safety orders")
self.broker.logger.log_this("Sending safety orders...",2,self.status.get_pair())
amount_of_so = self.config.get_concurrent_safety_orders() if not self.status.get_is_boosted() else self.config.get_boosted_concurrent_safety_orders()
max_initial_safety_orders = min(amount_of_so,self.status.get_no_of_safety_orders()) #To never send more than the max amount of safety orders
orders_placed = self.send_new_safety_order_batch(max_initial_safety_orders)
if orders_placed is not None:
self.broker.logger.log_this(f"{orders_placed}/{max_initial_safety_orders} safety orders placed",2,self.status.get_pair())
else:
self.broker.logger.log_this("Error sending safety orders. Cancelling take profit order and aborting",1,self.status.get_pair())
self.broker.cancel_order(self.status.get_take_profit_order()["id"],self.status.get_pair())
return 1
# Send cleanup order (if cleanup)
self.status.set_pause_reason("start_trader - doing cleanup (if needed)")
if self.config.get_cleanup() and not self.config.get_is_short(): #Short traders do not need cleanup.
self.do_cleanup()
self.status.set_deal_start_time(int(time.time()))
self.update_status(True)
self.set_pause(False)
return 0
def update_status(self, write_to_disk: bool) -> int:
'''
Updates the status dictionary
:param write_to_disk: bool - If True, writes the status file to disk.
:return: int
'''
try:
if self.status.get_safety_orders()!=[]:
self.status.set_next_so_price(self.status.get_safety_orders()[0]["price"])
elif len(self.status.get_safety_price_table())>self.status.get_safety_orders_filled():
self.status.set_next_so_price(self.status.get_safety_price_table()[self.status.get_safety_orders_filled()])
else:
self.status.set_next_so_price(0)
self.status.set_is_paused(self.pause)
self.status.set_is_short(self.config.get_is_short())
#self.status.set_no_of_safety_orders(self.config.get_no_of_safety_orders())
self.status.set_deal_uptime(int(time.time()) - self.status.get_deal_start_time())
self.status.set_total_uptime(int(time.time()) - self.status.get_start_time())
self.status.set_tp_mode(self.config.get_tp_mode())
self.status.set_profit_table(self.config.get_tp_table())
self.status.set_autoswitch(self.config.get_autoswitch())
self.status.set_liquidate_after_switch(self.config.get_liquidate_after_switch())
except Exception as e:
self.broker.logger.log_this(f"Can't update status dictionary. Exception: {e}",1,self.status.get_pair())
return 1
if write_to_disk:
self.status.save_to_file()
return 0
def dca_cost_calculator(self, order_size: float, amount_of_so: int, scalar: float) -> float:
'''
Returns the maximum amount of currency that can be used by a trader, given the initial order size
:param order_size: float
:param amount_of_so: int
:param scalar: float
:return: float
'''
r = scalar * 100
if abs(r - 1.0) < 1e-6:
return order_size * (amount_of_so + 1)
return order_size * (r * (r**amount_of_so - 1) / (r - 1)) + order_size
def base_add_calculation(self, base_currency_amount: float, max_so: int = 100):
'''
Calculates how many safety orders you can add to the trader with a given amount of base currency.
:param base_currency_amount: float
:return: int
'''
if not self.config.get_is_short(): # Only works for short traders.
return 0
amount_accumulated = 0
so_count = 0
for i in range(self.status.get_so_amount()+1,max_so+1):
amount_accumulated+= self.gib_so_size(self.status.get_order_size(),i,self.config.get_safety_order_scale())
if amount_accumulated >= base_currency_amount:
return so_count
so_count+=1
return so_count
def return_optimal_order_size(self, amount: float, min_size: float, amount_of_safety_orders: int, scalar: float) -> float:
'''
Calculates the optimal order size for a short trader, according to the amount passed as a parameter.
Due to performance issues, the step size that is used is 1/10th of the minimum order size.
:param amount: float
:param min_size: float
:param amount_of_safety_orders: int
:param scalar: float
:return: float
'''
low, high = min_size, amount
best = 0.0
while high - low > min_size / 10:
mid = (low + high) / 2
cost = self.dca_cost_calculator(mid, amount_of_safety_orders, scalar)
if cost <= amount:
best = mid
low = mid
else:
high = mid
return best
def parse_fees(self, order: dict) -> tuple:
'''
Returns the fees paid ordered in "base,quote"
Note: CCXT does not detail the fees paid if the exchange is Binance.
'''
basefee = 0
quotefee = 0
#Uncomment if you want to guesstimate Binance's fees (Should this be a flag?).
#if self.broker.get_exchange_name()=="binance":
# #Fees of buy orders are charged in base currency, fees of sell orders are charged in quote currency.
# try:
# fee_rate = self.market["maker"] if order["type"]=="limit" else self.market["taker"]
# except Exception as e:
# self.broker.logger.log_this(f"Exception fetching market information: {e}. Using default fee rate of 0.1%",1,self.status.get_pair())
# fee_rate = 0.001
#
# if order["side"]=="buy":
# basefee = order["filled"]*float(fee_rate)
# elif order["side"]=="sell":
# quotefee = order["cost"]*float(fee_rate)
# return basefee,quotefee
for x in order["fees"]:
if x["currency"]==self.base:
basefee+=float(x["cost"])
if x["currency"]==self.quote:
quotefee+=float(x["cost"])
return basefee,quotefee
def do_cleanup(self) -> int:
'''
Checks for any remaining base currency balance on the exchange
If it finds some and that amount is enough, it sends a sell order at the take profit price
It was implemented because some deals close with a little amount of base remaining
and it tends to pile up overtime
A more elegant solution would be to take note of the amount and the price at the moment of the deal closing that lead to
that small amount of change to appear, to make possible to calculate an optimal sell price of the remaining assets
instead of brute forcing it this way.
For smaller traders that might be overengineering it a bit anyway
'''
if self.config.get_is_short(): #Short traders do not need cleanup
return 0
balance_to_clean = self.fetch_free_base()
if balance_to_clean is None:
self.broker.logger.log_this("Can't fetch free base",1,self.status.get_pair())
return 1
#If the balance is greater than the size of the first safety order, sell the difference.
# Sometimes when an order is filled the balance is not updated immediately, so having a bit of a buffer irons out a couple of issues.
min_size = self.status.get_safety_orders()[0]["amount"]
if (balance_to_clean-min_size)*self.status.get_start_price()>self.broker.get_min_quote_size(self.status.get_pair()):
self.broker.logger.log_this(f"Balance to clean: {balance_to_clean-min_size} {self.base}",2,self.status.get_pair())
self.broker.logger.log_this("Sending cleanup order...",2,self.status.get_pair())
cleanup_order = self.broker.new_limit_order(self.status.get_pair(),balance_to_clean-min_size,"sell",self.status.get_take_profit_price(),no_retries=True)
if cleanup_order is None:
self.broker.logger.log_this("Problems with the cleanup order, new_limit_order returned None",1,self.status.get_pair())
return 1
elif cleanup_order==self.broker.get_empty_order():
self.broker.logger.log_this("Problems with the cleanup order, new_limit_order returned an empty order",1,self.status.get_pair())
return 1
else:
self.broker.logger.log_this("Cleanup successful",2,self.status.get_pair())
return 0
self.broker.logger.log_this("No cleanup needed",2,self.status.get_pair())
return 0
def calculate_order_size(self, free_base: float, min_base_size: float, amount_of_so: int = 30) -> tuple:
'''
Calculates the optimal order size and the amount of safety orders from the free_base and minimum base size
'''
optimal_order_size = 0
minimum_amount_of_safety_orders = 1 #This variable could be a config knob
while amount_of_so>minimum_amount_of_safety_orders:
optimal_order_size = self.return_optimal_order_size(free_base,min_base_size,amount_of_so,self.config.get_safety_order_scale()) #safety_order_scale: safety order growth factor
if optimal_order_size!=0:
self.broker.logger.log_this(f"Optimal order size is {optimal_order_size}",2,self.status.get_pair())
break
amount_of_so-=1
if optimal_order_size==0:
self.broker.logger.log_this("Not enough base to switch. Order size would be too small",1,self.status.get_pair())
self.set_pause(False)
return None,None
if optimal_order_size<min_base_size: #Sometimes amount_to_precision rounds to a value less than the minimum
self.broker.logger.log_this("Optimal order size is smaller than the minimum order size",1,self.status.get_pair())
self.set_pause(False)
return None,None
return optimal_order_size,amount_of_so
def fetch_free_base(self,currency: str = ""):
'''
Returns the amount of free currency on the exchange
'''
if currency=="":
currency = self.base
balance = self.broker.get_coins_balance()
if balance==[]:
self.broker.logger.log_this("Can't fetch free base from the exchange",1,self.status.get_pair())
return None
if currency in balance["free"]:
return float(balance["free"][currency])
self.broker.logger.log_this("Currency not present in balance",1,self.status.get_pair())
return 0
def switch_to_short(self) -> int:
'''
This method modifies the config file of a trader to convert it to short.
It automagically sets the order size according to the funds available.
If not enough funds, it returns 1
'''
if self.config.get_is_short(): #Check if the trader is already in short mode
return 1
#Let's do some type checking first
if self.status.get_take_profit_order() is None:
self.broker.logger.log_this("Take profit order is None, can't switch to short",1,self.status.get_pair())
return 1
#Pauses trader
self.set_pause(True,"switch_to_short")
#Fetch the real amount of available base
self.broker.logger.log_this(f"Fetching available {self.base}",2,self.status.get_pair())
free_base = self.fetch_free_base()
if free_base is None:
return 1
#Fetch the minimal order size
min_base_size = self.broker.get_min_base_size(self.status.get_pair())
if min_base_size is None:
self.broker.logger.log_this("Error. Can't fetch market info from the exchange",1,self.status.get_pair())
self.set_pause(False)
return 1
#Check if there is enough base
if self.broker.amount_to_precision(self.status.get_pair(),free_base+self.status.get_take_profit_order()["amount"])<=min_base_size:
self.broker.logger.log_this("Error. Not enough base currency",1,self.status.get_pair())
self.set_pause(False)
return 1
#Calculate order size
self.broker.logger.log_this("Calculating optimal order size",2,self.status.get_pair())
optimal_order_size,amount_of_so = self.calculate_order_size(free_base+self.status.get_take_profit_order()["amount"],min_base_size,amount_of_so=self.config.get_max_short_safety_orders())
if optimal_order_size is None or amount_of_so is None:
return 1
self.broker.logger.log_this(f"New order size: {optimal_order_size}",2,self.status.get_pair())
self.broker.logger.log_this(f"Amount of safety orders: {amount_of_so}",2,self.status.get_pair())
#Close old orders
self.broker.logger.log_this("Switching trader mode to short",2,self.status.get_pair())
self.broker.logger.log_this("Closing orders...",2,self.status.get_pair())
if self.broker.cancel_order(self.status.get_take_profit_order()["id"],self.status.get_pair())==1:
self.broker.logger.log_this("Can't cancel the take profit order. Can't switch mode",1,self.status.get_pair())
self.set_pause(False)
return 1
if self.status.get_take_profit_order()["id"]!="":
self.broker.cancel_order(self.status.get_take_profit_order()["id"],self.status.get_pair())
#Save the old take profit order info for later use
self.broker.logger.log_this("Saving state in status_dict",2,self.status.get_pair())
self.status.set_old_long({"tp_price": self.status.get_take_profit_order()["price"],
"tp_amount": self.status.get_take_profit_order()["amount"],
"quote_spent": self.status.get_quote_spent(),
"fees_paid_in_quote": self.status.get_fees_paid_in_quote(),
"datetime": time.strftime("[%Y/%m/%d %H:%M:%S]")
})
try:
with open(f"status/{self.base}{self.quote}.oldlong","w") as s:
s.write(dumps(self.status.get_old_long(),indent=4))
except Exception as e:
self.broker.logger.log_this(f"Exception while saving old_long file: {e}",1,self.status.get_pair())
#Modify config file accordingly
self.broker.logger.log_this("Modifying config file and saving a backup",2,self.status.get_pair())
try:
self.config.save_to_file(f"configs/{self.base}{self.quote}.bak")
self.config.set_is_short(True)
self.config.save_to_file()
self.broker.logger.log_this("Config file updated",2,self.status.get_pair())
except Exception as e:
self.broker.logger.log_this(f"Error. Can't write the config file. Exception: {e}",1,self.status.get_pair())
return 1
self.status.set_stop_when_profit(False)
#self.config.set_is_short(True)
self.broker.logger.log_this("Done configuring. Starting trader...",2,self.status.get_pair())
return 0
def switch_to_long(self, ignore_old_long: bool = False, already_received_quote: float = 0, double_check_price: bool = True) -> int:
'''
Takes a short trader and changes the mode to long.
Only does it if the current trader was previously a long one.
'''
if not self.config.get_is_short():
self.broker.logger.log_this("Trader already in long mode, nothing to do",1,self.status.get_pair())
return 1
self.broker.logger.log_this("Attempting to switch to long trader",0,self.status.get_pair())
if double_check_price:
#Waits a moment to see if the price has moved too much
time.sleep(self.broker.get_wait_time()*4)
if not self.check_old_long(True):
self.broker.logger.log_this("False positive. Nothing to do.",1,self.status.get_pair())
return 2
#Check old_long data
if not ignore_old_long and self.status.get_old_long()=={}:
self.broker.logger.log_this("Can't find old long info on status_dict, searching for oldlong file",1,self.status.get_pair())
try:
with open(f"status/{self.base}{self.quote}.oldlong") as f:
self.status.set_old_long(load(f))
except Exception as e:
#self.write_to_log(time.strftime(f"[%Y/%m/%d %H:%M:%S] | {self.status.get_pair()} | Can't find old long file"))
self.broker.logger.log_this(f"Can't file oldlong file. Exception: {e}",1,self.status.get_pair())
self.quit = True
return 1
#Cancel open orders
for order in self.status.get_safety_orders():
self.broker.cancel_order(order["id"],self.status.get_pair())
if self.status.get_take_profit_order() is not None:
self.broker.cancel_order(self.status.get_take_profit_order()["id"],self.status.get_pair())
else:
self.broker.logger.log_this("Safety order is None",1,self.status.get_pair())
#Sell all base currency
self.liquidate_base(ignore_profits=ignore_old_long, already_received_quote=already_received_quote)
if self.config.get_liquidate_after_switch():
self.quit = True
return 1
#Rewrite config file (if it exists)
if path.isfile(f"configs/{self.base}{self.quote}.bak") and path.isfile(f"configs/{self.base}{self.quote}.json"):
with open(f"configs/{self.base}{self.quote}.bak") as c:
old_config = load(c)
with open(f"configs/{self.base}{self.quote}.json","w") as c:
c.write(dumps(old_config, indent=4))
if self.config.load_from_file()==1:
self.config.reset_to_default()
else:
self.broker.logger.log_this("Config/backup file does not exist",1,self.status.get_pair())
self.config.reset_to_default()
self.config.save_to_file()
#Remove old_long file (if it exists)
if path.isfile(f"status/{self.base}{self.quote}.oldlong"):
self.broker.logger.log_this("Removing old_long file...",2,self.status.get_pair())
remove(f"status/{self.base}{self.quote}.oldlong")
#Set up a few variables
self.status.set_fees_paid_in_quote(0)
self.status.set_fees_paid_in_base(0)
self.status.set_take_profit_order(self.broker.get_empty_order())
self.status.set_safety_orders([])
self.status.set_safety_price_table([])
self.status.set_so_amount(0)
#Done. Ready for start_trader
return 0
def liquidate_base(self, ignore_profits: bool = True, already_received_quote: float = 0) -> int:
'''
Fetches the amount of free base on the exchange, sells the entirety of the amount and calculates profits
Sends the Telegram message and writes the profits to disk
'''
#Find out the amount of free base
free_base = self.fetch_free_base()
if free_base is None:
self.broker.logger.log_this("Can't fetch free base",1,self.status.get_pair())
return 1
#send market order selling the total amount of base in the last take profit short order
order = self.broker.new_market_order(self.status.get_pair(),free_base,"sell")
time.sleep(self.broker.get_wait_time()*2)
tries = self.broker.get_retries()
while True:
market_tp_order = self.broker.get_order(order["id"],self.status.get_pair())
if market_tp_order["status"]=="closed":
_, fees_paid = self.parse_fees(market_tp_order)
break
time.sleep(self.broker.get_wait_time())
tries-=1
if tries==0:
self.broker.logger.log_this("Liquidation order not filling. Skipping base liquidation",1,self.status.get_pair())
return 1
#calculate profits
if not ignore_profits:
profit = already_received_quote + market_tp_order["cost"] - self.status.get_old_long()["quote_spent"] - self.status.get_old_long()["fees_paid_in_quote"] - fees_paid
#Add profits to file and send telegram notifying profits
self.profit_to_db(profit,market_tp_order["id"],self.broker.get_write_order_history())
self.broker.logger.log_this(f"Switch successful. Profit: {round(profit,2)} {self.quote}",0,self.status.get_pair())
self.broker.logger.log_this(f"Sell price: {market_tp_order['price']} {self.quote}",0,self.status.get_pair())
self.broker.logger.log_this(f"Order ID: {market_tp_order['id']}",0,self.status.get_pair())
return 0
def force_close(self):
'''
This method forces the closing of a deal. It replaces the take profit order with a market order.
A simpler way of doing it would be to edit the price of the take profit order,
but KuCoin only supports order editing on high frequency orders.
'''
self.set_pause(True,"force_close - order handling")
#Close the take profit order
self.broker.cancel_order(self.status.get_take_profit_order()["id"],self.status.get_pair())
#Send the market order
amount = self.status.get_take_profit_order()["amount"]
market_order = self.broker.new_market_order(self.status.get_pair(),amount,"sell",amount_in_base=True)
time.sleep(self.broker.get_wait_time()*2)
#Wait for it to be filled
tries = self.broker.get_retries()
while True:
order = self.broker.get_order(market_order["id"],self.status.get_pair())
if order["status"]=="closed":
break
tries-=1
time.sleep(self.broker.get_wait_time())
if tries==0:
self.broker.logger.log_this("Forced market order not filling.",1,self.status.get_pair())
self.quit = True
return 1
#Call take profit routine
return self.take_profit_routine(order)
def take_profit_routine(self, filled_order: dict) -> int:
'''
When profit is reached, this method is called to handle the profit calculations, the closing of orders,
the reporting and the restart of the trader.
'''
self.set_pause(True,"take_profit_routine - order handling") #To stop the main thread to iterate through this trader's orders (just in case)
#Add the timestamp to the deals cache and trims it
self.deals_timestamps.append(time.time())
self.deals_timestamps = self.deals_timestamps[-self.config.get_boosted_deals_range():]
#Let's do some type checking first
if self.status.get_take_profit_order() is None:
self.status.set_pause_reason(time.strftime(f"[%Y/%m/%d %H:%M:%S] | {self.status.get_pair()} | TP order is None"))
self.broker.logger.log_this("Error. Take profit order is None, trader will be restarted",0,self.status.get_pair())
self.status.save_to_file(is_backup=True)
self.restart = True
return 1
#Save the order in history.
if self.broker.get_follow_order_history():
self.status.update_deal_order_history(filled_order, "tp")
#Cancel all the safety orders ASAP
for order in self.status.get_safety_orders():
self.broker.cancel_order(order["id"],self.status.get_pair())
#Check if some safety orders were filled
partial_filled_amount = 0
partial_filled_price = []
for order in self.status.get_safety_orders():
closed_order = self.broker.get_order(order["id"],self.status.get_pair())
if closed_order["filled"]==0: #If this order wasn't filled, it is safe to assume that no order coming after this one was.
break
partial_filled_amount+=closed_order["filled"]
partial_filled_price.append(closed_order["average"])
self.broker.logger.log_this(f"Old safety order is partially filled, ID: {closed_order['id']}, {closed_order['filled']}/{closed_order['amount']} {self.base} filled",1,self.status.get_pair())
if self.broker.get_follow_order_history():
self.status.update_deal_order_history(closed_order, "partial_fill")
if closed_order["remaining"]!=0: #If this order is not completely filled, it is safe to assume that no order coming after this one was partially filled.
break
#Now we can clear the safety order list
self.status.set_safety_orders([])
#Handle the partial fills
if not self.config.get_is_short():
#With short traders is just an accounting issue, since when the trader restarts it will be buying cheaper what it sold more expensive in the partially filled safety order(s)
if partial_filled_amount!=0 and len(partial_filled_price)>0 and partial_filled_amount>self.broker.get_min_base_size(self.status.get_pair()):
#send a market order and sum the profits and wait for it to be filled
self.broker.logger.log_this("Sending partial fill sell order...",1,self.status.get_pair())
market_order = self.broker.new_market_order(self.status.get_pair(),partial_filled_amount,"sell",amount_in_base=True)
time.sleep(self.broker.get_wait_time()*2)
tries = self.broker.get_retries()
while True:
partial_fill_order = self.broker.get_order(market_order["id"],self.status.get_pair())
if partial_fill_order["status"]=="closed":
avg_buy_price = sum(partial_filled_price)/len(partial_filled_price)
partial_profit = market_order["cost"]-(avg_buy_price*partial_filled_amount)-self.parse_fees(market_order)[1]
self.status.set_partial_profit(self.status.get_partial_profit()+partial_profit)
break
self.broker.logger.log_this("Waiting for partial fill sell order to fill.",1,self.status.get_pair())
tries-=1
time.sleep(self.broker.get_wait_time())
if tries==0:
self.broker.logger.log_this("Partial fill sell order not filling.",1,self.status.get_pair())
break
if not self.broker.check_for_duplicate_profit_in_db(filled_order):
self.status.set_pause_reason("calculating profit")
# Calculate the profit
if self.config.get_is_short():
profit = self.status.get_quote_spent()-filled_order["cost"]-self.status.get_fees_paid_in_quote()-self.parse_fees(filled_order)[1]
else:
profit = filled_order["cost"]-self.status.get_quote_spent()-self.status.get_fees_paid_in_quote()-self.parse_fees(filled_order)[1]
profit+=self.status.get_partial_profit()
#Reset partial profit
self.status.set_partial_profit(0)
#Checks if some base was left over.
base_profit = max(self.status.get_base_bought()-filled_order["filled"],0) #To avoid negative numbers in base_profit
# Write the profit to file and send telegram message
if profit>0: #Negative profits are not saved because the cleanup takes care of the unsold base currency (the notorious small change issue that plagues some exchanges)
self.profit_to_db(profit,filled_order["id"],self.broker.get_write_order_history())
else: #For logging purposes
self.broker.logger.log_this(f"NEGATIVE PROFIT - Total amount of base: {self.status.get_base_bought()}, base in the order: {filled_order['amount']}, base filled: {filled_order['filled']}, base 'profit': {base_profit}",1,self.status.get_pair())
self.telegram_bot_sendprofit(profit,filled_order,base_profit=base_profit)
# Print profit message on screen
extra = ' and {:.4f}'.format(base_profit) + f" {self.base}" if base_profit>0 else ""
self.broker.logger.log_this(f"Trader closed a deal. Profit: {'{:.4f}'.format(profit)} {self.quote}{extra}",2,self.status.get_pair())
self.broker.logger.log_this(f"Fill price: {filled_order['price']} {self.quote}",2,self.status.get_pair())
self.broker.logger.log_this(f"Safety orders triggered: {self.status.get_safety_orders_filled()}",2,self.status.get_pair())
self.status.set_pause_reason("take_profit_routine - check time limit")
#Checks if there is a time limit for the trader
if self.config.get_programmed_stop() and time.time()>int(self.config.get_programmed_stop_time()):
self.status.set_stop_when_profit(True)
self.status.set_pause_reason("take_profit_routine - if stop_when_profit")
if self.status.get_stop_when_profit(): #Signal to stop when trade is closed
self.broker.logger.log_this("Pair shutting down. So long and thanks for all the fish",0,self.status.get_pair())
self.quit = True
return 1
#Reloading config file (just in case any changes)
self.config.load_from_file()
self.status.set_pause_reason("Checking slippage")
if self.config.get_check_slippage():
self.broker.logger.log_this("Checking slippage...",2,self.status.get_pair())
price_to_compare = self.broker.get_top_bid_price(self.status.get_pair()) if self.config.get_is_short() else self.broker.get_top_ask_price(self.status.get_pair())
if abs(filled_order["price"]-price_to_compare)/filled_order["price"]>self.broker.get_slippage_default_threshold():
self.broker.logger.log_this(f"Slippage threshold exceeded, waiting for cooldown and restarting trader",1,self.status.get_pair())
time.sleep(self.broker.get_wait_time()*self.broker.get_cooldown_multiplier())
self.set_pause(False)
self.restart = True
return 1
elif self.check_orderbook_depth(self.broker.get_slippage_default_threshold(),self.status.get_order_size(),filled_order["price"]):
self.broker.logger.log_this(f"Orderbook depth not sufficient, waiting for cooldown and restarting trader",1,self.status.get_pair())
time.sleep(self.broker.get_wait_time()*self.broker.get_cooldown_multiplier())
self.set_pause(False)
self.restart = True
return 1
#Restarting the trader
self.status.set_pause_reason("take_profit_routine - restart_trader call")
restart_trader = self.start_trader()
self.status.set_pause_reason("take_profit_routine - restart_trader call - start_trader() called")
if restart_trader in self.trader_restart_errors.keys():
self.set_pause(False)
self.restart = True
self.status.save_to_file(is_backup=True)
self.broker.logger.log_this(self.trader_restart_errors[restart_trader],1,self.status.get_pair())
return restart_trader
def send_new_safety_order_batch(self, amount: int):
"""
Sends a new safety order batch to the broker
:param amount: int - The amount of safety orders to send.
:return: The amount of orders succesfully sent. None if an error occurs.
"""
if amount<1:
return 0
orders_to_place = min(self.status.get_no_of_safety_orders()-self.status.get_so_amount(),amount)
if orders_to_place<1:
return 0
orders_placed = 0
# if self.broker.get_exchange_name()!="binance": #Binance does not support sending multiple orders at once in Spot.
# self.broker.logger.log_this(f"Sending {orders_to_place} safety orders",2,self.status.get_pair())
# orders_to_send = []
# for i in range(orders_to_place):
# order_index = self.status.get_so_amount()+i+1
# so_size = self.gib_so_size(self.status.get_order_size(),order_index,self.config.get_safety_order_scale())
# if self.config.get_is_short():
# orders_to_send.append({"symbol": self.status.get_pair(),
# "type": "limit",
# "side": "sell",
# "amount": so_size,
# "price": self.status.get_safety_price_table()[order_index]})
# else:
# orders_to_send.append({"symbol": self.status.get_pair(),
# "type": "limit",
# "side": "buy",
# "amount": so_size/self.status.get_safety_price_table()[order_index],
# "price": self.status.get_safety_price_table()[order_index]})
# sent_orders = self.broker.new_limit_orders(orders_to_send)
# orders_placed = len(sent_orders)
# self.status.set_so_amount(self.status.get_so_amount()+orders_placed)
# for item in sent_orders:
# self.status.add_safety_order(item)
# else:
for i in range(orders_to_place):
self.broker.logger.log_this(f"Sending a new safety order ({i+1}/{orders_to_place})",2,self.status.get_pair())
so_size = self.gib_so_size(self.status.get_order_size(),self.status.get_so_amount()+1,self.config.get_safety_order_scale())
if self.config.get_is_short():
new_order = self.broker.new_limit_order(self.status.get_pair(),so_size,"sell",self.status.get_safety_price_table()[self.status.get_so_amount()+1])
else:
new_order = self.broker.new_limit_order(self.status.get_pair(),so_size/self.status.get_safety_price_table()[self.status.get_so_amount()+1],"buy",self.status.get_safety_price_table()[self.status.get_so_amount()+1])
if new_order==1:
self.broker.logger.log_this("Not enough balance to send a new safety order",1,self.status.get_pair())
self.status.set_no_of_safety_orders(self.status.get_so_amount()) #To avoid sending more safety orders, no_of_safety_order can be later modified manually.
return orders_placed
elif new_order is None:
self.broker.logger.log_this("new_limit_order returned None",1,self.status.get_pair())
self.status.set_no_of_safety_orders(self.status.get_so_amount()) #To avoid sending more safety orders, no_of_safety_order can be later modified manually.
return orders_placed
elif new_order==self.broker.get_empty_order():
self.broker.logger.log_this("new_limit_order returned an empty order",1,self.status.get_pair())
self.status.set_no_of_safety_orders(self.status.get_so_amount()) #To avoid sending more safety orders, no_of_safety_order can be later modified manually.
return orders_placed
else:
orders_placed+=1
self.status.add_safety_order(new_order)
self.status.set_so_amount(self.status.get_so_amount()+1)
return orders_placed
def renew_tp_and_so_routine(self, filled_safety_orders: list):
'''
Modifies the current take profit order and sends a new safety order
:return: 0 OK, 1 take profit order is None, 2 not enough funds, 3 can't cancel TP (filled?), 4 can't send new TP
'''
safety_orders_to_remove_by_id = []
#Check if current TP order is valid
if self.status.get_take_profit_order() is None:
self.broker.logger.log_this("Take profit order is None, can't send a new safety order",1,self.status.get_pair())
return 1
#Pause the trader
self.set_pause(True, "renew_tp_and_so_routine")
#Save the order
if self.broker.get_follow_order_history():
for item in filled_safety_orders:
self.status.update_deal_order_history(item, "so")
#Add the amount filled in the last safety orders to the totals
previous_base = self.status.get_base_bought()
previous_quote = self.status.get_quote_spent()
previous_fees_paid_in_quote = self.status.get_fees_paid_in_quote()
for order in filled_safety_orders:
safety_orders_to_remove_by_id.append(order["id"])
new_fees_base, new_fees_quote = self.parse_fees(order)
previous_fees_paid_in_quote += new_fees_quote
previous_base += order["filled"]
previous_quote += order["cost"]
self.status.set_base_bought(previous_base)
self.status.set_quote_spent(previous_quote)
self.status.set_fees_paid_in_quote(previous_fees_paid_in_quote)
#Remove the filled safety orders from the list
if safety_orders_to_remove_by_id!=[]:
new_order_list = []
#Remove filled orders from the list
for order in self.status.get_safety_orders():
if order["id"] not in safety_orders_to_remove_by_id:
new_order_list.append(order)
self.status.set_safety_orders(new_order_list)
#Cancel old TP order
if self.broker.cancel_order(self.status.get_take_profit_order()["id"],self.status.get_pair())==1:
error_string = f"{self.status.get_pair()} | {self.status.get_take_profit_order()['id']} | Old TP order probably filled. Can't cancel. This trader should be restarted"
self.broker.logger.log_this(f"Old take profit order is probably filled, can't cancel. This trader should be restarted. Order ID: {self.status.get_take_profit_order()['id']}",1,self.status.get_pair())
self.status.set_pause_reason(error_string)
return 2
#Check if old TP order was partially filled
old_tp_order = self.broker.get_order(self.status.get_take_profit_order()["id"],self.status.get_pair())
if old_tp_order["filled"]>0:
self.broker.logger.log_this(f"Old take profit order is partially filled, id {old_tp_order['id']}",1,self.status.get_pair())
if self.broker.get_follow_order_history():
self.status.update_deal_order_history(old_tp_order, "old_tp")
#self.status.set_base_bought(old_tp_order["remaining"])
# Partial profit calculation
if not self.config.get_is_short():
current_deal_price = self.status.get_quote_spent()/self.status.get_base_bought()
self.status.set_partial_profit(self.status.get_partial_profit()+old_tp_order["cost"]-(old_tp_order["filled"]*current_deal_price)-self.parse_fees(old_tp_order)[1])
self.update_status(True)
self.status.set_base_bought(self.status.get_base_bought() - old_tp_order["filled"] - self.parse_fees(old_tp_order)[0])
self.status.set_quote_spent(self.status.get_quote_spent() - old_tp_order["cost"])
self.status.set_fees_paid_in_quote(self.status.get_fees_paid_in_quote() + self.parse_fees(old_tp_order)[1])
self.status.set_fees_paid_in_base(self.status.get_fees_paid_in_base() + self.parse_fees(old_tp_order)[0])
#Cooldown
time.sleep(self.broker.get_wait_before_new_safety_order())
#Send new SO(s)
#Do not send new orders if the max amount is reached or surpassed.
#It can happen if the max amount of concurrent orders is modified through an API call.
max_orders = self.config.get_concurrent_safety_orders() if not self.status.get_is_boosted() else self.config.get_boosted_concurrent_safety_orders()
if len(self.status.get_safety_orders())<max_orders:
self.send_new_safety_order_batch(max_orders-len(self.status.get_safety_orders()))
#Cooldown
time.sleep(self.broker.get_wait_before_new_safety_order())
#Send new TP order
if self.send_new_tp_order()==1:
error_string = "Problems sending the new take profit order"
self.broker.logger.log_this("Problems sending the new take profit order",1,self.status.get_pair())
self.status.set_pause_reason(error_string)
return 4
#Update status dict
self.update_status(True)
#Toggle the pause flag
self.set_pause(False)
#Done
return 0
def check_orderbook_depth(self, threshold: float, order_size: float, old_price: float = 0, size_in_quote = True) -> bool:
'''
Checks if the orderbook depth exceeds the slippage threshold. Returns True if it is exceeded, False otherwise.
Parameters:
threshold (float): The threshold for the orderbook depth.
order_size (float): The size of the order to check..
old_price (float): The old price of the order.
size_in_quote (bool): If True, the order size is in quote currency. If False, the order size is in base currency.
Returns:
bool: True if the orderbook depth exceeds the threshold, False otherwise.
'''
if self.config.get_is_short(): #Do not check for slippage in short traders (Pending to be implemented)
return False
order_book = self.broker.get_order_book(self.status.get_pair(),no_retries=True)
if order_book=={}:
self.broker.logger.log_this("Can't fetch orderbook",1,self.status.get_pair())
return False
suma = 0
try:
mid_price = old_price if old_price!=0 else (order_book["asks"][0][0]+order_book["bids"][0][0])/2
if size_in_quote:
amount = (order_size/mid_price)*2 #Extra margin to avoid surprises.
else:
amount = order_size*2
top_price = order_book["asks"][0][0]
for x in order_book["asks"]:
suma += x[1]
if suma>=amount:
last_price = x[0]
break
if last_price-top_price>top_price*threshold:
#Threshold exceeded
return True
return False
except Exception as e:
self.broker.logger.log_this(f"Exception in check_orderbook_depth: {e}",1,self.status.get_pair())
return False
def check_old_long(self, fetch_price=False):
'''
Check if it is profitable to switch to back to long.
Returns True if it is profitable, False otherwise.
If selling the base currency left at the current market price plus the quote already received turns out to be more than the old long deal target,
it means that we already are in profit territory, switch back to long.
A more conservative approach would be old_target = self.status.get_old_long()["quote_spent"], just breaking even.
'''
price = self.status.get_price() if not fetch_price else self.broker.get_top_bid_price(self.status.get_pair())
old_target = self.status.get_old_long()["tp_price"]*self.status.get_old_long()["tp_amount"]
base_left = self.status.get_old_long()["tp_amount"]-self.status.get_base_bought()
return (base_left*price)+self.status.get_quote_spent()>=old_target
def check_status(self,open_orders: list) -> int: #Should I change the order? Check the SO first?
'''
Main routine. It checks for closed orders and proceeds accordingly.
'''
#It does not even try if it receives an empty list or the worker is paused
if open_orders==[] or self.pause:
self.update_status(False)
return 0
self.status.set_pause_reason("check for autoswitch")
#If it's a short trader that used to be long AND autoswitch is enabled
if self.config.get_is_short() and self.config.get_autoswitch() and self.status.get_old_long()!={}:
if self.check_old_long():
#Sell all base (market), report the profits and restart the trader
self.status.set_pause_reason("automatic_switch")
if self.switch_to_long(already_received_quote=self.status.get_quote_spent())!=2:
if not self.config.get_liquidate_after_switch():
self.restart = True
return 1
#Check for autoswitch (long->short)
if not self.config.get_is_short() and self.status.get_so_amount()==self.status.get_no_of_safety_orders() and self.config.get_autoswitch():
self.switch_to_short()
self.status.save_to_file(is_backup=True)
self.restart = True
return 0
#Extract ids from order list
self.status.set_pause_reason("filtering open orders")
open_orders_list = [order for order in open_orders if order["symbol"]==self.status.get_pair()]
open_orders_ids = [order["id"] for order in open_orders_list]
self.status.set_pause_reason("check if tp_order is valid")
#Checks if the take profit order is valid
if self.status.get_take_profit_order() is None:
self.broker.logger.log_this("Take profit order is None",1,self.status.get_pair())
return 1
if self.status.get_take_profit_order()["id"]=="":
self.broker.logger.log_this(f"Take profit order missing. Stopping trader. No order ID was provided.",1,self.status.get_pair())
#Cancelling safety orders
for item in self.status.get_safety_orders():
self.broker.cancel_order(item["id"],self.status.get_pair())
if self.config.get_attempt_restart():
self.status.save_to_file(is_backup=True)
self.restart = True
self.broker.logger.log_this("Raising restart flag: take profit order missing, trader will be restarted",0,self.status.get_pair())
else:
self.broker.logger.log_this("Take profit order missing. Trader restart disabled.",2,self.status.get_pair())
return 1
self.status.set_pause_reason("check if tp_order is filled")
#Checks if the take profit order is filled
if self.status.get_take_profit_order()["id"] not in open_orders_ids:
tp_status = self.broker.get_order(self.status.get_take_profit_order()["id"],self.status.get_pair())
if tp_status["status"]=="closed":
if tp_status["filled"]>0:
return self.take_profit_routine(tp_status)
self.broker.logger.log_this(f"Take profit order closed but not filled, 0 filled. Stopping trader. Order ID: {self.status.get_take_profit_order()['id']}",1,self.status.get_pair())
#Cancelling safety orders
for item in self.status.get_safety_orders():
self.broker.cancel_order(item["id"],self.status.get_pair())
if self.config.get_attempt_restart():
self.status.save_to_file(is_backup=True)
self.restart = True
self.broker.logger.log_this("Take profit order closed but not filled, trader will be restarted.",0,self.status.get_pair())
else:
self.broker.logger.log_this("Take profit order closed but not filled, trader restart disabled.",1,self.status.get_pair())
return 1
elif tp_status["status"]=="canceled":
#TODO: Here, if the safety order is still open, we could resend the tp order.
if self.config.get_attempt_restart():
self.broker.logger.log_this("Take profit order canceled. Restarting the trader.",1,self.status.get_pair())
self.status.save_to_file(is_backup=True)
self.restart = True
else:
self.broker.logger.log_this("Take profit order canceled. Trader restart disabled.",1,self.status.get_pair())
return 1
elif tp_status["status"]=="":
self.broker.logger.log_this(f"Take profit order search returned empty order. Order ID: {tp_status['id']}",1,self.status.get_pair())
return 1
self.status.set_pause_reason("check for any so is filled")
# Check if any safety order is filled
filled_ids = []
for order in self.status.get_safety_orders():
if order["id"] not in open_orders_ids:
filled_ids.append(order["id"])
if filled_ids!=[]:
filled_orders = []
for id in filled_ids:
order = self.broker.get_order(id, self.status.get_pair())
time.sleep(self.broker.get_wait_time())
if order["status"]=="closed":
filled_orders.append(order)
if len(filled_orders)>0: #To make sure that the safety orders are actually filled (Kucoin sometimes sends incomplete order lists)
self.status.set_safety_orders_filled(self.status.get_safety_orders_filled()+len(filled_orders))
renew_outcome = self.renew_tp_and_so_routine(filled_orders)
#0 OK, 1 take profit order is None, 2 not enough funds, 3 can't cancel TP (filled?), 4 can't send new TP
if renew_outcome==1:
self.broker.logger.log_this(f"Error in trader: TP order is None. Restart will be attempted. renew_tp_and_so_routine returned 1",0,self.status.get_pair())
if self.config.get_attempt_restart():
self.status.save_to_file(is_backup=True)
self.restart = True
return 1
elif renew_outcome==2:
#Not enough funds?
self.broker.logger.log_this(f"Can't send new safety order. Not enough funds? renew_tp_and_so_routine returned 2",1,self.status.get_pair())
#Set no_of_safety_orders to the same amount of orders open so the script does not try to send new safety orders
#This can be improved
self.status.set_no_of_safety_orders(self.status.get_so_amount())
return 1
elif renew_outcome==3:
self.broker.logger.log_this(f"Can't cancel old take profit order. renew_tp_and_so_routine returned 3",1,self.status.get_pair())
self.set_pause(False)
if self.config.get_attempt_restart():
self.status.save_to_file(is_backup=True)
self.restart = True
return 1
elif renew_outcome==4:
self.broker.logger.log_this(f"Error in trader: Can't send new take profit order. Restart will be attempted. renew_tp_and_so_routine returned 4",0,self.status.get_pair())
if self.config.get_attempt_restart():
self.status.save_to_file(is_backup=True)
self.restart = True
return 1
#Should we send more safety orders without touching the TP order?
#Necessary check if we add to no_of_safety_orders or modify concurrent_safety_orders at runtime
max_concurrent_safety_orders = self.config.get_boosted_concurrent_safety_orders() if self.status.get_is_boosted() else self.config.get_concurrent_safety_orders()
condition_a = len(self.status.get_safety_orders())<max_concurrent_safety_orders
condition_b = self.status.get_safety_orders_filled()+len(self.status.get_safety_orders())<self.status.get_no_of_safety_orders()
if condition_a and condition_b:
amount_to_send = max_concurrent_safety_orders-len(self.status.get_safety_orders())
self.set_pause(True, "sending safety order batch")
self.send_new_safety_order_batch(amount_to_send)
self.set_pause(False)
self.update_status(True)
#Render status line(s)
self.status.set_status_string(self.generate_status_strings())
#Wrap up
self.status.set_deal_uptime(int(time.time()) - self.status.get_deal_start_time())
self.status.set_total_uptime(int(time.time()) - self.status.get_start_time())
self.update_status(False)
self.last_time_seen = int(time.time())
return 0
def check_boosted(self):
'''
Checks if the trader qualifies for boost:
The last n deals must be within the last t seconds
'''
return len(self.deals_timestamps)>=self.config.get_boosted_deals_range() and time.time()-self.config.get_boosted_time_range()<=self.deals_timestamps[-self.config.get_boosted_deals_range()]
def get_tp_level(self, order_index: int = 0) -> float:
'''
Returns the correct take profit percentage, according to the strategy (config.get_tp_mode()):
0. Fixed percentage
1. Variable percentage (+0.5% to -0.5% of the fixed percentage)
2. Custom percentage table
3. Linear percentage table
'''
tp_level = 1
if self.config.get_is_short() or self.config.get_tp_mode()==0: #Fixed take profit percentage
tp_level = self.config.get_tp_level()
elif self.config.get_tp_mode()==1: #Variable percentage
limit = self.status.get_no_of_safety_orders()/3
if order_index<=1:
tp_level = self.config.get_tp_level()+0.005
elif order_index<=limit:
tp_level = self.config.get_tp_level()
elif limit<=order_index<=limit*2:
tp_level = self.config.get_tp_level()-0.0025
else:
tp_level = self.config.get_tp_level()-0.005
elif self.config.get_tp_mode()==2:
if self.config.get_tp_table()!=[]:
if len(self.config.get_tp_table())>=order_index:
tp_level = self.config.get_tp_table()[order_index] #Custom percentage table
tp_level = self.config.get_tp_table()[-1]
tp_level = self.config.get_tp_level()
elif self.config.get_tp_mode()==3: #Linear percentage table
profit_table = self.linear_space(self.config.get_tp_level()+0.005,self.config.get_tp_level()-0.005,self.status.get_no_of_safety_orders())
tp_level = profit_table[-1]
if order_index<len(profit_table): #If more safety orders were added, instead of recalculating the whole table
tp_level = profit_table[order_index] #it just returns the last value. Otherwise, the percentage gets very small.
#BOOST LOGIC: If the trader closed n amount of deals within the last t seconds, raise the take profit level by x%
if not self.config.get_is_short() and self.check_boosted():
self.status.set_is_boosted(True)
return tp_level+self.config.get_boosted_amount()
self.status.set_is_boosted(False)
return tp_level
def seconds_to_time(self, total_seconds: float) -> str:
'''
Returns a D:HH:MM:SS representation of total_seconds
'''
days = int(total_seconds // 86400)
h, m, sec = int((total_seconds % 86400) // 3600), int((total_seconds % 3600) // 60), int(total_seconds % 60)
return f"{days}:{h:02d}:{m:02d}:{sec:02d}"
def adjust_base(self):
time.sleep(self.broker.get_wait_time())
new_balance = self.broker.get_coins_balance()
if bool(new_balance):
self.broker.logger.log_this(f"Adjusting base amount to {new_balance['free'][self.base]}, total balance: {new_balance['total'][self.base]}",1,self.status.get_pair())
return new_balance["free"][self.base]
return None
def send_new_tp_order(self) -> int:
'''
Calculates the correct take profit price and sends the order to the exchange
'''
tries = self.broker.get_retries()
while tries>0:
if self.status.get_base_bought()==0:
self.broker.logger.log_this("Amount of base equals 0, can't send take profit order",1,self.status.get_pair())
return 1
if self.config.get_is_short():
self.status.set_take_profit_price(self.status.get_quote_spent()/self.status.get_base_bought()*(1-(self.get_tp_level(self.status.get_so_amount())-1)))
self.status.set_take_profit_order(self.broker.new_limit_order(self.status.get_pair(),self.status.get_base_bought(),"buy",self.status.get_take_profit_price()))
else:
self.status.set_take_profit_price(self.status.get_quote_spent()/self.status.get_base_bought()*self.get_tp_level(self.status.get_so_amount()))
self.status.set_take_profit_order(self.broker.new_limit_order(self.status.get_pair(),self.status.get_base_bought(),"sell",self.status.get_take_profit_price()))
if self.status.get_take_profit_order()==1: #This means that there was a miscalculation of base currency amount, let's correct it.
if self.config.get_is_short(): #If in short mode, we don't recalculate anything.
return 1
adjusted = self.adjust_base()
if adjusted is not None:
self.status.set_base_bought(adjusted)
self.status.set_take_profit_order(None) #Just to be able to iterate
if self.status.get_take_profit_order() not in [None,self.broker.get_empty_order()]:
return 0
tries-=1
time.sleep(self.broker.get_wait_time())
self.broker.logger.log_this("Problems sending take profit order",1,self.status.get_pair())
return 1
def profit_to_db(self, amount: float, orderid: str, write_deal_order_history: bool = False) -> int:
'''
Saves the profit to the db in the format (pair,timestamp,profit,exchange_name,order_id,order_history)
'''
retries = 5 #Hardcoded because it's not an API call
while retries>0:
try:
order_history = dumps(self.status.get_deal_order_history()) if write_deal_order_history else ""
dataset = (time.time(),self.status.get_pair(),amount,self.broker.get_exchange_name(),str(orderid),order_history)
#Write profit to cache
self.broker.write_profit_to_cache(dataset)
return self.broker.write_profit_to_db(dataset)
except Exception as e:
self.broker.logger.log_this(f"Exception while writing profit: {e}",1,self.status.get_pair())
retries-=1
time.sleep(.1) #Shorter wait time since it's not an API call
return 1
def telegram_bot_sendprofit(self,profit,order,base_profit=0) -> int:
'''
Sends the Telegram notification when profit is met
'''
try:
extra = f" and {round(base_profit,6)} {self.base}" if base_profit>0 else ""
message = f"{self.status.get_pair()} closed a {'short' if self.config.get_is_short() else 'long'} trade.\nProfit: {round(profit,6)} {self.quote}{extra}\nSafety orders triggered: {self.status.get_safety_orders_filled()}\nTake profit price: {order['price']} {self.quote}\nTrade size: {round(order['cost'],2)} {self.quote}\nDeal uptime: {self.seconds_to_time(self.status.get_deal_uptime())}\nOrder ID: {order['id']}\nExchange: {self.broker.get_exchange_name().capitalize()}\n"
self.broker.logger.send_tg_message(message)
return 0
except Exception as e:
self.broker.logger.log_this(f"Exception in telegram_bot_sendprofit: {e}",1,self.status.get_pair())
return 1
def gib_so_size(self, starting_order_size: float, so_number: int, scaling_factor: float) -> float:
'''
Returns the correct safety order size depending on the number
Scaling factor example: 5% = 0.0105
'''
return starting_order_size * (scaling_factor*100)**so_number
def clip_value(self,value,lower_limit,upper_limit):
'''
Clips a value to a given range
'''
if value<lower_limit:
return lower_limit
if value>upper_limit:
return upper_limit
return value
def calculate_safety_prices(self, start_price: float, no_of_safety_orders: int, safety_order_deviance: float) -> list:
'''
Generates a table of safety order's prices
'''
safety_price_table = [start_price]
if self.config.get_dynamic_so_deviance():
#bias should be a real number between -1 and 1 (1>n>-1, NOT 1=>n>=-1)
#If bias -> 1, more space between the first orders, if -> -1, more space between the last orders, if 0, no change..
deviance_factor = safety_order_deviance*self.clip_value(self.config.get_bias(),-.99,.99)
so_deviance_table = self.linear_space(safety_order_deviance+deviance_factor,safety_order_deviance-deviance_factor,no_of_safety_orders)
so_deviance_table.extend([so_deviance_table[-1]]*2) #This extra entries are needed in the next for loop
else:
so_deviance_table = [safety_order_deviance]*(no_of_safety_orders+2)
multiplier = -1 if self.config.get_is_short() else 1
for y in range(1, no_of_safety_orders+2): #+2 instead of the expected +1 because of a bug when updating the status dict. It could be any value, if we add SOs the table is recalculated anyway
safety_price_table.append(safety_price_table[-1]-multiplier*(safety_price_table[0]*so_deviance_table[y-1]/100))
return safety_price_table
def linear_space(self, start: float, stop: float, amount: int) -> list:
'''
Numpy's linspace local implementation.
Implemented here because:
- This is the only piece of code needed from Numpy
- Only executed when calculating the safety order table, so there's no need for outstanding performance.
'''
step = (stop - start) / (amount - 1)
return [start + i * step for i in range(amount)]
def switch_quote_currency(self, new_quote: str) -> int:
'''
Replaces the open orders with new updated orders, updates the config file and the status.
'''
#First let's check if the market exists
market = self.broker.fetch_market(f"{self.base}/{new_quote}")
if market is None:
self.broker.logger.log_this("Market might not exist",1,self.status.get_pair())
return 1
if "active" in market and not market["active"]:
self.broker.logger.log_this("Market is closed",1,self.status.get_pair())
return 1
if self.status.get_take_profit_order() is None:
self.broker.logger.log_this("Take profit order is None",1,self.status.get_pair())
return 1
#Replace the current take profit order with a new one with new quote currency
self.broker.logger.log_this("Replacing take profit order",2,self.status.get_pair())
self.status.set_take_profit_order(self.quote_currency_replace_order(self.status.get_take_profit_order(),new_quote))
if self.status.get_take_profit_order()==self.broker.get_empty_order():
return 1
#Replace the current safety orders (if any) with new ones with the new quote currency
#This is WRONG: We need to build a list of the newly sent orders and assign them with self.status.set_safety_orders()
new_order_list = []
for order in self.status.get_safety_orders():
self.broker.logger.log_this("Replacing safety order",2,self.status.get_pair())
new_order_list.append(self.quote_currency_replace_order(order,new_quote))
self.status.set_safety_orders(new_order_list)
#Calls switch_quote_currency_config
self.broker.logger.log_this("Modifying config file",2,self.status.get_pair())
self.quote_currency_switch_configs(new_quote)
#Updates status_dict
self.broker.logger.log_this("Updating status file",2,self.status.get_pair())
self.status.set_status_file_path(f"status/{self.base}{self.quote}.status")
self.update_status(True)
#Done
self.broker.logger.log_this("Quote swap successful",2,self.status.get_pair())
return 0
def quote_currency_replace_order(self, old_order: dict, new_quote: str) -> dict:
'''
Cancels the order and returns the new updated order
'''
#Cancels the old order
if self.broker.cancel_order(old_order["id"],self.status.get_pair())==1:
self.broker.logger.log_this(f"Can't cancel old order {old_order['id']}",1,self.status.get_pair())
return self.broker.get_empty_order()
#Sends the new order
return self.broker.new_limit_order(f"{self.base}/{new_quote}",old_order["amount"],old_order["side"],old_order["price"])
def quote_currency_switch_configs(self, new_quote: str) -> int:
'''
Updates the broker config file, changes all the variables and writes the new trader config file
'''
#Change broker config file
self.broker.remove_pair_from_config(f"{self.base}{self.quote}")
self.broker.add_pair_to_config(f"{self.base}{new_quote}")
if self.broker.rewrite_config_file()==1:
#Error writing broker config file, undoing changes
self.broker.logger.log_this("Error writing new broker config file",1,self.status.get_pair())
self.quote_currency_undo_changes(new_quote,self.quote,False)
return 1
#Change pair-related variables
old_quote = self.quote
self.quote = new_quote
self.config.set_pair(f"{self.base}/{self.quote}")
self.status.set_pair(f"{self.base}/{self.quote}")
self.profit_filename = f"profits/{self.base}{self.quote}.profits"
self.log_filename = f"logs/{self.base}{self.quote}.log"
#If there is an old_long file, also copy it
if self.config.get_is_short() and self.status.get_old_long()!={}:
try:
with open(f"status/{self.base}{self.quote}.oldlong","w") as c:
c.write(dumps(self.status.get_old_long(), indent=4))
except Exception as e:
self.broker.logger.log_this(f"Exception while writing new old_long file: {e}",1,self.status.get_pair())
#Write the new config file
self.config.set_config_file_path(f"configs/{self.base}{self.quote}.json")
if self.config.save_to_file()==1:
self.broker.logger.log_this(f"Error while writing the new trader config file",1,self.status.get_pair())
#Undoing changes
self.quote_currency_undo_changes(new_quote,old_quote,True)
return 1
#Done
return 0
def quote_currency_undo_changes(self, new_quote: str, old_quote: str, write_broker_file: bool = False) -> int:
'''
Revert changes made by switch_quote_currency()
'''
#Switching variables
self.quote = old_quote
self.broker.remove_pair_from_config(f"{self.base}{new_quote}")
self.broker.add_pair_to_config(f"{self.base}{self.quote}")
self.config.set_pair(f"{self.base}/{self.quote}")
self.status.set_pair(f"{self.base}/{self.quote}")
self.profit_filename = f"profits/{self.base}{self.quote}.profits"
self.log_filename = f"logs/{self.base}{self.quote}.log"
#Writing config file
if write_broker_file and self.broker.rewrite_config_file()==1:
self.broker.logger.log_this("Error in quote_currency_undo_changed: error writing new broker config file",1,self.status.get_pair())
#Done
return 0
def generate_status_strings(self) -> str:
'''
Returns the status string properly formatted for screen output
'''
decimals = 11
low_percentage = 1
mid_percentage = 10
high_percentage = 20
def draw_line(price,min_value,max_value,break_even):
'''
It draws the progress bar according to the inputs:
* If the price is bigger or equal to the break even price, the line's color is green
* If it's lower, the line's color is red
* All the way to the left, new safety order
* All the way to the right, profit!
'''
try:
value = int(((price-min_value)/(max_value-min_value))*80)
except Exception as e:
self.broker.logger.log_this(f"{e}")
value = 1
if min_value<max_value:
color = self.get_color("green") if price>=break_even else self.get_color("red")
else:
color = self.get_color("red") if price>=break_even else self.get_color("green")
return f"{color}{'='*value}{self.get_color('white')}{'='*max(0,(80-value))}"[:100]
low_price = self.status.get_next_so_price() if self.status.get_next_so_price() is not None else 0
mid_price = self.status.get_price() if self.status.get_price() is not None else 0
high_price = self.status.get_take_profit_price() if self.status.get_take_profit_price() is not None else 0
concurrent_so_amount = len(self.status.get_safety_orders())
if low_price==self.low_price_cache and mid_price==self.mid_price_cache and high_price==self.high_price_cache and concurrent_so_amount==self.concurrent_so_amount_cache:
#Only modifies the uptime
position = self.status.get_status_string().find("Uptime")
new_uptime = self.seconds_to_time(self.status.get_deal_uptime())
status_string = self.status.get_status_string()[:position+8] + new_uptime + self.status.get_status_string()[position+8+len(new_uptime):]
else:
#Update caches
self.low_price_cache = low_price
self.mid_price_cache = mid_price
self.high_price_cache = high_price
self.concurrent_so_amount_cache = concurrent_so_amount
#Formatting
low_boundary = '{:.20f}'.format(low_price)[:decimals].center(decimals)
mid_boundary = '{:.20f}'.format(mid_price)[:decimals].center(decimals)
high_boundary = '{:.20f}'.format(high_price)[:decimals].center(decimals)
percentage_to_profit = 100
pct_to_profit_str = "XX.XX"
if mid_price!=0:
diff = abs(high_price-mid_price)
percentage_to_profit = diff/mid_price*100
#Formatting (on-screen percentage not longer than 4 digits)
pct_to_profit_str = "{:.2f}".format(percentage_to_profit)
if len(pct_to_profit_str)==4:
pct_to_profit_str = f" {pct_to_profit_str}"
elif len(pct_to_profit_str)==6:
pct_to_profit_str = pct_to_profit_str[:5]
line3 = ""
if self.status.get_base_bought()!=0:
line3 = draw_line(mid_price,low_price,high_price,self.status.get_quote_spent()/self.status.get_base_bought())
low_boundary_color = self.get_color("red")
price_color = self.get_color("white")
target_price_color = self.get_color("green")
pair_color = self.get_color("cyan")
if self.config.get_is_short():
price_color = self.get_color("white")
pair_color = self.get_color("yellow")
if self.status.get_old_long()!={}:
if mid_price>self.status.get_old_long()["tp_price"]:
price_color = self.get_color("bright_green")
if high_price>self.status.get_old_long()["tp_price"]:
target_price_color = self.get_color("bright_green")
#Set percentage's color
pct_color = self.get_color("white")
if percentage_to_profit<low_percentage:
pct_color = self.get_color("green")
if percentage_to_profit>mid_percentage:
pct_color = self.get_color("yellow")
if percentage_to_profit>high_percentage:
pct_color = self.get_color("red")
multiplier = 0
if self.config.get_is_short() and self.status.get_old_long()!={}:
try:
#Logic to display switch price
old_target = self.status.get_old_long()["tp_price"]*self.status.get_old_long()["tp_amount"]
base_left = self.status.get_old_long()["tp_amount"]-self.status.get_base_bought()
minimum_switch_price = (old_target - self.status.get_quote_spent())/base_left
if old_target-self.status.get_quote_spent()>0 and base_left>0 and minimum_switch_price<low_price:
low_boundary_color = self.get_color("bright_green")
low_boundary = '{:.20f}'.format(minimum_switch_price)[:decimals].center(decimals)
if mid_price!=0:
multiplier = int(self.status.get_old_long()["tp_price"]/self.status.get_price())
except Exception as e:
print(e)
safety_order_string = f"{self.status.get_safety_orders_filled()}/{self.get_color('cyan')}{concurrent_so_amount}{self.get_color('white')}/{self.status.get_no_of_safety_orders()}".rjust(27)
prices = f"{low_boundary_color}{low_boundary}{self.get_color('white')}|{price_color}{mid_boundary}{self.get_color('white')}|{target_price_color}{high_boundary}{self.get_color('white')}|{pct_color}{pct_to_profit_str}%{self.get_color('white')}"
line1 = f"{pair_color}{self.status.get_pair().center(13)}{self.get_color('white')}| {safety_order_string} |{prices}| Uptime: {self.seconds_to_time(self.status.get_deal_uptime())}"
if self.status.get_is_boosted():
line1 = f"{line1} | BOOSTED"
if self.config.get_autoswitch():
auto_color = self.get_color("white")
if self.config.get_liquidate_after_switch():
auto_color = self.get_color("red")
line1 = f"{line1} | {auto_color}AUTO{self.get_color('white')}"
if multiplier>1:
#Only displays the multiplier if autoswitch is enabled.
line1 = f"{line1}{auto_color}x{multiplier}{self.get_color('white')}"
if self.config.get_programmed_stop() and time.time()<=self.config.get_programmed_stop_time():
line1 = f"{line1} | PROGRAMMED LAST DEAL"
if self.status.get_stop_when_profit():
line1 = f"{line1} | LAST DEAL"
status_string = f"{self.get_color('white')}{line1}\n{line3}{self.get_color('white')}"
return status_string
def set_pause(self, pause: bool, msg: str = "") -> None:
'''
Sets the pause state and reason
'''
self.pause = pause
self.status.set_pause_reason(msg)
return
def load_imported_trader(self) -> int:
'''
Loads status dictionary, orders and sets up variables
'''
#Load status dict
if self.status.load_from_file()==1:
self.broker.logger.log_this(f"Error: Couldn't load status dict. Aborting",1,self.status.get_pair())
self.quit = True
return 1
self.status.set_pause_reason("Importing trader")
#Refresh take profit order
order_id = self.status.get_take_profit_order()["id"]
self.status.set_take_profit_order(self.broker.get_order(order_id,self.status.get_pair()))
if self.status.get_take_profit_order()==self.broker.get_empty_order():
self.broker.logger.log_this("Couldn't load take profit order (broker returned empty order). Aborting.",1,self.status.get_pair())
self.quit = True
return 1
#Done
self.set_pause(False)
self.update_status(True)
return 0