DEV Community

Kakembo Muhammed
Kakembo Muhammed

Posted on

How to Build a Simple Momentum Stock Trading Workflow With Data

Trend Ribbon Tracer indicator - tradiecapital
Momentum is the rare market anomaly with both a 30-year academic paper trail and a structure simple enough to implement in an afternoon of Python. Stocks that outperformed over the past 3 to 12 months tend to keep outperforming, to the tune of roughly 1% per month in the classic studies. The catch is that retail traders mostly lose money on it anyway, because they trade the vibe instead of the data.

This post builds the data version: a momentum screen, an entry rule, position sizing, and a quick validation pass, all in pandas. No trading experience assumed, no financial advice given. We are engineering a workflow and checking whether it holds up.

The Architecture

Four components, cleanly separated:

Universe → Screen → Entry logic → Risk sizing → Validation
Enter fullscreen mode Exit fullscreen mode

Keeping them separate matters for the same reason it matters in any pipeline: you want to test and swap each stage independently. Most retail "strategies" fail because all four stages live inside one emotional decision made at 3:47 PM.

Step 1: Screening for Momentum

The screen encodes what decades of research say a momentum leader looks like: near its 52-week high, outperforming the index, in a structural uptrend, and liquid enough to trade.

import yfinance as yf
import pandas as pd
import numpy as np

TICKERS = ["AAPL", "MSFT", "NVDA", "AMZN", "META", "GOOGL", "AVGO",
           "TSLA", "LLY", "JPM", "V", "COST", "NFLX", "AMD", "CRM"]

def get_data(tickers, period="2y"):
    df = yf.download(tickers + ["SPY"], period=period, auto_adjust=True)
    return df["Close"]

def momentum_screen(close: pd.DataFrame) -> pd.DataFrame:
    spy = close["SPY"]
    rows = []
    for t in close.columns.drop("SPY"):
        s = close[t].dropna()
        if len(s) < 260:
            continue
        pct_off_high = s.iloc[-1] / s.rolling(252).max().iloc[-1] - 1
        rs_3m = (s.iloc[-1] / s.iloc[-63]) / (spy.iloc[-1] / spy.iloc[-63])
        rs_6m = (s.iloc[-1] / s.iloc[-126]) / (spy.iloc[-1] / spy.iloc[-126])
        sma50 = s.rolling(50).mean().iloc[-1]
        sma200 = s.rolling(200).mean().iloc[-1]
        rows.append({
            "ticker": t,
            "pct_off_52w_high": round(pct_off_high, 3),
            "rs_3m": round(rs_3m, 3),
            "rs_6m": round(rs_6m, 3),
            "trend_ok": sma50 > sma200,
        })
    df = pd.DataFrame(rows)
    passed = df[
        (df.pct_off_52w_high > -0.10)   # within 10% of 52w high
        & (df.rs_3m > 1.0)              # beating SPY, 3 months
        & (df.rs_6m > 1.0)              # beating SPY, 6 months
        & (df.trend_ok)                 # 50 SMA above 200 SMA
    ]
    return passed.sort_values("rs_6m", ascending=False)

close = get_data(TICKERS)
watchlist = momentum_screen(close)
print(watchlist)
Enter fullscreen mode Exit fullscreen mode

Run this on the full S&P 500 or Russell 1000 and a 5,000-name universe collapses to a ranked watchlist. The relative strength ratio (rs_3m > 1.0 means the stock beat SPY over 63 trading days) is the workhorse column. Everything else is a sanity filter.

One data honesty note: auto_adjust=True matters. Unadjusted prices show fake crashes at every stock split, and your screen will happily "detect" them.

Step 2: The Entry Rule (Pullback, Not Chase)

The counterintuitive part of momentum, and the part the data supports: you do not buy the stock on the day it is up 8% and trending on social media. Extended entries force wide stops and wreck the risk-reward math. The higher-quality signal is a pullback to a rising 20-day EMA within an established uptrend:

def pullback_signal(s: pd.Series) -> bool:
    ema20 = s.ewm(span=20).mean()
    touched = (s.iloc[-5:] <= ema20.iloc[-5:] * 1.01).any()  # tagged the EMA
    reclaimed = s.iloc[-1] > s.iloc[-2]                       # strength returning
    rising = ema20.iloc[-1] > ema20.iloc[-21]                 # EMA still rising
    return bool(touched and reclaimed and rising)

signals = {t: pullback_signal(close[t].dropna())
           for t in watchlist.ticker}
print({t: v for t, v in signals.items() if v})
Enter fullscreen mode Exit fullscreen mode

Plus one portfolio-level guard, the regime filter. It is a single boolean and it does more risk management than everything else combined:

spy = close["SPY"]
risk_on = spy.iloc[-1] > spy.rolling(200).mean().iloc[-1]
Enter fullscreen mode Exit fullscreen mode

When risk_on is False, the workflow stops taking entries. Momentum's known failure mode is violent crashes at market turning points, and the 200-day filter on the index is the cheapest defense ever discovered.

Step 3: Sizing by Risk, Not by Feelings

Position size is a formula: account risk divided by stop distance. Stops are volatility-scaled using ATR so that a calm mega cap and a volatile mid cap each get proportional room.

def position_size(account: float, risk_pct: float,
                  entry: float, atr: float, atr_mult: float = 2.0):
    stop = entry - atr_mult * atr
    risk_per_share = entry - stop
    shares = int((account * risk_pct) / risk_per_share)
    return shares, round(stop, 2)

def atr(high, low, close, n=14):
    tr = pd.concat([
        high - low,
        (high - close.shift()).abs(),
        (low - close.shift()).abs()
    ], axis=1).max(axis=1)
    return tr.rolling(n).mean()

# Example: $50k account, 1% risk, entry $150, ATR $4
shares, stop = position_size(50_000, 0.01, 150, 4)
print(f"Buy {shares} shares, stop at ${stop}")
# Buy 62 shares, stop at $142.0
Enter fullscreen mode Exit fullscreen mode

That is a $500 maximum loss on a $50,000 account, regardless of how confident anyone feels about the ticker. The formula is the whole point: sizing is the one stage of the pipeline where emotion does the most damage, so it gets zero degrees of freedom.

Step 4: Validate Before You Believe

Everything above is a hypothesis. The minimum viable validation is a vectorized backtest with three honesty mechanics: execute signals on the next bar (shift(1)), subtract realistic costs (~0.1 to 0.2% per round trip), and compare against buy-and-hold as the null hypothesis. I wrote a full guide to that layer, including the look-ahead and survivorship traps that silently inflate results, in a previous post on backtesting trading strategies in Python, so I will not repeat it here.

Two expectations to calibrate against before you run anything. First, a healthy momentum system wins roughly 40 to 55% of trades and profits through asymmetry, with winners 2 to 3 times the size of losers. If your test prints a 85% win rate, hunt for the bug before celebrating. Second, losing streaks of five-plus trades are a statistical certainty at those win rates, which is exactly why you validate: so a normal streak does not look like a broken system.

For the full trading-side workflow that sits on top of this data layer, including regime filters and exit rules with the numbers behind them, this breakdown of how to trade momentum stocks covers the parts that do not fit in a code post. And if you want to iterate on rule variants faster than rewriting pandas each time, platforms in this space have gotten good; Tradie accepts strategy rules in plain English and returns backtests with Monte Carlo distributions, then exports back to Python, which makes it a reasonable rapid-prototyping layer in front of the custom code you trust.

What This Workflow Is and Is Not

It is a data pipeline that removes discretion from four decisions: what to watch, when to enter, how much to buy, and when the environment is hostile. It is not a money printer, and nothing here is financial advice. The academic momentum premium is real, but capturing it net of costs, taxes, and your own behavior is an engineering problem, and like most engineering problems, it is lost or won in the boring parts: data quality, cost modeling, and validation.

Build the screen. Run it for a month against paper trades before real ones. Let the data tell you whether the edge survives contact with your execution.

Top comments (0)