Most crypto “backtests” you see online fall into one of two traps:
They rely on a single indicator on a single coin (usually BTC).
They completely ignore transaction costs and turnover.
In this article, we’ll build something more realistic:
A cross-sectional momentum strategy over multiple crypto pairs
A BTC-based regime filter to avoid trading when the market is weak
A proper backtest with trading costs, turnover, Sharpe, and drawdown
All of this is implemented in a single, readable Python function. We’ll walk through the logic step-by-step and look at actual performance over a multi-year period.
We assume you already have a price DataFrame called
closes:
Rows: timestamps (e.g., 1h or 4h bars)
Columns: symbols (e.g. "BTC/USDT",
"ETH/USDT", "BNB/USDT", etc.)
Values: close prices
Example shape:
import pandas as pd
# closes:
# BTC/USDT ETH/USDT BNB/USDT ...
# 2019-01-01 00:00 3800.12 135.5 6.42
# 2019-01-01 01:00 3810.35 136.2 6.44
# ...In the code, we’ll assume that:
btc_symbol = 'BTC/USDT'is present as one of the columns in closes.
If your symbols differ (e.g. "BTCUSDT"), just adjust the
string.
We’ll start with two helper functions: cumulative return momentum, and max drawdown.
Momentum here is defined as the cumulative return over a lookback window:
\[ \text{mom}(t, L) = \frac{P_t}{P_{t-L+1}} - 1 \]
In code:
import numpy as np
import pandas as pd
def cum_return_momentum(close_series: pd.Series) -> float:
"""
Cumulative return over the window:
P_last / P_first - 1
If the series is too short, we return NaN.
"""
if len(close_series) < 2:
return np.nan
return close_series.iloc[-1] / close_series.iloc[0] - 1.0We also need a function to compute max drawdown of an equity curve:
def max_drawdown(series: pd.Series) -> float:
"""
Max drawdown of a series (e.g., equity curve).
Returns the minimum drawdown (a negative number).
"""
roll_max = series.cummax()
dd = series / roll_max - 1.0
return dd.min()The core idea:
For each rebalance date:
For each symbol, compute two momentum measures:
Fast window (e.g., 30 bars)
Slow window (e.g., 90 bars)
Combine them into a single score:
\[
\text{score} = 0.3 \times \text{mom}_\text{fast} + 0.7 \times
\text{mom}_\text{slow}
\]
Use BTC’s score as a regime filter:
If BTC’s score is > 0: we assume a favorable regime for risk-on.
If not: we stay in cash.
If the regime is favorable:
Rank all symbols by score (descending).
Go long the top n_long symbols,
equal-weighted.
Between rebalances:
Keep weights fixed.
Apply returns on each bar.
At each rebalance:
Compute turnover = sum of absolute changes in weights.
Apply a trading cost proportional to turnover.
The key question we want to answer:
Does this simple cross-sectional momentum strategy, filtered by BTC’s own momentum, still perform well after transaction costs?
Here’s the full function. We’ll then walk through it section by section.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
btc_symbol = 'BTC/USDT'
def cum_return_momentum(close_series):
if len(close_series) < 2:
return np.nan
return close_series.iloc[-1] / close_series.iloc[0] - 1.0
def max_drawdown(series):
roll_max = series.cummax()
dd = series / roll_max - 1.0
return dd.min()
def backtest_xs_mom_with_costs(
closes,
lookback_fast=24,
lookback_slow=168,
rebalance_every=24,
n_long=3,
btc_slow_threshold=0.0,
start_equity=100000.0,
fee_bps=10 # 10 bps = 0.1% per unit of turnover
):
"""
Cross-sectional momentum with BTC regime filter + trading costs.
Turnover = sum(|w_new - w_old|). Cost each rebalance = fee_rate * turnover.
Costs are applied only on rebalance steps.
"""
if btc_symbol not in closes.columns:
raise ValueError(f"{btc_symbol} missing from closes columns.")
fee_rate = fee_bps / 10000.0
rets = closes.pct_change().fillna(0.0)
equity_gross = start_equity
equity_net = start_equity
equity_gross_curve = []
equity_net_curve = []
dates = []
current_weights = pd.Series(0.0, index=closes.columns)
prev_weights = current_weights.copy()
total_turnover = 0.0
for i in range(lookback_slow, len(closes) - 1):
trading_cost = 0.0
# Rebalance at schedule
if (i - lookback_slow) % rebalance_every == 0:
window_fast = closes.iloc[i - lookback_fast + 1 : i + 1]
window_slow = closes.iloc[i - lookback_slow + 1 : i + 1]
scores = {}
for sym in closes.columns:
mom_fast = cum_return_momentum(window_fast[sym])
mom_slow = cum_return_momentum(window_slow[sym])
if np.isfinite(mom_fast) and np.isfinite(mom_slow):
score = 0.3 * mom_fast + 0.7 * mom_slow
scores[sym] = score
w_new = pd.Series(0.0, index=closes.columns)
if len(scores) >= n_long:
btc_slow = scores.get(btc_symbol, np.nan)
if np.isfinite(btc_slow) and btc_slow > btc_slow_threshold:
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
long_syms = [s for s, _ in ranked[:n_long]]
w_long = 1.0 / len(long_syms)
for s_long in long_syms:
w_new[s_long] = w_long
# else: stay in cash (all zeros)
# turnover and cost at rebalance
turnover = (w_new - prev_weights).abs().sum()
total_turnover += turnover
trading_cost = fee_rate * turnover
prev_weights = w_new.copy()
current_weights = w_new
# Apply returns of next bar
next_ret_vec = rets.iloc[i + 1]
port_ret = (current_weights * next_ret_vec).sum()
equity_gross *= (1.0 + port_ret)
equity_net *= (1.0 + port_ret - trading_cost) # cost only when we rebalanced
dates.append(closes.index[i + 1])
equity_gross_curve.append(equity_gross)
equity_net_curve.append(equity_net)
equity_gross_series = pd.Series(equity_gross_curve, index=dates)
equity_net_series = pd.Series(equity_net_curve, index=dates)
def calc_stats(eq):
total_return = eq.iloc[-1] / eq.iloc[0] - 1
daily_eq = eq.resample('1D').last().dropna()
daily_rets = daily_eq.pct_change().dropna()
sharpe = np.sqrt(365) * daily_rets.mean() / daily_rets.std() if daily_rets.std() != 0 else np.nan
mdd = max_drawdown(eq)
return total_return, sharpe, mdd
gross_ret, gross_sharpe, gross_mdd = calc_stats(equity_gross_series)
net_ret, net_sharpe, net_mdd = calc_stats(equity_net_series)
print("\n=== GROSS (no costs) ===")
print(f"Final equity: {equity_gross_series.iloc[-1]:.2f}")
print(f"Total return: {gross_ret*100:.2f}%")
print(f"Sharpe: {gross_sharpe:.2f}")
print(f"Max drawdown: {gross_mdd*100:.2f}%")
print("\n=== NET (after costs) ===")
print(f"Final equity: {equity_net_series.iloc[-1]:.2f}")
print(f"Total return: {net_ret*100:.2f}%")
print(f"Sharpe: {net_sharpe:.2f}")
print(f"Max drawdown: {net_mdd*100:.2f}%")
print(f"\nTotal turnover over period (weights-sum): {total_turnover:.2f}")
plt.figure(figsize=(12, 6))
plt.plot(equity_gross_series.index, equity_gross_series.values, label='Gross')
plt.plot(equity_net_series.index, equity_net_series.values, label='Net (after costs)')
plt.legend()
plt.xlabel('Time')
plt.ylabel('Equity (USDT)')
plt.title('XS Momentum (fast+slow, BTC Filter) – Gross vs Net Equity')
plt.tight_layout()
plt.show()
return {
"equity_gross": equity_gross_series,
"equity_net": equity_net_series,
"gross": {"ret": gross_ret, "sharpe": gross_sharpe, "mdd": gross_mdd},
"net": {"ret": net_ret, "sharpe": net_sharpe, "mdd": net_mdd},
"turnover": total_turnover,
}Now let’s unpack the important parts.
Key parameters:
lookback_fast: window for short-term momentum (in
bars)
lookback_slow: window for longer-term
momentum
rebalance_every: how often we rebalance, in
bars
n_long: number of top alts to hold long
btc_slow_threshold: BTC momentum threshold; below
this we stay in cash
fee_bps: transaction cost per unit of turnover (in
basis points)
We compute returns:
rets = closes.pct_change().fillna(0.0)We initialize equity and weights:
equity_gross = start_equity
equity_net = start_equity
current_weights = pd.Series(0.0, index=closes.columns)
prev_weights = current_weights.copy()
total_turnover = 0.0The main loop starts at lookback_slow, because we need
at least that many bars to compute the slow momentum:
for i in range(lookback_slow, len(closes) - 1):At each step i, there are two things:
Possibly rebalance (if the schedule says so).
Apply next-bar returns to equity.
We only recompute weights every rebalance_every
bars:
if (i - lookback_slow) % rebalance_every == 0:
window_fast = closes.iloc[i - lookback_fast + 1 : i + 1]
window_slow = closes.iloc[i - lookback_slow + 1 : i + 1]We then compute momentum scores:
scores = {}
for sym in closes.columns:
mom_fast = cum_return_momentum(window_fast[sym])
mom_slow = cum_return_momentum(window_slow[sym])
if np.isfinite(mom_fast) and np.isfinite(mom_slow):
score = 0.3 * mom_fast + 0.7 * mom_slow
scores[sym] = scoreWe create a new weights vector, initially all zeros:
w_new = pd.Series(0.0, index=closes.columns)Now, the BTC regime filter:
if len(scores) >= n_long:
btc_slow = scores.get(btc_symbol, np.nan)
if np.isfinite(btc_slow) and btc_slow > btc_slow_threshold:
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
long_syms = [s for s, _ in ranked[:n_long]]
w_long = 1.0 / len(long_syms)
for s_long in long_syms:
w_new[s_long] = w_long
# else: stay in cash (all zeros)If BTC’s score is positive (above the threshold), we:
Rank all symbols by momentum score.
Take the top n_long.
Allocate equal weights among them.
If BTC is not strong enough, we remain in cash by
leaving w_new as all zeros.
We define turnover as the L1 difference in weights:
turnover = (w_new - prev_weights).abs().sum()
total_turnover += turnover
trading_cost = fee_rate * turnoverIf fee_bps = 10, then fee_rate = 0.001
(0.1% per unit turnover).
Turnover is dimensionless, but think of it as “fraction of portfolio
traded”.
Example:
You go from 0% invested to 100% invested → turnover = 1.0.
You rotate from one basket of coins to another completely different basket → turnover can be up to 2.0 (sell 100%, buy 100%).
We apply trading cost when we rebalance by subtracting
trading_cost from the return on that bar.
We then update:
prev_weights = w_new.copy()
current_weights = w_newWhether or not we rebalance, we always apply next-bar returns:
next_ret_vec = rets.iloc[i + 1]
port_ret = (current_weights * next_ret_vec).sum()
equity_gross *= (1.0 + port_ret)
equity_net *= (1.0 + port_ret - trading_cost)Note that trading_cost will be non-zero only on
rebalance bars.
We store the equity history for later analysis and plotting.
After the loop, we create equity series:
equity_gross_series = pd.Series(equity_gross_curve, index=dates)
equity_net_series = pd.Series(equity_net_curve, index=dates)We define a helper calc_stats:
def calc_stats(eq):
total_return = eq.iloc[-1] / eq.iloc[0] - 1
daily_eq = eq.resample('1D').last().dropna()
daily_rets = daily_eq.pct_change().dropna()
sharpe = np.sqrt(365) * daily_rets.mean() / daily_rets.std() if daily_rets.std() != 0 else np.nan
mdd = max_drawdown(eq)
return total_return, sharpe, mddThis gives us:
Total return (over the entire period)
Annualized Sharpe ratio (using daily returns, assuming 365 days/year)
Max drawdown
We compute stats for both gross and net equity curves and print them.
Assuming closes is your 5-year 1h close-price DataFrame,
you can run:
stats = backtest_xs_mom_with_costs(
closes,
lookback_fast=30,
lookback_slow=90,
rebalance_every=30,
n_long=3,
btc_slow_threshold=0.0,
start_equity=100000.0,
fee_bps=10 # adjust to your fee/slippage assumptions
)=== GROSS (no costs) === Final equity: 814892.26 Total return: 714.89% Sharpe: 2.87 Max drawdown: -24.05%
=== NET (after costs) === Final equity: 624719.76 Total return: 524.72% Sharpe: 2.54 Max drawdown: -25.16%
Total turnover over period (weights-sum): 265.33
So transaction costs eat into returns (as expected), but the strategy remains highly profitable and maintains a strong Sharpe, at least over this sample.
To turn this from a toy into a more serious research piece, you can add:
Benchmarks:
Buy & hold BTC
Equal-weight buy & hold of the same universe
Parameter sweeps:
Try varying (lookback_fast, lookback_slow)
Different n_long (e.g. 2, 5, 10)
Different rebalance_every values
Cost sensitivity:
fee_bps = 5, 10, 20, 30
See how Sharpe and total return degrade.
Out-of-sample test:
Use first 60–70% of the period to pick parameters.
Freeze them and evaluate on the remaining 30–40%.
More risk metrics:
Annualized volatility
Drawdown duration
% of time invested (BTC filter effect)
Each of these can be added on top of the existing function with small modifications and will significantly strengthen the credibility of the results.
We’ve built and fully walked through a cross-sectional crypto momentum strategy with:
Fast + slow momentum components,
BTC as a regime filter,
Explicit trading costs and turnover,
A clean backtest implementation in Python.
Even with costs, the example results show high returns, strong Sharpe, and relatively modest drawdowns, making this a promising starting point for more advanced research.
You can easily plug this into your own universe of coins, tune the lookbacks and costs to your exchange conditions, and extend it with signals from other domains (on-chain data, funding rates, order book features, etc.).
If you’d like, I can also help you:
Add a benchmark comparison section (BTC buy & hold, equal-weight portfolio), or
Turn this into a more formal “research note” style PDF for sharing on LinkedIn or with recruiters.