Overall Statistics
Total Orders
1398
Average Win
0.86%
Average Loss
-0.66%
Compounding Annual Return
21.524%
Drawdown
37.500%
Expectancy
0.409
Start Equity
10000000
End Equity
68651048.29
Net Profit
586.510%
Sharpe Ratio
0.764
Sortino Ratio
0.826
Probabilistic Sharpe Ratio
24.146%
Loss Rate
39%
Win Rate
61%
Profit-Loss Ratio
1.30
Alpha
0.055
Beta
1.09
Annual Standard Deviation
0.187
Annual Variance
0.035
Information Ratio
0.639
Tracking Error
0.098
Treynor Ratio
0.131
Total Fees
$241133.24
Estimated Strategy Capacity
$420000000.00
Lowest Capacity Asset
HD R735QTJ8XC9X
Portfolio Turnover
4.33%
# region imports
from AlgorithmImports import *
# endregion


class MarketCapFactor:

    def __init__(self, security):
        self._security = security

    @property
    def value(self):
        return self._security.fundamentals.market_cap


class SortinoFactor:

    def __init__(self, algorithm, symbol, lookback):
        self._sortino = algorithm.sortino(symbol, lookback, resolution=Resolution.DAILY)

    @property
    def value(self):
        return self._sortino.current.value


class CorrFactor:

    def __init__(self, algorithm, symbol, reference, lookback):
        self._c = algorithm.c(symbol, reference, lookback, correlation_type=CorrelationType.Pearson, resolution=Resolution.DAILY)

    @property
    def value(self):
        return 1 - abs(self._c.current.value)
    
    
class ROCFactor:

    def __init__(self, algorithm, symbol, lookback):
        self._roc = algorithm.roc(symbol, lookback, resolution=Resolution.DAILY)

    @property
    def value(self):
        return self._roc.current.value
# region imports
from AlgorithmImports import *

from scipy import optimize
from scipy.optimize import Bounds
from factors import *
# endregion


class FactorWeightOptimizationAlgorithm(QCAlgorithm):

    def initialize(self):
        self.set_start_date(2014, 12, 31)
        self.set_cash(10_000_000)
        self.settings.automatic_indicator_warm_up = True
        spy = Symbol.create('SPY', SecurityType.EQUITY, Market.USA)
        # Add a universe of hourly data.
        self.universe_settings.resolution = Resolution.HOUR
        universe_size = self.get_parameter('universe_size', 20)
        self._universe = self.add_universe(self.universe.etf(spy, universe_filter_func=lambda constituents: [c.symbol for c in sorted([c for c in constituents if c.weight], key=lambda c: c.weight)[-universe_size:]]))
        self._lookback = self.get_parameter('lookback', 21) # Set a 21-day trading lookback.
        # Create a Schedule Event to rebalance the portfolio.
        self.schedule.on(self.date_rules.month_start(spy), self.time_rules.after_market_open(spy, 31), self._rebalance)

    def on_securities_changed(self, changes):
        for security in changes.added_securities: # Create factors for assets that enter the universe.
            security.factors = [MarketCapFactor(security), SortinoFactor(self, security.symbol, self._lookback)]

    def _rebalance(self):
        # Get raw factor values of the universe constituents.
        factors_df = pd.DataFrame()
        for symbol in self._universe.selected:
            for i, factors in enumerate(self.securities[symbol].factors):
                factors_df.loc[symbol, i] = factors.value
        # Calculate the factor z-scores.
        factor_zscores = (factors_df - factors_df.mean()) / factors_df.std()
        # Run an optimization to find optimal factor weights. Objective: Maximize trailing return. Initial guess: Equal-weighted.
        trailing_return = self.history(list(self._universe.selected), self._lookback, Resolution.DAILY).close.unstack(0).pct_change(self._lookback-1).iloc[-1]
        num_factors = factors_df.shape[1]
        factor_weights = optimize.minimize(lambda weights: -(np.dot(factor_zscores, weights) * trailing_return).sum(), x0=np.array([1.0/num_factors] * num_factors), method='Nelder-Mead', bounds=Bounds([0] * num_factors, [1] * num_factors), options={'maxiter': 10}).x
        # Calculate the portfolio weights. Ensure the portfolio is long-only with 100% exposure, then rebalance the portfolio.
        portfolio_weights = (factor_zscores * factor_weights).sum(axis=1)
        portfolio_weights = portfolio_weights[portfolio_weights > 0]
        self.set_holdings([PortfolioTarget(symbol, weight/portfolio_weights.sum()) for symbol, weight in portfolio_weights.items()], True)