Skip to main content

crypto momentum algo - btc breakout strategy implementation

BTC broke out of 3-month range today.

my momentum algo caught it.

time to document the implementation.

the context
#

BTC been consolidating between $25,000 and $28,000 since june.

today it broke $28,500 with volume.

my algo was positioned.

the strategy
#

pure momentum. no predictions.

core logic:

  1. detect range breakout with volume confirmation
  2. enter on first pullback after breakout
  3. trail stop with ATR-based levels
  4. size based on volatility regime

the implementation
#

import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import Optional, Tuple, List
from enum import Enum
import ccxt
from datetime import datetime, timedelta
import asyncio

class PositionState(Enum):
    FLAT = 0
    LONG = 1
    SHORT = -1

@dataclass
class MomentumConfig:
    # range detection
    range_lookback: int = 60  # days for range calculation
    breakout_threshold: float = 1.02  # 2% above range high
    volume_multiplier: float = 2.0  # volume must be 2x average

    # entry
    pullback_pct: float = 0.015  # enter on 1.5% pullback
    max_pullback_pct: float = 0.04  # don't enter if pullback > 4%

    # exit
    atr_period: int = 14
    atr_stop_mult: float = 2.5  # stop at 2.5x ATR
    atr_trail_mult: float = 1.5  # trail at 1.5x ATR
    take_profit_mult: float = 4.0  # TP at 4x ATR

    # risk
    risk_per_trade: float = 0.01  # 1% account risk
    max_position_pct: float = 0.10  # max 10% of account in single position

@dataclass
class TradeState:
    position: PositionState = PositionState.FLAT
    entry_price: Optional[float] = None
    entry_time: Optional[datetime] = None
    stop_price: Optional[float] = None
    trail_high: Optional[float] = None
    size: float = 0.0

class CryptoMomentumAlgo:
    def __init__(self, config: MomentumConfig, exchange: ccxt.Exchange,
                 account_size: float):
        self.config = config
        self.exchange = exchange
        self.account_size = account_size
        self.state = TradeState()
        self.trade_history: List[dict] = []

    def calculate_atr(self, df: pd.DataFrame) -> pd.Series:
        """Average True Range calculation"""
        high = df['high']
        low = df['low']
        close = df['close'].shift(1)

        tr1 = high - low
        tr2 = abs(high - close)
        tr3 = abs(low - close)

        tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
        atr = tr.rolling(window=self.config.atr_period).mean()
        return atr

    def detect_range(self, df: pd.DataFrame) -> Tuple[float, float]:
        """Detect trading range over lookback period"""
        lookback_data = df.tail(self.config.range_lookback)
        range_high = lookback_data['high'].max()
        range_low = lookback_data['low'].min()
        return range_high, range_low

    def check_volume_confirmation(self, df: pd.DataFrame) -> bool:
        """Check if current volume confirms breakout"""
        current_vol = df['volume'].iloc[-1]
        avg_vol = df['volume'].tail(20).mean()
        return current_vol > avg_vol * self.config.volume_multiplier

    def calculate_position_size(self, entry_price: float,
                                stop_price: float) -> float:
        """Size position based on risk parameters"""
        risk_amount = self.account_size * self.config.risk_per_trade
        stop_distance = abs(entry_price - stop_price)

        if stop_distance == 0:
            return 0

        size_from_risk = risk_amount / stop_distance
        max_size = (self.account_size * self.config.max_position_pct) / entry_price

        return min(size_from_risk, max_size)

    async def check_breakout(self, df: pd.DataFrame) -> Optional[dict]:
        """Check for range breakout with confirmation"""
        range_high, range_low = self.detect_range(df)
        current_price = df['close'].iloc[-1]
        atr = self.calculate_atr(df).iloc[-1]

        breakout_signal = None

        # long breakout
        if current_price > range_high * self.config.breakout_threshold:
            if self.check_volume_confirmation(df):
                breakout_signal = {
                    'direction': 'LONG',
                    'breakout_level': range_high,
                    'current_price': current_price,
                    'atr': atr,
                    'pullback_entry': current_price * (1 - self.config.pullback_pct),
                    'stop': current_price - (atr * self.config.atr_stop_mult)
                }

        # short breakout
        elif current_price < range_low * (2 - self.config.breakout_threshold):
            if self.check_volume_confirmation(df):
                breakout_signal = {
                    'direction': 'SHORT',
                    'breakout_level': range_low,
                    'current_price': current_price,
                    'atr': atr,
                    'pullback_entry': current_price * (1 + self.config.pullback_pct),
                    'stop': current_price + (atr * self.config.atr_stop_mult)
                }

        return breakout_signal

    async def manage_position(self, current_price: float,
                              atr: float) -> Optional[str]:
        """Manage existing position with trailing stop"""
        if self.state.position == PositionState.FLAT:
            return None

        action = None

        if self.state.position == PositionState.LONG:
            # update trail high
            if self.state.trail_high is None or current_price > self.state.trail_high:
                self.state.trail_high = current_price
                self.state.stop_price = max(
                    self.state.stop_price,
                    current_price - (atr * self.config.atr_trail_mult)
                )

            # check stop
            if current_price < self.state.stop_price:
                action = 'EXIT_STOP'

            # check take profit
            entry = self.state.entry_price
            tp_level = entry + (atr * self.config.take_profit_mult)
            if current_price > tp_level:
                action = 'EXIT_TP'

        elif self.state.position == PositionState.SHORT:
            # update trail low
            if self.state.trail_high is None or current_price < self.state.trail_high:
                self.state.trail_high = current_price
                self.state.stop_price = min(
                    self.state.stop_price,
                    current_price + (atr * self.config.atr_trail_mult)
                )

            # check stop
            if current_price > self.state.stop_price:
                action = 'EXIT_STOP'

            # check take profit
            entry = self.state.entry_price
            tp_level = entry - (atr * self.config.take_profit_mult)
            if current_price < tp_level:
                action = 'EXIT_TP'

        return action

    async def execute_entry(self, signal: dict) -> bool:
        """Execute entry order via exchange API"""
        try:
            size = self.calculate_position_size(
                signal['pullback_entry'],
                signal['stop']
            )

            if size <= 0:
                return False

            # place limit order at pullback level
            order = await asyncio.to_thread(
                self.exchange.create_limit_buy_order,
                'BTC/USDT',
                size,
                signal['pullback_entry']
            )

            self.state = TradeState(
                position=PositionState.LONG if signal['direction'] == 'LONG'
                         else PositionState.SHORT,
                entry_price=signal['pullback_entry'],
                entry_time=datetime.now(),
                stop_price=signal['stop'],
                trail_high=signal['current_price'],
                size=size
            )

            return True

        except Exception as e:
            print(f"Entry failed: {e}")
            return False

    async def execute_exit(self, current_price: float, reason: str) -> bool:
        """Execute exit order via exchange API"""
        try:
            order = await asyncio.to_thread(
                self.exchange.create_market_sell_order,
                'BTC/USDT',
                self.state.size
            )

            # record trade
            pnl = (current_price - self.state.entry_price) * self.state.size
            if self.state.position == PositionState.SHORT:
                pnl = -pnl

            self.trade_history.append({
                'entry_time': self.state.entry_time,
                'exit_time': datetime.now(),
                'entry_price': self.state.entry_price,
                'exit_price': current_price,
                'size': self.state.size,
                'pnl': pnl,
                'pnl_pct': pnl / (self.state.entry_price * self.state.size),
                'reason': reason
            })

            # reset state
            self.state = TradeState()
            return True

        except Exception as e:
            print(f"Exit failed: {e}")
            return False

    def get_stats(self) -> dict:
        """Calculate strategy statistics"""
        if not self.trade_history:
            return {}

        df = pd.DataFrame(self.trade_history)
        wins = df[df['pnl'] > 0]
        losses = df[df['pnl'] <= 0]

        return {
            'total_trades': len(df),
            'win_rate': len(wins) / len(df) if len(df) > 0 else 0,
            'total_pnl': df['pnl'].sum(),
            'avg_win': wins['pnl'].mean() if len(wins) > 0 else 0,
            'avg_loss': losses['pnl'].mean() if len(losses) > 0 else 0,
            'profit_factor': abs(wins['pnl'].sum() / losses['pnl'].sum())
                if len(losses) > 0 and losses['pnl'].sum() != 0 else float('inf'),
            'largest_win': wins['pnl'].max() if len(wins) > 0 else 0,
            'largest_loss': losses['pnl'].min() if len(losses) > 0 else 0
        }

today’s trade
#

9:47am: BTC breaks $28,500 with 2.3x average volume

10:12am: algo sets pullback entry at $28,100

11:34am: BTC pulls back to $28,080, entry filled

stop: $26,850 (2.5x ATR below entry)

current: $28,920, trailing stop at $27,650

unrealized P&L: +$840 (holding)

why momentum works in crypto
#

crypto doesn’t mean revert like equities.

when BTC breaks a level with volume, it tends to trend.

24/7 markets, global participants, FOMO dynamics.

momentum > mean reversion in crypto. learned this the hard way in 2023.


2:48am friday (triple witching). BTC broke $28,500 range - 3 month consolidation ended. momentum algo caught the breakout. entry at $28,080 pullback, stop $26,850, currently at $28,920. algo uses ATR trailing stops and volume confirmation. holding overnight.

-AK

Related

mean reversion implementation - statistical edge in practice
finally deploying the mean reversion algo I’ve been backtesting since june. 6 months of development. time to go live. the edge # simple concept: prices that deviate from their mean tend to revert.
fall volatility algo adaptation - regime detection update
first real trading days since vacation. volatility already picking up. VIX hit 16.2 today. time to adapt. the seasonal shift # summer algo settings don’t work in fall.
coinbase advanced vs kraken - python API comparison for algo trading
been using both coinbase and kraken for 2+ years. here’s the real comparison for algo traders. quick verdict # coinbase advanced: better for fiat on/off ramp, simpler API
kraken vs coinbase - crypto algo trading python API comparison 2025
been using both for 2 years. kraken for serious trading. coinbase for fiat onramp. time for comparison. my setup # kraken:
researching crypto altcoin momentum - expanding beyond btc/eth
crypto allocation is 30% but only trading BTC/ETH. leaving money on table. been researching altcoins on r/CryptoCurrency and CoinGecko. current crypto setup # allocation: $107,700 (30% of $359k account)
momentum breakout strategy - how it works
momentum strategy has 5 wins, 0 losses. time to explain how it works. core concept # capture trending moves after consolidation breaks.