Overall Statistics
Total Orders
210
Average Win
0.61%
Average Loss
-0.48%
Compounding Annual Return
5.022%
Drawdown
16.400%
Expectancy
-0.155
Start Equity
1000000
End Equity
1050465.18
Net Profit
5.047%
Sharpe Ratio
-0.013
Sortino Ratio
-0.014
Probabilistic Sharpe Ratio
18.481%
Loss Rate
63%
Win Rate
37%
Profit-Loss Ratio
1.27
Alpha
0
Beta
0
Annual Standard Deviation
0.198
Annual Variance
0.039
Information Ratio
0.257
Tracking Error
0.198
Treynor Ratio
0
Total Fees
$1077.30
Estimated Strategy Capacity
$8400000000.00
Lowest Capacity Asset
ES YGT6HGVF2SQP
Portfolio Turnover
16.68%
#region imports
from AlgorithmImports import *
from datetime import timedelta
from utils import get_position_size
from futures import categories
#endregion
import pandas as pd

class FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(AlphaModel):

    _futures = []
    _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} # Given by author on https://gitfront.io/r/user-4000052/iTvUZwEUN2Ta/AFTS-CODE/blob/chapter7.py

    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._fast_ema_span = int(self._slow_ema_span / 4)                  # "Any ratio between the two moving average lengths of two and six gives statistically indistinguishable results." (p.165)
        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                                                    # Instrument Diversification Multiplier. Hardcoded in https://gitfront.io/r/user-4000052/iTvUZwEUN2Ta/AFTS-CODE/blob/chapter8.py
        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

    def update(self, algorithm: QCAlgorithm, data: Slice) -> List[Insight]:
        # Record the new contract in the continuous series
        if data.quote_bars.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.start_date - 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)
            if sigma_pct is None:
                continue
            sigma_pct_by_future[future] = sigma_pct
        
        # Create insights
        insights = []
        weight_by_symbol = get_position_size({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.total_portfolio_value * self._idm * instrument_weight * self._target_risk)                       /(future.symbol_properties.contract_multiplier * 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.convert_to(algorithm.time, algorithm.time_zone, future.exchange.time_zone)
            expiry = future.exchange.hours.get_next_market_open(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

    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

    def _consolidation_handler(self, sender: object, consolidated_bar: TradeBar) -> None:
        security = self._algorithm.securities[consolidated_bar.symbol]
        end_date = consolidated_bar.end_time.date()
        if security.symbol.is_canonical():
            # 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]

    def on_securities_changed(self, algorithm: QCAlgorithm, changes: SecurityChanges) -> None:
        for security in changes.added_securities:
            symbol = security.symbol

            # Create a consolidator to update the history
            security.consolidator = TradeBarConsolidator(timedelta(1))
            security.consolidator.data_consolidated += self._consolidation_handler
            algorithm.subscription_manager.add_consolidator(symbol, security.consolidator)

            if security.symbol.is_canonical():
                # 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.removed_securities:
            # Remove consolidator + indicators
            algorithm.subscription_manager.remove_consolidator(security.symbol, security.consolidator)
            if security.symbol.is_canonical():
                for indicator in security.automatic_indicators:
                    algorithm.deregister_indicator(indicator)
# region imports
from AlgorithmImports import *
# endregion

categories = {
    Symbol.create(Futures.Financials.Y_10_TREASURY_NOTE, SecurityType.FUTURE, Market.CBOT): ("Fixed Income", "Bonds"),
    Symbol.create(Futures.Indices.SP_500_E_MINI, SecurityType.FUTURE, Market.CME): ("Equity", "US")
}
# region imports
from datetime import timedelta
from AlgorithmImports import *

#from futures import future_datas
from universe import AdvancedFuturesUniverseSelectionModel
from alpha import FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel
from portfolio import BufferedPortfolioConstructionModel
# endregion


class FuturesFastTrendFollowingLongAndShortWithTrendStrenthAlgorithm(QCAlgorithm):

    _undesired_symbols_from_previous_deployment = []
    _checked_symbols_from_previous_deployment = False
    
    def initialize(self):
        self.set_start_date(2023, 3, 1)
        self.set_end_date(2024, 3, 1) 
        self.set_cash(1_000_000)

        self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
        self.set_security_initializer(BrokerageModelSecurityInitializer(self.brokerage_model, FuncSecuritySeeder(self.get_last_known_prices)))        
        self.settings.minimum_order_margin_portfolio_percentage = 0

        self.universe_settings.data_normalization_mode = DataNormalizationMode.BACKWARDS_PANAMA_CANAL
        self.universe_settings.data_mapping_mode = DataMappingMode.OPEN_INTEREST
        self.add_universe_selection(AdvancedFuturesUniverseSelectionModel())

        slow_ema_span = 2 ** self.get_parameter("slow_ema_span_exponent", 6) # Should be >= 5. "It's convenient to stick to a series of parameter values that are powers of two" (p.131)
        blend_years = self.get_parameter("blend_years", 3)                   # Number of years to use when blending sigma estimates
        self.add_alpha(FastTrendFollowingLongAndShortWithTrendStrenthAlphaModel(
            self,
            slow_ema_span, 
            self.get_parameter("abs_forecast_cap", 20),           # Hardcoded on p.173
            self.get_parameter("sigma_span", 32),                 # Hardcoded to 32 on p.604
            self.get_parameter("target_risk", 0.2),               # Recommend value is 0.2 on p.75
            blend_years
        ))

        self.settings.rebalance_portfolio_on_security_changes = False
        self.settings.rebalance_portfolio_on_insight_changes = False
        self.total_count = 0
        self._day = -1
        self.set_portfolio_construction(BufferedPortfolioConstructionModel(
            self._rebalance_func,
            self.get_parameter("buffer_scaler", 0.1)              # Hardcoded on p.167 & p.173
        ))

        self.add_risk_management(NullRiskManagementModel())

        self.set_execution(ImmediateExecutionModel())

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

    def _rebalance_func(self, time):
        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:
            self.total_count = self.insights.total_count
            self._day = self.time.day
            return time
        return None

    def on_data(self, data):
        # Exit positions that aren't backed by existing insights.
        # If you don't want this behavior, delete this method definition.
        if not self.is_warming_up and not self._checked_symbols_from_previous_deployment:
            for security_holding in self.portfolio.values():
                if not security_holding.invested:
                    continue
                symbol = security_holding.symbol
                if not self.insights.has_active_insights(symbol, self.utc_time):
                    self._undesired_symbols_from_previous_deployment.append(symbol)
            self._checked_symbols_from_previous_deployment = True
        
        for symbol in self._undesired_symbols_from_previous_deployment[:]:
            if self.is_market_open(symbol):
                self.liquidate(symbol, tag="Holding from previous deployment that's no longer desired")
                self._undesired_symbols_from_previous_deployment.remove(symbol)

#region imports
from AlgorithmImports import *
#endregion


class BufferedPortfolioConstructionModel(EqualWeightingPortfolioConstructionModel):

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

    def create_targets(self, algorithm: QCAlgorithm, insights: List[Insight]) -> List[PortfolioTarget]:
        targets = super().create_targets(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
#region imports
from AlgorithmImports import *
#endregion
# 08/29/2023: -Adjusted insight expiry so all insights end at the same time each day
#             https://www.quantconnect.com/terminal/processCache?request=embedded_backtest_e1c8af207b1a4da945a4696f7db3ef9a.html
#
# 08/31/2023: -Adjusted universe filter to ensure the Mapped contract is always in the universe
#             -Updated the Alpha model to rely on warm-up rather than history requests
#             -Reduced the `blend_years` parameter to 3 to avoid any data issues from far in the past
#             https://www.quantconnect.com/terminal/processCache?request=embedded_backtest_ecb85ecf7a6ea332088f4b369017fa09.html
# 
# 04/15/2024: -Updated to PEP8 style
#             https://www.quantconnect.com/terminal/processCache?request=embedded_backtest_f8e01739e5624ee03aa3a6e2ac5c5108.html 
# region imports
from AlgorithmImports import *
from pandas import Timedelta as timedelta
from datetime import datetime
from Selection.FutureUniverseSelectionModel import FutureUniverseSelectionModel
from futures import categories
# endregion


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)
#region imports
from AlgorithmImports import *
#endregion

def get_position_size(group):
    subcategories = {}
    for category, subcategory in group.values():
        if category not in subcategories:
            subcategories[category] = {subcategory: 0}
        elif subcategory not in subcategories[category]:
            subcategories[category][subcategory] = 0
        subcategories[category][subcategory] += 1

    category_count = len(subcategories.keys())
    subcategory_count = {category: len(subcategory.keys()) for category, subcategory in subcategories.items()}
    
    weights = {}
    for symbol in group:
        category, subcategory = group[symbol]
        weight = 1 / category_count / subcategory_count[category] / subcategories[category][subcategory]
        weights[symbol] = weight
    
    return weights