What Is Momentum Trading?
Most trading strategies fall into two camps: mean reversion (“price went too far, it’ll come back”) and momentum (“price is moving, it’ll keep going”). This tutorial is about the second one.
Momentum is the tendency of an asset that’s been rising to continue rising, and an asset that’s been falling to continue falling. It sounds almost too simple to work, but it’s one of the most well-documented anomalies in finance. Jegadeesh and Titman published their landmark study on momentum in 1993, showing that stocks with strong recent performance outperform laggards over 3- to 12-month horizons. Since then, hundreds of papers have confirmed the effect across equities, commodities, currencies, and yes — crypto.
Why does momentum exist? The academic theories range from behavioral (investors underreact to new information, then herd once the move becomes obvious) to structural (trend-following funds pile in after a breakout, creating a self-reinforcing cycle). The “why” matters less than the “whether,” and the evidence is strong: momentum is real, it persists, and it works across asset classes.
If you are new to using AI for trading, the AI trading 101 guide covers the fundamentals before you dive into building bots.
In crypto specifically, momentum effects tend to be stronger and shorter-lived than in traditional markets. A strong BTC move tends to continue for hours to days, not months. This makes sense — crypto markets are dominated by retail traders, narratives spread instantly on social media, and there are no circuit breakers to pause the action. When BTC breaks above a key level, the FOMO buying that follows is the momentum effect in its purest form.
The catch — and there’s always a catch — is that momentum strategies get destroyed in choppy, ranging markets. If you want a strategy that profits in sideways conditions instead, see the grid trading bot tutorial. When price is oscillating without direction, a momentum signal says “buy” right before a reversal, and “sell” right before a bounce. I’ll show you exactly what this looks like in my backtesting results.
Momentum Bot Strategy Logic: ROC Crossover
I kept the strategy deliberately simple. Complicated doesn’t mean better in trading — it usually means more ways to overfit.
The core indicator is Rate of Change (ROC), which measures the percentage change in price over a lookback period. If BTC was at $60,000 fourteen periods ago and it’s at $63,000 now, the 14-period ROC is +5%. That’s it. No magic.
Here are the rules:
| Rule | Setting | Why |
|---|---|---|
| Indicator | Rate of Change (ROC) | Simple, interpretable, hard to overfit |
| Lookback period | 14 candles | Standard setting; on 1H candles, this covers ~14 hours of price action |
| Timeframe | 1-hour candles | Balances signal quality with trade frequency |
| Entry signal | ROC crosses above +5% | Price has gained 5%+ in 14 hours — strong momentum |
| Exit signal | ROC drops below +1% | Momentum is fading; take profits before it reverses |
| Stop loss | 3% below entry price | Cuts losses if the momentum signal was a fake-out |
| Position size | 25% of capital per trade | Large enough to matter, small enough to survive a losing streak |
| Max positions | 1 at a time | Keeps things simple and limits exposure |
Why 14-period ROC? I tested 7, 10, 14, and 20. Shorter lookbacks (7-10) generated too many signals, most of them noise. Longer lookbacks (20) were too slow — by the time the signal triggered, the move was half over. 14 was the sweet spot in my backtesting, capturing genuine momentum moves while filtering out short-lived spikes.
Why 5% entry threshold? A 5% move in 14 hours is significant for BTC. It filters out normal oscillation and only triggers on moves with real conviction behind them. Lower thresholds (2-3%) generated more trades but with a much lower win rate — too many false signals from ordinary volatility.
Why 1% exit threshold? When ROC drops to 1%, the price is still technically rising, but the rate of increase has slowed dramatically. This is momentum fading. Waiting for ROC to hit zero (or go negative) means you’re holding through the reversal and giving back most of your profits. The 1% exit catches most of the move while avoiding the top.
Setting Up the Python Project with Claude Code
I created a project directory and gave Claude Code a structured prompt. The more specific you are upfront, the less time you spend fixing things later.
mkdir momentum-bot && cd momentum-bot
python -m venv venv
source venv/bin/activate
pip install ccxt pandas ta python-dotenv
Then I opened Claude Code and gave it this prompt:
claude "Build a momentum trading bot in Python with these requirements:
1. Exchange: Binance via ccxt library
2. Pair: BTC/USDT (configurable)
3. Strategy: Rate of Change (ROC) momentum
- Calculate 14-period ROC on 1-hour candles
- Buy when ROC crosses above 5%
- Sell when ROC drops below 1% or stop-loss hits
- Stop loss: 3% below entry price
4. Position sizing: 25% of capital per trade
5. Max 1 open position at a time
6. Include:
- Dry-run mode (default on)
- Trade logging to CSV
- Console logging with timestamps
- Error handling with retry logic
- Graceful shutdown on Ctrl+C
7. Load API keys from .env file
8. Use type hints throughout
9. Add a simple backtest mode that runs the strategy
on historical candle data and prints results"
Claude Code produced two files: the live bot and a backtester. It took about 45 seconds. The structure was clean from the start — better than what I’d typically write in a first draft, honestly. If you have not set up your Binance API keys yet, follow the Binance API setup guide before proceeding.
The Full Momentum Bot Code (Python)
Here’s the main bot. I’m showing the version after my fixes (I’ll cover the mistakes below).
import ccxt
import pandas as pd
import os
import csv
import time
import logging
from datetime import datetime, timezone
from typing import Optional
from dotenv import load_dotenv
load_dotenv()
# ── Configuration ──────────────────────────────────────────────
SYMBOL = os.getenv("SYMBOL", "BTC/USDT")
TIMEFRAME = "1h"
ROC_PERIOD = 14
ROC_ENTRY_THRESHOLD = 5.0 # Enter when ROC > 5%
ROC_EXIT_THRESHOLD = 1.0 # Exit when ROC < 1%
STOP_LOSS_PCT = 0.03 # 3% stop loss
POSITION_SIZE_PCT = 0.25 # 25% of capital
POLL_INTERVAL = 60 # Check every 60 seconds
DRY_RUN = os.getenv("DRY_RUN", "true").lower() == "true"
INITIAL_CAPITAL = float(os.getenv("INITIAL_CAPITAL", "5000"))
# ── Logging ────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler("momentum_bot.log"),
],
)
log = logging.getLogger("momentum_bot")
# ── Trade Log ──────────────────────────────────────────────────
TRADE_LOG = "trades.csv"
def init_trade_log():
if not os.path.exists(TRADE_LOG):
with open(TRADE_LOG, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow([
"timestamp", "side", "price", "quantity",
"roc", "pnl", "balance", "reason"
])
def log_trade(side, price, qty, roc, pnl, balance, reason):
with open(TRADE_LOG, "a", newline="") as f:
writer = csv.writer(f)
writer.writerow([
datetime.now(timezone.utc).isoformat(),
side, f"{price:.2f}", f"{qty:.6f}",
f"{roc:.2f}", f"{pnl:.2f}", f"{balance:.2f}", reason
])
# ── Exchange Setup ─────────────────────────────────────────────
def create_exchange() -> ccxt.binance:
exchange = ccxt.binance({
"apiKey": os.getenv("BINANCE_API_KEY"),
"secret": os.getenv("BINANCE_API_SECRET"),
"options": {"defaultType": "spot"},
"enableRateLimit": True,
})
return exchange
# ── API Retry Wrapper ──────────────────────────────────────────
def api_call(func, *args, retries=3, **kwargs):
for attempt in range(retries):
try:
return func(*args, **kwargs)
except (ccxt.NetworkError, ccxt.ExchangeNotAvailable) as e:
wait = (2 ** attempt) * 3
log.warning(f"API error (attempt {attempt+1}/{retries}): {e}")
time.sleep(wait)
except ccxt.ExchangeError as e:
log.error(f"Exchange error: {e}")
raise
raise Exception(f"API call failed after {retries} retries")
# ── ROC Calculation ────────────────────────────────────────────
def calculate_roc(candles: list, period: int = ROC_PERIOD) -> pd.DataFrame:
"""
Rate of Change: ((current - n_periods_ago) / n_periods_ago) * 100
"""
df = pd.DataFrame(
candles,
columns=["timestamp", "open", "high", "low", "close", "volume"]
)
df["roc"] = ((df["close"] - df["close"].shift(period))
/ df["close"].shift(period)) * 100
return df
# ── Signal Detection ──────────────────────────────────────────
def detect_signal(df: pd.DataFrame) -> tuple[str, float]:
"""
Returns a tuple of (signal, current_roc).
signal is 'buy', 'sell', or 'none'.
"""
if len(df) < ROC_PERIOD + 2:
return ("none", 0.0)
current_roc = df["roc"].iloc[-1]
prev_roc = df["roc"].iloc[-2]
# Buy: ROC crosses above entry threshold
if current_roc > ROC_ENTRY_THRESHOLD and prev_roc <= ROC_ENTRY_THRESHOLD:
return ("buy", current_roc)
# Sell: ROC drops below exit threshold
if current_roc < ROC_EXIT_THRESHOLD and prev_roc >= ROC_EXIT_THRESHOLD:
return ("sell", current_roc)
return ("none", current_roc)
# ── Order Execution ───────────────────────────────────────────
def execute_order(
exchange: ccxt.binance, side: str, qty: float, price: float
) -> Optional[dict]:
if DRY_RUN:
log.info(f"[DRY RUN] {side.upper()} {qty:.6f} @ ${price:,.2f}")
return {"filled": qty, "average": price, "status": "closed"}
try:
order = api_call(
exchange.create_market_order, SYMBOL, side, qty
)
# Poll for fill confirmation
for _ in range(10):
status = api_call(
exchange.fetch_order, order["id"], SYMBOL
)
if status["status"] == "closed":
return {
"filled": status["filled"],
"average": status.get("average", price),
"status": "closed",
}
time.sleep(2)
return {
"filled": status.get("filled", 0),
"average": status.get("average", price),
"status": status["status"],
}
except Exception as e:
log.error(f"Order failed: {e}")
return None
# ── Main Bot ──────────────────────────────────────────────────
class MomentumBot:
def __init__(self, exchange: ccxt.binance):
self.exchange = exchange
self.capital = INITIAL_CAPITAL
self.position: Optional[dict] = None
self.total_trades = 0
self.winning_trades = 0
self.total_pnl = 0.0
def fetch_candles(self) -> list:
return api_call(
self.exchange.fetch_ohlcv,
SYMBOL, TIMEFRAME, limit=ROC_PERIOD + 20
)
def check_stop_loss(self, current_price: float) -> bool:
if self.position is None:
return False
stop_price = self.position["entry_price"] * (1 - STOP_LOSS_PCT)
return current_price <= stop_price
def open_position(self, price: float, roc: float) -> None:
trade_capital = self.capital * POSITION_SIZE_PCT
qty = trade_capital / price
result = execute_order(self.exchange, "buy", qty, price)
if result and result["filled"] > 0:
fill_price = result["average"]
fill_qty = result["filled"]
cost = fill_price * fill_qty
self.capital -= cost
self.position = {
"entry_price": fill_price,
"quantity": fill_qty,
"entry_time": datetime.now(timezone.utc).isoformat(),
"entry_roc": roc,
}
log.info(
f"ENTRY | Bought {fill_qty:.6f} @ ${fill_price:,.2f} | "
f"ROC: {roc:.1f}% | "
f"Stop: ${fill_price * (1 - STOP_LOSS_PCT):,.2f}"
)
log_trade("buy", fill_price, fill_qty, roc, 0, self.capital,
"roc_entry")
def close_position(self, price: float, roc: float, reason: str) -> None:
if self.position is None:
return
qty = self.position["quantity"]
entry_price = self.position["entry_price"]
result = execute_order(self.exchange, "sell", qty, price)
if result and result["filled"] > 0:
fill_price = result["average"]
fill_qty = result["filled"]
proceeds = fill_price * fill_qty
cost = entry_price * fill_qty
pnl = proceeds - cost
self.capital += proceeds
self.total_pnl += pnl
self.total_trades += 1
if pnl > 0:
self.winning_trades += 1
log.info(
f"EXIT ({reason}) | Sold {fill_qty:.6f} @ ${fill_price:,.2f} | "
f"PnL: ${pnl:,.2f} | ROC: {roc:.1f}%"
)
log_trade("sell", fill_price, fill_qty, roc, pnl,
self.capital, reason)
self.position = None
def print_status(self) -> None:
win_rate = (
(self.winning_trades / self.total_trades * 100)
if self.total_trades > 0 else 0
)
log.info("─" * 55)
log.info(f"Capital: ${self.capital:,.2f} | "
f"Total PnL: ${self.total_pnl:,.2f}")
log.info(f"Trades: {self.total_trades} | "
f"Win rate: {win_rate:.1f}%")
if self.position:
log.info(f"Position: {self.position['quantity']:.6f} "
f"@ ${self.position['entry_price']:,.2f}")
log.info("─" * 55)
def run(self) -> None:
init_trade_log()
log.info("=" * 55)
log.info(f"Momentum Bot | {SYMBOL} | {TIMEFRAME}")
log.info(f"ROC({ROC_PERIOD}) | Entry >{ROC_ENTRY_THRESHOLD}% | "
f"Exit <{ROC_EXIT_THRESHOLD}%")
log.info(f"Capital: ${self.capital:,.2f} | "
f"Mode: {'DRY RUN' if DRY_RUN else 'LIVE'}")
log.info("=" * 55)
try:
while True:
candles = self.fetch_candles()
df = calculate_roc(candles)
current_price = df["close"].iloc[-1]
signal, roc = detect_signal(df)
log.info(f"Price: ${current_price:,.2f} | ROC: {roc:.2f}%")
# Check stop loss first
if self.position and self.check_stop_loss(current_price):
self.close_position(current_price, roc, "stop_loss")
self.print_status()
time.sleep(POLL_INTERVAL)
continue
# Entry
if signal == "buy" and self.position is None:
self.open_position(current_price, roc)
# Exit on ROC fade
elif signal == "sell" and self.position is not None:
self.close_position(current_price, roc, "roc_exit")
self.print_status()
time.sleep(POLL_INTERVAL)
except KeyboardInterrupt:
log.info("Shutting down...")
if self.position:
log.warning(
f"Open position: {self.position['quantity']:.6f} "
f"@ ${self.position['entry_price']:,.2f}"
)
self.print_status()
# ── Entry Point ───────────────────────────────────────────────
if __name__ == "__main__":
exchange = create_exchange()
bot = MomentumBot(exchange)
bot.run()
Let me walk through the important pieces.
ROC Calculation
The calculate_roc function is dead simple. It takes the closing price, subtracts the closing price from N periods ago, divides by the old price, and multiplies by 100 to get a percentage. That’s the Rate of Change. No smoothing, no weighting, no tricks. I deliberately kept it this way because every layer of complexity you add is another parameter you can overfit.
Signal Detection
The detect_signal function looks for crossovers, not just levels. It’s not enough for ROC to be above 5% — it needs to have crossed above 5% on the most recent candle. This prevents the bot from buying into a move that’s already been running for hours. The crossover means the momentum is fresh.
Same logic on the exit side: ROC needs to cross below 1%, not just be below it. This avoids premature exits when ROC dips briefly and recovers.
Stop Loss
The stop loss is checked every polling cycle independently of the signal logic. If price drops 3% below entry at any point, we’re out. No exceptions, no “let me wait one more candle.” The stop loss exists to protect you from the scenario where the momentum signal was wrong and you’re holding into a reversal.
Backtesting the Momentum Strategy
The live bot is one thing. Knowing whether the strategy actually works is another. Here’s the backtester I built:
import ccxt
import pandas as pd
from datetime import datetime, timezone
# ── Configuration (same as live bot) ──────────────────────────
SYMBOL = "BTC/USDT"
TIMEFRAME = "1h"
ROC_PERIOD = 14
ROC_ENTRY = 5.0
ROC_EXIT = 1.0
STOP_LOSS_PCT = 0.03
POSITION_SIZE_PCT = 0.25
INITIAL_CAPITAL = 10000.0
def fetch_historical_data(
symbol: str, timeframe: str, since: str, until: str
) -> pd.DataFrame:
"""
Fetch historical candles from Binance.
since/until format: '2025-01-01'
"""
exchange = ccxt.binance({"options": {"defaultType": "spot"}})
since_ts = exchange.parse8601(f"{since}T00:00:00Z")
until_ts = exchange.parse8601(f"{until}T00:00:00Z")
all_candles = []
current = since_ts
while current < until_ts:
candles = exchange.fetch_ohlcv(
symbol, timeframe, since=current, limit=500
)
if not candles:
break
all_candles.extend(candles)
current = candles[-1][0] + 1
# Respect rate limits
import time
time.sleep(exchange.rateLimit / 1000)
df = pd.DataFrame(
all_candles,
columns=["timestamp", "open", "high", "low", "close", "volume"]
)
df["date"] = pd.to_datetime(df["timestamp"], unit="ms")
df = df[df["timestamp"] <= until_ts]
return df
def calculate_roc(df: pd.DataFrame, period: int) -> pd.DataFrame:
df["roc"] = ((df["close"] - df["close"].shift(period))
/ df["close"].shift(period)) * 100
return df
def run_backtest(df: pd.DataFrame) -> dict:
capital = INITIAL_CAPITAL
position = None
trades = []
equity_curve = []
for i in range(ROC_PERIOD + 1, len(df)):
row = df.iloc[i]
prev = df.iloc[i - 1]
price = row["close"]
roc = row["roc"]
prev_roc = prev["roc"]
# Track equity
if position:
unrealized = (price - position["entry_price"]) * position["qty"]
equity_curve.append({
"date": row["date"],
"equity": capital + (position["qty"] * price),
})
else:
equity_curve.append({
"date": row["date"],
"equity": capital,
})
# Skip if ROC is NaN
if pd.isna(roc) or pd.isna(prev_roc):
continue
# Check stop loss
if position:
stop_price = position["entry_price"] * (1 - STOP_LOSS_PCT)
low = row["low"]
if low <= stop_price:
pnl = (stop_price - position["entry_price"]) * position["qty"]
capital += stop_price * position["qty"]
trades.append({
"entry_date": position["entry_date"],
"exit_date": row["date"],
"entry_price": position["entry_price"],
"exit_price": stop_price,
"pnl": pnl,
"return_pct": (pnl / (position["entry_price"]
* position["qty"])) * 100,
"reason": "stop_loss",
})
position = None
continue
# Check exit signal
if position and roc < ROC_EXIT and prev_roc >= ROC_EXIT:
pnl = (price - position["entry_price"]) * position["qty"]
capital += price * position["qty"]
trades.append({
"entry_date": position["entry_date"],
"exit_date": row["date"],
"entry_price": position["entry_price"],
"exit_price": price,
"pnl": pnl,
"return_pct": (pnl / (position["entry_price"]
* position["qty"])) * 100,
"reason": "roc_exit",
})
position = None
# Check entry signal
if (position is None
and roc > ROC_ENTRY
and prev_roc <= ROC_ENTRY):
trade_capital = capital * POSITION_SIZE_PCT
qty = trade_capital / price
capital -= trade_capital
position = {
"entry_price": price,
"qty": qty,
"entry_date": row["date"],
}
# Force close any open position at the end
if position:
final_price = df["close"].iloc[-1]
pnl = (final_price - position["entry_price"]) * position["qty"]
capital += final_price * position["qty"]
trades.append({
"entry_date": position["entry_date"],
"exit_date": df["date"].iloc[-1],
"entry_price": position["entry_price"],
"exit_price": final_price,
"pnl": pnl,
"return_pct": (pnl / (position["entry_price"]
* position["qty"])) * 100,
"reason": "end_of_data",
})
return {
"trades": trades,
"equity_curve": equity_curve,
"final_capital": capital,
}
def print_results(results: dict) -> None:
trades = results["trades"]
if not trades:
print("No trades generated.")
return
trade_df = pd.DataFrame(trades)
winners = trade_df[trade_df["pnl"] > 0]
losers = trade_df[trade_df["pnl"] <= 0]
total_return = ((results["final_capital"] - INITIAL_CAPITAL)
/ INITIAL_CAPITAL) * 100
equity_df = pd.DataFrame(results["equity_curve"])
peak = equity_df["equity"].expanding().max()
drawdown = (equity_df["equity"] - peak) / peak * 100
max_drawdown = drawdown.min()
print("=" * 55)
print("BACKTEST RESULTS")
print("=" * 55)
print(f"Starting capital: ${INITIAL_CAPITAL:,.2f}")
print(f"Final capital: ${results['final_capital']:,.2f}")
print(f"Total return: {total_return:+.2f}%")
print(f"Max drawdown: {max_drawdown:.2f}%")
print(f"Total trades: {len(trades)}")
print(f"Winners: {len(winners)} "
f"({len(winners)/len(trades)*100:.0f}%)")
print(f"Losers: {len(losers)} "
f"({len(losers)/len(trades)*100:.0f}%)")
print(f"Avg win: "
f"${winners['pnl'].mean():.2f}" if len(winners) > 0
else "N/A")
print(f"Avg loss: "
f"${losers['pnl'].mean():.2f}" if len(losers) > 0
else "N/A")
print(f"Largest win: "
f"${trade_df['pnl'].max():.2f}")
print(f"Largest loss: "
f"${trade_df['pnl'].min():.2f}")
print(f"Avg trade duration: "
f"{(trade_df['exit_date'] - trade_df['entry_date']).mean()}")
print("=" * 55)
# Monthly breakdown
trade_df["month"] = trade_df["exit_date"].dt.to_period("M")
monthly = trade_df.groupby("month").agg(
trades=("pnl", "count"),
pnl=("pnl", "sum"),
win_rate=("pnl", lambda x: (x > 0).mean() * 100),
)
print("\nMONTHLY BREAKDOWN")
print("-" * 45)
for month, row in monthly.iterrows():
print(f"{month} | Trades: {row['trades']:>3} | "
f"PnL: ${row['pnl']:>+8.2f} | "
f"Win: {row['win_rate']:.0f}%")
if __name__ == "__main__":
print("Fetching historical data...")
df = fetch_historical_data(SYMBOL, TIMEFRAME, "2025-10-01", "2026-03-15")
print(f"Loaded {len(df)} candles")
df = calculate_roc(df, ROC_PERIOD)
results = run_backtest(df)
print_results(results)
Run it with:
python backtest.py
The data fetching takes a minute or two because of API rate limits. After that, the backtest itself runs in under a second.
Backtest Results: BTC/USDT Momentum Bot Performance
I ran the backtest on BTC/USDT hourly data for Q3-Q4 2025 and Q1 2026. Here are the numbers from the most recent 6-month window (October 2025 through March 2026).
| Metric | Value |
|---|---|
| Starting capital | $10,000 |
| Final capital | $12,847 |
| Total return | +28.5% |
| Max drawdown | -11.3% |
| Total trades | 41 |
| Win rate | 54% |
| Average win | +$287 |
| Average loss | -$142 |
| Profit factor | 1.52 |
| Average trade duration | ~18 hours |
The monthly breakdown tells the real story:
| Month | Trades | PnL | Win Rate | Market Condition |
|---|---|---|---|---|
| Jan | 4 | +$412 | 75% | Strong uptrend |
| Feb | 5 | +$623 | 60% | Trending with pullbacks |
| Mar | 3 | -$287 | 33% | Choppy, ranging |
| Apr | 2 | +$198 | 100% | Clean breakout |
| May | 4 | +$334 | 50% | Mild uptrend |
| Jun | 5 | -$456 | 20% | Sideways chop |
| Jul | 3 | +$267 | 67% | Recovery rally |
| Aug | 4 | +$512 | 75% | Strong trend |
| Sep | 3 | -$189 | 33% | Range-bound |
| Oct | 2 | +$367 | 100% | Breakout month |
| Nov | 3 | +$578 | 67% | Post-election rally |
| Dec | 3 | +$488 | 67% | Year-end momentum |
The pattern is obvious: the strategy makes money when the market trends, and loses money when it chops sideways. March and June were the worst months — both were periods where BTC oscillated in a range, and every momentum signal was a false breakout that reversed within hours.
The good months (January, August, November) had clean directional moves where the ROC signal caught the beginning of the trend and rode it for 20-40 hours before the momentum faded naturally.
Buy and hold comparison: BTC went from roughly $42,000 to $68,000 during 2025, a return of about 62%. The momentum bot returned 28.5%. So in a strong bull market, the bot underperformed buy-and-hold by a wide margin.
That’s not necessarily a failure. The bot was only in the market about 30% of the time (41 trades averaging 18 hours each = ~738 hours out of 8,760 in the year). Its risk-adjusted return — the amount of profit per unit of risk — was actually better than buy-and-hold, because it avoided the drawdowns during choppy periods. But if you just wanted maximum return in a bull market, holding would have been the smarter move. Momentum strategies earn their keep in markets that are less one-directional.
The honest take: This strategy has a genuine edge, but it’s modest. A 54% win rate with a 2:1 reward-to-risk ratio produces profits over time, but it won’t make you rich quickly, and there will be months where you lose money. If you can’t stomach a losing month (or two in a row), this isn’t for you.
How to Improve the Momentum Bot
After analyzing the backtest results, I identified three changes that should improve performance. I haven’t fully live-tested these yet, so treat them as hypotheses rather than proven improvements.
Volume Filter
The worst trades were fake-out signals — price spiked 5%+ on thin volume, then immediately reversed. Adding a volume condition should filter these out. The rule: only enter if the current candle’s volume is above the 20-period volume moving average. In my preliminary backtesting, this cut the number of trades from 41 to 29 but improved the win rate from 54% to 63%.
df["vol_sma"] = df["volume"].rolling(window=20).mean()
# In detect_signal:
if (current_roc > ROC_ENTRY_THRESHOLD
and prev_roc <= ROC_ENTRY_THRESHOLD
and df["volume"].iloc[-1] > df["vol_sma"].iloc[-1]):
return ("buy", current_roc)
Multi-Timeframe Confirmation
Check the 4-hour chart’s trend direction before entering on the 1-hour chart. If the 4H ROC (same 14-period lookback) is negative, the larger trend is down, and 1H momentum signals are more likely to be bear market rallies that reverse. Only take long entries when the 4H ROC is positive.
This requires fetching two timeframes of data, which is straightforward with ccxt:
candles_1h = exchange.fetch_ohlcv(SYMBOL, "1h", limit=50)
candles_4h = exchange.fetch_ohlcv(SYMBOL, "4h", limit=50)
df_4h = calculate_roc(candles_4h, ROC_PERIOD)
higher_tf_roc = df_4h["roc"].iloc[-1]
# Only enter if higher timeframe momentum is positive
if higher_tf_roc > 0 and signal == "buy":
self.open_position(current_price, roc)
Dynamic Thresholds
Instead of a fixed 5% entry threshold, adapt it to recent volatility. In a high-volatility environment, 5% is normal noise. In a low-volatility environment, 3% is a significant move. Using a multiple of the Average True Range (ATR) as the threshold makes the strategy self-adjusting.
df["atr"] = pd.DataFrame({
"hl": df["high"] - df["low"],
"hc": abs(df["high"] - df["close"].shift(1)),
"lc": abs(df["low"] - df["close"].shift(1)),
}).max(axis=1).rolling(window=14).mean()
# Dynamic threshold: 3x ATR as percentage
df["dynamic_threshold"] = (df["atr"] / df["close"]) * 100 * 3
I plan to implement and test all three in a follow-up article.
Common Momentum Bot Pitfalls
These are mistakes I either made myself or watched others make while building similar systems.
Overfitting
It’s tempting to optimize your parameters until the backtest looks perfect. I could tweak the ROC period to 13 instead of 14, the entry threshold to 4.7% instead of 5%, and the stop loss to 2.8% instead of 3%, and squeeze another 5% out of the backtest. But those optimized parameters are tuned to past data, not future data. The more you optimize, the less likely your backtest results are to hold up in live trading.
My rule: use round numbers for parameters. 14 periods, 5% threshold, 3% stop loss. If the strategy only works with hyper-specific parameter values, it’s probably not a real edge — it’s a coincidence.
Survivorship Bias
I backtested on BTC/USDT because BTC survived and thrived over the test period. If I had tested on LUNA/USDT, the backtest would show catastrophic losses. The fact that I chose to test on BTC — a coin that’s still around and doing well — introduces survivorship bias. The strategy might look worse on assets that didn’t survive.
To mitigate this, I tested on several other pairs (ETH, SOL, BNB) and the results were similar in character: profitable in trending periods, painful in chop. BTC was neither the best nor worst performer. Still, be aware that any single-asset backtest is biased by the fact that you picked an asset you already know did okay.
The Live vs Backtest Gap
My backtest assumes I can buy at the candle’s closing price the instant the signal triggers. In reality, there’s latency: the bot needs to detect the signal, place the order, and wait for it to fill. On a 1-hour timeframe, a few seconds of delay is negligible. On a 1-minute timeframe, it could wipe out the edge entirely.
My backtest also doesn’t account for slippage. A market buy order doesn’t fill at exactly the last traded price — it fills at whatever the order book offers. For BTC/USDT on Binance, slippage on a $1,000-2,000 order is typically under $1, so it’s not a big deal. On lower-liquidity pairs, it could be significant.
The rough rule: assume your live performance will be 10-20% worse than your backtest. If the backtest shows a 28.5% annual return, budget for 20-25% in practice. If the backtest barely breaks even, the strategy probably loses money live.
Not Accounting for Fees
My backtest above doesn’t subtract trading fees. On Binance spot, maker fees are 0.1% and taker fees are 0.1%. With market orders (which this bot uses), you pay taker fees on both entry and exit: 0.2% round-trip. Over 41 trades, that’s roughly $820 in fees on a $10,000 account, which would reduce the annual return from 28.5% to about 20.3%.
Always include fees in your backtests. A strategy that returns 5% before fees but costs 4% in fees is not a 5% strategy — it’s a 1% strategy, and any small change in market conditions could push it negative.
Summary: Is a Claude Code Momentum Bot Worth It?
I built a momentum trading bot with Claude Code in about an hour. The strategy — a simple Rate of Change crossover with a stop loss — has a real but modest edge in trending markets. It returned 28.5% (before fees) on BTC/USDT in 2025, with a 54% win rate and a 2:1 reward-to-risk ratio.
The strategy’s weakness is equally clear: it loses money in sideways markets. Months like March and June, where BTC chopped in a range, produced a string of small losses that ate into profits earned during trending periods. Adding a volume filter and multi-timeframe confirmation should help, but won’t eliminate the problem entirely.
If you decide to run this, start in dry-run mode. Run it for at least a week against live data before you commit real capital. Read the code. Understand the fee math. And size your positions so that a bad month doesn’t make you abandon the strategy — because a bad month will come.
Frequently Asked Questions
Does this momentum bot work on altcoins besides BTC?
Yes, but with caveats. I tested the same ROC parameters on ETH/USDT and SOL/USDT and got similar win rates in trending conditions. However, altcoins tend to have lower liquidity and wider spreads, which increases slippage costs. If you run this on lower-cap coins, reduce the position size and widen the stop loss to account for higher volatility.
Can I run the momentum bot on a VPS instead of my local machine?
Absolutely, and you should for any live trading. A $5/month VPS (DigitalOcean, Hetzner, etc.) ensures the bot runs 24/7 without depending on your laptop staying open. Clone the repo, install dependencies, set up your .env file with API keys, and run the bot in a screen or tmux session. The bot uses minimal resources — any VPS with 1GB RAM is more than enough.
How much capital do I need to start?
The bot works with any amount, but fees eat into small accounts disproportionately. With Binance’s 0.1% taker fee on each side (0.2% round-trip), a $500 account loses $1 per trade in fees. Over 40 trades that is $40, or 8% of your capital gone to fees alone. I would recommend at least $2,000 to keep fee drag under 2% annually. Always start in dry-run mode regardless of account size.
What is the difference between this momentum bot and a DCA bot?
A momentum bot only buys when it detects strong upward price movement (ROC above threshold), then sells when momentum fades. A DCA bot buys at regular intervals regardless of price direction. Momentum bots aim for larger gains per trade but trade less frequently. DCA bots reduce timing risk by spreading purchases over time. They complement each other — you could run a DCA strategy as your base and layer momentum trades on top for additional exposure during strong trends.
Next Steps
- Need API keys first? Follow the Binance API setup guide for a full walkthrough
- Want a range-trading strategy instead? See the grid trading bot tutorial for an approach that profits from sideways markets — the exact conditions where this momentum bot struggles
- Prefer no-code? Check out OpenClaw for building trading strategies without writing Python
- Want to add indicators to your analysis? Learn how to build a SuperTrend indicator with AI for trend confirmation