Introduction
In this tutorial, we build upon the Combined Carry and Trend strategy to dynamically adjust the weight it gives to the trend and carry forecasts instead of using a fixed 60%-40% mix. The core idea is that the algorithm should give greater weight to the trend forecasts when they’re outperforming the carry forecasts and vice versa. The results show that adjusting the weighting scheme based on trailing performance improves the risk-adjusted returns of the strategy. This algorithm is a re-creation of strategy #16 from Advanced Futures Trading Strategies (Carver, 2023).
Background
We have a carry strategy and a trend strategy. We can also imagine a relative strategy, which is the outperformance of the trend strategy versus the carry strategy. Carver finds that all three of these strategies have positive autocorrelation in their annual returns. To exploit this effect, we should give more weight to the trend forecasts when the trend strategy is outperforming the carry strategy and give more weight to the carry forecasts when the trend strategy is underperforming.
The first step is to calculate the relative strategy daily performance, \(R\), given the daily performance as a percentage of capital for the trend strategy \(T\) and carry strategy \(C\).
The second step is to calculate the rolling one-year (256 business days) relative performance \(RP\), which is scaled by the volatility target, \(\tau\).
When \(RP_t = 0\), the trend and carry strategies have been performing equally well over the last year, so we should give them equal weight. \(RP_t > 0\), the trend strategy has been outperforming the carry strategy over the last year, so we should give the trend strategy more weight. When \(RP_t < 0\), the carry strategy has been outperforming the trend strategy, so we should give the carry strategy more weight.
To map \(RP_t\) to a weighting factor, we can use the following formulae:
The following chart shows how this formulae converts \(RP\) values to a forecast weight, before applying a 30-day EMWA smooth:
\(W_t \in [0, 1]\) is the weight we’ll give to the divergent trend forecasts and \(1 - W_t\) is the weight we’ll give to the convergent carry forecasts. In the Combined Carry and Trend strategy, we had \(W_t = 0.6\), but in this version of the strategy, \(W_t\) changes from day to day. However, the daily changes won’t be too drastic since we’re using 1 year of trailing data and we’re smoothing out the forecasts with a 30-day EMWA model.
Method
Let’s walk through how we can implement this trading algorithm with the LEAN trading engine. Most of the code is the same as the previous strategy, so let’s only review the differences.
Initialization
We need 1 year of trailing performance for the trend strategy and the carry strategy to calculate \(RP_t\) and we need 30 samples of \(RP_t\) to calculate \(W_t\). To ensure we can calculate \(W_t\) right when the algorithm starts, let’s increase the warm-up period in the main.py file.
self.SetWarmUp(timedelta(365+45+7))Universe Selection
We’ll use the same universe of 18 Futures as the previous strategies. However, in this iteration of the strategy, we need to store the after-fees performance of the trend and carry strategies for each Future. To accomplish this, adjust the OnSecuritiesChanged method in the alpha.py file.
class CarryAndTrendAlphaModel(AlphaModel):
def OnSecuritiesChanged(self, algorithm: QCAlgorithm, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
symbol = security.Symbol
# . . .
if symbol.IsCanonical():
# . . .
security.forecast_performance_data = pd.DataFrame()
# . . .
else:
future = algorithm.Securities[symbol.Canonical]
if not hasattr(future, "fee"):
# Save trading fees
order = MarketOrder(symbol, 1, algorithm.Time)
parameters = OrderFeeParameters(security, order)
future.fee = security.FeeModel.GetOrderFee(parameters).Value.Amount
# . . .
The forecast_performance_data member will have two columns. One column will store the theoretical holdings of the trend strategy over the last year and the other column will store the theoretical holdings of the carry strategy.
Tracking Strategy Holdings
At the beginning of each day, we calculate the forecasts just like the previous strategies, but now we save the following information to the target contract of each Future:
- The optimal position (excluding the forecast term) if we had a fixed capital base
- The aggregated trend forecasts
- The aggregated carry forecasts
We use a fixed capital base for the optimal position calculation because this is a theoretical position and shouldn’t be impacted by our portfolio value.
class CarryAndTrendAlphaModel(AlphaModel):
def Update(self, algorithm: QCAlgorithm, data: Slice) -> List[Insight]:
# . . .
for symbol, instrument_weight in weight_by_symbol.items():
future = algorithm.Securities[symbol]
sigma_pct = sigma_pcts_by_future[future]
daily_risk_price_terms = sigma_pct / (self.annulaization_factor) * future.target_contract.Price
# Calculate target position
position = (self.idm * instrument_weight * self.target_risk) \
/(future.SymbolProperties.ContractMultiplier * daily_risk_price_terms * (self.annulaization_factor))
future.target_contract.training_position = self.CAPITAL_REQUIRED * position
position = algorithm.Portfolio.TotalPortfolioValue * position
# Calculate forecast type 1: EMAC
trend_forecasts = self.calculate_emac_forecasts(future.ewmac_by_span, daily_risk_price_terms)
if not trend_forecasts:
continue
emac_combined_forecasts = sum(trend_forecasts) / len(trend_forecasts) # Aggregate EMAC factors -- equal-weight
future.target_contract.trend_forecast = self.apply_fdm_and_cap(len(trend_forecasts), emac_combined_forecasts)
# Calculate factor type 2: Carry
carry_forecasts = self.calculate_carry_forecasts(future.annualized_raw_carry_history, daily_risk_price_terms)
if not carry_forecasts:
continue
carry_combined_forecasts = sum(carry_forecasts) / len(carry_forecasts) # Aggregate Carry factors -- equal-weight
future.target_contract.carry_forecast = self.apply_fdm_and_cap(len(carry_forecasts), carry_combined_forecasts)
# . . .
After the Alpha model emits its Insight objects, the Portfolio Construction model determines the target holdings of the Futures contract for each strategy and saves it to the forecast_performance_data member.
class BufferedPortfolioConstructionModel(EqualWeightingPortfolioConstructionModel):
def CreateTargets(self, algorithm: QCAlgorithm, insights: List[Insight]) -> List[PortfolioTarget]:
targets = super().CreateTargets(algorithm, insights)
timestamp = algorithm.Time.date() + timedelta(1)
adj_targets = []
for insight in insights:
future_contract = algorithm.Securities[insight.Symbol]
future = algorithm.Securities[insight.Symbol.Canonical]
# Save optimal position if just trading trend/carry forecasts
self.save_optimal_position_for_factor(future, future_contract.training_position, future_contract.trend_forecast, "trend_buffered_position", timestamp)
self.save_optimal_position_for_factor(future, future_contract.training_position, future_contract.carry_forecast, "carry_buffered_position", timestamp)
# . . .
def save_optimal_position_for_factor(self, future, position, forecast, factor_name, timestamp):
optimal_position = forecast * position / 10
if factor_name in future.forecast_performance_data.columns:
current_holdings = future.forecast_performance_data[factor_name].iloc[-1]
else:
current_holdings = 0
future.forecast_performance_data.loc[timestamp, factor_name] = self.buffer_target(optimal_position, current_holdings, position)
The buffer_target method applies the trade buffering we introduced in the Fast Trend Following strategy.
Adjusting Strategy Weights
When the warm-up period is almost done, the forecast_performance_data DataFrames should have a year’s worth of simulated holdings for the trend and carry strategies. At this point, we can use the data to calculate the theoretical returns of trading each of these strategies on their own. Note that we include commissions in our cost calculations, but we don’t consider spread costs or margin impact.
class CarryAndTrendAlphaModel(AlphaModel):
def Update(self, algorithm: QCAlgorithm, data: Slice) -> List[Insight]:
# . . .
trailing_returns_by_factor = {}
for future in self.futures:
if future.forecast_performance_data.shape[0] > self.BUSINESS_DAYS_IN_YEAR + self.WEIGHTING_EMA_SPAN:
# Determine factor weights based on past performance
open_to_open_delta = future.adjusted_history['open'].diff().shift(-1).dropna()
precost_daily_dollar_returns = future.forecast_performance_data.mul(open_to_open_delta, axis=0).dropna() * future.SymbolProperties.ContractMultiplier
position_delta = future.forecast_performance_data.diff()
position_delta.iloc[0] = future.forecast_performance_data.iloc[0] # If the first row in `f` isn't 0, we lose some data to NaN when we do `.diff`
fees = position_delta.abs() * future.fee
postcost_daily_dollar_returns = (precost_daily_dollar_returns - fees).dropna()
pct_daily_returns = postcost_daily_dollar_returns / self.CAPITAL_REQUIRED
for factor in pct_daily_returns.columns:
if factor not in trailing_returns_by_factor:
trailing_returns_by_factor[factor] = pd.DataFrame()
trailing_returns_by_factor[factor][future.Symbol] = pct_daily_returns[factor]
Now we can use the trailing returns to calculate \(W_t\).
class CarryAndTrendAlphaModel(AlphaModel):
def Update(self, algorithm: QCAlgorithm, data: Slice) -> List[Insight]:
# . . .
if trailing_returns_by_factor:
relative_outperformance = trailing_returns_by_factor["trend_buffered_position"].sum(axis=1) - trailing_returns_by_factor["carry_buffered_position"].sum(axis=1)
scaled_relative_outperformance = relative_outperformance.rolling(self.BUSINESS_DAYS_IN_YEAR).sum().dropna() / self.target_risk
w = (0.5 + scaled_relative_outperformance / 2).clip(lower=0, upper=1).ewm(self.WEIGHTING_EMA_SPAN).mean().iloc[-1]
The last step is to mix the trend and carry forecasts with \(W_t\), apply the forecast diversification multiplier, and create the trading insights.
class CarryAndTrendAlphaModel(AlphaModel):
def Update(self, algorithm: QCAlgorithm, data: Slice) -> List[Insight]:
# . . .
insights = []
weight_by_symbol = GetPositionSize({future.Symbol: self.categories[future.Symbol].classification for future in sigma_pcts_by_future.keys()})
for symbol, instrument_weight in weight_by_symbol.items():
# . . .
# Combine forecasts from each substrategy
raw_combined_forecast = w * emac_combined_forecasts + (1-w) * carry_combined_forecasts
capped_combined_forecast = self.apply_fdm_and_cap(len(trend_forecasts) + len(carry_forecasts), raw_combined_forecast)
if capped_combined_forecast * position == 0:
continue
# Save some information for the PCM
future.target_contract.forecast = capped_combined_forecast
future.target_contract.position = position
# Create insights
expiry = self.futures[0].Exchange.Hours.GetNextMarketOpen(algorithm.Time, False) - timedelta(seconds=1)
direction = InsightDirection.Up if capped_combined_forecast * position > 0 else InsightDirection.Down
insights.append(Insight.Price(future.target_contract.Symbol, expiry, direction))
if insights:
self.day = data.Time.day
return insights
Results
We backtested the strategy from July 1, 2020 to July 1, 2023. The following chart shows the value of \(W_t\) throughout the backtest. At the beginning of the backtest, the algorithm gave about a 35-45% weight to trend strategy. In the middle of the backtest, the algorithm gave about a 95-100% weight to the trend factor. During the second half of the backtest, the algorithm gradually decreased the weight of the trend strategy until it had a weight of about 1-2%, leaving the carry strategy a weight of about 98-99%.
During the backtest period, the algorithm achieved a 1.11 Sharpe ratio. In contrast, the Combined Carry and Trend strategy, which uses a fixed 60%-40% weighting scheme, achieved a 0.828 Sharpe ratio over the same time period. Furthermore, the Trend Following and Carry in Different Risk Regimes strategy, which adjusts the forecasts based on market volatility, achieved a 0.88 Sharpe ratio. Therefore, adjusting the strategy weights based on their relative performance has had the greatest impact on the risk-adjusted returns of the portfolio. The following chart shows the equity curves of the three strategies:
Further Research
If you want to continue developing this strategy, some areas of further research include:
- Adjusting parameter values
- Adding more uncorrelated varieties of the carry and trend forecasts
- Incorporating other factors besides carry and trend
- Adjusting the rollover timing to match the timing outlined by Carver
- Excluding trading rules that have demonstrated a history of unprofitability
References
- Carver, R. (2023). Advanced Futures Trading Strategies: 30 fully tested strategies for multiple trading styles and time frames. Harriman House Ltd.
Derek Melchin
The material on this website is provided for informational purposes only and does not constitute an offer to sell, a solicitation to buy, or a recommendation or endorsement for any security or strategy, nor does it constitute an offer to provide investment advisory services by QuantConnect. In addition, the material offers no opinion with respect to the suitability of any security or specific investment. QuantConnect makes no guarantees as to the accuracy or completeness of the views expressed in the website. The views are subject to change, and may have become unreliable for various reasons, including changes in market conditions or economic circumstances. All investments involve risk, including loss of principal. You should consult with an investment professional before making any investment decisions.
To unlock posting to the community forums please complete at least 30% of Boot Camp.
You can continue your Boot Camp training progress from the terminal. We hope to see you in the community soon!