Introduction

In the Fast Trend Following strategy, we used a trend filter to forecast the upcoming trend of Futures contracts and to determine position sizing. We also incorporated buffering to reduce trading fees. In this tutorial, we extend upon the previous strategy so that the size of the forecast is adjusted to reflect the probability that the trend reverses. This algorithm is a re-creation of strategy #12 from Advanced Futures Trading Strategies (Carver, 2023). The results show that adding more trend filters and adjusting the trend forecasts causes an increase in trading costs and a decrease in risk-adjusted returns.

Adding More Trend Filters

The Fast Trend Following strategy used a single EMAC(16, 64) trend filter to produce forecasts. In this strategy, use the following trend filters:

  • EMAC(2, 8)
  • EMAC(4, 16)
  • EMAC(8, 32)
  • EMAC(16, 64)
  • EMAC(32, 128)
  • EMAC(64, 256)

The correlation from these trend filters is less than 1, so two individual filters from the set can confirm or negate the current trend direction. The fast EMA span of each EMAC follows a 2n pattern and the slow EMA span of each EMAC is 4 times the length of the fast span. As Carver points out, “any ratio between the two moving average lengths of two and six gives statistically indistinguishable results” (p. 165).

Adjusting Trend Forecasts

Carver analyzed the relationship between forecasts from the preceding EMAC trend filters and the realized risk adjusted returns following each forecast. He found that there is only a positive correlation between EMAC(2, 8) forecasts and risk adjusted returns when the forecasts are in the interval [-5, 5]. As a result, you should adjust the forecast capping of the EMAC(2, 8) trend filter from the previous strategy such that scaled forecasts (F) near zero are amplified and scaled forecasts further away from zero are dampened. Carver suggests the following “double V” mappings:

F<20:Capped forecast=020<F<10:Capped forecast=40(2×F)10<F<10:Capped forecast=2×F10<F<20:Capped forecast=40(2×F)F>20:Capped forecast=0

The following image visualizes the “double V” mapping function:

101036_1691609711.jpg

Carver also suggests adjusting the forecast capping of EMAC(4, 16) and EMAC(64, 256) to an absolute value of 15 instead of 20 and to apply a multiplier to ensure the forecasts still have the correct scaling. He suggests the following “scale and cap” mappings:

F<15:Capped forecast=15×1.25=18.7515<F<15:Capped forecast=F×1.25F>15:Capped forecast=15×1.25=18.75

The following image visualizes the “scale and cap” mapping function:

101036_1691609806.jpg

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 the following sections only explain the differences with the Alpha model.

Initialization

In the Initialization method in main.py, we replace the Alpha model with a new one, the AdjustedTrendAlphaModel.

  1.  from alpha import AdjustedTrendAlphaModel
  2. class AdjustedTrendFuturesAlgorithm(QCAlgorithm):
  3. def Initialize(self):
  4. # . . .
  5. emac_filters = self.GetParameter("emac_filters", 6)
  6. blend_years = self.GetParameter("blend_years", 3)
  7. self.AddAlpha(AdjustedTrendAlphaModel(
  8. self,
  9. emac_filters,
  10. self.GetParameter("abs_forecast_cap", 20),
  11. self.GetParameter("sigma_span", 32),
  12. self.GetParameter("target_risk", 0.2),
  13. blend_years
  14. ))

Alpha Model Constructor

We define the constructor of the Alpha model in the alpha.py file. The new constructor generates all of the EMA spans we need.

  1.  from futures import categories
  2. class AdjustedTrendAlphaModel(AlphaModel):
  3. BUSINESS_DAYS_IN_YEAR = 256
  4. def __init__(self, algorithm, emac_filters, abs_forecast_cap, sigma_span, target_risk, blend_years):
  5. self.algorithm = algorithm
  6. self.emac_spans = [2**x for x in range (1, self.emac_filters+1)]
  7. self.fast_ema_spans = self.emac_spans
  8. self.slow_ema_spans = [fast_span * 4 for fast_span in self.emac_spans]
  9. self.all_ema_spans = sorted(list(set(self.fast_ema_spans + self.slow_ema_spans)))
  10. self.annulaization_factor = self.BUSINESS_DAYS_IN_YEAR ** 0.5
  11. self.abs_forecast_cap = abs_forecast_cap
  12. self.sigma_span = sigma_span
  13. self.target_risk = target_risk
  14. self.blend_years = blend_years
  15. self.idm = 1.5 # Instrument Diversification Multiplier
  16. self.categories = categories
  17. self.total_lookback = timedelta(365*self.blend_years+self.all_ema_spans[-1])
  18. self.day = -1
+ Expand

Creating Trend Filters

We adjust the OnSecuritiesChanged method in alpha.py to create the new EMAC trend filters we need.

  1.  class AdjustedTrendAlphaModel(AlphaModel):
  2. def OnSecuritiesChanged(self, algorithm: QCAlgorithm, changes: SecurityChanges) -> None:
  3. for security in changes.AddedSecurities:
  4. symbol = security.Symbol
  5. # Create a consolidator to update the history
  6. security.consolidator = TradeBarConsolidator(timedelta(1))
  7. security.consolidator.DataConsolidated += self.consolidation_handler
  8. algorithm.SubscriptionManager.AddConsolidator(symbol, security.consolidator)
  9. if symbol.IsCanonical():
  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. ema_by_span = {span: algorithm.EMA(symbol, span, Resolution.Daily) for span in self.all_ema_spans}
  15. security.ewmac_by_span = {}
  16. for i, fast_span in enumerate(self.emac_spans):
  17. security.ewmac_by_span[fast_span] = IndicatorExtensions.Minus(ema_by_span[fast_span], ema_by_span[self.slow_ema_spans[i]])
  18. security.automatic_indicators = ema_by_span.values()
  19. self.futures.append(security)
  20. for security in changes.RemovedSecurities:
  21. # Remove consolidator + indicators
  22. algorithm.SubscriptionManager.RemoveConsolidator(security.Symbol, security.consolidator)
  23. if security.Symbol.IsCanonical():
  24. for indicator in security.automatic_indicators:
  25. algorithm.DeregisterIndicator(indicator)
+ Expand

Capping and Aggregating Forecasts

We adjust the Update method in alpha.py to apply the preceding forecast mappings and aggregate the forecasts from all the trend filters into a single value. To aggregate all the forecasts, use the following procedure:

  1. Calculate the average of all the forecasts.
  2. Multiply the result by a forecast diversification multiplier (FDM) to keep the average forecast at 10.
  3. Cap the result to fall in the interval [-20, 20].

The FDM depends on the number of trend filters you use for the specific Future. If you determine some trend filters are too expensive to trade for the Future, you can remove their contributions to the final adjusted forecast. In this tutorial, we follow the example provided by Carter and use all of the trend filters.

  1.  class AdjustedTrendAlphaModel(AlphaModel):
  2. FORECAST_SCALAR_BY_SPAN = {64: 1.91, 32: 2.79, 16: 4.1, 8: 5.95, 4: 8.53, 2: 12.1}
  3. FDM_BY_RULE_COUNT = {1: 1.0, 2: 1.03, 3: 1.08, 4: 1.13, 5: 1.19, 6: 1.26}
  4. SCALE_AND_CAP_MAPPING_MULTIPLIER = 1.25
  5. def Update(self, algorithm: QCAlgorithm, data: Slice) -> List[Insight]:
  6. # Record the new contract in the continuous series
  7. if data.QuoteBars.Count:
  8. for future in self.futures:
  9. future.latest_mapped = future.Mapped
  10. # If warming up and still > 7 days before start date, don't do anything
  11. # We use a 7-day buffer so that the algorithm has active insights when warm-up ends
  12. if algorithm.StartDate - algorithm.Time > timedelta(7):
  13. return []
  14. if self.day == data.Time.day or data.Bars.Count == 0:
  15. return []
  16. # Estimate the standard deviation of % daily returns for each future
  17. sigma_pct_by_future = {}
  18. for future in self.futures:
  19. # Estimate the standard deviation of % daily returns
  20. sigma_pct = self.estimate_std_of_pct_returns(future.raw_history, future.adjusted_history)
  21. if sigma_pct is None:
  22. continue
  23. sigma_pct_by_future[future] = sigma_pct
  24. # Create insights
  25. insights = []
  26. weight_by_symbol = GetPositionSize({future.Symbol: self.categories[future.Symbol] for future in sigma_pct_by_future.keys()})
  27. for symbol, instrument_weight in weight_by_symbol.items():
  28. future = algorithm.Securities[symbol]
  29. current_contract = algorithm.Securities[future.Mapped]
  30. 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)
  31. # Calculate target position
  32. position = (algorithm.Portfolio.TotalPortfolioValue * self.idm * instrument_weight * self.target_risk) \
  33. /(future.SymbolProperties.ContractMultiplier * daily_risk_price_terms * (self.annulaization_factor))
  34. # Adjust target position based on forecast
  35. capped_forecast_by_span = {}
  36. for span in self.emac_spans:
  37. risk_adjusted_ewmac = future.ewmac_by_span[span].Current.Value / daily_risk_price_terms
  38. scaled_forecast_for_ewmac = risk_adjusted_ewmac * self.FORECAST_SCALAR_BY_SPAN[span]
  39. if span == 2: # "Double V" forecast mapping (page 253-254)
  40. if scaled_forecast_for_ewmac < -20:
  41. capped_forecast_by_span[span] = 0
  42. elif -20 <= scaled_forecast_for_ewmac < -10:
  43. capped_forecast_by_span[span] = -40 - (2 * scaled_forecast_for_ewmac)
  44. elif -10 <= scaled_forecast_for_ewmac < 10:
  45. capped_forecast_by_span[span] = 2 * scaled_forecast_for_ewmac
  46. elif 10 <= scaled_forecast_for_ewmac < 20:
  47. capped_forecast_by_span[span] = 40 - (2 * scaled_forecast_for_ewmac)
  48. else:
  49. capped_forecast_by_span[span] = 0
  50. elif span in [4, 64]: # "Scale and cap" forecast mapping
  51. if scaled_forecast_for_ewmac < -15:
  52. capped_forecast_by_span[span] = -15 * self.SCALE_AND_CAP_MAPPING_MULTIPLIER
  53. elif -15 <= scaled_forecast_for_ewmac < 15:
  54. capped_forecast_by_span[span] = scaled_forecast_for_ewmac * self.SCALE_AND_CAP_MAPPING_MULTIPLIER
  55. else:
  56. capped_forecast_by_span[span] = 15 * self.SCALE_AND_CAP_MAPPING_MULTIPLIER
  57. else: # Normal forecast capping
  58. capped_forecast_by_span[span] = max(min(scaled_forecast_for_ewmac, self.abs_forecast_cap), -self.abs_forecast_cap)
  59. raw_combined_forecast = sum(capped_forecast_by_span.values()) / len(capped_forecast_by_span) # Calculate a weighted average of capped forecasts (p. 194)
  60. scaled_combined_forecast = raw_combined_forecast * self.FDM_BY_RULE_COUNT[len(capped_forecast_by_span)] # Apply a forecast diversification multiplier to keep the average forecast at 10 (p 193-194)
  61. capped_combined_forecast = max(min(scaled_combined_forecast, self.abs_forecast_cap), -self.abs_forecast_cap)
  62. if capped_combined_forecast * position == 0:
  63. continue
  64. # Save some data for the PCM
  65. current_contract.forecast = capped_combined_forecast
  66. current_contract.position = position
  67. # Create the insights
  68. local_time = Extensions.ConvertTo(algorithm.Time, algorithm.TimeZone, future.Exchange.TimeZone)
  69. expiry = future.Exchange.Hours.GetNextMarketOpen(local_time, False) - timedelta(seconds=1)
  70. insights.append(Insight.Price(future.Mapped, expiry, InsightDirection.Up if capped_combined_forecast * position > 0 else InsightDirection.Down))
  71. if insights:
  72. self.day = data.Time.day
  73. return insights
+ Expand

Results

We backtested the strategy from July 1, 2020 to July 1, 2023. The algorithm placed 909 trades, incurred $9,296.28 in fees, and achieved a 0.193 Sharpe ratio. In contrast, the Fast Trend Following strategy that uses a single EMAC trend filter and non-adjusted forecasts placed 607 trades, incurred $4,724.53 in fees, and achieved a 0.294 Sharpe ratio. Therefore, the addition of more trend filters and forecast adjustments causes the strategy to trade more frequently and reduces the risk-adjusted returns. 

Trades and Fees Increase

We investigated why this strategy (#12) had more trades and fees than the Fast Trend Following strategy (#8). The following image shows the forecasts of both Futures for both strategies. The results show that the forecasts for strategy #12 have the same general shape as the forecasts for strategy #8, but the forecasts are more volatile in strategy #12. This increase in forecast volatility leads to an increase in trades and fees.

We used all of the EMAC trend filters explained above to produce the forecasts. However, as Carver outlines, it is beneficial to only incorporate trend filters that have a historical record of profitability after considering fees, turnover, and rolling costs. Since this strategy doesn’t remove any trend filters, it may incorporate unprofitable ones, leading to a decrease in risk-adjusted returns relative to strategy #8.

It’s not just the increase in commission fees that causes the Sharpe ratio to decrease relative to strategy #8. If we eliminate all commission fees from both strategies, strategy #8 achieves a 0.297 Sharpe ratio (see backtest) and strategy #12 achieves a 0.212 Sharpe ratio (see backtest).

Trade Buffer and Position Sizing

Finally, we tested whether the trade buffering and position sizing logic still improve the strategy’s risk-adjusted return. The results show that if we remove the trade buffering logic, the Sharpe ratio increases from 0.193 to 0.225 but the fees increase from $9,296.28 to $11,583.68 (see backtest). Furthermore, if we limit the position sizing to just 1, -1, or 0 contracts, the Sharpe ratio decreases to -0.071 (see backtest). Therefore, the position sizing logic is still essential to increasing the strategy’s risk-adjusted returns.

Further Research

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

  • Tracking the performance of each trend filter and remove the ones that are too expensive to trade
  • Calculating FDM on the fly instead of using the hard-coded estimates provided above

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

March 2025