def __init__(self, exchanges, logger, db_other, db_client, models): self.exchanges = {i.get_name(): i for i in exchanges} self.logger = logger self.db_other = db_other self.db_client = db_client self.models = models self.id_gen = TradeID(db_other) self.pf = self.load_portfolio() self.trades_save_to_db = queue.Queue(0)
def __init__(self, exchanges, logger, db_other, db_client, models, telegram): self.exchanges = {i.get_name(): i for i in exchanges} self.logger = logger self.db_other = db_other self.db_client = db_client self.models = models self.telegram = telegram self.broker = None self.trades_save_to_db = queue.Queue(0) self.id_gen = TradeID(db_other) self.pf = self.load_portfolio() self.verify_portfolio_state(self.pf)
class Portfolio: """ Portfolio manages the net holdings for all models, issuing order events and reacting to fill events to open and close positions as strategies dictate. Capital allocations to strategies and risk parameters are defined here. """ MAX_SIMULTANEOUS_POSITIONS = 1 MAX_CORRELATED_TRADES = 2 MAX_ACCEPTED_DRAWDOWN = 25 # Percentage as integer. RISK_PER_TRADE = 2 # Percentage as integer OR 'KELLY' DEFAULT_STOP = 2 # Default (%) stop distance if none provided. def __init__(self, exchanges, logger, db_other, db_client, models): self.exchanges = {i.get_name(): i for i in exchanges} self.logger = logger self.db_other = db_other self.db_client = db_client self.models = models self.trades_save_to_db = queue.Queue(0) self.id_gen = TradeID(db_other) self.pf = self.load_portfolio() self.verify_portfolio_state(self.pf) def new_signal(self, events, event): """ Interpret incoming signal events to produce Order Events. Args: events: event queue object. event: new market event. Returns: None. Raises: None. """ signal = event.get_signal_dict() if self.within_risk_limits(signal): orders = [] # Generate sequential trade ID for new trade. trade_id = self.id_gen.new_id() # Handle single-instrument signals: if signal['instrument_count'] == 1: stop = self.calculate_stop_price(signal), size = self.calculate_position_size(stop[0], signal['entry_price']) # Entry order. orders.append( Order( self.logger, trade_id, # Parent trade ID. None, # Order ID as used by venue. signal['symbol'], # Instrument ticker code. signal['venue'], # Venue name. signal['direction'], # LONG or SHORT. size, # Size in native denomination. signal['entry_price'], # Order price. signal[ 'entry_type'], # LIMIT MARKET STOP_LIMIT/MARKET. "ENTRY", # ENTRY, TAKE_PROFIT, STOP. stop[0], # Order invalidation price. False, # Trail. False, # Reduce-only order. False)) # Post-only order. # Stop order. orders.append( Order(self.logger, trade_id, None, signal['symbol'], signal['venue'], event.inverse_direction(), size, stop[0], "STOP", "STOP", None, signal['trail'], True, False)) # Take profit order(s). if signal['targets']: count = 1 for target in signal['targets']: # Label final TP order as "FINAL_TAKE_PROFIT". tp_type = "TAKE_PROFIT" if count != len( signal['targets']) else "FINAL_TAKE_PROFIT" count += 1 orders.append( Order(self.logger, trade_id, None, signal['symbol'], signal['venue'], event.inverse_direction(), (size / 100) * target[1], target[0], "LIMIT", tp_type, stop[0], False, True, False)) # Set sequential order ID's, based on trade ID. count = 1 for order in orders: order.order_id = str(trade_id) + "-" + str(count) count += 1 # Parent trade object: trade = SingleInstrumentTrade( self.logger, signal['direction'], # Direction signal['venue'], # Venue name. signal['symbol'], # Instrument ticker code. signal['strategy'], # Model name. signal['entry_timestamp'], # Signal timestamp. None, # Position object. { str(i.get_order_dict()['order_id']): i.get_order_dict() for i in orders }) # noqa # Finalise trade object. Must be called to set ID + order count trade.set_batch_size_and_id(trade_id) # Queue the trade for DB storage and update portfolio state. self.trades_save_to_db.put(trade.get_trade_dict()) self.pf['trades'][str(trade_id)] = trade.get_trade_dict() self.save_porfolio(self.pf) # TODO: handle multi-instrument, multi-venue trades. elif signal['instrument_count'] == 2: pass elif signal['instrument_count'] > 2: pass # Set order batch size and queue orders for execution. batch_size = len(orders) for order in orders: order.batch_size = batch_size events.put(OrderEvent(order.get_order_dict())) self.logger.debug("Trade " + str(trade_id) + " registered.") def new_fill(self, fill_event): """ Process incoming fill event, update position, trade and order state accordingly. Args: events: event queue object. event: new market event. Returns: None. Raises: None. """ fill_conf = fill_event.get_order_conf() position = Position(fill_conf).get_pos_dict() t_id = str(position['trade_id']) if fill_conf['metatype'] == "ENTRY": # Create a position record and set trade to active. self.pf['trades'][t_id]['position'] = position self.pf['trades'][t_id]['active'] = True self.pf['total_active_trades'] += 1 elif fill_conf['metatype'] == "STOP": # Update the now closed postiion, trade is done. size = self.pf['trades'][t_id]['position']['size'] new_size = size - fill_conf['size'] if new_size != 0: raise Exception(new_size) self.pf['trades'][t_id]['position']['size'] = new_size self.pf['trades'][t_id]['position']['status'] = "CLOSED" self.trade_complete(t_id) elif fill_conf['metatype'] == "TAKE_PROFIT": # Update the modified position. size = self.pf['trades'][t_id]['position']['size'] new_size = size - fill_conf['size'] self.pf['trades'][t_id]['position']['size'] = new_size if new_size == 0: self.trade_complete(t_id) else: self.calculate_pnl_by_trade(t_id) elif fill_conf['metatype'] == "FINAL_TAKE_PROFIT": # Update the now closed postiion, trade is done. size = self.pf['trades'][t_id]['position']['size'] new_size = size - fill_conf['size'] self.pf['trades'][t_id]['position']['size'] = new_size self.pf['trades'][t_id]['position']['status'] = "CLOSED" if new_size != 0: raise Exception("Position close size error:", new_size) self.trade_complete(t_id) else: raise Exception("Order metatype error:", fill_conf['metatype']) self.save_porfolio(self.pf) def new_order_conf(self, order_confs: list, events): """ Update stored trade and order state to match given order confirmations. Args: order_confs: list of order dicts containing updated details. events: event queue object. Returns: None. Raises: None. """ # Update portfolio state. for conf in order_confs: t_id = str(conf['trade_id']) o_id = str(conf['order_id']) self.pf['trades'][t_id]['orders'][o_id] = conf # Create a fill event if order already filled (e.g. market orders). if conf['status'] == "FILLED": events.put(FillEvent(conf)) self.save_porfolio(self.pf) def trade_complete(self, trade_id): """ Check all orders and positions are closed, calculate pnl, run post trade checks/analytics. """ # Cancel all orders marching trade ID. self.cancel_orders_by_trade_id(trade_id) # Close positions, if still open. if self.check_position_open(trade_id): self.close_position_by_trade_id(trade_id) # Calculate trade pnl. self.calculate_pnl_by_trade(trade_id) # Run post-trade analytics. self.post_trade_analysis(trade_id) # Save updated portfolio state to DB. self.save_porfolio(self.pf) def cancel_orders_by_trade_id(self, t_id): """ Cancel all orders matching the given trade ID and update local portfolio state. """ o_ids = self.pf['trades'][t_id]['orders'].keys() v_ids = [ self.pf['trades'][t_id]['orders'][o]['venue_id'] for o in o_ids if self.pf['trades'][t_id]['orders'][o]['status'] != "FILLED" ] venue = self.pf['trades'][t_id]['venue'] print("v_ids to cancel", v_ids) cancel_confs = self.exchanges[venue].cancel_orders(v_ids) print("cancel_confs", cancel_confs) try: if cancel_confs['error']["message"] == 'Not Found': self.pf['trades'][t_id]['active'] = False for o in o_ids: self.pf['trades'][t_id]['orders'][o]['status'] == "FILLED" # Handle other error messages here except KeyError as ke: # print(traceback.format_exc(), ke) # Update portfolio state based on cancellation message. self.pf['trades'][t_id]['active'] = False for order_id in o_ids: for venue_id in v_ids: # Handle active orders actually cancelled. if self.pf['trades'][t_id]['orders'][order_id][ 'venue_id'] == venue_id and cancel_confs[ venue_id] == "SUCCESS": self.pf['trades'][t_id]['orders']['order_id'][ 'status'] = "CANCELLED" else: raise Exception("Order id mismatch:", cancel_confs[v_id]) def check_position_open(self, trade_id): """ Return true if position is still open according to local portfolio. """ if self.pf['trades'][trade_id]['position']['status'] == "OPEN": return True elif self.pf['trades'][trade_id]['position']['status'] == "CLOSED": return False else: raise Exception("Position status error:", self.pf['trades'][trade_id]['position']['status']) def close_position_by_trade_id(self, t_id): """ This method will close only the remaining amount for the given trade - it will not necessarily close an entire position, unless there is only one open position in that particular instrument. Then, update local portfolio state. Use close_position_absolute() to completely close all positions in for specifc instrument at a specific venue. """ close = self.exchanges[self.pf['trades'][t_id] ['venue']].close_position( self.pf['trades'][t_id]['symbol'], self.pf['trades'][t_id]['position']['size'], self.pf['trades'][t_id]['direction']) if close: self.pf['trades'][t_id]['position']['size'] = 0 self.pf['trades'][t_id]['position']['status'] = "CLOSED" def close_position_absolute(self, venue, symbol): """ Close ALL units of given instrument symbol indiscriminately. """ return self.exchanges[venue].close_position(symbol) def calculate_pnl_by_trade(self, t_id): """ Calculate pnl for the given trade and update local portfolio state. """ # Match internal order ids with venue ids {venue id: order id} o_ids = self.pf['trades'][t_id]['orders'].keys() id_pairs = { self.pf['trades'][t_id]['orders'][i]['venue_id']: i for i in o_ids } v_ids = id_pairs.keys() # Fetch all balance affecting executions. executions = self.exchanges[self.pf['trades'][t_id] ['venue']].get_executions( self.pf['trades'][t_id]['symbol']) unique_o_ids = list(set([i['order_id'] for i in executions])) # Sort execs {{order_id: [exc1, exc2, exc3, etc]}, ... } s_exc = {i: [] for i in unique_o_ids if i in o_ids} for exc in executions: if exc['order_id'] in o_ids: s_exc[exc['order_id']].append(exc) print(json.dumps(s_exc, indent=2)) # Avg total long and short for the trade. avg_long, long_total, avg_short, short_total, total_fee = 0, 0, 0, 0, 0 for o_id in o_ids: for sub_order in s_exc[o_id]: if sub_order['direction'] == "LONG": avg_long += sub_order['avg_exc_price'] * sub_order['size'] long_total += sub_order['size'] total_fee += sub_order['total_fee'] elif sub_order['direction'] == "SHORT": avg_short += sub_order['avg_exc_price'] * sub_order['size'] short_total += sub_order['size'] total_fee += sub_order['total_fee'] avg_long /= long_total avg_short /= short_total if self.pf['trades'][t_id]['direction'] == "LONG": pnl = avg_short - avg_long elif self.pf['trades'][t_id]['direction'] == "SHORT": pnl = avg_long - avg_short else: raise Exception(self.pf['trades'][t_id]['direction']) print("avg long exec:", avg_long) print("avg short exec:", avg_short) print("pnl:", pnl) print("total_fee:", total_fee) self.pf['current_balance'] += (pnl + total_fee) self.pf['balance_hsitory'][str(int(time.time()))] = { 'amt': pnl + total_fee, 'trade_id': t_id } print("New balance:", self.pf['current_balance']) def post_trade_analysis(self, trade_id): """ TODO: conduct post-trade analytics. """ pass def verify_portfolio_state(self, portfolio): """ Check stored portfolio data matches actual positions and orders. """ # TODO. self.save_porfolio(portfolio) self.logger.debug("Portfolio verification complete.") def load_portfolio(self, ID=1): """ Load portfolio matching ID from database or return empty portfolio. """ portfolio = self.db_other['portfolio'].find_one({"id": ID}, {"_id": 0}) if portfolio: return portfolio else: default_portfolio = { 'id': ID, 'balance_history': { str(int(time.time())): { 'amt': 1000, 'trade_id': "Initial deposit." } }, 'current_balance': 1000, 'current_drawdown': 0, 'avg_r_per_winner': 0, 'avg_r_per_loser': 0, 'avg_r_per_trade': 0, 'total_winning_trades': 0, 'total_losing_trades': 0, 'total_consecutive_wins': 0, 'total_consecutive_losses': 0, 'win_loss_ratio': 0, 'gain_to_pain_ratio': 0, 'risk_per_trade': self.RISK_PER_TRADE, 'max_correlated_trades': self.MAX_CORRELATED_TRADES, 'max_accepted_drawdown': self.MAX_ACCEPTED_DRAWDOWN, 'max_simultaneous_positions': self.MAX_SIMULTANEOUS_POSITIONS, 'default_stop': self.DEFAULT_STOP, 'model_allocations': { # Equal allocation by default. i.get_name(): (100 / len(self.models)) for i in self.models }, 'total_active_trades': 0, 'trades': {} } self.save_porfolio(default_portfolio) return default_portfolio def save_porfolio(self, portfolio): """ Save portfolio state to DB. """ result = self.db_other['portfolio'].replace_one( {"id": portfolio['id']}, portfolio, upsert=True) if result.acknowledged: self.logger.debug("Portfolio save successful.") else: self.logger.debug("Portfolio save unsuccessful.") def within_risk_limits(self, signal): """ Return true if the new signal would be within risk limits if traded. """ # TODO: Finish after signal > order > fill logic is done. # Position limit check. if self.pf['total_active_trades'] < self.pf[ 'max_simultaneous_positions']: if ( # Drawdown check. (self.pf['current_drawdown'] / self.pf['current_balance']) * 100) >= self.pf['max_accepted_drawdown'] or ( self.pf['current_drawdown'] == 0): if not self.correlated(signal): # Correlation check. return True else: self.logger.debug( "Trade skipped. Correlated positions limit reached.") return False else: self.logger.debug("Trade skipped. Drawdown limit reached.") return False else: self.logger.debug("Trade skipped. Position limit reached.") return False def calculate_exposure(self, trade): """ Calculate the currect capital at risk for the given trade. """ pass def correlated(self, signal): """ Return true if any active trades would be correlated with trades produced by the incoming signal. """ pass def calculate_stop_price(self, signal): """ Find the stop price for the given signal. """ if signal['stop_price'] is not None: return signal['stop_price'] else: if signal['direction'] == "LONG": return signal['entry_price'] / 100 * (100 - self.DEFAULT_STOP) elif signal['direction'] == "SHORT": return signal['entry_price'] / 100 * (100 + self.DEFAULT_STOP) def calculate_position_size(self, stop, entry): """ Find appropriate position size for the given parameters. """ # Fixed percentage per trade risk management. if isinstance(self.RISK_PER_TRADE, int): account_size = self.pf['current_balance'] risked_amt = (account_size / 1000) * self.RISK_PER_TRADE position_size = risked_amt // ((stop - entry) / entry) return abs(position_size) # TOOD: Kelly criteron risk management. elif self.RISK_PER_TRADE.upper() == "KELLY": pass def update_price(self, events, market_event): """ Check price and time updates against existing positions. Args: events: event queue object. event: new market event. Returns: None. Raises: None. """ pass def save_new_trades_to_db(self): """ Save trades in save-later queue to database. Args: None. Returns: None. Raises: pymongo.errors.DuplicateKeyError. """ count = 0 while True: try: trade = self.trades_save_to_db.get(False) except queue.Empty: if count: self.logger.debug("Wrote " + str(count) + " new trades to database " + str(self.db_other.name) + ".") break else: if trade is not None: count += 1 # Store signal in relevant db collection. try: self.db_other['trades'].insert_one(trade) # Skip duplicates if they exist. except pymongo.errors.DuplicateKeyError: continue self.trades_save_to_db.task_done()
class Portfolio: """ Portfolio manages the net holdings for all models, issuing order events and reacting to fill events to open and close positions as strategies dictate. Capital allocations to strategies and risk parameters are defined here. """ MAX_SIMULTANEOUS_POSITIONS = 1 MAX_CORRELATED_POSITIONS = 2 MAX_ACCEPTED_DRAWDOWN = 25 # Percentage as integer. RISK_PER_TRADE = 1 # Percentage as integer OR 'KELLY' DEFAULT_STOP = 2 # Default (%) stop distance if none provided. SNAPSHOT_SIZE = 100 # Lookback period for trade snapshot images def __init__(self, exchanges, logger, db_other, db_client, models, telegram): self.exchanges = {i.get_name(): i for i in exchanges} self.logger = logger self.db_other = db_other self.db_client = db_client self.models = models self.telegram = telegram self.trades_save_to_db = queue.Queue(0) self.id_gen = TradeID(db_other) self.pf = self.load_portfolio() self.verify_portfolio_state(self.pf) def new_signal(self, events, event): """ Convert incoming Signal events to Order events. Args: events: event queue object. event: new market event. Returns: None. Raises: None. """ signal = event.get_signal_dict() orders = [] # Generate sequential trade ID for new trade. trade_id = self.id_gen.new_id() # Handle single-instrument signals: if signal['instrument_count'] == 1: stop = self.calculate_stop_price(signal), size = self.calculate_position_size(stop[0], signal['entry_price']) # Entry order. orders.append( Order( self.logger, trade_id, # Parent trade ID. None, # Order ID as used by venue. signal['symbol'], # Instrument ticker code. signal['venue'], # Venue name. signal['direction'], # LONG or SHORT. size, # Size in native denomination. signal['entry_price'], # Order price. signal['entry_type'], # LIMIT MARKET STOP_LIMIT/MARKET. "ENTRY", # ENTRY, TAKE_PROFIT, STOP. stop[0], # Order invalidation price. False, # Trail. False, # Reduce-only order. False)) # Post-only order. # Stop order. orders.append( Order(self.logger, trade_id, None, signal['symbol'], signal['venue'], event.inverse_direction(), size, stop[0], "STOP", "STOP", None, signal['trail'], True, False)) # Take profit order(s). if signal['targets']: count = 1 for target in signal['targets']: # Label final TP order as "FINAL_TAKE_PROFIT". tp_type = "TAKE_PROFIT" if count != len( signal['targets']) else "FINAL_TAKE_PROFIT" count += 1 orders.append( Order(self.logger, trade_id, None, signal['symbol'], signal['venue'], event.inverse_direction(), (size / 100) * target[1], target[0], "LIMIT", tp_type, stop[0], False, True, False)) # Set sequential order ID's, based on trade ID. count = 1 for order in orders: order.order_id = str(trade_id) + "-" + str(count) count += 1 # Parent trade object: trade = SingleInstrumentTrade( self.logger, signal['direction'], # Direction signal['venue'], # Venue name. signal['symbol'], # Instrument ticker code. signal['strategy'], # Model name. signal['entry_timestamp'], # Signal timestamp. signal['timeframe'], # Signal timeframe. signal['entry_price'], # Entry price. None, # Position object. { str(i.get_order_dict()['order_id']): i.get_order_dict() for i in orders }) # noqa # Finalise trade object. Must be called to set ID + order count trade.set_batch_size_and_id(trade_id) # Queue the trade for storage. self.trades_save_to_db.put(trade.get_trade_dict()) # Set order batch size and queue orders for execution. batch_size = len(orders) for order in orders: order.batch_size = batch_size within_risk_limits = self.within_risk_limits(signal) # Generate static image of trade setup. t_dict = trade.get_trade_dict() self.generate_trade_setup_image(t_dict, signal['op_data'], within_risk_limits) # Only raise orders and add to portfilio if within risk limits. if within_risk_limits: self.pf['trades'][str(trade_id)] = t_dict self.save_porfolio(self.pf) for order in orders: events.put(OrderEvent(order.get_order_dict())) # TODO: handle multi-instrument, multi-venue trades. elif signal['instrument_count'] == 2: pass elif signal['instrument_count'] > 2: pass self.logger.info("Trade " + str(trade_id) + " registered.") def new_fill(self, fill_event): """ Process incoming fill event, update position, trade and order state accordingly. Args: events: event queue object. event: new market event. Returns: None. Raises: None. """ fill_conf = fill_event.get_order_conf() position = Position(fill_conf).get_pos_dict() t_id = str(position['trade_id']) if fill_conf['metatype'] == "ENTRY": # Create a position record and set trade to active. self.pf['trades'][t_id]['position'] = position self.pf['trades'][t_id]['active'] = True self.pf['total_active_trades'] += 1 elif fill_conf['metatype'] == "STOP": # Update the now closed postiion, trade is done. size = self.pf['trades'][t_id]['position']['size'] new_size = size - fill_conf['size'] # Should be 0 if new_size > 0: raise Exception(new_size) # Can be negative if user modifies positions manually elif new_size < 0: new_size = 0 self.pf['trades'][t_id]['position']['size'] = new_size self.pf['trades'][t_id]['position']['status'] = "CLOSED" self.trade_complete(t_id) elif fill_conf['metatype'] == "TAKE_PROFIT": # Update the modified position. size = self.pf['trades'][t_id]['position']['size'] new_size = size - fill_conf['size'] self.pf['trades'][t_id]['position']['size'] = new_size if new_size == 0: self.trade_complete(t_id) else: self.calculate_pnl_by_trade(t_id, take_profit=True) elif fill_conf['metatype'] == "FINAL_TAKE_PROFIT": # Update the now closed postiion, trade is done. size = self.pf['trades'][t_id]['position']['size'] new_size = size - fill_conf['size'] self.pf['trades'][t_id]['position']['size'] = new_size self.pf['trades'][t_id]['position']['status'] = "CLOSED" if new_size != 0: raise Exception("Position close size error:", new_size) self.trade_complete(t_id) else: raise Exception("Order metatype error:", fill_conf['metatype']) self.save_porfolio(self.pf) def new_order_conf(self, order_confs: list, events): """ Update stored trade and order state to match given order confirmations. Args: order_confs: list of order dicts containing updated details. events: event queue object. Returns: None. Raises: None. """ # Update portfolio state. for conf in order_confs: t_id = str(conf['trade_id']) o_id = str(conf['order_id']) self.pf['trades'][t_id]['orders'][o_id] = conf # Create a fill event if order already filled (e.g. market orders). if conf['status'] == "FILLED": events.put(FillEvent(conf)) self.save_porfolio(self.pf) def trade_complete(self, trade_id): """ Check all orders and positions are closed, calculate pnl, run post trade checks/analytics. """ self.cancel_orders_by_trade_id(trade_id) # Close positions, if still open. if self.check_position_open(trade_id): self.close_position_by_trade_id(trade_id) self.calculate_pnl_by_trade(trade_id) self.run_post_trade_analysis(trade_id) # Reduce active trade count by 1. self.pf['total_active_trades'] -= 1 if self.pf[ 'total_active_trades'] > 0 else 0 # Mark trade as inactive self.pf['trades'][str(trade_id)]['active'] = False # Save updated portfolio state to DB. self.save_porfolio(self.pf) def cancel_orders_by_trade_id(self, trade_id): """ Cancel all orders matching the given trade ID and update local portfolio state. """ t_id = str(trade_id) o_ids = self.pf['trades'][t_id]['orders'].keys() v_ids = [ self.pf['trades'][t_id]['orders'][o]['venue_id'] for o in o_ids if self.pf['trades'][t_id]['orders'][o]['status'] != "FILLED" ] venue = self.pf['trades'][t_id]['venue'] cancel_confs = self.exchanges[venue].cancel_orders(v_ids) if cancel_confs: # Handle cancellation failure messages try: if cancel_confs['error']["message"] == 'Not Found': self.pf['trades'][t_id]['active'] = False for o in o_ids: self.pf['trades'][t_id]['orders'][o][ 'status'] == "FILLED" # Handle other error messages here else: raise Exception("Unhandled case", cancel_confs['error']["message"]) # Handle successful cancellation messages except KeyError: self.pf['trades'][t_id]['active'] = False for order_id in o_ids: for venue_id in set(v_ids): # Set order status to cancelled if self.pf['trades'][t_id]['orders'][order_id][ 'venue_id'] == venue_id and cancel_confs[ venue_id] == "SUCCESS": self.pf['trades'][t_id]['orders'][order_id][ 'status'] = "CANCELLED" # No active cancellations ocurred, trade was vetoed else: pass def check_position_open(self, trade_id): """ Return true if position is still open according to local portfolio. """ t_id = str(trade_id) if self.pf['trades'][t_id]['position'] is None: return False if self.pf['trades'][t_id]['position']['status'] == "OPEN": return True elif self.pf['trades'][t_id]['position']['status'] == "CLOSED": return False else: raise Exception("Position status error:", self.pf['trades'][t_id]['position']['status']) def close_position_by_trade_id(self, t_id): """ This method will close only the remaining amount for the given trade - it will not necessarily close an entire position, unless there is only one open position in that particular instrument. Then, update local portfolio state. Use close_position_absolute() to completely close all positions in for specifc instrument at a specific venue. """ close = self.exchanges[self.pf['trades'][t_id] ['venue']].close_position( self.pf['trades'][t_id]['symbol'], self.pf['trades'][t_id]['position']['size'], self.pf['trades'][t_id]['direction']) if close: self.pf['trades'][t_id]['position']['size'] = 0 self.pf['trades'][t_id]['position']['status'] = "CLOSED" def close_position_absolute(self, venue, symbol): """ Close ALL units of given instrument symbol indiscriminately. """ return self.exchanges[venue].close_position(symbol) def calculate_pnl_by_trade(self, trade_id, take_profit=False): """ Calculate pnl for the given trade and update portfolio state. """ t_id = str(trade_id) trade = self.pf['trades'][t_id] # Get order executions for trade in period from trade signal to current time. execs = self.exchanges[trade['venue']].get_executions( trade['symbol'], trade['signal_timestamp'], int(datetime.now().timestamp())) # Handle two-order trades (single exit, single entry). if len(trade['orders']) == 2: entry_oid = trade['orders'][t_id + "-1"]['order_id'] exit_oid = trade['orders'][t_id + "-2"]['order_id'] # TODO: Handle trade types with more than 2 orders elif len(trade['orders']) >= 3: entry_oid = None exit_oid = None # tp_oids = [] # Entry executions will match direction of trade and bear the entry order id. entries = [ i for i in execs if i['direction'] == trade['direction'] and i['order_id'] == entry_oid ] # API-submitted exit executions should be the reverse exits = [ i for i in execs if i['direction'] != trade['direction'] and i['order_id'] == exit_oid ] manual_exit = False # Exit orders placed manually wont bear the order id and cant be evaluated with certainty # if there were multiple trades with executions in the same period as the current trade. # If manual exit, notify user if the exit total is differnt to entry total. if not exits: exits = [i for i in execs if i['direction'] != trade['direction']] manual_exit = True if exits else None if entries and exits: avg_entry = sum(i['avg_exc_price'] for i in entries) / len(entries) avg_exit = (sum(i['avg_exc_price'] for i in exits) / len(exits)) fees = sum(i['total_fee'] for i in (entries + exits)) percent_change = abs((avg_entry - avg_exit) / avg_entry) * 100 abs_pnl = abs((trade['orders'][t_id + "-1"]['size'] / 100) * percent_change) - fees if trade['direction'] == "LONG": final_pnl = abs_pnl if avg_exit > avg_entry + fees else -abs_pnl elif trade['direction'] == "SHORT": final_pnl = abs_pnl if avg_exit < avg_entry - fees else -abs_pnl self.pf['current_balance'] += final_pnl self.pf['balance_history'][str(int(time.time()))] = { 'amt': final_pnl, 'trade_id': t_id } self.logger.info("Trade " + t_id + " returned " + str(final_pnl) + " USD.") # No matching entry or exit executions exist. else: pass if manual_exit: self.logger.info( "Manual exit orders detected for trade " + t_id + ". Please manually verify position is closed and final pnl figure. Avoid closing positions or cancelling orders manually." ) def run_post_trade_analysis(self, trade_id): """ Conduct post-trade analytics. """ # TODO: Update the following: # 'peak_balance' # 'low_balance' # 'avg_r_per_winner' # 'avg_r_per_loser' # 'avg_r_per_trade' # 'total_winning_trades' # 'total_losing_trades' # 'total_consecutive_wins' # 'total_consecutive_losses' # 'win_loss_ratio' # 'gain_to_pain_ratio' # 'total_active_trades' def verify_portfolio_state(self, portfolio): """ Check stored portfolio data matches actual positions and orders. """ # TODO. self.save_porfolio(portfolio) self.logger.info("Portfolio verification complete.") def load_portfolio(self, ID=1): """ Load portfolio matching ID from database or return empty portfolio. """ portfolio = self.db_other['portfolio'].find_one({"id": ID}, {"_id": 0}) if portfolio: return portfolio else: default_portfolio = { 'id': ID, 'balance_history': { str(int(time.time())): { 'amt': 1000, 'trade_id': "Initial deposit." } }, 'current_balance': 1000, 'peak_balance': 0, 'low_balance': 0, 'avg_r_per_winner': 0, 'avg_r_per_loser': 0, 'avg_r_per_trade': 0, 'total_winning_trades': 0, 'total_losing_trades': 0, 'total_consecutive_wins': 0, 'total_consecutive_losses': 0, 'win_loss_ratio': 0, 'gain_to_pain_ratio': 0, 'risk_per_trade': self.RISK_PER_TRADE, 'max_correlated_positions': self.MAX_CORRELATED_POSITIONS, 'max_accepted_drawdown': self.MAX_ACCEPTED_DRAWDOWN, 'max_simultaneous_positions': self.MAX_SIMULTANEOUS_POSITIONS, 'default_stop': self.DEFAULT_STOP, 'model_allocations': { # Equal allocation by default. i.get_name(): (100 / len(self.models)) for i in self.models }, 'total_active_trades': 0, 'trades': {} } return default_portfolio def save_porfolio(self, portfolio): """ Save portfolio state to DB. """ result = self.db_other['portfolio'].replace_one( {"id": portfolio['id']}, portfolio, upsert=True) if result.acknowledged: self.logger.info("Portfolio save successful.") else: self.logger.info("Portfolio save unsuccessful.") def within_risk_limits(self, signal): """ Return true if signal would not exceed risk limits when traded. """ # Position limit check. if self.pf['total_active_trades'] < self.pf[ 'max_simultaneous_positions']: # Drawdown limit check. if ((self.pf['current_drawdown'] / self.pf['current_balance']) * 100) >= self.pf['max_accepted_drawdown'] or ( self.pf['current_drawdown'] == 0): # Correlation check. if not self.correlated(signal): self.logger.info("New trade within risk limits.") return True else: self.logger.info( "New trade skipped. Correlated positions limit reached." ) return False else: self.logger.info("New trade skipped. Drawdown limit reached.") return False else: self.logger.info("New trade skipped. Position limit reached.") return False def calculate_exposure(self, trade): """ Calculate the currect capital at risk for the given trade. """ # TODO. def correlated(self, signal): """ Return true if any active trades would be correlated with trades produced by the incoming signal. """ # TODO return False def calculate_stop_price(self, signal): """ Find stop price for the given signal. """ if signal['stop_price'] is not None: return signal['stop_price'] else: if signal['direction'] == "LONG": return signal['entry_price'] / 100 * (100 - self.DEFAULT_STOP) elif signal['direction'] == "SHORT": return signal['entry_price'] / 100 * (100 + self.DEFAULT_STOP) def calculate_position_size(self, stop, entry): """ Find appropriate position size according to portfolio risk parameters """ # Fixed percentage per trade risk management. if isinstance(self.RISK_PER_TRADE, int): account_size = self.pf['current_balance'] risked_amt = (account_size / 1000) * self.RISK_PER_TRADE position_size = risked_amt // ((stop - entry) / entry) return abs(position_size) # TOOD: Kelly criteron risk management. elif self.RISK_PER_TRADE.upper() == "KELLY": pass else: raise Exception("RISK_PER_TRADE must be an integer or 'KELLY': " + self.RISK_PER_TRADE) def update_price(self, events, market_event): """ Check price and time updates against existing positions. Args: events: event queue object. event: new market event. Returns: None. Raises: None. """ # TODO. def save_new_trades_to_db(self): """ Save trades in save-later queue to database. Args: None. Returns: None. Raises: pymongo.errors.DuplicateKeyError. """ count = 0 while True: try: trade = self.trades_save_to_db.get(False) except queue.Empty: if count: self.logger.info("Wrote " + str(count) + " new trades to database " + str(self.db_other.name) + ".") break else: if trade is not None: count += 1 # Store signal in relevant db collection. try: self.db_other['trades'].insert_one(trade) # Skip duplicates if they exist. except pymongo.errors.DuplicateKeyError: continue self.trades_save_to_db.task_done() def generate_trade_setup_image(self, trade, op_data, within_risk_limits: bool): """ Create a snapshot image of trade setup and send to user. """ self.logger.info("Creating signal snapshot image") # Create image directory if it doesnt exist if not os.path.exists("setup_images"): os.mkdir("setup_images") # Dump trade data to file for ease of testing next stage # Remove from production # op_data.to_csv('op_data.csv') # with open('trade.json', 'w') as outfile: # json.dump(trade, outfile) # Reformat dataframe for mplfinance compatibility df = op_data.copy(deep=True) df.rename( { 'open': 'Open', 'high': 'High', 'low': 'Low', 'close': 'Close', 'volume': 'Volume' }, axis=1, inplace=True) df = df.tail(self.SNAPSHOT_SIZE) # Get markers for trades triggered by the current bar entry_marker = [np.nan for i in range(self.SNAPSHOT_SIZE)] entry_marker[-1] = trade['entry_price'] stop = None stop_marker = [np.nan for i in range(self.SNAPSHOT_SIZE)] for order in trade['orders'].values(): if order['order_type'] == "STOP": stop = order['price'] stop_marker[-1] = stop # TODO: Trades triggered by interaction with historic bars # Create plot figures adp, hlines = self.create_addplots(df, mpl, stop, entry_marker, stop_marker) mc = mpl.make_marketcolors(up='w', down='black', wick="w", edge='w') style = mpl.make_mpf_style(gridstyle='', base_mpf_style='nightclouds', marketcolors=mc) filename = "setup_images/" + str(trade['trade_id']) + "_" + str( trade['signal_timestamp'] ) + '_' + trade['model'] + "_" + trade['timeframe'] try: plot = mpl.plot(df, type='candle', addplot=adp, style=style, hlines=hlines, title="\n" + trade['model'] + " - " + trade['timeframe'], datetime_format='%d-%m %H:%M', figscale=1, savefig=filename, tight_layout=False) except ValueError: traceback.print_exc() print(df) print(df['Open']) sys.exit(0) message = "Trade " + str(trade['trade_id']) + " - " + trade[ 'model'] + " " + trade['timeframe'] + "\n\nEntry: " + str( trade['entry_price']) + " \nStop: " + str(stop) + "\n" options = [[ str(trade['trade_id']) + " - Accept", str(trade['trade_id']) + " - Veto" ]] try: self.telegram.send_image(filename + ".png", message) if within_risk_limits is True: self.telegram.send_option_keyboard(options) else: self.telegram.send_message("Trade would exceed risk limits.") except Exception as ex: self.logger.info("Failed to send setup image via telegram.") print(ex) traceback.print_exc() def create_addplots(self, df, mpl, stop, entry_marker, stop_marker): """ Helper method for generate_trade_setup_image. Formats plot artifacts for mplfinance. """ adps, hlines = [], { 'hlines': [], 'colors': [], 'linestyle': '--', 'linewidths': 0.5 } # Add technical feature data (indicator values, etc). for col in list(df): if (col != "Open" and col != "High" and col != "Low" and col != "Close" and col != "Volume"): adps.append(mpl.make_addplot(df[col])) # Add entry marker adps.append( mpl.make_addplot(entry_marker, type='scatter', markersize=500, marker="_", color='limegreen')) # Add stop marker if stop: adps.append( mpl.make_addplot(stop_marker, type='scatter', markersize=500, marker='_', color='crimson')) return adps, hlines
class Portfolio: """ Portfolio manages the net holdings for all models, issuing order events and reacting to fill events to open and close positions and strategies dictate. Capital allocations to strategies and risk parameters defined here. """ MAX_SIMULTANEOUS_POSITIONS = 20 MAX_CORRELATED_TRADES = 1 MAX_ACCEPTED_DRAWDOWN = 15 # Percentage as integer. RISK_PER_TRADE = 1 # Percentage as integer OR 'KELLY' DEFAULT_STOP = 3 # % stop distance if none provided. def __init__(self, exchanges, logger, db_other, db_client, models): self.exchanges = {i.get_name(): i for i in exchanges} self.logger = logger self.db_other = db_other self.db_client = db_client self.models = models self.id_gen = TradeID(db_other) self.pf = self.load_portfolio() self.trades_save_to_db = queue.Queue(0) def new_signal(self, events, event): """ Interpret incoming signal events to produce Order Events. Args: events: event queue object. event: new market event. Returns: None. Raises: None. """ signal = event.get_signal_dict() if self.within_risk_limits(signal): orders = [] # Prepare orders for single-instrument signals: if signal['instrument_count'] == 1: stop = self.calculate_stop_price(signal), size = self.calculate_position_size(stop[0], signal['entry_price']) # Generate sequential trade ID for order and trade objects. trade_id = self.id_gen.new_id() # Entry order. orders.append( Order( self.logger, trade_id, # Parent trade ID. None, # Related position ID. None, # Order ID as used by venue. signal['direction'], # LONG or SHORT. size, # Size in native denomination. signal['entry_price'], # Order price. signal[ 'entry_type'], # LIMIT MARKET STOP_LIMIT/MARKET. "ENTRY", # ENTRY, TAKE_PROFIT, STOP. stop[0], # Order invalidation price. False, # Trail. False, # Reduce-only order. False)) # Post-only order. # Stop order. orders.append( Order(self.logger, trade_id, None, None, event.inverse_direction(), size, stop[0], "STOP_MARKET", "STOP", None, signal['trail'], True, False)) # Take profit order(s). if signal['targets']: for target in signal['targets']: tp_size = (size / 100) * target[1] orders.append( Order(self.logger, trade_id, None, None, event.inverse_direction(), tp_size, target[0], "LIMIT", "TAKE_PROFIT", stop[0], False, True, False)) # Parent trade object: trade = SingleInstrumentTrade( self.logger, signal['venue'], # Exchange or broker traded with. signal['symbol'], # Instrument ticker code. signal['strategy'], # Model name. None, # Position object. [i.get_order_dict() for i in orders], # Open orders dicts. None) # Filled order dicts. # Set trade_id manually, since we already generated it above. trade.trade_id = trade_id # Queue the trade for storage and update portfolio state. self.trades_save_to_db.put(trade.get_trade_dict()) self.pf['trades'].append(trade.get_trade_dict()) self.save_porfolio(self.pf) # TODO: Other trade types (multi-instrument, multi-venue etc). # Queue orders for execution. for order in orders: events.put(OrderEvent(order.get_order_dict())) self.logger.debug("Trade " + str(trade_id) + " registered.") def new_fill(self, events, event): """ Process incoming fill event and update position records accordingly. Args: events: event queue object. event: new market event. Returns: None. Raises: None. """ pass def update_price(self, events, market_event): """ Check price and time updates against existing positions. Args: events: event queue object. event: new market event. Returns: None. Raises: None. """ pass def load_portfolio(self, ID=1): """ Load portfolio matching ID from database or return empty portfolio. """ portfolio = self.db_other['portfolio'].find_one({"id": ID}, {"_id": 0}) if portfolio: self.verify_portfolio_state(portfolio) return portfolio else: empty_portfolio = { 'id': ID, 'start_date': int(time.time()), 'initial_funds': 0, 'current_value': 0, 'current_drawdown': 0, 'trades': [], 'model_allocations': { # Equal allocation by default. i.get_name(): (100 / len(self.models)) for i in self.models }, 'risk_per_trade': self.RISK_PER_TRADE, 'max_correlated_trades': self.MAX_CORRELATED_TRADES, 'max_accepted_drawdown': self.MAX_ACCEPTED_DRAWDOWN, 'max_simultaneous_positions': self.MAX_SIMULTANEOUS_POSITIONS, 'default_stop': self.DEFAULT_STOP } self.save_porfolio(empty_portfolio) return empty_portfolio def verify_portfolio_state(self, portfolio): """ Check stored portfolio data matches actual positions and orders. """ trades = self.db_other['trades'].find({"active": "True"}, {"_id": 0}) # If trades marked active exist (in DB), check their orders and # positions match actual trade state, update portfoilio if disparate. if trades: self.logger.debug("Verifying trade records match trade state.") for venue in [trade['venue'] for trade in trades]: print("Fetched positions and orders.") positions = self.exchanges[venue].get_positions() orders = self.exchanges[venue].get_orders() # TODO: state checking. self.save_porfolio(portfolio) self.logger.debug("Portfolio verification complete.") return portfolio def save_porfolio(self, portfolio): """ Save portfolio state to DB. """ result = self.db_other['portfolio'].replace_one( {"id": portfolio['id']}, portfolio, upsert=True) if result.acknowledged: self.logger.debug("Portfolio update successful.") else: self.logger.debug("Portfolio update unsuccessful.") def within_risk_limits(self, signal): """ Return true if the new signal would be within risk limits if traded. """ # TODO: Finish after signal > order > fill logic is done. return True def calculate_exposure(self, trade): """ Calculate the currect capital at risk for the given trade. """ pass def correlated(self, instrument): """ Return true if any active trades are correlated with 'instrument'. """ pass def calculate_stop_price(self, signal): """ Find the stop price for the given signal. """ if signal['stop_price'] is not None: stop = signal['stop_price'] else: stop = signal['entry_price'] / 100 * (100 - self.DEFAULT_STOP) return stop def calculate_position_size(self, stop, entry): """ Find appropriate position size for the given parameters. """ # Fixed percentage per trade risk management. if isinstance(self.RISK_PER_TRADE, int): account_size = self.pf['current_value'] risked_amt = (account_size / 100) * self.RISK_PER_TRADE position_size = risked_amt // ((stop - entry) / entry) return abs(position_size) # TOOD: Kelly criteron risk management. elif self.RISK_PER_TRADE.upper() == "KELLY": pass def fees(self, trade): """ Calculate total current fees paid for the given trade object. """ def save_new_trades_to_db(self): """ Save trades in save-later queue to database. Args: None. Returns: None. Raises: pymongo.errors.DuplicateKeyError. """ count = 0 while True: try: trade = self.trades_save_to_db.get(False) except queue.Empty: if count: self.logger.debug("Wrote " + str(count) + " new trades to database " + str(self.db_other.name) + ".") break else: if trade is not None: count += 1 # Store signal in relevant db collection. try: self.db_other['trades'].insert_one(trade) # Skip duplicates if they exist. except pymongo.errors.DuplicateKeyError: continue self.trades_save_to_db.task_done()