Trading and managing portfolios with Zipline
In the previous chapter, we introduced Zipline to simulate the computation of alpha factors from trailing market, fundamental, and alternative data for a cross-section of stocks. In this section, we will start acting on the signals emitted by alpha factors. We'll do this by submitting buy and sell orders so we can enter long and short positions or rebalance the portfolio to adjust our holdings to the most recent trade signals.
We will postpone optimizing the portfolio weights until later in this chapter and, for now, just assign positions of equal value to each holding. As mentioned in the previous chapter, an in-depth introduction to the testing and evaluation of strategies that include ML models will follow in Chapter 6, The Machine Learning Process.
Scheduling signal generation and trade execution
We will use the custom MeanReversion factor developed in the previous chapter (see the implementation in 01_backtest_with_trades.ipynb).
The Pipeline created by the compute_factors() method returns a table with columns containing the 50 longs and shorts. It selects the equities according to the largest negative and positive deviations, respectively, of their last monthly return from the annual average, normalized by the standard deviation:
def compute_factors():
"""Create factor pipeline incl. mean reversion,
filtered by 30d Dollar Volume; capture factor ranks"""
mean_reversion = MeanReversion()
dollar_volume = AverageDollarVolume(window_length=30)
return Pipeline(columns={'longs' : mean_reversion.bottom(N_LONGS),
'shorts' : mean_reversion.top(N_SHORTS),
'ranking': mean_reversion.rank(ascending=False)},
screen=dollar_volume.top(VOL_SCREEN))
It also limited the universe to the 1,000 stocks with the highest average trading volume over the last 30 trading days. before_trading_start() ensures the daily execution of the Pipeline and the recording of the results, including the current prices:
def before_trading_start(context, data):
"""Run factor pipeline"""
context.factor_data = pipeline_output('factor_pipeline')
record(factor_data=context.factor_data.ranking)
assets = context.factor_data.index
record(prices=data.current(assets, 'price'))
The new rebalance() method submits trade orders to the exec_trades() method for the assets flagged for long and short positions by the Pipeline with equal positive and negative weights. It also pests any current holdings that are no longer included in the factor signals:
def exec_trades(data, assets, target_percent):
"""Place orders for assets using target portfolio percentage"""
for asset in assets:
if data.can_trade(asset) and not get_open_orders(asset):
order_target_percent(asset, target_percent)
def rebalance(context, data):
"""Compute long, short and obsolete holdings; place trade orders"""
factor_data = context.factor_data
assets = factor_data.index
longs = assets[factor_data.longs]
shorts = assets[factor_data.shorts]
pest = context.portfolio.positions.keys() - longs.union(shorts)
exec_trades(data, assets=pest, target_percent=0)
exec_trades(data, assets=longs, target_percent=1 / N_LONGS if N_LONGS
else 0)
exec_trades(data, assets=shorts, target_percent=-1 / N_SHORTS if N_SHORTS
else 0)
The rebalance() method runs according to date_rules and time_rules set by the schedule_function() utility at the beginning of the week, right after market_open, as stipulated by the built-in US_EQUITIES calendar (see the Zipline documentation for details on rules).
You can also specify a trade commission both in relative terms and as a minimum amount. There is also an option to define slippage, which is the cost of an adverse change in price between trade decision and execution:
def initialize(context):
"""Setup: register pipeline, schedule rebalancing,
and set trading params"""
attach_pipeline(compute_factors(), 'factor_pipeline')
schedule_function(rebalance,
date_rules.week_start(),
time_rules.market_open(),
calendar=calendars.US_EQUITIES)
set_commission(us_equities=commission.PerShare(cost=0.00075,
min_trade_cost=.01))
set_slippage(us_equities=slippage.VolumeShareSlippage(volume_limit=0.0025, price_impact=0.01))
The algorithm continues to execute after calling the run_algorithm() function and returns the same backtest performance DataFrame that we saw in the previous chapter.
Implementing mean-variance portfolio optimization
We demonstrated in the previous section how to find the efficient frontier using scipy.optimize. In this section, we will leverage the PyPortfolioOpt library, which offers portfolio optimization (using SciPy under the hood), including efficient frontier techniques and more recent shrinkage approaches that regularize the covariance matrix (see Chapter 7, Linear Models – From Risk Factors to Return Forecasts, on shrinkage for linear regression). The code example lives in 02_backtest_with_pf_optimization.ipynb.
We'll use the same setup with 50 long and short positions derived from the MeanReversion factor ranking. The rebalance() function receives the suggested long and short positions and passes each subset on to a new optimize_weights() function to obtain dictionaries with asset: target_percent pairs:
def rebalance(context, data):
"""Compute long, short and obsolete holdings; place orders"""
factor_data = context.factor_data
assets = factor_data.index
longs = assets[factor_data.longs]
shorts = assets[factor_data.shorts]
pest = context.portfolio.positions.keys() - longs.union(shorts)
exec_trades(data, positions={asset: 0 for asset in pest})
# get price history
prices = data.history(assets, fields='price',
bar_count=252+1, # 1 yr of returns
frequency='1d')
if len(longs) > 0:
long_weights = optimize_weights(prices.loc[:, longs])
exec_trades(data, positions=long_weights)
if len(shorts) > 0:
short_weights = optimize_weights(prices.loc[:, shorts], short=True)
exec_trades(data, positions=short_weights)
The optimize_weights() function uses the EfficientFrontier object, provided by PyPortfolioOpt, to find the weights that maximize the Sharpe ratio based on the last year of returns and the covariance matrix, both of which the library also computes:
def optimize_weights(prices, short=False):
returns = expected_returns.mean_historical_return(prices=prices,
frequency=252)
cov = risk_models.sample_cov(prices=prices, frequency=252)
# get weights that maximize the Sharpe ratio
ef = EfficientFrontier(expected_returns=returns,
cov_matrix=cov,
weight_bounds=(0, 1),
gamma=0)
weights = ef.max_sharpe()
if short:
return {asset: -weight for asset, weight in ef.clean_weights().items()}
else:
return ef.clean_weights()
It returns normalized weights that sum to 1, set to negative values for the short positions.
Figure 5.3 shows that, for this particular set of strategies and time frame, the mean-variance optimized portfolio performs significantly better:
Figure 5.3: Mean-variance vs equal-weighted portfolio performance
PyPortfolioOpt also finds the minimum volatility portfolio. More generally speaking, this example illustrates how you can add logic to tweak portfolio weights using the methods presented in the previous section, or any other of your choosing.
We will now turn to common measures of portfolio return and risk, and how to compute them using the pyfolio library.