Overall Statistics
Total Orders
1613
Average Win
0.95%
Average Loss
-0.85%
Compounding Annual Return
4.341%
Drawdown
23.600%
Expectancy
0.087
Start Equity
1000000
End Equity
1624862.86
Net Profit
62.486%
Sharpe Ratio
0.088
Sortino Ratio
0.103
Probabilistic Sharpe Ratio
0.412%
Loss Rate
49%
Win Rate
51%
Profit-Loss Ratio
1.12
Alpha
0.008
Beta
0.012
Annual Standard Deviation
0.097
Annual Variance
0.009
Information Ratio
-0.411
Tracking Error
0.174
Treynor Ratio
0.709
Total Fees
$14041.32
Estimated Strategy Capacity
$220000000.00
Lowest Capacity Asset
IDPH R735QTJ8XC9X
Portfolio Turnover
1.83%
Drawdown Recovery
862
# region imports
from AlgorithmImports import *
# 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._assets_per_side = 10
        self.universe_settings.resolution = Resolution.DAILY
        # Mid- and small-cap style boxes: the Lazy Prices anomaly's habitat, away from mega-caps.
        self._style_boxes = [
            StyleBox.MID_VALUE, StyleBox.MID_CORE, StyleBox.MID_GROWTH,
            StyleBox.SMALL_VALUE, StyleBox.SMALL_CORE, StyleBox.SMALL_GROWTH
        ]
        self._universe = self.add_universe(self._fundamental_filter)

    def _fundamental_filter(self, fundamental):
        # Keep the most liquid mid/small-cap names that have fundamental data.
        eligible = [
            f for f in fundamental
            if f.has_fundamental_data and f.asset_classification.style_box in self._style_boxes
        ]
        return [f.symbol for f in sorted(eligible, 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.quarter_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))
            history = sorted(history_10k + history_10q, key=lambda p: p.end_time)[::-1]
            score = None
            # Search for the most recent valid similarity score, preferring risk factors statement over full report sentiment.
            for point in history:
                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
        if len(similarity_by_symbol) < 2 * self._assets_per_side:
            return
        # 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)
        short_symbols = ranked[:self._assets_per_side]
        long_symbols = ranked[-self._assets_per_side:]
        weight = 0.5 / self._assets_per_side
        targets = [PortfolioTarget(s, weight) for s in long_symbols] + [PortfolioTarget(s, -weight) for s in short_symbols]
        self.set_holdings(targets, True)