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:
Market Regime Filter: Only taking trades when the market (represented by BTC-USD) is in an upward trend as determined by its moving average.
Risk-Adjusted Momentum: Evaluating assets based on their recent returns relative to volatility.
Inverse Volatility Weighting: Allocating higher weights to assets that are less volatile.
Stop-Loss Cap: Applying a post-hoc return floor to mitigate extreme drawdowns.
Below is the annotated code along with explanations that detail each part of the process.
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-tripExplanation:
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.
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.
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.
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.
The core of the strategy is executed in a loop where for each holding period the strategy:
Checks the market regime.
Calculates the momentum (returns) and volatility for each asset over the lookback period.
Computes a risk-adjusted metric (return/volatility).
Selects the top N assets based on this metric.
Applies inverse volatility weighting.
Calculates the portfolio’s weekly return and subtracts transaction costs.
Applies a stop-loss cap.
# --- 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:
The market regime check ensures positions are only taken in a bull market.
The momentum calculation uses a 30-day window.
Volatility is computed from daily returns (you can choose to annualize if desired).
The risk-adjusted metric ranks assets by return per unit of volatility.
Inverse volatility weighting allocates more weight to assets with lower volatility.
Transaction costs are deducted from the returns.
A stop-loss cap is applied to limit weekly losses.
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:
DataFrames creation: Weekly returns and cumulative returns are calculated.
Visualization:
The cumulative return curves are plotted along with an optional
benchmark (BTC-USD Buy & Hold) for context.
Performance Metrics:
The final cumulative returns, Sharpe ratios, and maximum drawdown
figures give you quantitative insights into the strategy’s
performance.
Let’s see the performance of the strategy for 2020 to 2025:
--- Results ---
Final Cumulative Return: 194.15%
Annualized Sharpe Ratio: 3.10
Maximum Drawdown: -8.61%
--- Results ---
Final Cumulative Return: 655.19%
Annualized Sharpe Ratio: 3.49
Maximum Drawdown: -5.00%
--- Results ---
Final Cumulative Return: 12.86%
Annualized Sharpe Ratio: 0.80
Maximum Drawdown: -9.75%
--- Results ---
Final Cumulative Return: 89.81%
Annualized Sharpe Ratio: 2.52
Maximum Drawdown: -6.54%
--- Results ---
Final Cumulative Return: 122.05%
Annualized Sharpe Ratio: 1.90
Maximum Drawdown: -26.83%
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.