Overall Statistics
Total Trades
2188
Average Win
0.07%
Average Loss
-0.09%
Compounding Annual Return
5.062%
Drawdown
44.700%
Expectancy
0.033
Net Profit
5.461%
Sharpe Ratio
0.314
Probabilistic Sharpe Ratio
21.114%
Loss Rate
40%
Win Rate
60%
Profit-Loss Ratio
0.72
Alpha
0.164
Beta
-0.21
Annual Standard Deviation
0.381
Annual Variance
0.145
Information Ratio
-0.172
Tracking Error
0.518
Treynor Ratio
-0.571
Total Fees
$2195.86
from datetime import timedelta

from QuantConnect import *
from QuantConnect.Algorithm import *
from QuantConnect.Algorithm.Framework.Alphas import *
from QuantConnect.Algorithm.Framework.Execution import *
from QuantConnect.Algorithm.Framework.Portfolio import *
from QuantConnect.Algorithm.Framework.Selection import *
from QuantConnect.Data.UniverseSelection import *

from holdmonths import HoldMonthsTracker
from pricehist import PriceHistoryManager
from qcconsts import *
from qcselectors import *

"""
Algorithm selects for high ebit/ev and low variance in the coarse selection phase.
Then it selects for highest returns over the previous 11 months
"""


def has_all_fundamentals(fine_data):
    return (ev_ebit.has_ebit_ev_fundamentals(fine_data)
            and fine_data.MarketCap
            and fine_data.FinancialStatements.BalanceSheet.CurrentLiabilities
            and fine_data.OperationRatios.AssetsTurnover)


def compute_returns(history):
    return history[-1]/history[0] - 1


def log_symbols(algorithm, prefix, symbols):
    algorithm.Log(prefix + ' '.join(map(lambda s: s.Value, symbols)))


class ValueWithDownFlatMomentum(QCAlgorithm):

    def __init__(self):
        # Number of stocks to select from the alpha model
        super().__init__()
        self._alpha_target_count = 20
        self._hold_months = HoldMonthsTracker(3)
        self._p_coarse_vol = 0.5
        self._p_coarse_price = 0.7
        self._price_history_manager = PriceHistoryManager(int(BARS_PER_YEAR / 4))
        self._market_cap_percentile = 50

    def Initialize(self):
        self.SetCash(100000)
        self.SetStartDate(2020, 1, 1)
        # If not specified, the backtesting EndDate would be today
        self.UniverseSettings.Resolution = Resolution.Daily
        # self.SetEndDate(2015, 7, 1)
        self.AddUniverseSelection(FineFundamentalUniverseSelectionModel(self.select_coarse, self.select_fine))
        self.AddAlpha(LongShortValueDownFlatAlphaModel(self._alpha_target_count, self._hold_months.clone()))
        self.SetPortfolioConstruction(EqualWeightingPortfolioConstructionModel(rebalance=None))
        self.SetExecution(VolumeWeightedAveragePriceExecutionModel())
        # self.SetExecution(StandardDeviationExecutionModel(deviations=1, period=5, resolution=Resolution.Daily))

    @property
    def close_histories(self):
        return self._price_history_manager.close_histories

    def select_by_variance(self, algorithm):
        close_variances = {}
        for sym, closes in self.close_histories.items():
            variance = closes.diff().var()
            if m.isfinite(variance):
                close_variances[sym] = variance
            else:
                algorithm.Log(f'Dropping {sym} which has variance {variance}')
        sorted_by_var = sorted(close_variances.items(), key=lambda kv: kv[1])
        head_tail_size = int(len(sorted_by_var) / 4)
        medium_var = sorted_by_var[head_tail_size:-head_tail_size]
        algorithm.Log('Dropping highest and lowest {} by variance. Min/max: {}/{}'
                      .format(head_tail_size, medium_var[0][1], medium_var[-1][1]))
        return [s[0] for s in medium_var]

    def select_coarse(self, coarse):
        if not self._hold_months.should_trade(self.Time.month):
            return Universe.Unchanged
        self.Log('Running coarse selection')
        with_fundamental = [x for x in coarse if x.HasFundamentalData]
        by_vol = sort_and_select_subset(self, with_fundamental, lambda s: s.DollarVolume,
                                        reverse=True, proportion=self._p_coarse_vol, value_name='Volume')
        by_price = sort_and_select_subset(self, with_fundamental, lambda s: s.Price,
                                          reverse=True, proportion=self._p_coarse_price, value_name='Price')
        symbols_by_pv = [s.Symbol for s in (set(by_vol).intersection(by_price))]
        self._price_history_manager.update_close_histories(self, symbols_by_pv)
        return self.select_by_variance(self)

    def select_fine(self, fine):
        if not self._hold_months.should_trade_and_set(self.Time.month):
            return Universe.Unchanged
        self.Log('Running fine selection')
        with_fundamentals = [f for f in fine if has_all_fundamentals(f) and is_tradable(f)]
        with_cap = select_by_market_cap(self, with_fundamentals, self._market_cap_percentile)
        return [f.Symbol for f in with_cap]


class LongShortValueDownFlatAlphaModel(AlphaModel):

    def __init__(self, _alpha_max_count, _hold_months):
        super().__init__()
        self._hold_months = _hold_months
        self._max_long_count = _alpha_max_count
        self._max_short_count = _alpha_max_count
        self._candidate_factor = 4
        # Longs and shorts will be set by OnSecuritiesChanged, and will be used in
        # Update. The latter will not do any data retrieval.
        self._longs = []
        self._shorts = []
        self._price_history_manager = PriceHistoryManager(BARS_PER_YEAR)
        self._universe = set([])

    def split_long_short_candidates(self):
        ordered_candidates = candidates_ordered_by_combined_rank(
            rank_by_long_term_debt_to_equity(self._universe),
            rank_by_ebit_ev(self._universe))
        long_candidates_count = self._max_long_count * self._candidate_factor
        short_candidates_count = self._max_short_count * self._candidate_factor
        if long_candidates_count + short_candidates_count > len(ordered_candidates):
            long_candidates_count = int(len(ordered_candidates) / 2)
            short_candidates_count = len(ordered_candidates) - long_candidates_count
        return ordered_candidates[:long_candidates_count], list(reversed(ordered_candidates[-short_candidates_count:]))

    def order_candidates(self, candidates):
        down_phase, flat_phase = self._price_history_manager.split_at_ratio(0.75, candidates)
        down_ranks = (pd.Series({s: compute_returns(h) for s, h in down_phase.items()})
                      .rank(ascending=True)
                      .sort_index())
        flat_ranks = (pd.Series({s1: compute_returns(h1) for s1, h1 in flat_phase.items()})
                      .abs()
                      .rank(ascending=True)
                      .sort_index())
        aggregate_rank = pd.DataFrame({'down': down_ranks, 'flat': flat_ranks}).max(1)
        return aggregate_rank.sort_values().index

    def OnSecuritiesChanged(self, algorithm, changes):
        added = set(changes.AddedSecurities)
        removed = set(changes.RemovedSecurities)
        self._universe = self._universe.union(added).difference(removed)
        algorithm.Log(f'Updating securities in alpha model.'
                      f' Added: {len(added)}. Removed: {len(removed)}. Universe: {len(self._universe)}')
        algorithm.Log(f'Active securities: {algorithm.ActiveSecurities.Count}')
        long_candidates, short_candidates = self.split_long_short_candidates()
        self._price_history_manager.update_close_histories(algorithm, long_candidates + short_candidates)
        self._longs = self.order_candidates(long_candidates)[:self._max_long_count]
        # Reversing the list that's sorted by max of down-rank and flat-rank is
        # not the same as taking the highest min values of the down-rank and the
        # flat rank. We will need to implement long and short computation better.
        self._shorts = list(reversed(self.order_candidates(short_candidates)))[:self._max_short_count]

    def Update(self, algorithm, data):
        if not self._hold_months.should_trade_and_set(algorithm.Time.month):
            return []
        insights = []
        # Months have different numbers of days, so this will be approximate
        hold_duration = timedelta(BARS_PER_MONTH * self._hold_months.get - 1)
        for kvp in algorithm.Portfolio:
            holding = kvp.Value
            symbol = holding.Symbol
            if holding.Invested and symbol not in self._longs and symbol not in self._shorts:
                insights.append(Insight.Price(symbol, hold_duration, InsightDirection.Flat))
        log_symbols(algorithm, "Long positions selected: ", self._longs)
        for symbol in self._longs:
            insights.append(Insight.Price(symbol, hold_duration, InsightDirection.Up))
        # For now, just focus on long positions
        # log_symbols(algorithm, "Short positions selected: ", self._longs)
        # for symbol in self._shorts:
        #     insights.append(Insight.Price(symbol, hold_duration, InsightDirection.Down))
        return insights