I am a College Student and I Built My Own Robo Advisor (Part 1)
I’m Rao, and I’m an intern at Alpaca working on building an open-source robo advisor. This post is the combination of various threads and forums that I read through looking for answers.
I’m Rao, and I’m an intern at Alpaca working on building an open-source robo advisor. I don’t have much experience in the space, and I had to find a lot of answers. While there’s a wealth of material available on the web, very little is organized. This post is the combination of various threads and forums that I read through looking for answers.
If you wan to jump ahead to the full code, here is a link on Quantopian.
What is a Robo Advisor anyway?!
Robo advisors are automated advising services that require little to no user interaction. They specialize in maintaining portfolios based on the investors chosen risk level. Btw, they were launched at the start of the financial crisis in 2008.
The actual logic for a robo advisor is straightforward.
- “Allocation” — Given a risk level, portions of capital are allocated to different positions.
- “Distance” — Over a regular time interval, the adviser scans to see if there’s a significant change in the portfolio balance, and
- “Rebalancing” — If necessary, rebalances.
Allocation:
The process of allocation is where different weights are assigned to asset classes based on the developer’s algorithm. These asset classes cover the various aspects of the market, and keep a diversified portfolio. Wealthfront sees these asset classes as:
- US Stocks
- Foreign Stocks
- US Bonds
- Foreign Bonds
- Inflation Protection
Each of these assets is represented with ETFs, who capture a broad look at the asset class.
Distance:
Rebalancing a portfolio comes with a certain amount of overhead and cost. To avoid this, the robo advisor computes a “distance” vector. To trigger a rebalance, this distance will have to be greater than a given threshold. Most robo advisors usually have this threshold set to 5 percent.
The distance in this case, is the individual change in weight of each position. This is done by first calculating the current allocation. This is the value of a position against the total value of the portfolio. If any one position has a weight that’s 5 percent greater (current_weight/target_weight > 1.05), then the robo advisor will trigger a rebalance
Rebalancing:
Rebalancing is the act of buying and selling to return the portfolio to its target state. It’s important that all selling is done first. Once all cash available is gathered, necessary purchases are made.
To calculate how much to sell, use the target allocation of cash (weight * portfolio value), and see the number of shares that result from that cash. The difference between the number of shares currently held and the target level of shares is the amount to buy/sell.
Implementing a Robo Advisor with the Vanguard Core Series:
Now that we’ve established the various parts of what makes a robo advisor, we’ll start executing the steps to build one. We’ll build our robo advisor using Quantopian’s IDE.
When implementing our advisor, I find it easiest to do in steps. Quantopian’s IDE can only run backtests on a full algorithm. What that means, is there’s no “halfway” development. So, each step is a self-contained algorithm by itself.
Note: The implementation of Vanguard’s Core Series is using the information found here. The portfolio includes a 2% investment in a money market fund, but that isn’t available on Quantopian, so for the purposes of this tutorial, we’ll ignore it.
Risk-Based Allocation:
The first step, is to determine our universe, and assign weights using risk level. Our universe will be the Vanguard Core series:
- VTI — Domestic Equity
- VXUS — International Equity
- BND — Domestic Fixed Income (Bonds)
- BNDX — International Fixed Income (Bonds)
The risk level is actually the ratio of fixed income to equity, with equity being more volatile. So, a risk level 0 would have all capital allocated to fixed income (BND, BNDX), while a risk level of 5 would be 50/50. All 11 possible risk levels are catalogued in a dictionary, with the keys being the risk level, and values being tuples containing weight allocation:
risk_level = 5
risk_based_allocation = { 0: (0,0,0.686,0.294),
1: (0.059,0.039,0.617,0.265),
2: (0.118,0.078,0.549,0.235),
3: (0.176,0.118,0.480,0.206),
4: (0.235,0.157,0.412,0.176),
5: (0.294,0.196,0.343,0.147),
6: (0.353,0.235,0.274,0.118),
7: (0.412,0.274,0.206,0.088),
8: (0.470,0.314,0.137,0.059),
9: (0.529,0.353,0.069,0.029),
10: (0.588,0.392,0,0)}
The next step will be to implement our allocation in Quantopian. For this step, the only methods we’ll need are initialize and handle_data. The rest are superfluous.
The initialize method is the main. When a backtest is started, it automatically calls initialize. So, initialize will be the function that contains the dictionary for risk levels, as well as choosing the risk levels. The handle_data method is called automatically after initialize. It’s here that we’ll actually purchase the positions outlined in initialize.
Both initialize and handle_data have the variables context and data. Context allows the user to store global variables that get passed from method to method. Data helps the algorithm fetch different sorts of data. (Note: This is a change from Quantopian 1 where data was the object where global variables were stored).
Let’s get started. In initialize, copy and paste the following code:
context.stocks = symbols(‘VTI’, ‘VXUS’, ‘BND’, ‘BNDX’)
context.bought = False
risk_level = 5
risk_based_allocation = {0: (0,0,0.686,0.294),
1: (0.059,0.039,0.617,0.265),
2: (0.118,0.078,0.549,0.235),
3: (0.176,0.118,0.480,0.206),
4: (0.235,0.157,0.412,0.176),
5: (0.294,0.196,0.343,0.147),
6: (0.353,0.235,0.274,0.118),
7: (0.412,0.274,0.206,0.088),
8: (0.470,0.314,0.137,0.059),
9: (0.529,0.353,0.069,0.029),
10: (0.588,0.392,0,0)}
#Saves the weights to easily access during rebalance
context.target_allocation = dict(zip(context.stocks,
risk_based_allocation[risk_level]))
#To make initial purchase
context.bought = False
The variable context.stocks is a list of stock objects. The symbols function turns the strings into objects. The objects have attributes like current price, closing price, etc. Using that list, as well as the dictionary for allocation, we’ll create a second dictionary context.target_allocation. The keys are each ticker (VTI, VXUS, etc.) and the values are the ticker’s weights. This dictionary will be useful for both allocation and rebalancing.
Copy and paste the following code to create handle_data:
if not context.bought:
for stock in context.stocks:
amount = (context.target_allocation[stock] *
context.portfolio.cash) / data.current(stock,’price’)
if (amount != 0):
order(stock, int(amount))
#log purchase
log.info(“buying “ + str(int(amount)) + “ shares of “ +
str(stock))
#now won’t purchase again and again
context.bought = True
The variable context.bought refers to the value originally set to False in initialize. Since handle_data is called every time a market event occurs. So, using context.bought ensures the shares are only bought once.
To buy shares for each stock, the list of stock objects is iterated through. For each stock, the total number of shares are calculated by allocating the proper amount of capital (weight * capital), and then dividing that capital by the current price. Since we can only buy whole shares, leftover capital is added back to the cash on hand.
Lastly, for a smoother backtesting experience, all transactions are logged. The log is in the lower right hand corner. Build the current algorithm. Set the start date to June 2013 or later as the ETF BNDX was first tradeable on June 7, 2013.
Calculating Distance:
Before rebalancing a portfolio, it’s best to first calculate if the current portfolio is worth rebalancing. In more sophisticated robo advisors, this step would have a target portfolio calculated with an algorithm. For this example, our target portfolio is going to be the initial allocation defined by the risk level in initialize.
As the value of each position fluctuates, their portfolio weight may change too. The distance vector will be the change in weight of each individual position. Calculating the distance takes place in three steps:
- Calculating the current weights of each position
- Calculating the target weights (already done)
- Comparing the two weights
We want to check the balance daily, so we’ll go ahead and add the following function , before_trading_starts, to our code:
def before_trading_starts(context, data):
#total value
value = context.portfolio.portfolio_value +
context.portfolio.cash
#calculating current weights for each position
for stock in context.stocks:
if (context.target_allocation[stock] == 0):
continue
current_holdings = data.current(stock,’close’) *
context.portfolio.positions[stock].amount
weight = current_holdings/value
growth = float(weight) /
float(context.target_allocation[stock])
if (growth >= 1.025 or growth <= 0.975):
log.info(“Need to rebalance portfolio”)
break
We first calculate total value. Since that value doesn’t change, it can be down outside the loop. Then, each position’s individual weight is calculated, and compared to the target weight (we’re using division since we’re interested in the relative growth of the position, not the absolute). If that growth exceeds the threshold (currently 2.5%) or is under the threshold (de-valued), then a rebalance is triggered. We don’t have an actual rebalance function written, so for now, we’ll simply log that a rebalance is necessary. It’s important to note the break added. Once the algorithm realizes that it’s time to rebalance, there’s no need to continue checking the rest of the stock. It creates a better best-case scenario.
However, we’re not actually done. We have to call before_trading_start, but calling it in a loop is inefficient. That’s why we’ll use the schedule_function command in initialize. Add this line of code to the end of the function block:
schedule_function(
func=before_trading_starts,
date_rule=date_rules.every_day(),
time_rule=time_rules.market_open(hours=1))
This schedules a distance calculation as the market opens every day. By computing the distance in the morning, we have the time and space necessary to execute rebalancing actions.
Rebalancing
The act of rebalancing a portfolio occurs in two steps. First, all assets that need to be sold are sold, and then all assets that need to bought are bought. Selling first makes sure that we don’t run out of cash.
The following is the code for a rebalance function:
def rebalance(context, data):
for stock in context.stocks:
current_weight = (data.current(stock, ‘close’) *
context.portfolio.positions[stock].amount) /
context.portfolio.portfolio_value
target_weight = context.target_allocation[stock]
distance = current_weight — target_weight
if (distance > 0):
amount = -1 * (distance * context.portfolio.portfolio_value) /
data.current(stock,’close’)
if (int(amount) == 0):
continue
log.info(“Selling “ + str(int(amount)) + “ shares of “ +
str(stock))
order(stock, int(amount))
for stock in context.stocks:
current_weight = (data.current(stock, ‘close’) *
context.portfolio.positions[stock].amount) /
context.portfolio.portfolio_value
target_weight = context.target_allocation[stock]
distance = current_weight — target_weight
if (distance < 0):
amount = -1 * (distance * context.portfolio.portfolio_value) /
data.current(stock,’close’)
if (int(amount) == 0):
continue
log.info(“Buying “ + str(int(amount)) + “ shares of “ +
str(stock))
order(stock, int(amount))
The selling and buying mechanics are the same. The only difference is when selling stock, you use a negative value when calling the order function. In both cases, we take the absolute difference in weight (target — current), and use that value to find the number of shares to buy or sell.
Multiple Universes
Vanguard provides several more universes beyond the core-series. Currently, we’re able to manipulate the risk level, and observe the outcome. Let’s add the functionality of also being able to select a universe.
The first and straightforward method, is to implement multiple dictionaries. Here’s how something like that would look. The CRSP series was added to our algorithm. The user now chooses both the universe and the risk level.
core_series = symbols(‘VTI’, ‘VXUS’, ‘BND’, ‘BNDX’)
crsp_series = symbols(‘VUG’, ‘VTV’, ‘VB’, ‘VEA’, ‘VWO’, ‘BSV’, ‘BIV’, ‘BLV’, ‘VMBS’, ‘BNDX’)
#universe risk based allocation
core_series_weights = {0: (0,0,0.686,0.294),
1: (0.059,0.039,0.617,0.265),
2: (0.118,0.078,0.549,0.235),
3: (0.176,0.118,0.480,0.206),
4: (0.235,0.157,0.412,0.176),
5: (0.294,0.196,0.343,0.147),
6: (0.353,0.235,0.274,0.118),
7: (0.412,0.274,0.206,0.088),
8: (0.470,0.314,0.137,0.059),
9: (0.529,0.353,0.069,0.029),
10: (0.588,0.392,0,0)}
crsp_series_weights = {0: (0,0,0,0,0,0.273,0.14,0.123,0.15,0.294),
1: (0.024,0.027,0.008,0.03,0.009,0.245,0.126,0.111,0.135,0.265),
2: (0.048,0.054,0.016,0.061,0.017,0.218,0.112,0.099,0.12,0.235),
3: (0.072,0.082,0.022,0.091,0.027,0.191,0.098,0.086,0.105,0.206),
4: (0.096,0.109,0.03,0.122,0.035,0.164,0.084,0.074,0.09,0.176),
5: (0.120,0.136,0.038,0.152,0.044,0.126,0.07,0.062,0.075,0.147),
6: (0.143,0.163,0.047,0.182,0.053,0.109,0.056,0.049,0.06,0.118),
7: (0.167,0.190,0.055,0.213,0.061,0.082,0.042,0.037,0.045,0.088),
8: (0.191,0.217,0.062,0.243,0.071,0.055,0.028,0.024,0.030,0.059),
9: (0.215,0.245,0.069,0.274,0.079,0.027,0.014,0.013,0.015,0.029),
10: (0.239,0.272,0.077,0.304,0.088,0,0,0,0,0)}
#set universe and risk level
context.stocks = crsp_series
risk_based_allocation = crsp_series_weights
risk_level = 1
The user can use the three variables, context.stocks, risk_based_allocation, and risk_level, to set both the universe and the risk level.
What’s Next
Developing in Quantopian is a great experience — they provide lots of useful tools. But, it’s also limiting, being forced to only work in one file, etc. To build further on this robo advisor, I plan to migrate my work so far to the backend, and use Quantopian’s open source python library Zipline to run backtests. The next installment will cover all this and more!
You can find the algorithm here. Try it yourself!