├── .flake8 ├── README.md └── portfolio_manager.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501 W504 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Portfolio Manager For Alpaca Trade API 2 | 3 | The script [`portfolio_manager.py`](./portfolio_manager.py) contains a class that 4 | makes it easy to manage busket of stocks based on the shares or weight for each symbol name. 5 | It is designed so that you can feed your own desired portfolio either by 6 | calculation or simply from CSV. 7 | 8 | ## Usage 9 | 10 | You need to obtain Alpaca API key, found in the [dashboard](https://app.alpaca.markets/) and set 11 | it in the environment variables. Please read [API documents](https://docs.alpaca.markets/) or 12 | [Python SDK documents](https://github.com/alpacahq/alpaca-trade-api-python) for more details. 13 | 14 | The `PortfolioManager` class implements the following methods. 15 | 16 | - add_items(items) 17 | Add desired portfolio structure, in two dimentional list. 18 | - rebalance(order_style, timeout=60) 19 | Start rebalancing based on the shares by sending orders now. `order_style` can be either "timeout" or "block" 20 | - percent_rebalance(order_style, timeout=60) 21 | Start rebalancing based on the percentage by sending orders now. `order_style` can be either "timeout" or "block" 22 | 23 | The `timeout` order style times out if orders are not executed in the specified timeout seconds. 24 | The `block` order style blocks the process indifinitely until orders are executed. 25 | 26 | ## Example Code 27 | 28 | ```py 29 | import os 30 | from portfolio_manager import PortfolioManager 31 | 32 | os.environ['APCA_API_KEY_ID'] = 'API_KEY_ID' 33 | os.environ['APCA_API_SECRET_KEY'] = 'API_SECRET_KEY' 34 | 35 | manager = PortfolioManager() 36 | # Hedging SPY with GLD 1:1 37 | manager.add_items([ 38 | ['SPY', 0.5], 39 | ['GLD', 0.5], 40 | ]) 41 | manager.percent_rebalance('block') 42 | ``` 43 | -------------------------------------------------------------------------------- /portfolio_manager.py: -------------------------------------------------------------------------------- 1 | import alpaca_trade_api as tradeapi 2 | import threading 3 | import time 4 | 5 | 6 | class PortfolioManager(): 7 | def __init__(self): 8 | self.api = tradeapi.REST() 9 | 10 | self.r_positions = {} 11 | 12 | def format_percent(self, num): 13 | if(str(num)[-1] == "%"): 14 | return float(num[:-1]) / 100 15 | else: 16 | return float(num) 17 | 18 | def clear_orders(self): 19 | try: 20 | self.api.cancel_all_orders() 21 | print("All open orders cancelled.") 22 | except Exception as e: 23 | print(f"Error: {str(e)}") 24 | 25 | def add_items(self, data): 26 | ''' Expects a list of lists containing two items: symbol and position qty/pct 27 | 28 | ''' 29 | for row in data: 30 | self.r_positions[row[0]] = [row[1], 0] 31 | 32 | def percent_rebalance(self, order_style, timeout=60): 33 | print(f"Desired positions: ") 34 | for sym in self.r_positions: 35 | print(f"{sym} - {self.r_positions.get(sym)[0]} of portfolio.") 36 | print() 37 | 38 | positions = self.api.list_positions() 39 | account = self.api.get_account() 40 | portfolio_val = float(account.portfolio_value) 41 | for sym in self.r_positions: 42 | price = self.api.get_barset(sym, "minute", 1)[sym][0].c 43 | self.r_positions[sym][0] = int( 44 | self.format_percent( 45 | self.r_positions.get(sym)[0]) * portfolio_val / price) 46 | 47 | print(f"Current positions: ") 48 | for position in positions: 49 | print( 50 | f"{position.symbol} - {round(float(position.market_value) / portfolio_val * 100, 2)}% of portfolio.") 51 | print() 52 | 53 | self.clear_orders() 54 | 55 | print("Clearing extraneous positions.") 56 | for position in positions: 57 | if(self.r_positions.get(position.symbol)): 58 | self.r_positions.get(position.symbol)[1] = int(position.qty) 59 | else: 60 | self.send_basic_order( 61 | position.symbol, position.qty, ("buy", "sell")[ 62 | position.side == "long"]) 63 | print() 64 | 65 | if(order_style == "send"): 66 | for sym in self.r_positions: 67 | qty = self.r_positions.get( 68 | sym)[0] - self.r_positions.get(sym)[1] 69 | self.send_basic_order(sym, qty, ("buy", "sell")[qty < 0]) 70 | elif(order_style == "timeout"): 71 | threads = [] 72 | for i, sym in enumerate(self.r_positions): 73 | qty = self.r_positions.get( 74 | sym)[0] - self.r_positions.get(sym)[1] 75 | threads.append( 76 | threading.Thread( 77 | target=self.timeout_execution, args=( 78 | sym, qty, ("buy", "sell")[ 79 | qty < 0], self.r_positions.get(sym)[0], timeout))) 80 | threads[i].start() 81 | 82 | for i in range(len(threads)): 83 | threads[i].join() 84 | elif(order_style == "block"): 85 | threads = [] 86 | for i, sym in enumerate(self.r_positions): 87 | qty = self.r_positions.get( 88 | sym)[0] - self.r_positions.get(sym)[1] 89 | threads.append( 90 | threading.Thread( 91 | target=self.confirm_full_execution, args=( 92 | sym, qty, ("buy", "sell")[ 93 | qty < 0], self.r_positions.get(sym)[0]))) 94 | threads[i].start() 95 | 96 | for i in range(len(threads)): 97 | threads[i].join() 98 | 99 | def rebalance(self, order_style, timeout=60): 100 | print(f"Desired positions: ") 101 | for sym in self.r_positions: 102 | print(f"{sym} - {self.r_positions.get(sym)[0]} shares.") 103 | print("\n") 104 | 105 | positions = self.api.list_positions() 106 | 107 | print(f"Current positions: ") 108 | for position in positions: 109 | print(f"{position.symbol} - {position.qty} shares owned.") 110 | print() 111 | 112 | self.clear_orders() 113 | 114 | print("Clearing extraneous positions.") 115 | for position in positions: 116 | if(self.r_positions.get(position.symbol)): 117 | self.r_positions[position.symbol][1] = int(position.qty) 118 | else: 119 | self.send_basic_order( 120 | position.symbol, position.qty, ("buy", "sell")[ 121 | position.side == "long"]) 122 | print() 123 | 124 | if(order_style == "send"): 125 | for sym in self.r_positions: 126 | qty = int(self.r_positions.get(sym)[ 127 | 0]) - self.r_positions.get(sym)[1] 128 | self.send_basic_order(sym, qty, ("buy", "sell")[qty < 0]) 129 | elif(order_style == "timeout"): 130 | threads = [] 131 | for i, sym in enumerate(self.r_positions): 132 | qty = int(self.r_positions.get(sym)[ 133 | 0]) - self.r_positions.get(sym)[1] 134 | threads.append( 135 | threading.Thread( 136 | target=self.timeout_execution, args=( 137 | sym, qty, ("buy", "sell")[ 138 | qty < 0], self.r_positions.get(sym)[0], timeout))) 139 | threads[i].start() 140 | 141 | for i in range(len(threads)): 142 | threads[i].join() 143 | elif(order_style == "block"): 144 | threads = [] 145 | for i, sym in enumerate(self.r_positions): 146 | qty = int(self.r_positions.get(sym)[ 147 | 0]) - self.r_positions.get(sym)[1] 148 | threads.append( 149 | threading.Thread( 150 | target=self.confirm_full_execution, args=( 151 | sym, qty, ("buy", "sell")[ 152 | qty < 0], self.r_positions.get(sym)[0]))) 153 | threads[i].start() 154 | 155 | for i in range(len(threads)): 156 | threads[i].join() 157 | 158 | def send_basic_order(self, sym, qty, side): 159 | qty = int(qty) 160 | if(qty == 0): 161 | return 162 | q2 = 0 163 | try: 164 | position = self.api.get_position(sym) 165 | curr_pos = int(position.qty) 166 | if((curr_pos + qty > 0) != (curr_pos > 0)): 167 | q2 = curr_pos 168 | qty = curr_pos + qty 169 | except BaseException: 170 | pass 171 | try: 172 | if q2 != 0: 173 | self.api.submit_order(sym, abs(q2), side, "market", "gtc") 174 | try: 175 | self.api.submit_order(sym, abs(qty), side, "market", "gtc") 176 | except Exception as e: 177 | print( 178 | f"Error: {str(e)}. Order of | {abs(qty) + abs(q2)} {sym} {side} | partially sent ({abs(q2)} shares sent).") 179 | return False 180 | else: 181 | self.api.submit_order(sym, abs(qty), side, "market", "gtc") 182 | print(f"Order of | {abs(qty) + abs(q2)} {sym} {side} | submitted.") 183 | return True 184 | except Exception as e: 185 | print( 186 | f"Error: {str(e)}. Order of | {abs(qty) + abs(q2)} {sym} {side} | not sent.") 187 | return False 188 | 189 | def confirm_full_execution(self, sym, qty, side, expected_qty): 190 | sent = self.send_basic_order(sym, qty, side) 191 | if(not sent): 192 | return 193 | 194 | executed = False 195 | while(not executed): 196 | try: 197 | position = self.api.get_position(sym) 198 | if int(position.qty) == int(expected_qty): 199 | executed = True 200 | else: 201 | print(f"Waiting on execution for {sym}...") 202 | time.sleep(20) 203 | except BaseException: 204 | print(f"Waiting on execution for {sym}...") 205 | time.sleep(20) 206 | print( 207 | f"Order of | {abs(qty)} {sym} {side} | completed. Position is now {expected_qty} {sym}.") 208 | 209 | def timeout_execution(self, sym, qty, side, expected_qty, timeout): 210 | sent = self.send_basic_order(sym, qty, side) 211 | if(not sent): 212 | return 213 | output = [] 214 | executed = False 215 | timer = threading.Thread( 216 | target=self.set_timeout, args=( 217 | timeout, output)) 218 | timer.start() 219 | while(not executed): 220 | if(len(output) == 0): 221 | try: 222 | position = self.api.get_position(sym) 223 | if int(position.qty) == int(expected_qty): 224 | executed = True 225 | else: 226 | print(f"Waiting on execution for {sym}...") 227 | time.sleep(20) 228 | except BaseException: 229 | print(f"Waiting on execution for {sym}...") 230 | time.sleep(20) 231 | else: 232 | timer.join() 233 | try: 234 | position = self.api.get_position(sym) 235 | curr_qty = position.qty 236 | except BaseException: 237 | curr_qty = 0 238 | print( 239 | f"Process timeout at {timeout} seconds: order of | {abs(qty)} {sym} {side} | not completed. Position is currently {curr_qty} {sym}.") 240 | return 241 | print( 242 | f"Order of | {abs(qty)} {sym} {side} | completed. Position is now {expected_qty} {sym}.") 243 | 244 | def set_timeout(self, timeout, output): 245 | time.sleep(timeout) 246 | output.append(True) 247 | --------------------------------------------------------------------------------