Overall Statistics
Total Orders
384
Average Win
0.33%
Average Loss
-0.51%
Compounding Annual Return
33.036%
Drawdown
14.100%
Expectancy
0.162
Start Equity
100000
End Equity
110014.67
Net Profit
10.015%
Sharpe Ratio
0.84
Sortino Ratio
0.862
Probabilistic Sharpe Ratio
48.794%
Loss Rate
29%
Win Rate
71%
Profit-Loss Ratio
0.64
Alpha
0.224
Beta
-0.487
Annual Standard Deviation
0.238
Annual Variance
0.056
Information Ratio
0.532
Tracking Error
0.281
Treynor Ratio
-0.41
Total Fees
$452.40
Estimated Strategy Capacity
$36000.00
Lowest Capacity Asset
PGY XZJVEH1WSORP
Portfolio Turnover
7.47%
Drawdown Recovery
14
from AlgorithmImports import *
from decimal import Decimal, ROUND_HALF_UP


class ConnorsCrash(QCAlgorithm):

    def initialize(self):
        self.set_start_date(2024, 9, 1)
        self.set_end_date(2024, 12, 31)
        self.set_cash(100_000)
        self._rsi_period = 3
        self._streak_period = 2
        self._pct_rank_period = 100
        self._vola_period = 100
        self._crsi_entry = 90
        self._crsi_exit = 30
        self._max_shorts = 40
        self.settings.automatic_indicator_warm_up = True
        self.settings.seed_initial_prices = True
        self.universe_settings.resolution = Resolution.DAILY
        self._spy = self.add_equity("SPY")
        # Select liquid, tradeable-priced equities for the universe.
        self._universe = self.add_universe(
            lambda fundamentals: [f.symbol for f in fundamentals if f.price > 5 and f.dollar_volume > 1e6]
        )
        self.schedule.on(
            self.date_rules.every_day(self._spy),
            self.time_rules.after_market_open(self._spy, 1),
            self._rebalance
        )

    def on_securities_changed(self, changes):
        for security in changes.added_securities:
            # Attach ConnorsRSI indicator to each security.
            security.connors = self.crsi(security, self._rsi_period, self._streak_period, self._pct_rank_period)
            # Initialize and warm up the custom volatility indicator.
            security.volatility = CustomVolatility(self._vola_period)
            for bar in self.history[TradeBar](security, self._vola_period + 1):
                security.volatility.update(bar)
            self.register_indicator(security, security.volatility)
        for security in changes.removed_securities:
            self.deregister_indicator(security.connors)
            self.deregister_indicator(security.volatility)
            self.liquidate(security)

    def _rebalance(self):
        if not self._universe.selected or not Extensions.is_market_open(self._spy, False):
            return
        securities = [self.securities[symbol] for symbol in self._universe.selected]
        securities = [s for s in securities if s.connors.is_ready and s.volatility.is_ready]
        filter_vola = [s for s in securities if s.volatility.value > 100]
        # Find currently invested short positions.
        short_positions = [s for s in securities if s.holdings.is_short]
        # Liquidate short positions when ConnorsRSI falls below exit threshold.
        for security in short_positions:
            if security.connors.current.value < self._crsi_exit:
                self.liquidate(security)
        # Exclude symbols with pending open orders.
        pending_symbols = {t.symbol for t in self.transactions.get_open_order_tickets()}
        # Find short entry candidates that are not currently invested (high volatility and high CRSI).
        short_candidates = sorted([
            s for s in filter_vola
            if (s.connors.current.value > self._crsi_entry and
            not s.holdings.invested and
            s.symbol not in pending_symbols)
        ], key=lambda s: (s.connors.current.value, s.volatility.value))
        # Set union: liquidated positions are only counted once.
        short_position_symbols = {s.symbol for s in short_positions}
        occupied = short_position_symbols | pending_symbols
        available_slots = self._max_shorts - len(occupied)
        if available_slots <= 0 or not short_candidates:
            return
        n_orders = min(len(short_candidates), available_slots)
        target_weight = -1 / self._max_shorts
        for security in short_candidates[-n_orders:]:
            quantity = int(self.calculate_order_quantity(security, target_weight))
            if quantity:
                limit_price = float((Decimal('1.03') * Decimal(str(security.price))).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))
                self.limit_order(security, quantity, limit_price)


class CustomVolatility(PythonIndicator):

    def __init__(self, period):
        super().__init__()
        self.value = 0
        self._window = RollingWindow[float](period)

    def update(self, input_: BaseData):
        # Annualized log-return volatility.
        price = input_.value
        if price <= 0:
            return self.is_ready
        self._window.add(price)
        if self._window.is_ready:
            prices = np.array(list(self._window)[::-1])
            log_diffs = np.diff(np.log(prices))
            self.value = np.std(log_diffs) * math.sqrt(252) * 100.0
        return self.is_ready

    @property
    def is_ready(self) -> bool:
        return self._window.is_ready