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?
The question
"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.
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
| Group | Signals | Avg Return | StdDev | Win Rate | Sharpe-like |
|---|---|---|---|---|---|
| Eligible pool | 14,125 | +0.35% | 6.29% | 52.9% | 0.055 |
| SPY | 876 | +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
| Group | Signals | Avg Return | StdDev | Win Rate | Sharpe-like |
|---|---|---|---|---|---|
| Eligible pool | 13,851 | +1.00% | 12.11% | 54.6% | 0.083 |
| SPY | 872 | +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
- Universe: US common stocks, market cap over $2B, price above $10, average volume above 2M shares, excluding biotech
- Eligibility filter: price above rising 50-day SMA, RSI 50-68, above-average volume, SPY in uptrend (above rising 50-day SMA)
- Composite score: 30% 60-day return, 25% 20-day return, 20% relative strength vs SPY, 15% volume trend, 10% trend quality
- Rebalancing: weekly, top 5 stocks per week
- Period: September 2017 to present (7+ years)
- Forward returns: measured from weekly close
- Costs: no transaction costs, slippage, or position sizing modeled. Returns represent raw signal quality.
- Data source: Shibui Finance, covering NYSE + NASDAQ (~9,900 securities), daily prices and 56 technical indicators
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:
- Insider Buying During Crashes Backtest - does following insiders during market drawdowns predict returns?
- SPY Flush-Recovery Backtest - testing prior-day support and resistance signals on SPY
- All research 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 →