brighter-trading/trade.py

695 lines
29 KiB
Python

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: <str> - ['exchange'|'backtester'] The destination for this trade.
:param symbol: <str> - The symbol of the trading pair.
:param side: <str> - BUY|SELL
:param price: <float> - Asking price for LIMIT orders.
:param qty: <float> - Base quantity to buy
:param order_type: <str> - LIMIT|MARKET
:param tif: <str> - ['GTC'|'IOC'|'FOK'] Time-in-force. Defaults to GTC. This flag is used externally.
:param unique_id <hex> - Identifier
:param status: <str> ['inactive'|'unfilled'|'filled'|'part-filled'|'closed'] - order status.
:param stats: <dic> ['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: <float> - 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: <float> - a positive profit or a negative loss.
"""
return self.stats['profit']
def get_pl_pct(self):
"""
Return: <float> - a percentage - positive profit or a negative loss.
"""
return self.stats['profit_pct']
def get_status(self):
"""
Return: <str> - Returns the status of the trade.
"""
return self.status
# def place_order(self):
# """
# :return: on success: order, on fail: <str>'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: <float> - Most recent price update.
:return: <str> - 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 <float> - The quantity filled.
:param price <float> - 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: <list?dict?> 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: <Exchange obj> - 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 <str>: 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: <str>'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: <str> - ['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: <hex> - The unique ID of the trade.
:return: <bool> - 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: <hex> - The Unique id of a tradee
:param qty: <float> - The quantity to reduce the trade by.
:return: <float> - 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}")