← Back to Home
Cross-Sectional Crypto Momentum with a BTC Regime Filter

Cross-Sectional Crypto Momentum with a BTC Regime Filter

Most crypto “backtests” you see online fall into one of two traps:

  1. They rely on a single indicator on a single coin (usually BTC).

  2. They completely ignore transaction costs and turnover.

In this article, we’ll build something more realistic:

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.

1. Data Setup

We assume you already have a price DataFrame called closes:

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.

2. Building Blocks: Momentum and Drawdown

We’ll start with two helper functions: cumulative return momentum, and max drawdown.

2.1 Cumulative Return Momentum

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.0

2.2 Max Drawdown

We 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()

3. Strategy Logic: Cross-Sectional Momentum with BTC Filter

The core idea:

  1. 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} \]

  2. 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.

  3. If the regime is favorable:

    • Rank all symbols by score (descending).

    • Go long the top n_long symbols, equal-weighted.

  4. Between rebalances:

    • Keep weights fixed.

    • Apply returns on each bar.

  5. 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?

4. The Backtest Function

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.

5. Walkthrough of the Backtest

5.1 Inputs and Setup

Key parameters:

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.0

5.2 Rolling Through Time

The 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:

  1. Possibly rebalance (if the schedule says so).

  2. Apply next-bar returns to equity.

5.3 Rebalancing Logic

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] = score

We 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:

If BTC is not strong enough, we remain in cash by leaving w_new as all zeros.

5.4 Turnover and Trading Costs

We define turnover as the L1 difference in weights:

turnover = (w_new - prev_weights).abs().sum()
total_turnover += turnover
trading_cost = fee_rate * turnover

If 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:

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_new

5.5 Applying Returns

Whether 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.

6. Performance Statistics

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, mdd

This gives us:

We compute stats for both gross and net equity curves and print them.

7. Results

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.

8. Extensions and Robustness Checks

To turn this from a toy into a more serious research piece, you can add:

  1. Benchmarks:

    • Buy & hold BTC

    • Equal-weight buy & hold of the same universe

  2. Parameter sweeps:

    • Try varying (lookback_fast, lookback_slow)

    • Different n_long (e.g. 2, 5, 10)

    • Different rebalance_every values

  3. Cost sensitivity:

    • fee_bps = 5, 10, 20, 30

    • See how Sharpe and total return degrade.

  4. Out-of-sample test:

    • Use first 60–70% of the period to pick parameters.

    • Freeze them and evaluate on the remaining 30–40%.

  5. 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.

9. Conclusion

We’ve built and fully walked through a cross-sectional crypto momentum strategy with:

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: