Introduction

In this tutorial, we use a trend filter to forecast the upcoming trend of Futures contracts and to determine position sizing. We also add buffering to reduce trading fees. This algorithm is a re-creation of strategy #8 from Advanced Futures Trading Strategies (Carver, 2023). The results show that applying the trend filter and diversified position sizing logic to a portfolio of Futures contracts for the S&P 500 E-mini and 10-year treasury note outperforms a buy-and-hold benchmark strategy with the same contracts.

Forecasting Trends

A common technique to quantitatively classify the trend direction of an instrument is to use an exponential moving average crossover (EMAC) on historical prices.

EMAC(16, 64)=EMA(16)EMA(64)

When the EMAC is positive, the instrument is in an uptrend and when the EMAC is negative, the instrument is in a downtrend. In the case of Futures, the EMA indicators are applied to the adjusted prices of the continuous contract because their adjusted prices remove price jumps that could give a false change of the EMAC sign.

The sign of the EMCA can only signal the trend direction for an individual security. To convert the EMAC value to something that you can use to compare the trend of two different securities, divide the EMAC value by the security’s standard deviation of returns.

Raw forecast=EMAC(16,64)σp

We want to know whether the raw forecast is relatively large or small for an individual security. To this end, we can divide the raw forecast by the average absolute value of trailing raw forecasts. Furthermore, Carver suggests scaling the forecast to have an average absolute value of 10 to make it easier to understand.

Scaled forecast=Raw forecast×10÷Average absolute value of raw forecast

We can name the latter part of this equation the forecast scalar.

Forecast scalar=10÷Average absolute value of raw forecast

Scaled forecast=Raw forecast×Forecast scalar

Carver provides the forecast scalar value of 4.1 for this strategy.

Scaled forecast=Raw forecast×4.1

We then cap the scaled forecast to fall within the interval [-20, 20].

Capped forecast=max(min(Scaled forecast,20),20)

The capped forecast is a factor used to determine our Future contract trade direction and position size.

Position Sizing

To determine the optimal position size of each Future under consideration, we use the following formula:

Ni,t=Capped forecasti,t×Capital×IDM×Weighti×τ10×Multiplieri×Pricei,t×σ%i,t

where

  • Ni,t is the number of contracts to hold for instrument i at time t.
  • Capped forecasti,t is the capped forecast of instrument i at time t.
  • Capital is the current total portfolio value.
  • IDM is the instrument diversification multiplier. Carver provides a methodology for dynamically calculating the IDM in later strategies, but this algorithm only trades two instruments, so we follow the recommendation of setting the IDM to 1.5.
  • Weighti is the classification weight of instrument i. The algorithm we create here only trades 2 Futures, so Weighti always 0.5.
  • τ is the target portfolio risk. This is oftentimes a subjective value that’s a function of the investor’s risk profile. In this algorithm, we use the recommended value of 0.2 (20%) from Carver.
  • Multiplieri is the contract multiplier of instrument i.
  • Pricei,t is the price of instrument i at time t.
  • σ%i,t is the annualized risk of instrument i at time t.

Trading Buffers

To avoid very small orders and reduce trading fees, we set a trading buffer. The trading buffer is a region surrounding the optimal position size where we don’t bother trading. To calculate the buffer size, we apply a fraction F in the following equation:

Bi,t=F×Capital×IDM×Weighti×τMultiplieri×Pricei,t×σ%i,t

It’s possible to calculate F from factors like the profitability of the trading rule and its associated costs, but Carver suggests a conservative value of 0.1 (10%).

Next, we can calculate the lower and upper boundaries of the buffer.

Bi,tU=round(Ni,t+Bi,t)

Bi,tL=round(Ni,tBi,t)

Therefore, if our current holdings are above the buffer, we rebalance to be at Bi,tU. If our current holdings are below the buffer, we rebalance to be at Bi,tL. If our current holdings are inside the buffer zone, we don’t adjust the position.

Method

Let’s walk through how we can implement this trading algorithm with the LEAN trading engine.

Initialization

We start with the Initialization method, where we set the start date, end date, cash, and some settings.

  1. class FuturesFastTrendFollowingLongAndShortWithTrendStrenthAlgorithm(QCAlgorithm):
  2. def initialize(self):
  3. self.set_start_date(2020, 7, 1)
  4. self.set_end_date(2023, 7, 1)
  5. self.set_cash(1_000_000)
  6. self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
  7. self.set_security_initializer(BrokerageModelSecurityInitializer(self.brokerage_model, FuncSecuritySeeder(self.get_last_known_prices)))
  8. self.settings.minimum_order_margin_portfolio_percentage = 0

Universe Selection

We follow the example algorithm outlined in the book and configure the Universe Selection model to return Futures contracts for the S&P 500 E-mini and 10-year treasury note. To accomplish this, follow these steps:

1. In the futures.py file, define the Futures symbols and their respective market category.

  1. categories = {
  2. Symbol.create(Futures.financials.y10_treasury_note, SecurityType.FUTURE, Market.CBOT): ("Fixed Income", "Bonds"),
  3. Symbol.create(Futures.indices.SP500E_MINI, SecurityType.FUTURE, Market.CME): ("Equity", "US")
  4. }

2. In the universe.py file, import the categories and define the Universe Selection model.

  1. from Selection.FutureUniverseSelectionModel import FutureUniverseSelectionModel
  2. from futures import categories
  3. class AdvancedFuturesUniverseSelectionModel(FutureUniverseSelectionModel):
  4. def __init__(self) -> None:
  5. super().__init__(timedelta(1), self.select_future_chain_symbols)
  6. self.symbols = list(categories.keys())
  7. def select_future_chain_symbols(self, utc_time: datetime) -> List[Symbol]:
  8. return self.symbols
  9. def filter(self, filter: FutureFilterUniverse) -> FutureFilterUniverse:
  10. return filter.expiration(0, 365)

3. In the main.py file, extend the Initialization method to configure the universe settings and add a Universe Selection model

  1. class FuturesFastTrendFollowingLongAndShortWithTrendStrenthAlgorithm(QCAlgorithm):
  2. def initialize(self):
  3. # . . .
  4. self.universe_settings.data_normalization_mode = DataNormalizationMode.BACKWARDS_PANAMA_CANAL
  5. self.universe_settings.data_mapping_mode = DataMappingMode.OPEN_INTEREST
  6. self.add_universe_selection(AdvancedFuturesUniverseSelectionModel())

To form the continuous contract price series, select the current contract based on open interest and adjust historical prices using the BackwardsPanamaCanal DataNormalizationMode. Note that the original algorithm by Carver rolls over contracts n days before they expire instead of rolling based on open interest, so our results will be slightly different.

Forecasting Future Trends

We first define the constructor of the Alpha model in the alpha.py file.

  1. from futures import categories
  2. class FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(AlphaModel):
  3. BUSINESS_DAYS_IN_YEAR = 256
  4. FORECAST_SCALAR_BY_SPAN = {64: 1.91, 32: 2.79, 16: 4.1, 8: 5.95, 4: 8.53, 2: 12.1}
  5. def __init__(self, algorithm, slow_ema_span, abs_forecast_cap, sigma_span, target_risk, blend_years):
  6. self.algorithm = algorithm
  7. self.slow_ema_span = slow_ema_span
  8. self.slow_ema_smoothing_factor = self.calculate_smoothing_factor(self.slow_ema_span)
  9. self.fast_ema_span = int(self.slow_ema_span / 4)
  10. self.fast_ema_smoothing_factor = self.calculate_smoothing_factor(self.fast_ema_span)
  11. self.annulaization_factor = self.BUSINESS_DAYS_IN_YEAR ** 0.5
  12. self.abs_forecast_cap = abs_forecast_cap
  13. self.sigma_span = sigma_span
  14. self.target_risk = target_risk
  15. self.blend_years = blend_years
  16. self.idm = 1.5
  17. self.forecast_scalar = self.FORECAST_SCALAR_BY_SPAN[self.fast_ema_span]
  18. self.categories = categories
  19. self.total_lookback = timedelta(365*self.blend_years+self.slow_ema_span)
  20. self.day = -1
+ Expand

When new Futures are added to the universe, we set up a consolidator to keep the trailing data updated as the algorithm executes. If the security that’s added is a continuous contract, we combine two EMA indicators to create the EMAC.

  1. class FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(AlphaModel):
  2. def on_securities_changed(self, algorithm: QCAlgorithm, changes: SecurityChanges) -> None:
  3. for security in changes.added_securities:
  4. symbol = security.symbol
  5. # Create a consolidator to update the history
  6. security.consolidator = TradeBarConsolidator(timedelta(1))
  7. security.consolidator.data_consolidated += self.consolidation_handler
  8. algorithm.subscription_manager.add_consolidator(symbol, security.consolidator)
  9. if security.symbol.is_canonical():
  10. # Add some members to track price history
  11. security.adjusted_history = pd.Series()
  12. security.raw_history = pd.Series()
  13. # Create indicators for the continuous contract
  14. security.fast_ema = algorithm.EMA(security.symbol, self.fast_ema_span, Resolution.DAILY)
  15. security.slow_ema = algorithm.EMA(security.symbol, self.slow_ema_span, Resolution.DAILY)
  16. security.ewmac = IndicatorExtensions.minus(security.fast_ema, security.slow_ema)
  17. security.automatic_indicators = [security.fast_ema, security.slow_ema]
  18. self.futures.append(security)
  19. for security in changes.removed_securities:
  20. # Remove consolidator + indicators
  21. algorithm.subscription_manager.remove_consolidator(security.symbol, security.consolidator)
  22. if security.symbol.is_canonical():
  23. for indicator in security.automatic_indicators:
  24. algorithm.deregister_indicator(indicator)
+ Expand

The consolidation handler simply updates the security history and trims off any history that’s too old.

  1. class FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(AlphaModel):
  2. def consolidation_handler(self, sender: object, consolidated_bar: TradeBar) -> None:
  3. security = self.algorithm.securities[consolidated_bar.symbol]
  4. end_date = consolidated_bar.end_time.date()
  5. if security.symbol.is_canonical():
  6. # Update adjusted history
  7. security.adjusted_history.loc[end_date] = consolidated_bar.close
  8. security.adjusted_history = security.adjusted_history[security.adjusted_history.index >= end_date - self.total_lookback]
  9. else:
  10. # Update raw history
  11. continuous_contract = self.algorithm.securities[security.symbol.canonical]
  12. if consolidated_bar.symbol == continuous_contract.latest_mapped:
  13. continuous_contract.raw_history.loc[end_date] = consolidated_bar.close
  14. continuous_contract.raw_history = continuous_contract.raw_history[continuous_contract.raw_history.index >= end_date - self.total_lookback]

Next, we define a helper method to estimate the standard deviation of returns. 

  1. class FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(AlphaModel):
  2. def estimate_std_of_pct_returns(self, raw_history, adjusted_history):
  3. # Align history of raw and adjusted prices
  4. idx = sorted(list(set(adjusted_history.index).intersection(set(raw_history.index))))
  5. adjusted_history_aligned = adjusted_history.loc[idx]
  6. raw_history_aligned = raw_history.loc[idx]
  7. # Calculate exponentially weighted standard deviation of returns
  8. returns = adjusted_history_aligned.diff().dropna() / raw_history_aligned.shift(1).dropna()
  9. rolling_ewmstd_pct_returns = returns.ewm(span=self.sigma_span, min_periods=self.sigma_span).std().dropna()
  10. if rolling_ewmstd_pct_returns.empty: # Not enough history
  11. return None
  12. # Annualize sigma estimate
  13. annulized_rolling_ewmstd_pct_returns = rolling_ewmstd_pct_returns * (self.annulaization_factor)
  14. # Blend the sigma estimate (p.80)
  15. blended_estimate = 0.3*annulized_rolling_ewmstd_pct_returns.mean() + 0.7*annulized_rolling_ewmstd_pct_returns.iloc[-1]
  16. return blended_estimate
+ Expand

The Update method calculates the forecast of the Futures at the beginning of each trading day and returns some Insight objects for the Portfolio Construction model.

  1. class FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(AlphaModel):
  2. def update(self, algorithm: QCAlgorithm, data: Slice) -> List[Insight]:
  3. # Record the new contract in the continuous series
  4. if data.quote_bars.count:
  5. for future in self.futures:
  6. future.latest_mapped = future.mapped
  7. # If warming up and still > 7 days before start date, don't do anything
  8. # We use a 7-day buffer so that the algorithm has active insights when warm-up ends
  9. if algorithm.start_date - algorithm.time > timedelta(7):
  10. return []
  11. if self.day == data.time.day or data.bars.count == 0:
  12. return []
  13. # Estimate the standard deviation of % daily returns for each future
  14. sigma_pct_by_future = {}
  15. for future in self.futures:
  16. # Estimate the standard deviation of % daily returns
  17. sigma_pct = self.estimate_std_of_pct_returns(future.raw_history, future.adjusted_history, future)
  18. if sigma_pct is None:
  19. continue
  20. sigma_pct_by_future[future] = sigma_pct
  21. # Create insights
  22. insights = []
  23. weight_by_symbol = GetPositionSize({future.symbol: self.categories[future.symbol] for future in sigma_pct_by_future.keys()})
  24. for symbol, instrument_weight in weight_by_symbol.items():
  25. future = algorithm.securities[symbol]
  26. current_contract = algorithm.securities[future.mapped]
  27. daily_risk_price_terms = sigma_pct_by_future[future] / (self.annulaization_factor) * current_contract.price # "The price should be for the expiry date we currently hold (not the back-adjusted price)" (p.55)
  28. # Calculate target position
  29. position = (algorithm.portfolio.total_portfolio_value * self.idm * instrument_weight * self.target_risk) \
  30. /(future.symbol_properties.contract_multiplier * daily_risk_price_terms * (self.annulaization_factor))
  31. # Adjust target position based on forecast
  32. risk_adjusted_ewmac = future.ewmac.current.value / daily_risk_price_terms
  33. scaled_forecast_for_ewmac = risk_adjusted_ewmac * self.forecast_scalar
  34. forecast = max(min(scaled_forecast_for_ewmac, self.abs_forecast_cap), -self.abs_forecast_cap)
  35. if forecast * position == 0:
  36. continue
  37. # Save some data for the PCM
  38. current_contract.forecast = forecast
  39. current_contract.position = position
  40. # Create the insights
  41. local_time = Extensions.convert_to(algorithm.time, algorithm.time_zone, future.exchange.time_zone)
  42. expiry = future.exchange.hours.get_next_market_open(local_time, False) - timedelta(seconds=1)
  43. insights.append(Insight.price(future.mapped, expiry, InsightDirection.UP if forecast * position > 0 else InsightDirection.DOWN))
  44. if insights:
  45. self.day = data.time.day
  46. return insights
+ Expand

Finally, to add the Alpha model to the algorithm, we extend the Initialization method in the main.py file to set the new Alpha model and add a warm-up period.

  1. from alpha import FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel
  2. class FuturesFastTrendFollowingLongAndShortWithTrendStrenthAlgorithm(QCAlgorithm):
  3. def initialize(self):
  4. # . . .
  5. slow_ema_span = 2 ** self.get_parameter("slow_ema_span_exponent", 6)
  6. blend_years = self.get_parameter("blend_years", 3)
  7. self.add_alpha(FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(
  8. self,
  9. slow_ema_span,
  10. self.get_parameter("abs_forecast_cap", 20),
  11. self.get_parameter("sigma_span", 32),
  12. self.get_parameter("target_risk", 0.2),
  13. blend_years
  14. ))
  15. self.set_warm_up(timedelta(365*blend_years + slow_ema_span + 7))
+ Expand

Calculating Position Sizes

We first define a custom Portfolio Construction model in the portfolio.py file that calculates the optimal position for each Future and applies the buffer zone logic. 

  1. class BufferedPortfolioConstructionModel(EqualWeightingPortfolioConstructionModel):
  2. def __init__(self, rebalance, buffer_scaler):
  3. super().__init__(rebalance)
  4. self.buffer_scaler = buffer_scaler
  5. def create_targets(self, algorithm: QCAlgorithm, insights: List[Insight]) -> List[PortfolioTarget]:
  6. targets = super().create_targets(algorithm, insights)
  7. adj_targets = []
  8. for insight in insights:
  9. future_contract = algorithm.securities[insight.symbol]
  10. optimal_position = future_contract.forecast * future_contract.position / 10
  11. # Create buffer zone to reduce churn
  12. buffer_width = self.buffer_scaler * abs(future_contract.position)
  13. upper_buffer = round(optimal_position + buffer_width)
  14. lower_buffer = round(optimal_position - buffer_width)
  15. # Determine quantity to put holdings into buffer zone
  16. current_holdings = future_contract.holdings.quantity
  17. if lower_buffer <= current_holdings <= upper_buffer:
  18. continue
  19. quantity = lower_buffer if current_holdings < lower_buffer else upper_buffer
  20. # Place trades
  21. adj_targets.append(PortfolioTarget(insight.symbol, quantity))
  22. # Liquidate contracts that have an expired insight
  23. for target in targets:
  24. if target.quantity == 0:
  25. adj_targets.append(target)
  26. return adj_targets
+ Expand

Then to add the Portfolio Construction model to the algorithm, we extend the Initialization method in the main.py file to set the new model.

  1. from portfolio import BufferedPortfolioConstructionModel
  2. class FuturesFastTrendFollowingLongAndShortWithTrendStrenthAlgorithm(QCAlgorithm):
  3. def initialize(self):
  4. # . . .
  5. self.settings.rebalance_portfolio_on_security_changes = False
  6. self.settings.rebalance_portfolio_on_insight_changes = False
  7. self.total_count = 0
  8. self.day = -1
  9. self.set_portfolio_construction(BufferedPortfolioConstructionModel(
  10. self.rebalance_func,
  11. self.get_parameter("buffer_scaler", 0.1) # Hardcoded on p.167 & p.173
  12. ))
  13. def rebalance_func(self, time):
  14. if (self.total_count != self.insights.total_count or self.day != self.time.day) and not self.is_warming_up and self.current_slice.quote_bars.count > 0:
  15. self.total_count = self.insights.total_count
  16. self.day = self.time.day
  17. return time
  18. return None
+ Expand

Results

We backtested the strategy from July 1, 2020 to July 1, 2023. The algorithm placed 607 trades, incurred $4,724.53 in fees, and achieved a 0.294 Sharpe ratio. In contrast, if we remove the trade buffering logic, the algorithm places 738 trades, incurs $5,607.51 in fees, and achieves a 0.301 Sharpe ratio (see backtest). Therefore, the buffering logic decreased the trades and fees, but also slightly decreased the Sharpe ratio.

A second benchmark to compare the strategy to is a buy-and-hold portfolio with variable risk position sizing applied to the same Futures contracts. This benchmark achieves a -0.335 Sharpe ratio (see backtest), so the strategy outperforms it in terms of risk-adjusted returns.

Lastly, we found the position sizing logic is vital to the success of the strategy. The backtest shows that the exposure ranges from -1,000% to 1,000%. However, if we cap Ni,t to fall in the interval [-1, 1], then the Sharpe ratio drops from 0.294 to 0.129 (see backtest).

Further Research

If you want to continue developing this strategy, some areas of further research include:

  • Increasing the universe size
  • Adjusting parameter values
  • Incorporating the estimate of IDM
  • Adjusting the rollover timing to match the timing outlined by Carver

References

  1. Carver, R. (2023). Advanced Futures Trading Strategies: 30 fully tested strategies for multiple trading styles and time frames. Harriman House Ltd.

Author

Derek Melchin

September 2023