Overall Statistics
Total Orders
979
Average Win
1.88%
Average Loss
-1.48%
Compounding Annual Return
14.489%
Drawdown
41.500%
Expectancy
0.246
Start Equity
1000000
End Equity
4691984.64
Net Profit
369.198%
Sharpe Ratio
0.434
Sortino Ratio
0.472
Probabilistic Sharpe Ratio
2.369%
Loss Rate
45%
Win Rate
55%
Profit-Loss Ratio
1.27
Alpha
0.023
Beta
1
Annual Standard Deviation
0.239
Annual Variance
0.057
Information Ratio
0.124
Tracking Error
0.189
Treynor Ratio
0.104
Total Fees
$23653.52
Estimated Strategy Capacity
$210000000.00
Lowest Capacity Asset
UNP R735QTJ8XC9X
Portfolio Turnover
4.42%
Drawdown Recovery
799
# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
# Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from AlgorithmImports import *
from scipy.optimize import minimize

### <summary>
### Provides an implementation of a portfolio optimizer that maximizes the portfolio Sharpe Ratio.
### The interval of weights in optimization method can be changed based on the long-short algorithm.
### The default model uses flat risk free rate and weight for an individual security range from -1 to 1.'''
### </summary>
class MaxSharpeRatioPortfolioOptimizer:
    '''Provides an implementation of a portfolio optimizer that maximizes the portfolio Sharpe Ratio.
   The interval of weights in optimization method can be changed based on the long-short algorithm.
   The default model uses flat risk free rate and weight for an individual security range from -1 to 1.'''
    def __init__(self,
                 minimum_weight = -1,
                 maximum_weight = 1,
                 risk_free_rate = 0):
        '''Initialize the MaxSharpeRatioPortfolioOptimizer
        Args:
            minimum_weight(float): The lower bounds on portfolio weights
            maximum_weight(float): The upper bounds on portfolio weights
            risk_free_rate(float): The risk free rate'''
        self.minimum_weight = minimum_weight
        self.maximum_weight = maximum_weight
        self.risk_free_rate = risk_free_rate
        self.expected_returns = []

    def optimize(self, historical_returns, expected_returns = None, covariance = None):
        '''
        Perform portfolio optimization for a provided matrix of historical returns and an array of expected returns
        args:
            historical_returns: Matrix of annualized historical returns where each column represents a security and each row returns for the given date/time (size: K x N).
            expected_returns: Array of double with the portfolio annualized expected returns (size: K x 1).
            covariance: Multi-dimensional array of double with the portfolio covariance of annualized returns (size: K x K).
        Returns:
            Array of double with the portfolio weights (size: K x 1)
        '''
        if covariance is None:
            covariance = historical_returns.cov()
        if expected_returns is None:
            expected_returns = historical_returns.mean()
        expected_returns = expected_returns - self.risk_free_rate

        size = covariance.columns.size   # K x 1
        x0 = np.array(size * [1. / size])

        # Direct Sharpe Ratio Maximization via SLSQP.
        # The Charnes-Cooper substitution (min variance s.t. (µ-rf)^T w = 1) is only
        # valid when NO per-weight inequality bounds exist. With bounds (e.g. long-only)
        # the scaling variable kappa couples into the bound constraints, making the
        # reformulation non-convex and incorrect. See: Tütüncü (2003) §5.2
        # https://www.ie.bilkent.edu.tr/~mustafap/courses/OIF.pdf and
        # https://quant.stackexchange.com/questions/18521/sharpe-maximization-under-quadratic-constraints
        # SLSQP handles the fractional (non-linear) objective directly without substitution.
        constraints = [
            # Σw = 1
            {'type': 'eq', 'fun': lambda weights: self.get_budget_constraint(weights)}]

        opt = minimize(lambda weights: -expected_returns.dot(weights) / np.sqrt(self.portfolio_variance(weights, covariance)),
                       x0,                                                        # Initial guess
                       bounds = self.get_boundary_conditions(size),               # Bounds for variables: lw ≤ w ≤ up
                       constraints = constraints,                                 # Constraints definition
                       method='SLSQP')        # Optimization method:  Sequential Least SQuares Programming

        return opt['x'] if opt['success'] else x0

    def portfolio_variance(self, weights, covariance):
        '''Computes the portfolio variance
        Args:
            weighs: Portfolio weights
            covariance: Covariance matrix of historical returns'''
        variance = np.dot(weights.T, np.dot(covariance, weights))
        if variance == 0 and np.any(weights):
            # variance can't be zero, with non zero weights
            raise ValueError(f'MaxSharpeRatioPortfolioOptimizer.portfolio_variance: Volatility cannot be zero. Weights: {weights}')
        return variance

    def get_boundary_conditions(self, size):
        '''Creates the boundary condition for the portfolio weights'''
        return tuple((self.minimum_weight, self.maximum_weight) for x in range(size))

    def get_budget_constraint(self, weights):
        '''Defines a budget constraint: the sum of the weights equals unity'''
        return np.sum(weights) - 1
# region imports
from AlgorithmImports import *
from MaxSharpeRatioPortfolioOptimizer import MaxSharpeRatioPortfolioOptimizer
# endregion


class LazyPricesStrategy(QCAlgorithm):

    def initialize(self):
        self.set_start_date(2015, 1, 1)
        self.set_end_date(2026, 6, 1)
        self.set_cash(1_000_000)
        self.settings.seed_initial_prices = True
        self.settings.min_absolute_portfolio_target_percentage = 0.0
        self._optimizer = SharpePortfolioOptimizer()
        self.universe_settings.resolution = Resolution.DAILY
        self._universe = self.add_universe(lambda fundamental:
            [f.symbol for f in sorted([f for f in fundamental if f.has_fundamental_data], key=lambda f: f.dollar_volume)[-100:]]
        )

    def on_warmup_finished(self):
        # Rebalance on the first trading day of each quarter at 8 AM.
        time_rule = self.time_rules.at(8, 0)
        self.schedule.on(self.date_rules.month_start("SPY"), time_rule, self._rebalance)
        # Rebalance today too.
        if self.live_mode:
            self._rebalance()
        else:
            self.schedule.on(self.date_rules.today, time_rule, self._rebalance)

    def on_securities_changed(self, changes):
        for security in changes.added_securities:
            security.brain_10K = self.add_data(BrainCompanyFilingLanguageMetrics10K, security.symbol, Resolution.DAILY).symbol
            security.brain_10Q = self.add_data(BrainCompanyFilingLanguageMetricsAll, security.symbol, Resolution.DAILY).symbol

    def _rebalance(self):
        if self.is_warming_up or not self._universe.selected:
            return
        similarity_by_symbol = {}
        for symbol in self._universe.selected:
            security = self.securities[symbol]
            # Fetch 10K and 10Q filings over 500 days and merge them sorted by date to find the most recent similarity score.
            history_10k = list(self.history[BrainCompanyFilingLanguageMetrics10K](security.brain_10K, timedelta(days=500), Resolution.DAILY))
            history_10q = list(self.history[BrainCompanyFilingLanguageMetricsAll](security.brain_10Q, timedelta(days=500), Resolution.DAILY))
            score = None
            # Search for the most recent valid similarity score, preferring risk factors statement over full report sentiment.
            for point in sorted(history_10k + history_10q, key=lambda p: p.end_time):
                if point.risk_factors_statement_sentiment.similarity.all is not None:
                    score = float(point.risk_factors_statement_sentiment.similarity.all)
                elif point.report_sentiment.similarity.all is not None:
                    score = float(point.report_sentiment.similarity.all)
                if score is not None:
                    break
            if score is not None:
                similarity_by_symbol[symbol] = score
        # Rank by similarity ascending: low scores (textual divergence) signal shorts, high scores (convergence) signal longs.
        ranked = sorted(similarity_by_symbol, key=similarity_by_symbol.get)
        weight_by_symbol = self._optimizer.get_weights(self, ranked[-10:])
        if not weight_by_symbol:
            return
        targets = [PortfolioTarget(symbol, weight) for symbol, weight in weight_by_symbol.items() if self.securities[security].price]
        self.set_holdings(targets, True)


class SharpePortfolioOptimizer:

    def __init__(self, period: int = 252):
        self._period = period
        self._optimizer = MaxSharpeRatioPortfolioOptimizer(0, 1)

    def get_weights(self, algorithm: QCAlgorithm, symbols: list) -> dict:
        history = algorithm.history(symbols, self._period + 1, Resolution.DAILY)
        if history.empty:
            return {}

        returns = history["close"].unstack(level=0).pct_change().dropna()
        if returns.empty or returns.shape[1] < 2:
            return {}

        # Maximize the portfolio Sharpe ratio using the long-only optimizer.
        raw_weights = self._optimizer.optimize(returns)
        return {symbol: float(raw_weights[i]) for i, symbol in enumerate(returns.columns)}