← Back to Home
Regime-Filtered Risk-Adjusted Momentum Strategy with Inverse Volatility Weighting 12% to 655% Annual Returns

Regime-Filtered Risk-Adjusted Momentum Strategy with Inverse Volatility Weighting 12% to 655% Annual Returns

In this article, I present an advanced cryptocurrency trading strategy built around several robust components: market regime filtering, risk-adjusted momentum asset selection, inverse volatility weighting for portfolio construction, and a built-in stop-loss cap. The strategy aims to capture upside momentum during bullish periods while managing downside risk.

Overview:
The strategy focuses on trading a universe of cryptocurrencies by:

Strategy Components and Code Explanation

Below is the annotated code along with explanations that detail each part of the process.

1. Setting Up the Environment

The code begins by importing libraries such as yfinance for data retrieval, pandas for data manipulation, and matplotlib for visualization. It also sets key parameters for the backtest, including time periods, lookback windows, and transaction cost assumptions.

import requests
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt

# --- Parameters ---
START_DATE = '2022-01-01'
END_DATE = '2023-01-01'  # You can change these dates to run the backtest for different years
LOOKBACK_DAYS = 30
HOLDING_DAYS = 7
N_TOP_ASSETS = 10
STOP_LOSS_THRESHOLD = -0.05  # The post-hoc analytical loss cap
MARKET_REGIME_MA_PERIOD = 50  # Moving Average period to filter the market regime
MARKET_REGIME_TICKER = 'BTC-USD'
TRANSACTION_COST_PER_ROUND_TRIP = 0.002  # 0.2% cost per weekly trade round-trip

Explanation:
These parameters give you control over the lookback window for calculating momentum and volatility, the number of assets to include, and the thresholds for risk management. Adjusting START_DATE and END_DATE allows you to run the strategy over different historical periods.

2. Defining the Asset Universe

We define a list of cryptocurrency pairs. In this example, placeholder tickers are used; however, you can replace these with your own data source (like BinanceSymbols).

print("Using placeholder ticker list. Replace with BinanceSymbols if needed.")
crypto_pairs_binance_format = ['BTCUSDC', 'ETHUSDC', 'BNBUSDC', 'ADAUSDC', 'SOLUSDC',
                               'XRPUSDC', 'DOTUSDC', 'DOGEUSDC', 'AVAXUSDC', 'LUNA1-USD',
                               'MATICUSDC', 'LTCUSDC', 'LINKUSDC', 'TRXUSDC', 'ATOMUSDC']
# Exclude the market regime ticker from the asset selection list.
tickers_to_trade = [pair[:-4] + '-USD' for pair in crypto_pairs_binance_format 
                    if pair[:-4] + '-USD' != MARKET_REGIME_TICKER]

# Ensure the market regime ticker is part of the download.
all_tickers_to_download = tickers_to_trade + [MARKET_REGIME_TICKER]

Explanation:
The list crypto_pairs_binance_format holds sample tickers. We then convert these to a format compatible with yfinance (e.g., BTC-USD) and ensure that our market regime indicator remains separate.

3. Data Download and Preparation

Historical price data is fetched for the assets using yfinance. The code makes sure that only the closing prices are retained and that the tickers are sorted chronologically.

print(f"Downloading data for: {', '.join(all_tickers_to_download)}")
try:
    data = yf.download(all_tickers_to_download, start=START_DATE, end=END_DATE, progress=False)
    if data.empty:
        raise ValueError("No data downloaded. Check tickers and date range.")
    data = data['Close']  # Use closing prices
    data = data.sort_index()
    data = data.dropna(axis=1, how='all')  # Drop columns with no data
    tickers_to_trade = [t for t in tickers_to_trade if t in data.columns]
    if MARKET_REGIME_TICKER not in data.columns:
        raise ValueError(f"Market regime ticker {MARKET_REGIME_TICKER} could not be downloaded.")
    print(f"Successfully downloaded data for {len(data.columns)} tickers.")
except Exception as e:
    print(f"Error downloading data: {e}")
    exit()

Explanation:
This block downloads the data, ensures it is valid (non-empty), and extracts just the closing prices. It also checks that the market regime ticker is successfully downloaded.

4. Market Regime Filter

A 50-day moving average is computed for the market regime asset. The strategy will only trade if BTC-USD’s current price is above its moving average, signaling a bullish trend.

data[f'{MARKET_REGIME_TICKER}_MA'] = data[MARKET_REGIME_TICKER].rolling(window=MARKET_REGIME_MA_PERIOD).mean()

Explanation:
By adding a moving average column, the strategy creates a simple trend indicator to help avoid entering positions during bearish market conditions.

5. Backtesting Loop and Asset Selection

The core of the strategy is executed in a loop where for each holding period the strategy:

# --- Backtesting ---
results = []
start_index = max(LOOKBACK_DAYS, MARKET_REGIME_MA_PERIOD)

print("Starting backtest...")
for i in range(start_index, len(data) - HOLDING_DAYS, HOLDING_DAYS):
    current_date = data.index[i]

    # Check market regime: Only trade when BTC-USD is above its moving average
    try:
        is_bull_market = data[MARKET_REGIME_TICKER].iloc[i] > data[f'{MARKET_REGIME_TICKER}_MA'].iloc[i]
    except IndexError:
        print(f"Warning: Market regime check failed for {current_date}. Skipping week.")
        is_bull_market = False

    weekly_return = 0.0

    if is_bull_market:
        # Define the lookback period for calculating momentum and volatility
        current_prices = data.iloc[i]
        lookback_start_prices = data.iloc[i - LOOKBACK_DAYS]

        # Only consider assets with valid price data over the lookback period
        valid_tickers = lookback_start_prices[tickers_to_trade].dropna().index
        valid_tickers = current_prices[valid_tickers].dropna().index

        if len(valid_tickers) >= N_TOP_ASSETS:
            # Calculate return over the lookback period
            lookback_returns = (current_prices[valid_tickers] / lookback_start_prices[valid_tickers]) - 1

            # Calculate volatility over the lookback period (daily standard deviation)
            daily_returns = data[valid_tickers].iloc[i - LOOKBACK_DAYS : i].pct_change().dropna(how='all')
            volatilities = daily_returns.std().replace(0, 1e-9)

            # Calculate risk-adjusted returns as the ratio of return to volatility
            risk_adjusted = lookback_returns.loc[volatilities.index] / volatilities
            valid_risk_adjusted = risk_adjusted.dropna()

            # Select the top N assets based on the risk-adjusted metric
            if len(valid_risk_adjusted) >= N_TOP_ASSETS:
                topN_tickers = valid_risk_adjusted.nlargest(N_TOP_ASSETS).index

                # Check that the required prices exist for the holding period
                if not current_prices[topN_tickers].isnull().any() and not data.iloc[i + HOLDING_DAYS][topN_tickers].isnull().any():
                    # Weight allocation: Inverse volatility weighting
                    selected_vols = volatilities[topN_tickers]
                    inv_vols = 1 / selected_vols
                    weights = inv_vols / inv_vols.sum()

                    next_week_prices = data.iloc[i + HOLDING_DAYS][topN_tickers]
                    individual_returns = (next_week_prices / current_prices[topN_tickers]) - 1

                    # Re-align weights if some data is missing (should rarely happen given previous checks)
                    individual_returns = individual_returns.dropna()
                    weights = weights.loc[individual_returns.index]
                    weights = weights / weights.sum()

                    gross_return = (individual_returns * weights).sum()
                    weekly_return = gross_return - TRANSACTION_COST_PER_ROUND_TRIP
                else:
                    pass  # Missing price data for selected tickers; skip the week.
            else:
                pass  # Not enough assets with valid risk-adjusted scores; skip the week.

    # Apply the stop-loss cap to manage downside risk
    capped_return = max(weekly_return, STOP_LOSS_THRESHOLD)
    results.append((current_date, capped_return))

print("Backtest finished.")

Explanation:

6. Post-Processing and Visualization

Once the backtest loop is complete, cumulative returns and performance metrics (including Sharpe ratio and maximum drawdown) are computed. The results are then plotted for visual analysis.

# --- Post-Processing & Plotting ---
df_results = pd.DataFrame(results, columns=['Week Start', 'Avg 1 Week Return'])
df_results['Cumulative Return'] = (df_results['Avg 1 Week Return'] + 1).cumprod() - 1
df_results = df_results.set_index('Week Start')

plt.figure(figsize=(14, 7))
plt.plot(df_results.index, df_results['Cumulative Return'], label=f'Strategy')

# Optional: Buy & Hold Benchmark for BTC-USD
if MARKET_REGIME_TICKER in data.columns:
     btc_buy_hold = data[MARKET_REGIME_TICKER].iloc[start_index:]
     btc_cum_ret = (btc_buy_hold / btc_buy_hold.iloc[0]) - 1
     plt.plot(btc_cum_ret.index, btc_cum_ret, label=f'{MARKET_REGIME_TICKER} Buy & Hold', linestyle='--', alpha=0.7)

plt.xlabel('Date')
plt.ylabel('Cumulative Return')
plt.title('Inverse Volatility Weight with Regime Filter')
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()

# --- Performance Metrics ---
print("\n--- Results ---")
print(f"Final Cumulative Return: {df_results['Cumulative Return'].iloc[-1]:.2%}")

# Annualized Sharpe Ratio (using weekly returns, 52 weeks per year)
sharpe_ratio = (df_results['Avg 1 Week Return'].mean() / df_results['Avg 1 Week Return'].std()) * (52**0.5)
print(f"Annualized Sharpe Ratio: {sharpe_ratio:.2f}")

# Maximum Drawdown Calculation
def calculate_max_drawdown(cum_returns):
    roll_max = (cum_returns + 1).cummax()
    drawdown = (cum_returns + 1) / roll_max - 1
    return drawdown.min()

max_drawdown = calculate_max_drawdown(df_results['Cumulative Return'])
print(f"Maximum Drawdown: {max_drawdown:.2%}")

Explanation:

Running the Backtest for Different Years

Let’s see the performance of the strategy for 2020 to 2025:

Pasted image 20250414183511.png
--- Results ---
Final Cumulative Return: 194.15%
Annualized Sharpe Ratio: 3.10
Maximum Drawdown: -8.61%
Pasted image 20250414183632.png
--- Results ---
Final Cumulative Return: 655.19%
Annualized Sharpe Ratio: 3.49
Maximum Drawdown: -5.00%
Pasted image 20250414183339.png
--- Results ---
Final Cumulative Return: 12.86%
Annualized Sharpe Ratio: 0.80
Maximum Drawdown: -9.75%
Pasted image 20250414183744.png
--- Results ---
Final Cumulative Return: 89.81%
Annualized Sharpe Ratio: 2.52
Maximum Drawdown: -6.54%
Pasted image 20250414183842.png
--- Results ---
Final Cumulative Return: 122.05%
Annualized Sharpe Ratio: 1.90
Maximum Drawdown: -26.83%

Conclusion

The Regime-Filtered Risk-Adjusted Momentum Strategy with Inverse Volatility Weighting uses a combination of trend detection, risk-adjusted momentum ranking, and volatility-based weighting to navigate the volatile cryptocurrency market. With a market regime filter and stop-loss protection in place, the strategy aims to harness trends during bull markets while reducing exposure during less favorable conditions. We saw the impressive annual returns in the backtests from 12% up to 655%. Keep in mind that we only took long positions so we didn’t make money when the market was bearish.

By understanding and adjusting each component of the code, you can tailor the backtest to various time periods and refine the strategy further with your results. This modular approach not only provides robust insights into historical performance but also sets the foundation for future enhancements and live trading implementations.