Overall Statistics
Total Orders
515
Average Win
0.30%
Average Loss
-0.15%
Compounding Annual Return
3.724%
Drawdown
18.100%
Expectancy
1.042
Start Equity
100000
End Equity
172072.65
Net Profit
72.073%
Sharpe Ratio
0.19
Sortino Ratio
0.181
Probabilistic Sharpe Ratio
1.012%
Loss Rate
32%
Win Rate
68%
Profit-Loss Ratio
2.01
Alpha
-0.009
Beta
0.224
Annual Standard Deviation
0.054
Annual Variance
0.003
Information Ratio
-0.647
Tracking Error
0.119
Treynor Ratio
0.046
Total Fees
$108.73
Estimated Strategy Capacity
$0
Lowest Capacity Asset
FAMA_FRENCH_5_MARKET_EQ.QuantpediaFamaFrenchEquity 2S
Portfolio Turnover
0.31%
from AlgorithmImports import *

class LastDateHandler():
    _last_update_date:Dict[Symbol, datetime.date] = {}

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

# 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])

        if config.Symbol not in LastDateHandler._last_update_date:
            LastDateHandler._last_update_date[config.Symbol] = datetime(1,1,1).date()
        if data.Time.date() > LastDateHandler._last_update_date[config.Symbol]:
            LastDateHandler._last_update_date[config.Symbol] = data.Time.date()

        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])

        if config.Symbol.Value not in LastDateHandler._last_update_date:
            LastDateHandler._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()
        if data.Time.date() > LastDateHandler._last_update_date[config.Symbol.Value]:
            LastDateHandler._last_update_date[config.Symbol.Value] = data.Time.date()

        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):
        # Check if custom data is still coming.
        if any(
            [
            self.securities[x].get_last_data() and self.time.date() > data_tools.LastDateHandler.get_last_update_date()[x] 
            for x in [(f'fama_french_5_{factor_name}_eq').upper() for factor_name in self.ff_factor_names] + [self.fama_french_symbol]
            ]
        ):
            self.liquidate()
            return

        # 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)