1+ import alpaca_trade_api as tradeapi
2+ import pandas as pd
3+ import statistics
4+ import sys
5+ import time
6+
7+ from datetime import datetime , timedelta
8+ from pytz import timezone
9+
10+ stocks_to_hold = 150 # Max 200
11+
12+ # Only stocks with prices in this range will be considered.
13+ max_stock_price = 26
14+ min_stock_price = 6
15+
16+ # API datetimes will match this format. (-04:00 represents the market's TZ.)
17+ api_time_format = '%Y-%m-%dT%H:%M:%S.%f-04:00'
18+
19+ # Rate stocks based on the volume's deviation from the previous 5 days and
20+ # momentum. Returns a dataframe mapping stock symbols to ratings and prices.
21+ # Note: If algo_time is None, the API's default behavior of the current time
22+ # as `end` will be used. We use this for live trading.
23+ def get_ratings (symbols , algo_time ):
24+ assets = api .list_assets ()
25+ assets = [asset for asset in assets if asset .tradable ]
26+ ratings = pd .DataFrame (columns = ['symbol' , 'rating' , 'price' ])
27+ index = 0
28+ batch_size = 200 # The maximum number of stocks to request data for
29+ window_size = 5 # The number of days of data to consider
30+ formatted_time = None
31+ if algo_time is not None :
32+ # Convert the time to something compatable with the Alpaca API.
33+ formatted_time = algo_time .date ().strftime (api_time_format )
34+ while index < len (assets ):
35+ symbol_batch = [
36+ asset .symbol for asset in assets [index :index + batch_size ]
37+ ]
38+ # Retrieve data for this batch of symbols.
39+ barset = api .get_barset (
40+ symbols = symbol_batch ,
41+ timeframe = 'day' ,
42+ limit = window_size ,
43+ end = formatted_time
44+ )
45+
46+ for symbol in symbol_batch :
47+ bars = barset [symbol ]
48+ if len (bars ) == window_size :
49+ # Make sure we aren't missing the most recent data.
50+ latest_bar = bars [- 1 ].t .to_pydatetime ().astimezone (
51+ timezone ('EST' )
52+ )
53+ gap_from_present = algo_time - latest_bar
54+ if gap_from_present .days > 1 :
55+ continue
56+
57+ # Now, if the stock is within our target range, rate it.
58+ price = bars [- 1 ].c
59+ if price <= max_stock_price and price >= min_stock_price :
60+ price_change = price - bars [0 ].c
61+ # Calculate standard deviation of previous volumes
62+ past_volumes = [bar .v for bar in bars [:- 1 ]]
63+ volume_stdev = statistics .stdev (past_volumes )
64+ if volume_stdev == 0 :
65+ # The data for the stock might be low quality.
66+ continue
67+ # Then, compare it to the change in volume since yesterday.
68+ volume_change = bars [- 1 ].v - bars [- 2 ].v
69+ volume_factor = volume_change / volume_stdev
70+ # Rating = Number of volume standard deviations * momentum.
71+ rating = price_change / bars [0 ].c * volume_factor
72+ if rating > 0 :
73+ ratings = ratings .append ({
74+ 'symbol' : symbol ,
75+ 'rating' : price_change / bars [0 ].c * volume_factor ,
76+ 'price' : price
77+ }, ignore_index = True )
78+ index += 200
79+ ratings = ratings .sort_values ('rating' , ascending = False )
80+ ratings = ratings .reset_index (drop = True )
81+ return ratings [:stocks_to_hold ]
82+
83+
84+ def get_shares_to_buy (ratings_df , portfolio ):
85+ total_rating = ratings_df ['rating' ].sum ()
86+ shares = {}
87+ for _ , row in ratings_df .iterrows ():
88+ shares [row ['symbol' ]] = int (
89+ row ['rating' ] / total_rating * portfolio / row ['price' ]
90+ )
91+ return shares
92+
93+
94+ # Returns a string version of a timestamp compatible with the Alpaca API.
95+ def api_format (dt ):
96+ return dt .strftime (api_time_format )
97+
98+ def backtest (api , days_to_test , portfolio_amount ):
99+ # This is the collection of stocks that will be used for backtesting.
100+ assets = api .list_assets ()
101+ # Note: for longer testing windows, this should be replaced with a list
102+ # of symbols that were active during the time period you are testing.
103+ symbols = [asset .symbol for asset in assets ]
104+
105+ now = datetime .now (timezone ('EST' ))
106+ beginning = now - timedelta (days = days_to_test )
107+
108+ # The calendars API will let us skip over market holidays and handle early
109+ # market closures during our backtesting window.
110+ calendars = api .get_calendar (
111+ start = beginning .strftime ("%Y-%m-%d" ),
112+ end = now .strftime ("%Y-%m-%d" )
113+ )
114+ shares = {}
115+ cal_index = 0
116+ for calendar in calendars :
117+ # See how much we got back by holding the last day's picks overnight
118+ portfolio_amount += get_value_of_assets (api , shares , calendar .date )
119+ print ('Portfolio value on {}: ${:0.2f}' .format (calendar .date .strftime (
120+ '%Y-%m-%d' ), portfolio_amount )
121+ )
122+
123+ if cal_index == len (calendars ) - 1 :
124+ # We've reached the end of the backtesting window.
125+ break
126+
127+ # Get the ratings for a particular day
128+ ratings = get_ratings (symbols , timezone ('EST' ).localize (calendar .date ))
129+ shares = get_shares_to_buy (ratings , portfolio_amount )
130+ for _ , row in ratings .iterrows ():
131+ # "Buy" our shares on that day and subtract the cost.
132+ shares_to_buy = shares [row ['symbol' ]]
133+ cost = row ['price' ] * shares_to_buy
134+ portfolio_amount -= cost
135+ cal_index += 1
136+
137+ # Print market (S&P500) return for the time period
138+ sp500_bars = api .get_barset (
139+ symbols = 'SPY' ,
140+ timeframe = 'day' ,
141+ start = api_format (calendars [0 ].date ),
142+ end = api_format (calendars [- 1 ].date )
143+ )['SPY' ]
144+ sp500_change = (sp500_bars [- 1 ].c - sp500_bars [0 ].c ) / sp500_bars [0 ].c
145+ print ('S&P 500 change during backtesting window: {:.4f}%' .format (
146+ sp500_change * 100 )
147+ )
148+
149+ return portfolio_amount
150+
151+
152+ # Used while backtesting to find out how much our portfolio would have been
153+ # worth the day after we bought it.
154+ def get_value_of_assets (api , shares_bought , on_date ):
155+ if len (shares_bought .keys ()) == 0 :
156+ return 0
157+
158+ total_value = 0
159+ formatted_date = api_format (on_date )
160+ barset = api .get_barset (
161+ symbols = shares_bought .keys (),
162+ timeframe = 'day' ,
163+ limit = 1 ,
164+ end = formatted_date
165+ )
166+ for symbol in shares_bought :
167+ total_value += shares_bought [symbol ] * barset [symbol ][0 ].o
168+ return total_value
169+
170+
171+ def run_live (api ):
172+ cycle = 0 # Only used to print a "waiting" message every few minutes.
173+
174+ # See if we've already bought or sold positions today. If so, we don't want to do it again.
175+ # Useful in case the script is restarted during market hours.
176+ bought_today = False
177+ sold_today = False
178+ try :
179+ # The max stocks_to_hold is 200, so we shouldn't see more than 400
180+ # orders on a given day.
181+ orders = api .list_orders (
182+ after = api_format (datetime .today () - timedelta (days = 1 )),
183+ limit = 400 ,
184+ status = 'all'
185+ )
186+ for order in orders :
187+ if order .side == 'buy' :
188+ bought_today = True
189+ # This handles an edge case where the script is restarted
190+ # right before the market closes.
191+ sold_today = True
192+ break
193+ else :
194+ sold_today = True
195+ except :
196+ # We don't have any orders, so we've obviously not done anything today.
197+ pass
198+
199+ while True :
200+ # We'll wait until the market's open to do anything.
201+ clock = api .get_clock ()
202+ if clock .is_open and not bought_today :
203+ if sold_today :
204+ # Wait to buy
205+ time_until_close = clock .next_close - clock .timestamp
206+ # We'll buy our shares a couple minutes before market close.
207+ if time_until_close .seconds <= 120 :
208+ print ('Buying positions...' )
209+ portfolio_cash = float (api .get_account ().cash )
210+ ratings = get_ratings (
211+ api , None
212+ )
213+ shares_to_buy = get_shares_to_buy (ratings , portfolio_cash )
214+ for symbol in shares_to_buy :
215+ api .submit_order (
216+ symbol = symbol ,
217+ qty = shares_to_buy [symbol ],
218+ side = 'buy' ,
219+ type = 'market' ,
220+ time_in_force = 'day'
221+ )
222+ print ('Positions bought.' )
223+ bought_today = True
224+ else :
225+ # We need to sell our old positions before buying new ones.
226+ time_after_open = clock .next_open - clock .timestamp
227+ # We'll sell our shares just a minute after the market opens.
228+ if time_after_open .seconds >= 60 :
229+ print ('Liquidating positions.' )
230+ api .close_all_positions ()
231+ sold_today = True
232+ else :
233+ bought_today = False
234+ sold_today = False
235+ if cycle % 10 == 0 :
236+ print ("Waiting for next market day..." )
237+ time .sleep (30 )
238+ cycle += 1
239+
240+
241+
242+ if __name__ == '__main__' :
243+ api = tradeapi .REST ()
244+
245+ if len (sys .argv ) < 2 :
246+ print ('Error: please specify a command; either "run" or "backtest <cash balance> <number of days to test>".' )
247+ else :
248+ if sys .argv [1 ] == 'backtest' :
249+ # Run a backtesting session using the provided parameters
250+ start_value = float (sys .argv [2 ])
251+ testing_days = int (sys .argv [3 ])
252+ portfolio_value = backtest (api , testing_days , start_value )
253+ portfolio_change = (portfolio_value - start_value ) / start_value
254+ print ('Portfolio change: {:.4f}%' .format (portfolio_change * 100 ))
255+ elif sys .argv [1 ] == 'run' :
256+ run_live (api )
257+ else :
258+ print ('Error: Unrecognized command ' + sys .argv [1 ])
0 commit comments