Skip to content
All library documents

Machine-Learning Stock Strategy with Cross-Sectional Return Labels

Article Strategy library · Author: QuantRocket

Summary

This example describes a monthly U.S. stock strategy that uses a machine-learning model to rank stocks by their predicted likelihood of outperforming the cross-sectional median. Its input features are past closing-price returns measured over several lookback windows. The training target marks whether a stock’s forward return exceeds the universe median over the specified forecast horizon. The model’s predictions are converted into long signals for the highest-ranked stocks, with equal-weight allocation among selected names.

The implementation also illustrates timing and return accounting: weights are shifted to model entering after a signal, and gross returns are calculated from consecutive opening prices using lagged positions. A per-share commission is specified. This is an instructional template rather than evidence of a profitable strategy: it supplies no performance results, validation procedure, or discussion of slippage and model choice. Its stated universe, feature set, forecast horizon, and selection count are examples that would need independent testing before deployment.

Key ideas

  • Past close-to-close returns over multiple lookback periods serve as model features.
  • Training labels identify stocks whose forward returns exceed the cross-sectional median.
  • Predicted probabilities rank stocks, and the top-ranked names receive equal weights.
  • Signal weights and positions are shifted to represent delayed entry and returns are measured between opens.
  • A per-share commission is specified, but no strategy performance evidence is provided.

Tags

Full text
# DemoMLStrategy


# DemoMLStrategy









## Source (Apache-2.0)

```python
# Copyright 2020-2024 QuantRocket LLC - All Rights Reserved
#
# 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.

import pandas as pd
from moonshot import MoonshotML
from moonshot.commission import PerShareCommission

class USStockCommission(PerShareCommission):
    BROKER_COMMISSION_PER_SHARE = 0.005

class DemoMLStrategy(MoonshotML):

    CODE = "demo-ml"
    DB = "usstock-free-1d"
    DB_FIELDS = ["Open", "Close"]
    UNIVERSES = "usstock-free"
    LOOKBACK_WINDOWS = [1,2,3,4,5,6,7,8,9,10,12,14,16,18,20,30,40,50,60,80,100,125,150,175,200]
    LOOKBACK_WINDOW = max(LOOKBACK_WINDOWS) # set lookback to longest moving average; more on lookback windows: https://www.quantrocket.com/docs/#moonshot-lookback-windows
    FORWARD_RETURNS_WINDOW = 22
    TOP_N = 3 # number of positions to hold
    COMMISSION_CLASS = USStockCommission
    REBALANCE_INTERVAL = "M" # M = monthly; see https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#offset-aliases

    def prices_to_features(self, prices):
        """
        From a DataFrame of prices, return a tuple of features and targets to be
        provided to the machine learning model.

        The returned features can be a list or dict of DataFrames, where each
        DataFrame is a feature and should have the same shape, with a Date index
        and sids as columns.

        The returned targets should be a DataFrame with an index
        matching the index of the features DataFrames. Targets are
        used in training and are ignored for prediction.
        """
        closes = prices.loc["Close"]
        opens = prices.loc["Open"]

        # FEATURES
        features = {}

        for n in self.LOOKBACK_WINDOWS:
            features[f'return_{n}'] = closes.pct_change(n)

        # TARGET
        returns = opens.pct_change(self.FORWARD_RETURNS_WINDOW)

        # Calculate median cross-sectional returns (a Series)...
        median_returns = returns.median(axis=1)
        # ...and broadcast back to shape of original DataFrame
        median_returns = closes.apply(lambda x: median_returns)

        # Find stocks which will outperfom in the future
        outperformers = returns > median_returns
        targets = outperformers.shift(-self.FORWARD_RETURNS_WINDOW).fillna(False).astype(int)

        # when Moonshot calls predict(), we want it to actually call predict_proba()
        # see https://www.quantrocket.com/docs/#ml-predict-probabilities
        if self.model:
            self.model.predict = self.model.predict_proba

        return features, targets

    def predictions_to_signals(self, predictions, prices):
        """
        From a DataFrame of predictions produced by a machine learning model,
        return a DataFrame of signals. By convention, signals should be
        1=long, 0=cash, -1=short.

        The index of predictions will match the index of the features
        DataFrames returned in prices_to_features.
        """
        # Rank by probability of outperforming
        winner_ranks = predictions.rank(axis=1, ascending=False)

        signals = winner_ranks <= self.TOP_N
        signals = signals.astype(int)

        # Resample using the rebalancing interval.
        # Keep only the last signal of the month, then fill it forward
        signals = signals.resample(self.REBALANCE_INTERVAL).last()
        signals = signals.reindex(predictions.index, method="ffill")

        return signals

    def signals_to_target_weights(self, signals: pd.DataFrame, prices: pd.DataFrame):
        """
        This method receives a DataFrame of integer signals (-1, 0, 1) and
        should return a DataFrame indicating how much capital to allocate to
        the signals, expressed as a percentage of the total capital allocated
        to the strategy (for example, -0.25, 0, 0.1 to indicate 25% short,
        cash, 10% long).
        """
        weights = self.allocate_equal_weights(signals)
        return weights

    def target_weights_to_positions(self, weights: pd.DataFrame, prices: pd.DataFrame):
        """
        This method receives a DataFrame of allocations and should return a
        DataFrame of positions. This allows for modeling the delay between
        when the signal occurs and when the position is entered, and can also
        be used to model non-fills.
        """
        # Enter the position in the period/day after the signal
        return weights.shift()

    def positions_to_gross_returns(self, positions: pd.DataFrame, prices: pd.DataFrame):
        """
        This method receives a DataFrame of positions and a DataFrame of
        prices, and should return a DataFrame of percentage returns before
        commissions and slippage.
        """
        # We'll enter on the open, so our return is today's open to
        # tomorrow's open
        opens = prices.loc["Open"]
        # The return is the security's percent change over the period,
        # multiplied by the position.
        gross_returns = opens.pct_change() * positions.shift()
        return gross_returns

```

Shown in full with attribution under the source's licence. Licence: Apache-2.0

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.