S&P 500 Regime Filter (US Market)
A market regime filter that exits all positions when the broad market turns bearish, avoiding sustained losses during systemic downturns. This strategy uses market breadth -- the count of stocks in bullish vs. bearish moving-average alignment -- to determine the overall market direction, serving as a master switch for individual stock selection.
Strategy Logic
Market Breadth Indicator
The ls_order_position function counts across all stocks in the market:
- Bullish alignment: Short MA >= Medium MA >= Long MA
- Bearish alignment: Short MA < Medium MA < Long MA
When the number of stocks in bullish alignment exceeds those in bearish alignment, the market is judged to be in an uptrend and positions are allowed. Otherwise, all positions are exited. The default parameters use 5-day (short), 10-day (medium), and 30-day (long) moving averages.
Individual Stock Selection
At the stock level, a simple momentum condition is applied: the closing price must be above both its 20-day and 60-day prior levels, indicating short-to-medium-term upward momentum.
The final portfolio is the intersection of the stock-level condition and the market breadth filter: stocks are only held when both the individual momentum condition and the broad market indicator are positive.
Code
from finlab import data
from finlab.backtest import sim
from finlab.market import USMarket
data.set_market('us')
close = data.get('price:adj_close')
close = close[close.index.dayofweek < 5] # remove weekends
# Market breadth: bullish vs. bearish alignment count
def ls_order_position(short=5, mid=10, long=30):
short_ma = close.average(short)
mid_ma = close.average(mid)
long_ma = close.average(long)
bullish = (short_ma >= mid_ma) & (mid_ma >= long_ma)
bullish_count = bullish.sum(axis=1)
bearish = (short_ma < mid_ma) & (mid_ma < long_ma)
bearish_count = bearish.sum(axis=1)
entry = bullish_count > bearish_count
cond = ~close.isna()
position = cond & entry
return position
# Individual stock momentum condition
buy = (close > close.shift(20)) & (close > close.shift(60))
# Apply market breadth filter
position = buy & ls_order_position()
report = sim(position, market=USMarket(), fee_ratio=0.001, tax_ratio=0)
report.display()
Key Parameters
short=5, mid=10, long=30: The moving average periods for the breadth indicator. The 5/10/30 combination captures short-term trend shifts while filtering out daily noise. These can be adjusted to tune sensitivity.bullish_count > bearish_count: The market is considered bullish when more stocks are in bullish MA alignment than bearish. This uses a market-breadth concept, which reflects the health of the overall market more accurately than simply looking at an index moving average.close > close.shift(20)andclose > close.shift(60): The stock must be above its price from both 20 and 60 trading days ago, confirming both short-term and medium-term momentum.- No
resampleparameter: Defaults to daily rebalancing, allowing the strategy to react quickly to changes in the market regime indicator.
Expected Behavior
- The market breadth filter is a reusable function (
ls_order_position) that can be combined with any individual stock selection strategy as a top-level regime overlay. - In bear markets, the filter effectively moves the portfolio to cash, significantly reducing drawdowns compared to a strategy without the filter.
- In choppy, range-bound markets, the filter may generate frequent entry and exit signals, leading to whipsaws and transaction costs.
- The breadth-based approach is more robust than tracking a single index moving average because it reflects the participation of the entire market -- a market where only a few large-cap stocks are rising while most stocks decline will correctly register as weak breadth.
- Since this uses daily rebalancing, the strategy is responsive but may incur higher turnover. Consider adding
resample='W'if you prefer weekly adjustments to reduce trading frequency.