1750 lines
95 KiB
Python
Executable File
1750 lines
95 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.warnings = {
|
|
"short_price_exceeds_old_long": False,
|
|
"speol_notified": 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"}
|
|
|
|
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()
|
|
|
|
#Status string caches
|
|
self.low_price_cache = None
|
|
self.mid_price_cache = None
|
|
self.high_price_cache = None
|
|
self.concurrent_so_amount_cache = None
|
|
|
|
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.pause = False
|
|
self.restart = True
|
|
else:
|
|
self.quit = True
|
|
elif start_result==3: #Not enough liquidity
|
|
self.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.pause = True
|
|
self.status.set_pause_reason("start_trader")
|
|
|
|
if self.status.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 rounding errors (rare)
|
|
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 diff>min_base_size:
|
|
self.broker.logger.log_this(f"Buying missing {diff} {self.base}",1,self.status.get_pair())
|
|
self.broker.new_market_order(self.status.get_pair(),diff,"buy",amount_in_base=True)
|
|
time.sleep(self.broker.get_wait_time()*2)
|
|
#Re-quering 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_pause_reason("start_trader - after slippage")
|
|
self.status.set_order_size(self.config.get_order_size())
|
|
|
|
#Sending initial order
|
|
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_time())
|
|
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)
|
|
|
|
# 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.pause = False
|
|
self.status.set_pause_reason("")
|
|
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
|
|
balance_to_clean /= 2 #Maybe it's a good idea, sort of DCAing the dust.
|
|
min_base_size = self.broker.get_min_base_size(self.status.get_pair())
|
|
#minimum_cleanup_size = self.status.get_safety_orders()[0]["amount"]*2
|
|
|
|
if balance_to_clean >= min_base_size:
|
|
self.broker.logger.log_this(f"Balance to clean: {balance_to_clean} {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,"sell",self.status.get_take_profit_price())
|
|
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.pause = False
|
|
self.status.set_pause_reason("")
|
|
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.pause = False
|
|
self.status.set_pause_reason("")
|
|
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.pause = True
|
|
self.status.set_pause_reason("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.pause = False
|
|
self.status.set_pause_reason("")
|
|
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.pause = False
|
|
self.status.set_pause_reason("")
|
|
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.pause = False
|
|
self.status.set_pause_reason("")
|
|
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())
|
|
#self.pause = False
|
|
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) -> 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())
|
|
|
|
#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
|
|
try:
|
|
for order in self.status.get_safety_orders():
|
|
self.broker.cancel_order(order["id"],self.status.get_pair())
|
|
except Exception as e:
|
|
self.broker.logger.log_this(f"Error in cancel_order while cancelling safety order. Exception: {e}",1,self.status.get_pair())
|
|
try:
|
|
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())
|
|
except Exception as e:
|
|
self.broker.logger.log_this(f"Error in cancel_order while cancelling take profit order. Exception: {e}",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")
|
|
tries = self.broker.get_retries()
|
|
while True:
|
|
time.sleep(self.broker.get_wait_time())
|
|
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
|
|
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.pause = True
|
|
self.status.set_pause_reason("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)
|
|
|
|
#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
|
|
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.pause = True #To stop the main thread to iterate through this trader's orders (just in case)
|
|
self.status.set_pause_reason("take_profit_routine - order handling") #start_trader will set this flag to False again once it starts
|
|
|
|
#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)
|
|
|
|
#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
|
|
#Sum the filled amounts
|
|
partial_filled_amount+=closed_order["filled"]
|
|
partial_filled_price.append(closed_order["average"])
|
|
#Better than this, the total filled and total cost can be used to send a sell market order of the partial filled amount, and add that to the profit.
|
|
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())
|
|
#self.status.set_base_bought(self.status.get_base_bought() + closed_order["filled"] - self.parse_fees(closed_order)[0])
|
|
#self.status.set_quote_spent(self.status.get_quote_spent() + closed_order["cost"])
|
|
#Save the order
|
|
if self.broker.get_follow_order_history():
|
|
self.status.update_deal_order_history(closed_order)
|
|
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.status.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
|
|
market_order = self.broker.new_market_order(self.status.get_pair(),partial_filled_amount,"sell",amount_in_base=True)
|
|
#Wait for it to be filled
|
|
tries = self.broker.get_retries()
|
|
while True:
|
|
time.sleep(self.broker.get_wait_time())
|
|
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
|
|
tries-=1
|
|
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())
|
|
#The trader is restarted by the instance instead of by itself to allow a couple of more seconds for the price to return to normal.
|
|
#This could also be the default behavior.
|
|
self.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.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")
|
|
#retries = self.broker.get_retries()
|
|
if restart_trader in self.trader_restart_errors.keys():
|
|
self.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.pause = True
|
|
self.status.set_pause_reason("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)
|
|
|
|
#Add the amount filled in the last safety orders to the totals
|
|
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)
|
|
self.status.set_fees_paid_in_quote(self.status.get_fees_paid_in_quote() + new_fees_quote)
|
|
self.status.set_base_bought(self.status.get_base_bought() + order["filled"] - new_fees_base)
|
|
self.status.set_quote_spent(self.status.get_quote_spent() + order["cost"])
|
|
|
|
#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)
|
|
#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_time())
|
|
|
|
#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.pause = False
|
|
self.status.set_pause_reason("")
|
|
|
|
#Done
|
|
return 0
|
|
|
|
|
|
def check_old_long_price(self) -> int:
|
|
'''
|
|
Checks if short price exceeds old long price. If so, send a Telegram message
|
|
'''
|
|
|
|
price_exceeds = False
|
|
if self.status.get_old_long()!={}:
|
|
price_exceeds = self.status.get_price()>float(self.status.get_old_long()["tp_price"])
|
|
if price_exceeds:
|
|
self.warnings["short_price_exceeds_old_long"] = True
|
|
else:
|
|
self.warnings["short_price_exceeds_old_long"] = False
|
|
self.warnings["speol_notified"] = False
|
|
if not self.warnings["speol_notified"] and price_exceeds:
|
|
#Only notify one time AND if autoswitch is off
|
|
self.warnings["speol_notified"] = True
|
|
if not self.config.get_autoswitch():
|
|
message = f"{self.base}@{self.status.get_price()} ({str(self.broker.exchange)}), exceeds old long price of {self.status.get_old_long()['tp_price']}"
|
|
self.broker.logger.log_this(message,0,self.status.get_pair())
|
|
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_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
|
|
|
|
#Check if short price exceeds old long price. If so, send a Telegram message
|
|
if self.config.get_is_short() and self.status.get_old_long()!={} and self.config.get_check_old_long_price():
|
|
self.check_old_long_price()
|
|
|
|
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 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.
|
|
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()
|
|
if (base_left*self.status.get_price())+self.status.get_quote_spent()>=old_target:
|
|
#Sell all base (market), report the profits and restart the trader
|
|
self.status.set_pause_reason("automatic_switch")
|
|
self.switch_to_long(already_received_quote=self.status.get_quote_spent())
|
|
if not self.config.get_liquidate_after_switch():
|
|
self.restart = True
|
|
return 1
|
|
|
|
#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:
|
|
# Check if the order has a wrong id. If so, update the order.
|
|
# To cover a very rare case that happens if the trader sends a new take profit order but is interrupted before saving the status.
|
|
# Not sure if it is worth to keep this code.
|
|
for order in open_orders_list:
|
|
if order["amount"]==self.status.get_take_profit_order()["amount"] and order["price"]==self.status.get_take_profit_order()["price"] and order["side"]==self.status.get_take_profit_order()["side"]:
|
|
#Right order, wrong id. Update order
|
|
self.broker.logger.log_this(f"Updating take profit order for {self.status.get_pair()}",1,self.status.get_pair())
|
|
self.status.set_take_profit_order(order)
|
|
self.update_status(True)
|
|
return 0
|
|
|
|
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!=[]:
|
|
#closed_orders = self.broker.get_closed_orders(self.status.get_pair())
|
|
#filled_orders = [item for item in closed_orders if item["id"] in filled_ids and item["status"]=="closed"]
|
|
filled_orders = []
|
|
for id in filled_ids:
|
|
order = self.broker.get_order(id, self.status.get_pair())
|
|
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.pause = False
|
|
self.status.set_pause_reason("")
|
|
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.pause = True
|
|
self.send_new_safety_order_batch(amount_to_send)
|
|
self.pause = False
|
|
self.update_status(True)
|
|
|
|
#Check for autoswitch (long->short)
|
|
#Commented out because i'm not sure where this should go
|
|
#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
|
|
|
|
#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())
|
|
p = "*PAUSED*" if self.pause==True else ""
|
|
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"{p}{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 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.pause = False
|
|
self.status.set_pause_reason("")
|
|
self.update_status(True)
|
|
return 0
|
|
|