import json import uuid import requests from datetime import datetime class Trade: def __init__(self, dest, symbol, side, price, qty, order_type='MARKET', tif='GTC', unique_id=None, status=None, stats=None, order=None, fee=0.1): """ This object contains the information,value and statistics for individual active_trades. Flags and stats can be set in the constructor in order to load previous active_trades. Creating a trade does not execute it on the exchange. :param dest: - ['exchange'|'backtester'] The destination for this trade. :param symbol: - The symbol of the trading pair. :param side: - BUY|SELL :param price: - Asking price for LIMIT orders. :param qty: - Base quantity to buy :param order_type: - LIMIT|MARKET :param tif: - ['GTC'|'IOC'|'FOK'] Time-in-force. Defaults to GTC. This flag is used externally. :param unique_id - Identifier :param status: ['inactive'|'unfilled'|'filled'|'part-filled'|'closed'] - order status. :param stats: ['opening_price']:float | ['current_price']:float | ['opening_value']:float | ['current_value']:float | ['profit'] | ['profit_pct']:float - Price and P/L values and percentages for the trade. :param fee: - the fee rate charged when trading in or out. """ # Set the ID or generate one if not provided. # Trade ID should be the same as order id. if unique_id is None: self.unique_id = uuid.uuid4().hex else: self.unique_id = unique_id # Store the destination for the trade. ['exchange'|'backtester'] self.dest = dest # Set the symbol of the pair being traded. self.symbol = symbol # Set the side - buy or sell self.side = side # Set the order_type. Defaults to 'MARKET'. This flag is used externally when placing the order. # LIMIT: Places an order at a set asking price. # MARKET: Accept an offer off the table. # TRAILING: Follow the price with LIMIT orders a set value # from the price until it fills. self.order_type = order_type # Set Time-in-force. Defaults to GTC. This flag is used externally. # GTC (Good-Till-Cancel) - leave it on the orderbook. # IOC (Immediate-Or-Cancel) - if it can't at least partially fill immediately cancel the order. # FOK (Fill-Or-Kill) - If it can't fully fill immediately cancel the order. self.time_in_force = tif # Set the quantity to be traded. self.base_order_qty = qty # Set the fee rate the exchange is charging. self.fee = fee # If status is None this is a new trade. Set the default # values. if status is None: # Trade status flag. ['inactive'|'unfilled'|'filled'|'part_filled'|'closed'] self.status = 'inactive' # Trade statistics. self.stats = { 'opening_price': price, 'opening_value': (qty * price), 'current_price': price, 'current_value': (qty * price), 'settled_price': 0, 'settled_value': 0, 'qty_filled': 0, 'qty_settled': 0, 'profit': 0, 'profit_pct': 0, 'fee_paid': 0 } else: # Loading an existing trade. Set the values. self.status = status self.stats = stats self.order = order def get_position_size(self): """ Position_size is the value of the trade in the quote currency. eg .1 BTC @ $10,000 BTCUSD is equal to $1,000 USD. """ return self.stats['current_value'] def get_pl(self): """ Return: - a positive profit or a negative loss. """ return self.stats['profit'] def get_pl_pct(self): """ Return: - a percentage - positive profit or a negative loss. """ return self.stats['profit_pct'] def get_status(self): """ Return: - Returns the status of the trade. """ return self.status # def place_order(self): # """ # :return: on success: order, on fail: 'failed' # """ # # try: # print('Trade:place_order(): Attempting to place order') # print(f"symbol={self.symbol}, side={self.side}, type={self.order_type}, timeInForce='GTC',", # f" quantity={self.position_size}, price={self.stats['opening_price']}") # # # Market orders immediately fill with best price available in the orderbook. # if self.order_type == 'MARKET': # order = self.exchange.client.create_test_order(symbol=self.symbol, # side=self.side, # type=self.order_type, # quoteOrderQty=self.quoteOrderQty, # newClientOrderId=self.unique_id) # # Limit orders are posted in the orderbook. # # The order's behavior is modified by setting time_in_force. # # GTC (Good-Till-Cancel), IOC (Immediate-Or-Cancel), FOK (Fill-Or-Kill) # elif self.order_type == 'LIMIT': # order = self.exchange.client.create_test_order(symbol=self.symbol, # side=self.side, # type=self.order_type, # timeInForce=self.time_in_force, # quantity=self.position_size, # price=self.stats['opening_price'], # newClientOrderId=self.unique_id) # # This is here incase I want to make a custom order type. # else: # print(f'Trade: No Implementation for trade.order: {self.order_type}') # return None # # If we get here an exception was not thrown by the exchange. # print('Trade:place_order(): Order successfully created.') # self.flags['order_placed'] = True # return order # # Report error if order fails # except Exception as e: # print(e, "error") # return 'failed' def update_values(self, current_price): """ Update the P/L values and percentages. :param current_price - current price :return: None """ def percent(part, whole): # Return a percentage. if whole == 0: return 0 pct = 100 * float(part) / float(whole) return pct # Update the current price. self.stats['current_price'] = current_price # Get the value at entry into the position. initial_value = self.stats['opening_value'] # Calculate the current value in the quote currency. self.stats['current_value'] = float(self.stats['qty_filled'] * current_price) # Calculate and set the profit or loss. self.stats['profit'] = self.stats['current_value'] - initial_value # If it is a short, reverse the sign. if self.side == 'sell': self.stats['profit'] *= -1 # Subtract the trade in/out fees that would be paid if settled at the current price. projected_fees = (self.stats['current_value'] * self.fee) + (self.stats['opening_value'] * self.fee) self.stats['profit'] -= projected_fees # Calculate and set the profit/loss percentage. self.stats['profit_pct'] = percent(self.stats['profit'], initial_value) return def update(self, current_price): """ Update the trade and return the status of the trade. :param current_price: - Most recent price update. :return: - Trade status. """ # If the trade has been closed return status without updating. if self.status == 'closed': return self.status # Update all price dependent values in the trade. self.update_values(current_price) # If everything is updated and trade is live return status. return self.status def order_placed(self, order): """ Set the status and store the order after it has been placed on the exchange. :param order: The order received in response to placing an order on the exchange. :return: None """ self.status = 'unfilled' self.order = order return def trade_filled(self, qty, price): """ Records the quantity filled and updates the values and sets the status. :param qty - The quantity filled. :param price - The price paid. :return: None """ # Todo maybe inactive flag is un-necessary. # If the trade is being filled it must have been active. if self.status == 'inactive': self.status = 'unfilled' # If none of the trade had been filled yet. if self.status == 'unfilled': # Record the quantity that has been filled. self.stats['qty_filled'] = qty # Record the price actually paid. self.stats['opening_price'] = price else: # If adding to a partially filled trade, calculate and record # the weighted average of the trade as the opening price. sum_of_values = (qty * price) + self.stats['opening_value'] t_qty = self.stats['qty_filled'] + qty weighted_average = sum_of_values / t_qty self.stats['opening_price'] = weighted_average # Add to the quantity that has been filled. self.stats['qty_filled'] += qty # Adjust the opening_value record. self.stats['opening_value'] = self.stats['qty_filled'] * self.stats['opening_price'] # Set the appropriate status for the trade. if self.stats['qty_filled'] == self.base_order_qty: self.status = 'filled' else: self.status = 'part_filled' def settle(self, qty, price): """ Settle all or part of the trade. :param: qty - The quantity that was settled. :param: price - The price the trade was settled at. :return: None """ # If noting has been settled yet. if self.stats['qty_settled'] == 0: self.stats['settled_price'] = price self.stats['settled_value'] = qty * price self.stats['qty_settled'] = qty else: # If settling previously reduced trade, calculate and record. # the weighted average of the settled trade as the settled price. sum_of_values = (qty * price) + self.stats['settled_value'] t_qty = self.stats['qty_settled'] + qty weighted_average = sum_of_values / t_qty self.stats['settled_price'] = weighted_average # Add to the quantity that has been settled. self.stats['qty_settled'] += qty # Adjust the settled value record. self.stats['settled_value'] = self.stats['qty_settled'] * self.stats['settled_price'] # If the entire trade is settled, mark the trade as closed. if self.stats['qty_settled'] == self.base_order_qty: self.status = 'closed' return # TODO left over from changes # Check if the trade has been filled. # if self.flags['order_filled'] is False: # # if it hasn't been filled cancel the order. # resp = self.exchange.client.cancel_order(self.unique_id) # if resp["status"] == "CANCELED": # return True # else: # # Toggle the side settings. # if self.side == 'buy': # self.side = 'sell' # else: # self.side = 'buy' # # option MARKET closed the trade for what ever it can get. # if option == 'MARKET': # self.order_type = 'MARKET' # self.place_order() class Trades: def __init__(self, loaded_trades=None): """ This class receives, executes, tracks and stores all active_trades. :param loaded_trades: A bunch of active_trades to create and store. """ # Object that maintains exchange and account data self.exchange = None # Socket connection to the exchange. self.client = None # Exchange fees. Will be fetched from the server and over writen on connection. self.exchange_fees = {'maker': 0.01, 'taker': 0.05} # Hedge mode allows long and shorts to be placed simultaneously. self.hedge_mode = False # If hedge mode is disabled this is either {'buy','sell'}. self.side = None # A list of active trades. self.active_trades = [] # A list of trades that have been closed. self.settled_trades = [] self.stats = {'num_trades': 0, 'total_position': 0, 'total_position_value': 0} # Load any trades that were passed into the constructor. if loaded_trades is not None: # Create the active_trades loaded from file. self.load_trades(loaded_trades) def connect_exchange(self, exchange): """ Connect an exchange. :param exchange: - The exchange to connect. :return: None """ self.exchange = exchange self.client = self.exchange.client # TODO figure out if I need anything like this. # Find out if hedge-mode is enabled and set a local flag. # pm = self.client.futures_get_position_mode() # self.hedge_mode = pm.dualSidePosition return def exchange_connected(self): """ Report if an exchange has been connected to ths object. """ if self.exchange is None: return False else: return True def get_trades(self, form): # Return a python object of all the trades stored in this instance. if form == 'obj': return self.active_trades # Return a JSON object of all the trades stored in this instance. elif form == 'json': trades = self.active_trades json_str = [] for trade in trades: json_str.append(trade.to_json()) return json_str # Return a dictionary object of all the trades stored in this instance. elif form == 'dict': trades = self.active_trades t_list = [] for trade in trades: dic = trade.__dict__ t_list.append(dic) return t_list def get_trades_by_status(self, status): """ Evaluate all active_trades. Return a list of active_trades with the specified status. :param status : status to evaluate - ['inactive'|'unfilled'|'filled'|'part_filled'|'closed'] :return: List of any trade that have the flag set. """ ret_list = [] for trade in self.active_trades: if trade.status is status: ret_list.append(trade) return ret_list def get_trade_by_id(self, trade_id): """ Returns 1st trade found that matches the ID specified. :param trade_id: unique id :return: trade object """ for trade in self.active_trades: if trade.unique_id == trade_id: return trade def load_trades(self, trades): """ Creates trade objects from a list trades and attributes. :param trades: a list of active_trades to load. """ # Loop through the list of active_trades provided. for trade in trades: # Append a local list of active_trades with a new trade created trade object. # Pass the properties of trade and a reference of the exchange into the constructor. self.active_trades.append(Trade(self.exchange, **trade)) return def place_order(self, trade): """ Execute a place order command on the exchange. :param trade: Trade object :return: on success: order, on fail: 'failed' """ try: # Log the action to the command line. print('\nTrades:place_order(): Attempting to place order on the exchange.') print(f"symbol={trade.symbol}, side={trade.side}, type={trade.order_type}, timeInForce=trade.timeInForce,", f" quantity={trade.base_order_qty}, price={trade.stats['opening_price']}") # Market orders immediately fill with best price available in the orderbook. if trade.order_type == 'MARKET': order = self.exchange.client.create_test_order(symbol=trade.symbol, side=trade.side, type=trade.order_type, quantity=trade.base_order_qty, newClientOrderId=trade.unique_id) # Limit orders are posted in the orderbook. # The order's behavior is modified by setting time_in_force. # GTC (Good-Till-Cancel), IOC (Immediate-Or-Cancel), FOK (Fill-Or-Kill) elif trade.order_type == 'LIMIT': order = self.exchange.client.create_test_order(symbol=trade.symbol, side=trade.side, type=trade.order_type, timeInForce=trade.time_in_force, quantity=trade.base_order_qty, price=trade.stats['opening_price'], newClientOrderId=trade.unique_id) # This is here incase I want to make a custom order type. else: print(f'Trade: No Implementation for trade.order: {trade.order_type}') return 'failed' # If we get here an exception was not thrown by the exchange. # Log the success to the command line. print('Trade:place_order(): Order successfully created.') print(order) # Store the order and set the status flag in the trade object. trade.order_placed(order) return 'success' # Report error if order fails. except Exception as e: # Log the error to the commandline. print(e, "error") return 'failed' def update(self, price): """ Updates the price all active trades. :param price: current. :return: list of updated trade data """ r_update = [] for trade in self.active_trades: # If the trade hasn't been filled check the status on the exchange. if trade.status == 'unfilled' or trade.status == 'part-filled': # Get an updated version of the order from the exchange. posted_order = self.client.get_order(symbol=trade.symbol, orderId=trade.order.orderId) # Check to see if the order is filled. if posted_order.orderStatus == 'FILLED': # If the order is filled update the trade object. trade.trade_filled(posted_order.toAmount, posted_order.fromAmount) else: # If the order is not filled, do nothing. pass if trade.status == 'inactive': # Check to see if this trade is supposed to be posted on the exchange. if trade.dest == 'exchange': # Todo: Not sure if a use-case exist where the order will fail to be placed in new_trade. # If the trade is still inactive new_trade must have failed to place the order. # Check to see if the order actually doesn't exist. open_orders = self.client.get_open_orders(symbol=trade.symbol) order_not_placed = True for order in open_orders: if order.orderId is trade.unique_id: order_not_placed = False if order_not_placed: # TODO: Log error encountered in new_trade to avoid failing repeatedly. # Attempt to place the order again. result = self.place_order(trade) if result == 'success': print(f'Trades:Update(): Order successfully placed on exchange.') ret = trade.update(price) if ret == 'updated' or ret == 'trade_filled': r_update.append({ 'status': ret, 'id': trade.unique_id, 'pl': trade.stats['profit'], 'pl_pct': trade.stats['profit_pct'] }) else: r_update.append({'id': trade.unique_id, 'status': ret}) return r_update def new_trade(self, target, symbol, side, order_type, qty, price=None, offset=None): """ Return a reference to a newly created a Trade object. :param target: - ['exchange'|'backtester']The destination for this trade. :param symbol - Symbol of the trading pair. :param side - Buy|Sell :param order_type - Market|Limit|Trailing :param qty - Quantity of order :param price - Price to buy at. A relative price may be set by passing a string preceded with a +/- :param offset - Is an amount to offset a trailing limit order. """ # For now, you must be connected to an exchange to make a trade. if not self.exchange_connected(): # Todo: There are some values pulled from the exchange here that make this cumbersum. # I think this should be able to que trades and place them after connection. # Clarify whether or not all references to precision, min and max # values could be move to the place_order() method. To de-couple this method from Exchange. return 'Error', 'No exchange connected.' # The required level of precision for this trading pair. precision = self.exchange.get_precision(symbol) # Function to convert a value to a float set to the desired precision. def f_val(val): return float(f"{val:.{precision}f}") # The minimum value aloud to be traded. minimum_n_qty = f_val(self.exchange.get_min_notional_qty(symbol)) # The minimum quantity aloud to be traded. minimum_qty = f_val(self.exchange.get_min_qty(symbol)) # If quantity isn't supplied set it to the smallest allowable trade. try: qty = f_val(qty) except (Exception,): qty = minimum_qty # If quantity was supplied, but it's too low, return an error. if qty < minimum_qty: return 'Error', 'Order-size too low.' # TODO: A hedge mode toggle complicates the behavior of settling and reducing trades. # Leaving it out for now. # Notes - A behavior diagram would be helpful to sort out the logic here. # When do you define a side? Trading out is not a side-change. # If hedge mode is disabled return False if trade is on opposite side. # if self.hedge_mode is False: # if self.side is None: # self.side = side # if self.side != side: # return 'Error', 'Wrong side. Settle trade or enable hedge mode.' # Ensure offset is at least set to the smallest allowable value of the quote asset. if offset is None or offset < minimum_n_qty: offset = minimum_n_qty # If no price is given, set relative offset to the current price. if price is None: if side == 'buy': price = f'-{offset}' else: price = f'+{offset}' # If price is a string process and convert it to a float. if type(price) == str: # If it is proceeded with a +/-, processes as a relative limit order. if ('+' in price) or ('-' in price): # Covert the relative value into an absolute offset of the current price. price = self.exchange.get_price(symbol) + float(price) # Convert the string into a float. price = f_val(price) # position_size is the value of the trade in the parent currency. # eg .1 BTC @ $10,000 USD/BTC is equal to $1,000 USD. position_size = f_val(qty * price) # Log to terminal for development. Todo remove when stable. print('\nTrades:new_trade(): Creating a trade object.') print(f'Order amount: {position_size:.6f}') print(f'Minimum usd: {minimum_n_qty:.2f}') # Create a trade. trade = Trade(dest=target, symbol=symbol, side=side, price=price, qty=qty, order_type=order_type, tif='GTC', unique_id=None, status=None, stats=None, order=None) # If the trade is meant to be on the exchange. Call the method to place the trade on the exchange. if target == 'exchange': # Attempt to place the order. result = self.place_order(trade) if result == 'success': print(f'Trades:new_trade(): Order successfully placed on exchange.') # Add the trade to a list of active_trades instance. self.active_trades.append(trade) # Return the trade ID return 'Success', trade.unique_id def close_trade(self, trade_id): """ Close the trade. :param trade_id: - The unique ID of the trade. :return: - Return True if successful. """ trade = self.get_trade_by_id(trade_id) qty = trade.base_order_qty price = self.exchange.get_price(trade.symbol) trade.settle(qty=qty, price=price) if trade.status == 'closed': self.active_trades.remove(trade) self.settled_trades.append(trade) return True else: return False def reduce_trade(self, trade_id, qty): """ Reduce the position of a trade. :param trade_id: - The Unique id of a tradee :param qty: - The quantity to reduce the trade by. :return: - The base quantity left over. """ # Fetch the trade object. trade = self.get_trade_by_id(trade_id) if trade.status == 'closed': # Can't reduce a trade that's been closed. return None if trade.status == 'inactive' or trade.status == 'unfilled': # If it has been placed but not filled. if trade.status == 'unfilled': # Cancel the order. self.client.cancel_order(symbol=trade.symbol, orderId=trade.unique_id) # The order hasn't been placed so just reduce the order quantity. # Make sure you are not reducing more than the original order. if qty > trade.base_order_qty: qty = trade.base_order_qty trade.base_order_qty -= qty # Adjust the opening value. trade.stats['opening_value'] = (trade.base_order_qty * trade.stats['opening_price']) # Re-calculate values so the PL will represent the new value - fees. trade.update_values(trade.stats['opening_price']) # If it has been previously placed but not filled and now canceled. if trade.status == 'unfilled': # Replace the order. self.place_order(trade=trade) # Return the new total quantity to buy. return trade.base_order_qty # You can't settle more than you own. if qty > trade.stats['qty_filled']: qty = trade.stats['qty_filled'] # Retrieve the current price. price = self.exchange.get_price(trade.symbol) # Settle the quantity requested. trade.settle(qty=qty, price=price) # If the trade is closed remove from the list of open active_trades. if trade.status == 'closed': self.active_trades.remove(trade) self.settled_trades.append(trade) return 0 else: # If the trade is not closed then return the total left open. left = trade.stats['qty_filled'] - trade.stats['qty_settled'] return float(f"{left:.3f}")