Created with Highcharts 12.1.2Equity2006200820102012201420162018202020222024202675k100k125k150k-50-25000.050.1-2020250k500k01020
Overall Statistics
Total Orders
12575
Average Win
0.05%
Average Loss
-0.12%
Compounding Annual Return
-0.467%
Drawdown
31.300%
Expectancy
-0.011
Start Equity
100000
End Equity
90980.47
Net Profit
-9.020%
Sharpe Ratio
-0.437
Sortino Ratio
-0.418
Probabilistic Sharpe Ratio
0.000%
Loss Rate
29%
Win Rate
71%
Profit-Loss Ratio
0.39
Alpha
-0.026
Beta
0.025
Annual Standard Deviation
0.055
Annual Variance
0.003
Information Ratio
-0.518
Tracking Error
0.162
Treynor Ratio
-0.961
Total Fees
$681.37
Estimated Strategy Capacity
$0
Lowest Capacity Asset
13.QuantpediaEquity 2S
Portfolio Turnover
1.83%
# https://quantpedia.com/strategies/time-series-factor-momentum/
#
# To support their strategy, authors built on the already known theory about momentum.
# Firstly, it is the fact that individual factors exhibit robust time-series momentum - 
# a performance persistence phenomenon by which an asset’s recent return predicts its future returns.
# Secondly, the research shows that individual factors can indeed be successfully timed based on their past performance.
# The TSFM strategy is compared to two natural benchmarks
# – an equal-weighted average of the raw factors and the traditional stock-level momentum strategy using the 2-12 formation strategy.
# The second benchmark partially explains the performance of TSFM, particularly when TSFM is based on a matched 2-12 formation period.
# While the factor momentum is strongest at the one-month horizon, there are benefits to longer formation periods as well.
# An important differentiating feature of TSFM is the stability of its behaviour with respect to the look-back window.
# TSFM is robust and exhibits positive momentum, whether it is based on prior one-month, one-year, or even five-year performance.
# A natural alternative strategy is to construct factor momentum relative to the performance of the other factors in the cross-section (cross-section factor momentum – CSFM).
# CSFM and TSFM have a correlation above 0.90 for any formation window, and the standalone average returns and Sharpe ratios of CSFM and TSFM are very similar.
# However, when we regress TSFM returns on CSFM, we find positive and highly significant TSFM alphas,
# yet CSFM generally has negative (and significant) alphas controlling for TSFM.
# Their high correlation and opposing alphas reveal that TSFM and CSFM are fundamentally the same phenomena,
# but that the time series approach provides a purer measure of expected factor returns than the cross-sectional method.
# The net standalone Sharpe ratios of TSFM and CSFM continue to exceed those of stock momentum, industry momentum, short-term reversal, and the Fama-French factors.
#
# QC Implementation changes:
#   - Investment universe consists of Quantpedia's equity long-short anomalies.

#region imports
from AlgorithmImports import *
import numpy as np
from typing import List, Dict
#endregion

class TimeSeriesFactorMomentum(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2005, 1, 1)
        self.SetCash(100000)
        
        # daily price data
        self.data:Dict[str, SymbolData] = {}
        self.period:int = 12 * 21 * 3 # Three years daily closes
        self.leverage:int = 10
        self.SetWarmUp(self.period, Resolution.Daily)
        
        csv_string_file:str = self.Download('data.quantpedia.com/backtesting_data/equity/quantpedia_strategies/backtest_end_year.csv')
        lines:str = csv_string_file.split('\r\n')
        last_id:None|str = None
        for line in lines[1:]:
            split:str = line.split(';')
            id:str = str(split[0])
            
            data:QuantpediaEquitz = self.AddData(QuantpediaEquity, id, Resolution.Daily)
            data.SetLeverage(self.leverage)
            data.SetFeeModel(CustomFeeModel())

            self.data[id] = SymbolData(self.period)
            
            if not last_id:
                last_id = id
                
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.recent_month:int = -1
    
    def OnData(self, data):
        if self.IsWarmingUp:
            return
        
        # Update equity close each day.
        for symbol in self.data:
            if symbol in data and data[symbol]:
                self.data[symbol].update(data[symbol].Value)
        
        if self.Time.month == self.recent_month:
            return
        self.recent_month = self.Time.month
        
        long:List[str] = []
        short:List[str] = []

        _last_update_date:Dict[str, datetime.date] = QuantpediaEquity.get_last_update_date()

        for symbol in self.data:
            if not self.data[symbol].is_ready():
                continue
            
            if symbol in data and data[symbol]:
                if _last_update_date[symbol] > self.Time.date():
                    # Calculate factore score for each equity
                    equity_score:float = self.data[symbol].calculate_z_score()
                    
                    # Go long if equity_score is positive
                    # and short if equity_score is negative
                    if equity_score >= 0:
                        long.append(symbol)
                    else:
                        short.append(symbol)
                
        # Trade execution.
        invested:List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
        for symbol in invested:
            if symbol not in long + short:
                self.Liquidate(symbol)
                
        long_length:int = len(long)
        short_length:int = len(short)
        
        # Equally weighted long and short portfolio
        for symbol in long:
            self.SetHoldings(symbol, 1 / long_length)
            
        for symbol in short:
            self.SetHoldings(symbol, -1 / short_length)

class SymbolData():
    def __init__(self, period:int):
        self.closes:RollingWindow[float] = RollingWindow[float](period)
        
    def update(self, close:float):
        self.closes.Add(close)
        
    def is_ready(self) -> bool:
        return self.closes.IsReady
        
    def calculate_z_score(self) -> float:
        closes:List[float] = [x for x in self.closes]
        
        values:np.ndarray = np.array(closes) # Full period daily closes
        daily_returns:np.ndarray = (values[:-1] - values[1:]) / values[1:] # Daily returns for full period
        full_period_volatility:float = np.std(daily_returns) # Full period volatility
        
        monthly_returns:List[float] = [(values[i] - values[i + 20]) / values[i + 20] for i in range(0, len(values), 21)]
        
        # This equation is first in paper on page 10.
        return min(max( sum(monthly_returns) / full_period_volatility, -2), 2)

# Quantpedia strategy equity curve data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaEquity(PythonData):
    def GetSource(self, config, date, isLiveMode):
        return SubscriptionDataSource("data.quantpedia.com/backtesting_data/equity/quantpedia_strategies/{0}.csv".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)

    _last_update_date:Dict[str, datetime.date] = {}

    @staticmethod
    def get_last_update_date() -> Dict[str, datetime.date]:
       return QuantpediaEquity._last_update_date

    def Reader(self, config, line, date, isLiveMode):
        data = QuantpediaEquity()
        data.Symbol = config.Symbol
        
        if not line[0].isdigit(): return None
        split = line.split(';')
        
        data.Time = datetime.strptime(split[0], "%Y-%m-%d") + timedelta(days=1)
        data['close'] = float(split[1])
        data.Value = float(split[1])
        
        # store last update date
        if config.Symbol.Value not in QuantpediaEquity._last_update_date:
            QuantpediaEquity._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()

        if data.Time.date() > QuantpediaEquity._last_update_date[config.Symbol.Value]:
            QuantpediaEquity._last_update_date[config.Symbol.Value] = data.Time.date()
        
        return data

# Custom fee model.
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))