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.

\[\textrm{EMAC(16, 64)} = \textrm{EMA(16)} - \textrm{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.

\[\textrm{Raw forecast} = \frac{EMAC(16, 64)}{\sigma_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.

\[\textrm{Scaled forecast} = \textrm{Raw forecast} \times 10 \div \textrm{Average absolute value of raw forecast}\]

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

\[\textrm{Forecast scalar} = 10 \div \textrm{Average absolute value of raw forecast}\]

\[\textrm{Scaled forecast} = \textrm{Raw forecast} \times \textrm{Forecast scalar}\]

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

\[\textrm{Scaled forecast} = \textrm{Raw forecast} \times 4.1\]

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

\[\textrm{Capped forecast} = \max(\min(\textrm{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:

\[N_{i,t}  = \frac{\textrm{Capped forecast}_{i,t} \times \textrm{Capital} \times \textrm{IDM} \times \textrm{Weight}_i \times \tau }{10 \times \textrm{Multiplier}_i \times \textrm{Price}_{i,t}  \times \sigma_{\%i,t}}\]

where

  • \(N_{i,t}\) is the number of contracts to hold for instrument \(i\) at time \(t\).
  • \(\textrm{Capped forecast}_{i,t}\) is the capped forecast of instrument \(i\) at time \(t\).
  • \(\textrm{Capital}\) is the current total portfolio value.
  • \(\textrm{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.
  • \(\textrm{Weight}_i\) is the classification weight of instrument \(i\). The algorithm we create here only trades 2 Futures, so \(\textrm{Weight}_i\) always 0.5.
  • \(\tau\) 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.
  • \(\textrm{Multiplier}_i\) is the contract multiplier of instrument \(i\).
  • \(\textrm{Price}_{i,t}\) is the price of instrument \(i\) at time \(t\).
  • \(\sigma_{\%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:

\[B_{i,t}  = \frac{\textrm{F} \times \textrm{Capital} \times \textrm{IDM} \times \textrm{Weight}_i \times \tau }{\textrm{Multiplier}_i \times \textrm{Price}_{i,t}  \times \sigma_{\%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.

\[B^U_{i,t} = round(N_{i,t} + B_{i,t})\]

\[B^L_{i,t} = round(N_{i,t} - B_{i,t})\]

Therefore, if our current holdings are above the buffer, we rebalance to be at \(B^U_{i,t}\). If our current holdings are below the buffer, we rebalance to be at \(B^L_{i,t}\). 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.

class FuturesFastTrendFollowingLongAndShortWithTrendStrenthAlgorithm(QCAlgorithm):
    
    def Initialize(self):
        self.SetStartDate(2020, 7, 1)
        self.SetEndDate(2023, 7, 1)
        self.SetCash(1_000_000)

        self.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.Margin)
        self.SetSecurityInitializer(BrokerageModelSecurityInitializer(self.BrokerageModel, FuncSecuritySeeder(self.GetLastKnownPrices)))        
        self.Settings.MinimumOrderMarginPortfolioPercentage = 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.

categories = {
    Symbol.Create(Futures.Financials.Y10TreasuryNote, SecurityType.Future, Market.CBOT): ("Fixed Income", "Bonds"),
    Symbol.Create(Futures.Indices.SP500EMini, SecurityType.Future, Market.CME): ("Equity", "US")
}

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

from Selection.FutureUniverseSelectionModel import FutureUniverseSelectionModel
from futures import categories

class AdvancedFuturesUniverseSelectionModel(FutureUniverseSelectionModel):
    def __init__(self) -> None:
        super().__init__(timedelta(1), self.select_future_chain_symbols)
        self.symbols = list(categories.keys())

    def select_future_chain_symbols(self, utc_time: datetime) -> List[Symbol]:
        return self.symbols

    def Filter(self, filter: FutureFilterUniverse) -> FutureFilterUniverse:
        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

class FuturesFastTrendFollowingLongAndShortWithTrendStrenthAlgorithm(QCAlgorithm):
    def Initialize(self):
        # . . .
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.BackwardsPanamaCanal
        self.UniverseSettings.DataMappingMode = DataMappingMode.OpenInterest
        self.AddUniverseSelection(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.

from futures import categories

class FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(AlphaModel):

    BUSINESS_DAYS_IN_YEAR = 256
    FORECAST_SCALAR_BY_SPAN = {64: 1.91, 32: 2.79, 16: 4.1, 8: 5.95, 4: 8.53, 2: 12.1}

    def __init__(self, algorithm, slow_ema_span, abs_forecast_cap, sigma_span, target_risk, blend_years):
        self.algorithm = algorithm
        self.slow_ema_span = slow_ema_span
        self.slow_ema_smoothing_factor = self.calculate_smoothing_factor(self.slow_ema_span)
        self.fast_ema_span = int(self.slow_ema_span / 4)
        self.fast_ema_smoothing_factor = self.calculate_smoothing_factor(self.fast_ema_span)
        self.annulaization_factor = self.BUSINESS_DAYS_IN_YEAR ** 0.5

        self.abs_forecast_cap = abs_forecast_cap
        
        self.sigma_span = sigma_span
        self.target_risk = target_risk
        self.blend_years = blend_years

        self.idm = 1.5
        self.forecast_scalar = self.FORECAST_SCALAR_BY_SPAN[self.fast_ema_span] 

        self.categories = categories
        self.total_lookback = timedelta(365*self.blend_years+self.slow_ema_span)

        self.day = -1

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.

class FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(AlphaModel):

    def OnSecuritiesChanged(self, algorithm: QCAlgorithm, changes: SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            symbol = security.Symbol

            # Create a consolidator to update the history
            security.consolidator = TradeBarConsolidator(timedelta(1))
            security.consolidator.DataConsolidated += self.consolidation_handler
            algorithm.SubscriptionManager.AddConsolidator(symbol, security.consolidator)

            if security.Symbol.IsCanonical():
                # Add some members to track price history
                security.adjusted_history = pd.Series()
                security.raw_history = pd.Series()
                
                # Create indicators for the continuous contract
                security.fast_ema = algorithm.EMA(security.Symbol, self.fast_ema_span, Resolution.Daily)
                security.slow_ema = algorithm.EMA(security.Symbol, self.slow_ema_span, Resolution.Daily)
                security.ewmac = IndicatorExtensions.Minus(security.fast_ema, security.slow_ema)

                security.automatic_indicators = [security.fast_ema, security.slow_ema]

                self.futures.append(security)

        for security in changes.RemovedSecurities:
            # Remove consolidator + indicators
            algorithm.SubscriptionManager.RemoveConsolidator(security.Symbol, security.consolidator)
            if security.Symbol.IsCanonical():
                for indicator in security.automatic_indicators:
                    algorithm.DeregisterIndicator(indicator)

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

class FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(AlphaModel):

    def consolidation_handler(self, sender: object, consolidated_bar: TradeBar) -> None:
        security = self.algorithm.Securities[consolidated_bar.Symbol]
        end_date = consolidated_bar.EndTime.date()
        if security.Symbol.IsCanonical():
            # Update adjusted history
            security.adjusted_history.loc[end_date] = consolidated_bar.Close
            security.adjusted_history = security.adjusted_history[security.adjusted_history.index >= end_date - self.total_lookback]
        else:
            # Update raw history
            continuous_contract = self.algorithm.Securities[security.Symbol.Canonical]
            if consolidated_bar.Symbol == continuous_contract.latest_mapped:
                continuous_contract.raw_history.loc[end_date] = consolidated_bar.Close
                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. 

class FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(AlphaModel):

    def estimate_std_of_pct_returns(self, raw_history, adjusted_history):
        # Align history of raw and adjusted prices
        idx = sorted(list(set(adjusted_history.index).intersection(set(raw_history.index))))
        adjusted_history_aligned = adjusted_history.loc[idx]
        raw_history_aligned = raw_history.loc[idx]

        # Calculate exponentially weighted standard deviation of returns
        returns = adjusted_history_aligned.diff().dropna() / raw_history_aligned.shift(1).dropna() 
        rolling_ewmstd_pct_returns = returns.ewm(span=self.sigma_span, min_periods=self.sigma_span).std().dropna()
        if rolling_ewmstd_pct_returns.empty: # Not enough history
            return None
        # Annualize sigma estimate
        annulized_rolling_ewmstd_pct_returns = rolling_ewmstd_pct_returns * (self.annulaization_factor)
        # Blend the sigma estimate (p.80)
        blended_estimate = 0.3*annulized_rolling_ewmstd_pct_returns.mean() + 0.7*annulized_rolling_ewmstd_pct_returns.iloc[-1]
        return blended_estimate

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.

class FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(AlphaModel):

    def Update(self, algorithm: QCAlgorithm, data: Slice) -> List[Insight]:
        # Record the new contract in the continuous series
        if data.QuoteBars.Count:
            for future in self.futures:
                future.latest_mapped = future.Mapped

        # If warming up and still > 7 days before start date, don't do anything
        # We use a 7-day buffer so that the algorithm has active insights when warm-up ends
        if algorithm.StartDate - algorithm.Time > timedelta(7):
            return []

        if self.day == data.Time.day or data.Bars.Count == 0:
            return []

        # Estimate the standard deviation of % daily returns for each future
        sigma_pct_by_future = {}
        for future in self.futures:
            # Estimate the standard deviation of % daily returns
            sigma_pct = self.estimate_std_of_pct_returns(future.raw_history, future.adjusted_history, future)
            if sigma_pct is None:
                continue
            sigma_pct_by_future[future] = sigma_pct
        
        # Create insights
        insights = []
        weight_by_symbol = GetPositionSize({future.Symbol: self.categories[future.Symbol] for future in sigma_pct_by_future.keys()})
        for symbol, instrument_weight in weight_by_symbol.items():
            future = algorithm.Securities[symbol]
            current_contract = algorithm.Securities[future.Mapped]
            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)

            # Calculate target position
            position = (algorithm.Portfolio.TotalPortfolioValue * self.idm * instrument_weight * self.target_risk) \
                      /(future.SymbolProperties.ContractMultiplier * daily_risk_price_terms * (self.annulaization_factor))

            # Adjust target position based on forecast
            risk_adjusted_ewmac = future.ewmac.Current.Value / daily_risk_price_terms
            scaled_forecast_for_ewmac = risk_adjusted_ewmac * self.forecast_scalar 
            forecast = max(min(scaled_forecast_for_ewmac, self.abs_forecast_cap), -self.abs_forecast_cap)

            if forecast * position == 0:
                continue
            # Save some data for the PCM
            current_contract.forecast = forecast
            current_contract.position = position

            # Create the insights
            local_time = Extensions.ConvertTo(algorithm.Time, algorithm.TimeZone, future.Exchange.TimeZone)
            expiry = future.Exchange.Hours.GetNextMarketOpen(local_time, False) - timedelta(seconds=1)
            insights.append(Insight.Price(future.Mapped, expiry, InsightDirection.Up if forecast * position > 0 else InsightDirection.Down))
        
        if insights:
            self.day = data.Time.day

        return insights

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.

from alpha import FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel

class FuturesFastTrendFollowingLongAndShortWithTrendStrenthAlgorithm(QCAlgorithm):
    
    def Initialize(self):
        # . . .
        slow_ema_span = 2 ** self.GetParameter("slow_ema_span_exponent", 6) 
        blend_years = self.GetParameter("blend_years", 3)
        self.AddAlpha(FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(
            self,
            slow_ema_span, 
            self.GetParameter("abs_forecast_cap", 20),
            self.GetParameter("sigma_span", 32),
            self.GetParameter("target_risk", 0.2),
            blend_years
        ))

        self.SetWarmUp(timedelta(365*blend_years + slow_ema_span + 7))

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. 

class BufferedPortfolioConstructionModel(EqualWeightingPortfolioConstructionModel):

    def __init__(self, rebalance, buffer_scaler):
        super().__init__(rebalance)
        self.buffer_scaler = buffer_scaler

    def CreateTargets(self, algorithm: QCAlgorithm, insights: List[Insight]) -> List[PortfolioTarget]:
        targets = super().CreateTargets(algorithm, insights)
        adj_targets = []
        for insight in insights:
            future_contract = algorithm.Securities[insight.Symbol]
            optimal_position = future_contract.forecast * future_contract.position / 10

            # Create buffer zone to reduce churn
            buffer_width = self.buffer_scaler * abs(future_contract.position)
            upper_buffer = round(optimal_position + buffer_width)
            lower_buffer = round(optimal_position - buffer_width)
            
            # Determine quantity to put holdings into buffer zone
            current_holdings = future_contract.Holdings.Quantity
            if lower_buffer <= current_holdings <= upper_buffer:
                continue
            quantity = lower_buffer if current_holdings < lower_buffer else upper_buffer

            # Place trades
            adj_targets.append(PortfolioTarget(insight.Symbol, quantity))

        # Liquidate contracts that have an expired insight
        for target in targets:
            if target.Quantity == 0:
                adj_targets.append(target)

        return adj_targets

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.

from portfolio import BufferedPortfolioConstructionModel

class FuturesFastTrendFollowingLongAndShortWithTrendStrenthAlgorithm(QCAlgorithm):

    def Initialize(self):
        # . . . 
        self.Settings.RebalancePortfolioOnSecurityChanges = False
        self.Settings.RebalancePortfolioOnInsightChanges = False
        self.total_count = 0
        self.day = -1
        self.SetPortfolioConstruction(BufferedPortfolioConstructionModel(
            self.rebalance_func,
            self.GetParameter("buffer_scaler", 0.1)              # Hardcoded on p.167 & p.173
        ))


    def rebalance_func(self, time):
        if (self.total_count != self.Insights.TotalCount or self.day != self.Time.day) and not self.IsWarmingUp and self.CurrentSlice.QuoteBars.Count > 0:
            self.total_count = self.Insights.TotalCount
            self.day = self.Time.day
            return time
        return None

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 \(N_{i,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.