Overall Statistics
Total Orders
509
Average Win
0.30%
Average Loss
-0.15%
Compounding Annual Return
3.627%
Drawdown
18.100%
Expectancy
1.047
Start Equity
100000
End Equity
169486.95
Net Profit
69.487%
Sharpe Ratio
0.18
Sortino Ratio
0.171
Probabilistic Sharpe Ratio
0.885%
Loss Rate
32%
Win Rate
68%
Profit-Loss Ratio
2.03
Alpha
-0.01
Beta
0.223
Annual Standard Deviation
0.054
Annual Variance
0.003
Information Ratio
-0.664
Tracking Error
0.119
Treynor Ratio
0.044
Total Fees
$108.22
Estimated Strategy Capacity
$0
Lowest Capacity Asset
FAMA_FRENCH_5_MARKET_EQ.QuantpediaFamaFrenchEquity 2S
Portfolio Turnover
0.31%
from AlgorithmImports import *

# Quantpedia data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaFamaFrench(PythonData):
    def GetSource(self, config, date, isLiveMode):
        return SubscriptionDataSource(f'data.quantpedia.com/backtesting_data/equity/fama_french/{config.Symbol.Value.lower()}.csv', SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)

    def Reader(self, config, line, date, isLiveMode):
        data = QuantpediaFamaFrench()
        data.Symbol = config.Symbol
        
        if not line[0].isdigit():
            return None
        
        split = line.split(';')
        
        data.Time = datetime.strptime(split[0], "%d.%m.%Y") + timedelta(days=1)
        data['market'] = float(split[1])
        data['size'] = float(split[2])
        data['value'] = float(split[3])
        data['profitability'] = float(split[4])
        data['investment'] = float(split[5])

        return data

class QuantpediaFamaFrenchEquity(PythonData):
    def GetSource(self, config, date, isLiveMode):
        return SubscriptionDataSource(f'data.quantpedia.com/backtesting_data/equity/fama_french/{config.Symbol.Value.lower()}.csv', SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)

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

        return data

# custom fee model
class CustomFeeModel:
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))
# https://quantpedia.com/strategies/mean-variance-factor-timing/
#
# The investment universe consists of all AMEX, NYSE, and NASDAQ-listed U.S. stocks. The data come from Kenneth French’s website. Create factor portfolios based on five factors: 
# size, value, momentum, investment, and profitability.
# Using the Markowitz model, construct a long-short efficient portfolio maximizing the Sharpe ratio. Each month run out-of-sample estimation using previous 60-month data.
#
# QC Implementation changes:

#region imports
from AlgorithmImports import *
from scipy.optimize import minimize
import data_tools
#endregion

class MeanVarianceFactorTiming(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100000)
        
        self.period:int = 60 * 21

        # warm up fama french values for idiosyncratic volatility
        self.SetWarmup(self.period, Resolution.Daily)

        self.data:dict = {}
        
        self.fama_french_symbol:Symbol = self.AddData(data_tools.QuantpediaFamaFrench, 'fama_french_5_factor', Resolution.Daily).Symbol
        self.ff_factor_names:list[str] = ['market', 'size', 'value', 'profitability', 'investment']

        # ff performance data
        self.fama_french_data:dict = { ff_factor_name : RollingWindow[float](self.period) for ff_factor_name in self.ff_factor_names }
        
        # ff traded symbols
        for factor_name in self.ff_factor_names:
            data:Security = self.AddData(data_tools.QuantpediaFamaFrenchEquity, f'fama_french_5_{factor_name}_eq', Resolution.Daily)
            data.SetLeverage(3)
            data.SetFeeModel(data_tools.CustomFeeModel())

        self.recent_month:int = -1
        self.settings.minimum_order_margin_portfolio_percentage = 0.

    def OnData(self, data):
        # update fama french values on daily basis
        if self.fama_french_symbol in data and data[self.fama_french_symbol]:
            for ff_factor_name in self.ff_factor_names:
                self.fama_french_data[ff_factor_name].Add(data[self.fama_french_symbol].GetProperty(ff_factor_name))
        
        if self.recent_month == self.Time.month:
            return
        self.recent_month = self.Time.month

        # optimization
        if all(x[1].IsReady for x in self.fama_french_data.items()):
            perf_df:pd.DataFrame = pd.DataFrame(columns=self.ff_factor_names)
            for ff_factor_name in self.ff_factor_names:
                perf_df[ff_factor_name] = np.array([x for x in self.fama_french_data[ff_factor_name]][::-1])

            opt, weights = self.optimization_method(perf_df)
            for ff_factor_symbol, w in weights.items():
                traded_symbol:str = f'fama_french_5_{ff_factor_symbol}_eq'
                if abs(w) > 0.001:
                    self.SetHoldings(traded_symbol, w)
                else:
                    self.Liquidate(traded_symbol)
        
    def optimization_method(self, returns:pd.DataFrame):
        '''Maximize sharpe ratio method'''
        # objective function
        fun = lambda weights: - np.sum(returns.mean() * weights) * 252 / np.sqrt(np.dot(weights.T, np.dot(returns.cov() * 252, weights)))

        # Constraint #1: The weights can be negative, which means investors can short a security.
        constraints = [{'type': 'eq', 'fun': lambda w: 1 - np.sum(w)}]

        size = returns.columns.size
        x0 = np.array(size * [1. / size])
        # bounds = tuple((self.minimum_weight, self.maximum_weight) for x in range(size))
        bounds = tuple((0, 1) for x in range(size))

        opt = minimize(fun,                         # Objective function
                       x0,                          # Initial guess
                       method='SLSQP',              # Optimization method:  Sequential Least SQuares Programming
                       bounds = bounds,             # Bounds for variables 
                       constraints = constraints)   # Constraints definition

        return opt, pd.Series(opt['x'], index = returns.columns)