Insights
Building a Risk-Regime Filter in Python
Alphanume Team · June 2, 2026
Conditioning a strategy on the daily regime flag.
Most backtests treat every day as equivalent — full exposure in bull markets, bear markets, and everything in between. A what a market regime filter is changes that: it shrinks or eliminates your position when the macro environment turns hostile, and lets it run when conditions favour risk-taking. This tutorial builds a regime filter python practitioners can drop into any strategy, using the S&P 500 Risk Regime dataset to drive the switching logic. The endpoint returns a daily binary flag — 1 for Risk-Off, 0 for Risk-On — that you can align to your own returns in a handful of lines.
The setup
You need requests, pandas, and optionally matplotlib for the equity curve comparison. Keep your API key in an environment variable — never paste it into source files. All Alphanume endpoints share the same request shape: a GET with query parameters, a JSON envelope with count and data, and a single get_data() helper you can reuse across calls.
import os
import requests
import pandas as pd
import matplotlib.pyplot as plt
BASE_URL = "https://api.alphanume.com/v1"
API_KEY = os.environ["ALPHANUME_API_KEY"]
def get_data(endpoint, **params):
params["api_key"] = API_KEY
resp = requests.get(f"{BASE_URL}/{endpoint}", params=params, timeout=30)
resp.raise_for_status()
return resp.json()["data"]
The raise_for_status() call is not optional. A rate-limit or auth error returns a JSON body that pandas will silently parse into garbage — failing loudly saves hours of confusing downstream behaviour. Unwrapping ["data"] discards the envelope metadata you do not need for analysis.
Pulling the regime series
The sp500-risk-regime endpoint accepts a start and end date and returns one row per trading day. Each row carries a date field and a regime field: 0 for Risk-On, 1 for Risk-Off. Load the full range you need in one call and index it by date immediately.
def get_regime(start, end):
rows = get_data("sp500-risk-regime", start=start, end=end)
df = pd.DataFrame(rows)
df["date"] = pd.to_datetime(df["date"])
df = df.set_index("date").sort_index()
df["regime"] = df["regime"].astype(int)
return df[["regime"]]
regime = get_regime("2015-01-01", "2024-12-31")
print(regime.head())
print(f"\nRisk-Off days: {regime['regime'].sum()}")
print(f"Risk-On days: {(regime['regime'] == 0).sum()}")
Checking the counts is a useful sanity step. Risk-Off days should be a minority of the sample — if the split looks implausible, verify the date range and re-examine the raw response before proceeding.
The look-ahead rule — shift by one day
This is the most important part of the whole implementation. Today's regime flag is computed from data observed during today's session. You do not know it until after the close — which means you cannot act on it at today's open. Using today's flag to decide today's position is look-ahead bias: it implies knowledge that did not exist when your order would have been filled. The fix is one line: shift(1). Align yesterday's regime to today's position.
The concept is the same one described in classifying risk-on vs risk-off days — the classification is only actionable on the following trading day. Skipping the shift inflates filtered returns and invalidates any conclusion you draw.
# regime_lag is what you KNEW at each day's open
regime["regime_lag"] = regime["regime"].shift(1)
# Drop the first row — no prior day's regime exists for it
regime = regime.dropna(subset=["regime_lag"])
regime["regime_lag"] = regime["regime_lag"].astype(int)
Once you have regime_lag in the DataFrame, every downstream filter is clean. The shift is the entire difference between a publishable result and an artefact.
Applying the filter to a strategy
Assume you already have a Series of daily strategy returns indexed by date — from a backtest, a live signal, or a simple benchmark like SPY. Align it to the regime DataFrame with a left join, then compute a position multiplier: 1 when the prior day was Risk-On, 0 when it was Risk-Off. That multiplier scales every return in the filtered series.
# Replace this with your own daily returns Series
# strategy_returns = pd.read_csv("my_strategy.csv",
# index_col="date", parse_dates=True)["returns"]
# --- skeleton using SPY as a stand-in ---
import yfinance as yf # install separately if needed
spy = yf.download("SPY", start="2015-01-01", end="2024-12-31",
auto_adjust=True)["Close"]
strategy_returns = spy.pct_change().dropna()
strategy_returns.index = pd.to_datetime(strategy_returns.index)
# Align regime to returns
combined = strategy_returns.to_frame("returns").join(
regime[["regime_lag"]], how="left"
)
combined["regime_lag"] = combined["regime_lag"].ffill()
# Position: 1 when Risk-On (regime_lag == 0), 0 when Risk-Off
base_position = 1.0
combined["position"] = base_position * (combined["regime_lag"] == 0).astype(float)
# Filtered vs unfiltered equity curves
combined["filtered_returns"] = combined["returns"] * combined["position"]
equity_unfiltered = (1 + combined["returns"]).cumprod()
equity_filtered = (1 + combined["filtered_returns"]).cumprod()
fig, ax = plt.subplots(figsize=(10, 5))
equity_unfiltered.plot(ax=ax, label="Unfiltered")
equity_filtered.plot(ax=ax, label="Regime-filtered (Risk-Off = cash)")
ax.set_title("Filtered vs Unfiltered Equity Curve")
ax.set_ylabel("Growth of $1")
ax.legend()
plt.tight_layout()
plt.show()
The ffill() call handles the occasional trading day where the regime endpoint has no entry — it carries the last known classification forward rather than dropping rows or introducing NaN returns.
Interpreting the comparison honestly
Plotting the two curves tells you whether the regime filter helped over the historical sample. That is not the same as knowing it will help in the future, and there are several things the comparison does not show.
First, going to cash during Risk-Off periods eliminates downside in crises — but it also misses every sharp rally that begins inside a Risk-Off window. Some of the best single-day gains in equity history occur when conditions still look hostile by any classification scheme. Second, transaction costs from switching matter. If the filter cycles in and out of a position several times per year, bid-ask spread, commissions, and market impact can erode a meaningful share of the gross improvement. Third, the regime classification is a statistical model, not a fact — it has its own error rate, and any backtest that treats it as a clean signal is underestimating uncertainty.
None of this means regime filters are useless. It means you should test them on out-of-sample periods, size the claimed improvement conservatively, and never build a strategy whose sole edge is the filter itself.
Extensions and next steps
The binary position — fully in or fully out — is the simplest possible implementation. A softer version scales the position continuously: for example, a 50 % reduction during Risk-Off rather than a full exit. That halves the transaction cost from switching and reduces the cost of false positives.
You can also combine the regime flag with other signals. A momentum strategy might cap gross exposure at 50 % when Risk-Off is flagged but maintain the signal's long-short structure; a volatility-targeting strategy might tighten its vol target rather than exiting entirely. The key invariant in every variant is the same: always use regime_lag, never regime. That single discipline separates a usable filter from an artefact.