Overall Statistics
Total Orders
95
Average Win
0.10%
Average Loss
-0.19%
Compounding Annual Return
-6.575%
Drawdown
2.900%
Expectancy
-0.326
Start Equity
100000
End Equity
97757.79
Net Profit
-2.242%
Sharpe Ratio
-2.979
Sortino Ratio
-0.911
Probabilistic Sharpe Ratio
1.756%
Loss Rate
57%
Win Rate
43%
Profit-Loss Ratio
0.56
Alpha
-0.105
Beta
0.081
Annual Standard Deviation
0.034
Annual Variance
0.001
Information Ratio
-1.466
Tracking Error
0.103
Treynor Ratio
-1.243
Total Fees
$101.95
Estimated Strategy Capacity
$13000000.00
Lowest Capacity Asset
LRCX R735QTJ8XC9X
Portfolio Turnover
6.63%
Drawdown Recovery
0
# region imports
from AlgorithmImports import *
from sklearn.model_selection import RandomizedSearchCV, train_test_split
from sklearn.preprocessing import MinMaxScaler
import xgboost as xgb
# endregion


class ModulatedNadionReplicator(QCAlgorithm):

    def initialize(self):
        self.set_start_date(2024, 9, 1)
        self.set_end_date(2024, 12, 31)
        self.set_cash(100_000)
        self.settings.free_portfolio_value_percentage = 0.05
        self.settings.seed_initial_prices = True
        self._spy = self.add_equity("SPY", Resolution.HOUR)

        # Create universe parameters.
        self.universe_settings.resolution = Resolution.HOUR
        self.universe_settings.asynchronous = True
        date_rule = self.date_rules.month_start(self._spy)
        self.universe_settings.schedule.on(date_rule)
        self.add_universe_selection(EarningsVolumeUniverseSelectionModel(10))

        # Add the other framework models.
        self.add_alpha(XGBoostAlphaModel(self, date_rule, self._spy))
        self.set_portfolio_construction(InsightWeightingPortfolioConstructionModel())
        self.set_risk_management(BracketRiskModel(0.05, 0.15))

        # Add a warm up so the algorithm has insights on deployment day.
        self.set_warm_up(timedelta(45))


class EarningsVolumeUniverseSelectionModel(FundamentalUniverseSelectionModel):
    # Selects symbols by liquidity and nearest earnings report date.
    def __init__(self, universe_size=10):
        self._universe_size = universe_size
        super().__init__(self._select)

    def _select(self, fundamental):
        # Sort the top 30 by price and dollar volume.
        liquid = sorted(
            [f for f in fundamental if f.has_fundamental_data and 20 <= f.price <= 200 and f.dollar_volume > 5_000_000],
            key=lambda f: f.dollar_volume
        )[-30:]

        # Select symbols with nearest earnings report dates.
        selected = sorted(
            [f for f in liquid if f.earning_reports.file_date],
            key=lambda f: str(f.earning_reports.file_date)
        )[:self._universe_size]

        return [f.symbol for f in selected]


class XGBoostAlphaModel(AlphaModel):

    def __init__(self, algorithm, date_rule, spy):
        self._algorithm = algorithm
        self._universe = []
        self._insights = []
        self._feature_window = 24
        self._history_bars = 48
        self._rsi_period = 12
        self._scaler = MinMaxScaler(feature_range=(-1, 1))
        self._spy = spy

        # Create rate-of-change indicator for SPY with specified window.
        self._spy.rocp = algorithm.rocp(spy, self._feature_window)
        self._spy.rocp.window.size = self._feature_window
        algorithm.indicator_history(self._spy.rocp, spy, self._spy.rocp.period + self._spy.rocp.window.size)

        algorithm.train(date_rule, algorithm.time_rules.at(8, 0), self._train_models)
        algorithm.schedule.on(date_rule, algorithm.time_rules.after_market_open(self._spy, 30), self._create_insights)

    def _create_insights(self):
        self._insights.clear()

        # Extract predictions from all trained models.
        for security in self._universe:
            # Skip prediction if model not yet trained.
            if not (security.model and security.rocp.window.is_ready and self._spy.rocp.window.is_ready):
                continue
            features, _ = self._build_features(security)
            # Get prediction from the trained model.
            magnitude = security.model.predict(features)[-1]
            # Determine signal direction based on prediction magnitude.
            direction = InsightDirection.FLAT
            if magnitude > 0.05:
                direction = InsightDirection.UP
            elif magnitude < -0.05:
                direction = InsightDirection.DOWN
            # Generate price insights with computed weights and directions.
            self._insights.append(Insight.price(security, timedelta(1), direction, weight=0.3 * abs(magnitude)))

    def update(self, algorithm, data):
        if algorithm.is_warming_up:
            return []

        insights = self._insights.copy()
        self._insights.clear()

        return insights

    def on_securities_changed(self, algorithm, changes):
        for security in changes.added_securities:
            if security == self._spy or security in self._universe:
                continue
            # Add security and register indicators to universe.
            self._universe.append(security)
            security.model = None
            security.rocp = algorithm.rocp(security, self._feature_window)
            security.rsi = algorithm.rsi(security, self._rsi_period, MovingAverageType.SIMPLE)
            security.atr = algorithm.atr(security, self._feature_window, MovingAverageType.SIMPLE)
            # Configure window sizes and populate history for indicator and window.
            bars = algorithm.history[TradeBar](security, self._feature_window * 2)
            for indicator in [security.rocp, security.rsi, security.atr]:
                indicator.window.size = self._feature_window
                for bar in bars:
                    indicator.update(bar)

        for security in changes.removed_securities:
            if security not in self._universe:
                continue
            # Remove security and deregister indicators from universe.
            self._universe.remove(security)
            for indicator in [security.rocp, security.rsi, security.atr]:
                algorithm.deregister_indicator(indicator)

    def _build_features(self, security):
        # Compute momentum and volatility indicators and normalize all features to consistent range.
        scaled = self._scaler.fit_transform(np.hstack(
            [self._get_indicator_history(self._spy.rocp)]
            + [self._get_indicator_history(indicator) for indicator in [security.rocp, security.rsi, security.atr]]
        ))

        return scaled[:, :3], scaled[:, [3]]

    def _get_indicator_history(self, indicator):
        return np.array([x.value for x in indicator.window])[::-1].reshape(-1, 1)

    def _train_models(self):
        # Iterate through all security in the universe for model training.
        for security in self._universe:
            if not (security.rocp.window.is_ready and self._spy.rocp.window.is_ready):
                continue

            features, scaled_rocp = self._build_features(security)
            target = scaled_rocp.ravel()

            # Prepare training and validation splits from the feature matrix.
            x_train, x_valid, y_train, y_valid = train_test_split(
                features, target, test_size=0.35, random_state=42,
            )

            # Define hyperparameter grid for randomized search optimization.
            parameters = {
                'n_estimators': [100, 200, 300, 400],
                'learning_rate': [0.001, 0.005, 0.01, 0.05],
                'max_depth': [8, 10, 12, 15],
                'gamma': [0.001, 0.005, 0.01, 0.02],
                'random_state': [42]
            }
            eval_set = [(x_train, y_train), (x_valid, y_valid)]
            base_model = xgb.XGBRegressor(objective="reg:squarederror", verbosity=0)
            model = RandomizedSearchCV(
                estimator=base_model, param_distributions=parameters, n_iter=5, scoring="neg_mean_squared_error", cv=4, verbose=0,
            )

            # Execute randomized search for optimal model hyperparameters.
            model.fit(x_train, y_train, eval_set=eval_set, verbose=False)
            security.model = model


class BracketRiskModel(RiskManagementModel):

    def __init__(self, drawdown_pct, profit_pct):
        # Store drawdown and profit thresholds as percentage values.
        self._drawdown_pct = -abs(drawdown_pct)
        self._profit_pct = abs(profit_pct)

    def manage_risk(self, algorithm, targets):
        # Build list of risk-adjusted targets.
        adjusted_targets = []

        # Iterate through all securities to evaluate positions.
        for symbol, security in algorithm.securities.items():
            # Reset trailing high for non-invested securities.
            if not security.invested:
                security.trailing_high = None
            # Take profit when unrealized gains exceed the threshold.
            elif security.holdings.unrealized_profit_percent > self._profit_pct:
                adjusted_targets.append(PortfolioTarget(symbol, 0))
                algorithm.insights.cancel([symbol])
            # Initialize trailing high from the entry price.
            elif security.trailing_high is None:
                security.trailing_high = security.holdings.average_price
            # Update trailing high if a new high is reached.
            elif security.trailing_high < security.high:
                security.trailing_high = security.high
            # Exit position when drawdown from trailing high exceeds limit.
            elif (security.low / security.trailing_high) - 1 < self._drawdown_pct:
                adjusted_targets.append(PortfolioTarget(symbol, 0))
                algorithm.insights.cancel([symbol])

        return adjusted_targets