Composite Momentum Strategy Backtest

Does ranking stocks by a weighted momentum score add value over a simple trend filter?

← Back to research studies

Momentum strategies rank stocks by recent performance and buy the top picks. The theory: stocks that have been going up will keep going up. This study tests a 5-factor composite momentum ranking on US stocks over $2B market cap, rebalanced weekly from 2017 to present. The question: does the ranking itself add value, or does the simple eligibility filter do all the work?

Connect to Claude →

The question

You ask

"Backtest a composite momentum strategy on US stocks over $2B market cap: each week, rank eligible stocks by a weighted score of 60-day return, 20-day return, relative strength vs SPY, volume trend, and trend quality. Take the top 5 and measure 5-day and 21-day forward returns compared to the full eligible pool and SPY buy-and-hold."

The generated SQL

Claude produced this query automatically. It joins daily prices, 56 pre-computed technical indicators, market cap data, and index benchmarks across 14 CTEs. This is the raw output, not cleaned up or optimized.

generated SQL
WITH daily AS (
  SELECT sq.symbol, sq.date, sq.close, sq.volume,
    ti.rsi_14, ti.sma_50, ti.volume_ma_20,
    LAG(sq.close, 60) OVER (PARTITION BY sq.symbol ORDER BY sq.date) AS close_60d_ago,
    LAG(sq.close, 20) OVER (PARTITION BY sq.symbol ORDER BY sq.date) AS close_20d_ago,
    LEAD(sq.close, 5)  OVER (PARTITION BY sq.symbol ORDER BY sq.date) AS close_fwd5,
    LEAD(sq.close, 21) OVER (PARTITION BY sq.symbol ORDER BY sq.date) AS close_fwd21
  FROM shibui.stock_quotes sq
  INNER JOIN shibui.technical_indicators ti ON sq.symbol = ti.symbol AND sq.date = ti.date
  WHERE sq.date >= DATE '2017-09-01' AND sq.date <= CURRENT_DATE - INTERVAL '35 days'
),
weekly_flag AS (
  SELECT d.*, ROW_NUMBER() OVER (PARTITION BY d.symbol,
    DATE_TRUNC('week', d.date) ORDER BY d.date DESC) AS week_rn
  FROM daily d
),
weekly AS (SELECT * FROM weekly_flag WHERE week_rn = 1),
weekly_prev AS (
  SELECT w.*,
    LAG(w.sma_50) OVER (PARTITION BY w.symbol ORDER BY w.date) AS prev_week_sma50,
    LAG(w.volume_ma_20, 4) OVER (PARTITION BY w.symbol ORDER BY w.date) AS vol_ma20_4wk_ago
  FROM weekly w
),
val AS (
  SELECT symbol, date, market_cap FROM shibui.valuation WHERE date >= DATE '2017-09-01'
),
spy_daily AS (
  SELECT sq.date, sq.close AS spy_close, ti.sma_50 AS spy_sma50,
    LAG(sq.close, 60) OVER (ORDER BY sq.date) AS spy_60d_ago,
    LAG(ti.sma_50) OVER (ORDER BY sq.date) AS spy_prev_sma50,
    LEAD(sq.close, 5) OVER (ORDER BY sq.date) AS spy_fwd5,
    LEAD(sq.close, 21) OVER (ORDER BY sq.date) AS spy_fwd21
  FROM shibui.stock_quotes sq
  INNER JOIN shibui.technical_indicators ti ON sq.symbol = ti.symbol AND sq.date = ti.date
  WHERE sq.ticker = 'SPY' AND sq.date >= DATE '2017-09-01'
    AND sq.date <= CURRENT_DATE - INTERVAL '35 days'
),
combined AS (
  SELECT wp.symbol, wp.date AS week_date, wp.close, wp.volume, wp.rsi_14,
    wp.sma_50, wp.prev_week_sma50, wp.volume_ma_20, wp.vol_ma20_4wk_ago,
    wp.close_60d_ago, wp.close_20d_ago, wp.close_fwd5, wp.close_fwd21,
    v.market_cap, g.type, g.country_iso, g.gics_sub_industry,
    sd.spy_close, sd.spy_sma50, sd.spy_prev_sma50, sd.spy_60d_ago,
    sd.spy_fwd5, sd.spy_fwd21
  FROM weekly_prev wp
  INNER JOIN val v ON wp.symbol = v.symbol AND wp.date = v.date
  INNER JOIN shibui.general_info g ON wp.symbol = g.symbol
  INNER JOIN spy_daily sd ON wp.date = sd.date
),
eligible AS (
  SELECT c.*,
    (c.close - c.close_60d_ago) / NULLIF(c.close_60d_ago,0) AS ret60,
    (c.close - c.close_20d_ago) / NULLIF(c.close_20d_ago,0) AS ret20,
    ((c.close - c.close_60d_ago)/NULLIF(c.close_60d_ago,0))
      - ((c.spy_close - c.spy_60d_ago)/NULLIF(c.spy_60d_ago,0)) AS rs_vs_spy,
    (c.volume_ma_20 - c.vol_ma20_4wk_ago)
      / NULLIF(c.vol_ma20_4wk_ago,0) AS vol_trend,
    (c.close - c.sma_50) / NULLIF(c.sma_50,0) AS trend_quality
  FROM combined c
  WHERE c.type = 'Common Stock' AND c.country_iso = 'US'
    AND (c.gics_sub_industry NOT ILIKE '%Biotechnology%'
         OR c.gics_sub_industry IS NULL)
    AND c.market_cap > 2e9 AND c.close > 10 AND c.volume_ma_20 > 2e6
    AND c.close > c.sma_50 AND c.sma_50 > c.prev_week_sma50
    AND c.rsi_14 BETWEEN 50 AND 68 AND c.volume > c.volume_ma_20
    AND c.spy_close > c.spy_sma50 AND c.spy_sma50 > c.spy_prev_sma50
),
ranked AS (
  SELECT e.*,
    PERCENT_RANK() OVER (PARTITION BY e.week_date ORDER BY e.ret60) AS pr_ret60,
    PERCENT_RANK() OVER (PARTITION BY e.week_date ORDER BY e.ret20) AS pr_ret20,
    PERCENT_RANK() OVER (PARTITION BY e.week_date ORDER BY e.rs_vs_spy) AS pr_rs,
    PERCENT_RANK() OVER (PARTITION BY e.week_date ORDER BY e.vol_trend) AS pr_voltrend,
    PERCENT_RANK() OVER (PARTITION BY e.week_date ORDER BY e.trend_quality) AS pr_trendq
  FROM eligible e
  WHERE e.ret60 IS NOT NULL AND e.ret20 IS NOT NULL
    AND e.rs_vs_spy IS NOT NULL AND e.vol_trend IS NOT NULL
    AND e.trend_quality IS NOT NULL
),
scored AS (
  SELECT r.*,
    (0.30*r.pr_ret60 + 0.25*r.pr_ret20 + 0.20*r.pr_rs
     + 0.15*r.pr_voltrend + 0.10*r.pr_trendq) AS composite_score
  FROM ranked r
),
topN AS (
  SELECT s.*, RANK() OVER (PARTITION BY s.week_date
    ORDER BY s.composite_score DESC) AS rank_in_week
  FROM scored s
),
top5 AS (SELECT * FROM topN WHERE rank_in_week <= 5),
all_returns AS (
  SELECT 'Top 5 (signal)' AS group_label, 5 AS horizon_days,
    (close_fwd5 - close) / NULLIF(close,0) * 100 AS return_pct FROM top5
  UNION ALL
  SELECT 'Top 5 (signal)', 21,
    (close_fwd21 - close) / NULLIF(close,0) * 100 FROM top5
  UNION ALL
  SELECT 'Eligible pool', 5,
    (close_fwd5 - close) / NULLIF(close,0) * 100 FROM eligible
  UNION ALL
  SELECT 'Eligible pool', 21,
    (close_fwd21 - close) / NULLIF(close,0) * 100 FROM eligible
  UNION ALL
  SELECT DISTINCT 'SPY', 5,
    (spy_fwd5 - spy_close)/NULLIF(spy_close,0)*100 FROM combined
  UNION ALL
  SELECT DISTINCT 'SPY', 21,
    (spy_fwd21 - spy_close)/NULLIF(spy_close,0)*100 FROM combined
)
SELECT group_label, horizon_days,
  COUNT(*) AS signals,
  ROUND(AVG(return_pct),3) AS avg_ret_pct,
  ROUND(STDDEV(return_pct),3) AS stddev_pct,
  ROUND(SUM(CASE WHEN return_pct>0 THEN 1 ELSE 0 END)*100.0
    /NULLIF(COUNT(return_pct),0),1) AS win_rate_pct,
  ROUND(AVG(return_pct)/NULLIF(STDDEV(return_pct),0),3) AS sharpe_like
FROM all_returns
WHERE return_pct IS NOT NULL
GROUP BY group_label, horizon_days
ORDER BY horizon_days, group_label
LIMIT 20

The results

Ran in 18.2 seconds across 31M rows of daily price data, 56 technical indicators per stock per day, and 7+ years of weekly rebalancing dates.

5-day forward returns

GroupSignalsAvg ReturnStdDevWin RateSharpe-like
Eligible pool14,125+0.35%6.29%52.9%0.055
SPY876+0.22%2.74%60.5%0.081
Top 5 (signal)1,460-0.11%8.54%50.1%-0.013

21-day forward returns

GroupSignalsAvg ReturnStdDevWin RateSharpe-like
Eligible pool13,851+1.00%12.11%54.6%0.083
SPY872+1.05%5.51%68.0%0.190
Top 5 (signal)1,445+0.19%17.24%50.5%0.011

What the data shows

The composite momentum ranking does not add value. The top-5 ranked stocks averaged -0.11% at 5 days and +0.19% at 21 days, both worse than the unranked eligible pool (+0.35% and +1.00%) and SPY (+0.22% and +1.05%). Win rates hover near 50%, compared to 53-61% for the baseline groups.

The interesting finding is that the simple eligibility filter - price above rising SMA50, RSI 50-68, volume above average, market cap over $2B, SPY in uptrend - performs better than the complex ranking on top of it. The pool averaged +0.35% at 5 days with a 52.9% win rate. Adding the composite momentum score to select the "best" 5 stocks actually selected the most overextended names that were more likely to mean-revert.

This is a useful negative result. Over-engineering a signal with multiple momentum factors, relative strength, volume trends, and trend quality scoring does not automatically improve returns. The additional complexity concentrates risk (standard deviation nearly doubles from 6.3% to 8.5%) without improving the average return. Simpler filters with broader diversification outperformed the focused ranking.

Methodology

Frequently asked questions

How was this study run?

A single prompt to Claude with the Shibui MCP connector. Claude generated the SQL automatically, joining prices, technical indicators, and fundamentals across approximately 9,900 securities.

Why does the ranking underperform?

Selecting the highest-momentum stocks from an already-filtered pool concentrates in names that have moved the most. These tend to mean-revert in the short term, especially at 5-day horizons.

Is the eligibility filter useful?

Yes. The pool of stocks passing the eligibility filter (above SMA50, RSI not overbought, volume expanding, market in uptrend) outperforms SPY at 5 days. The filter adds value; the ranking within it does not.

Can I modify the weights or criteria?

Yes. Connect Shibui to Claude and adjust the factor weights, RSI range, market cap floor, or rebalancing frequency. Claude will regenerate the SQL for your variant.

Is this financial advice?

No. This is a statistical backtest using historical data. Past performance does not predict future results. No transaction costs are modeled.

Related studies:

Data note: Results shown on this page are from a specific date and will change as new data arrives. Shibui covers NYSE + NASDAQ (~9,900 securities), daily prices since 1962 (~31M rows), quarterly financials, and 56 technical indicators. This is a data tool, not financial advice.

Run your own backtest

Connect Shibui to Claude in 2 minutes. Describe any strategy in plain English and get a statistical backtest with real data, no coding required.

Connect to Claude →