DEV Community

maymay5692
maymay5692

Posted on • Edited on

I Built a Crypto Trading Bot in Python — Here's the Whole Thing

I wanted a trading bot that actually ran on real exchanges, not a tutorial that stops at "and now you have a backtest." So I built one. It downloads market data, backtests 50 strategies, picks the best ones, and trades live on an exchange with real money. The whole thing is in Python, and I'm planning to open-source it soon.

This is everything I learned building it — the architecture, the code, the parts that broke, and the parts I'd do differently.

Why I Built This

I kept finding the same two kinds of crypto bot tutorials online. The first kind calculates a moving average on a DataFrame and calls it a day. The second kind is a sales pitch for some cloud platform. Neither of them actually connects to an exchange API, places real orders, or handles what happens when your bot crashes mid-trade.

I wanted something end-to-end. Download data, test strategies against real historical prices, then flip a switch and let it trade. One codebase, no gaps between "research" and "production."

What You'll Need

Before we get into code — the prerequisites:

  • Python 3.11+ (I use 3.12, but 3.11 works fine)
  • An exchange account — you need API keys to fetch data and place orders. I use MEXC because the spot maker fees are zero and the API is fast. Any ccxt-compatible exchange works, though.

Once the repo is public, setup will look like this:

cd crypto-backtest-engine
pip install -e ".[dev]"
Enter fullscreen mode Exit fullscreen mode

The Architecture

Here's the project layout:

crypto-backtest-engine/
├── src/
│   ├── core/           # Backtest engine, portfolio, metrics
│   ├── data/           # Data download and storage (Parquet)
│   ├── strategies/     # 50+ strategy implementations
│   ├── optimization/   # Grid search, Bayesian, Walk-Forward
│   ├── reporting/      # HTML reports with equity curves
│   └── live/           # Live trading bot
│       ├── main.py     # Main loop
│       ├── exchange.py # Exchange API wrapper (ccxt)
│       ├── bridge.py   # Signal → Order conversion
│       ├── config.py   # Environment-based config
│       └── risk/       # Circuit breaker, stop loss
├── scripts/            # CLI scripts for backtesting
├── data/               # Historical data (Parquet files)
└── results/            # Backtest reports
Enter fullscreen mode Exit fullscreen mode

Two distinct systems live in the same repo. The backtesting engine (src/core/, src/strategies/) runs historical simulations. The live bot (src/live/) runs on an exchange. They share strategy logic but have completely separate execution paths.

I tried putting them in a single unified system at first, but backtesting and live trading have completely different failure modes. A backtest can crash and you just re-run it. A live bot that crashes mid-order might leave you with an open position and no stop loss. The live bot needs state persistence, circuit breakers, and graceful shutdown — none of which make sense in a backtest.

Backtesting

Downloading Data

The first step is always data. The engine fetches OHLCV (Open, High, Low, Close, Volume) candles from exchanges via ccxt and stores them as Parquet files.

python scripts/download_data.py --symbols BTCUSDT --timeframes 1d --start-date 2023-01-01
Enter fullscreen mode Exit fullscreen mode

This gives you daily BTC/USDT candles from January 2023 to today. Parquet is faster than CSV for repeated reads, which matters when you're running 50 strategies back to back.

Running a Backtest

Pick a strategy, point it at your data:

python scripts/run_backtest.py \
  --strategy ema_crossover \
  --symbol BTCUSDT \
  --timeframe 1d \
  --generate-report
Enter fullscreen mode Exit fullscreen mode

This produces an HTML report in results/ with equity curves, drawdown charts, and monthly return heatmaps. The engine handles position sizing, fee calculation (0.1% per trade, 0.2% round trip), and all the metrics you'd expect — Sharpe ratio, Sortino, max drawdown, win rate, profit factor.

Mass Backtesting

The real power is running all strategies at once:

python scripts/run_mass_backtest.py \
  --symbols BTCUSDT \
  --timeframes 1d \
  --generate-reports
Enter fullscreen mode Exit fullscreen mode

I ran all 50 strategies on BTC/USDT daily data from 2023 through early 2026. Out of 50, only 12 cleared a Sharpe ratio of 1.0. The rest were mediocre or outright terrible.

Top Performers

Rank Strategy Sharpe Return Max Drawdown Trades
1 Multi-Timeframe 1.50 +546% -31.8% 2
2 EMA Crossover 1.30 +491% -34.0% 34
3 Parabolic SAR 1.25 +456% -37.3% 94
4 Triple MA 1.25 +502% -39.4% 20
5 MACD 1.17 +428% -33.2% 84

A word of caution: Multi-Timeframe is #1 by Sharpe, but it only made 2 trades. That's not statistically meaningful. EMA Crossover at #2 with 34 trades is a much better candidate for live deployment. MACD at #5 with 84 trades also gives you confidence that the results aren't just luck.

The 2023-2026 window is a strong bull run for BTC, so trend-following strategies look fantastic here. That doesn't mean they'll work in a sideways or bear market. Walk-Forward analysis helps catch that.

Walk-Forward Analysis: Catching Overfitting

This is the step most tutorials skip, and it's probably the most important one.

A standard backtest optimizes parameters on all available data, then evaluates performance on that same data. That's a recipe for overfitting — you find parameters that fit the past perfectly and predict the future terribly.

Walk-Forward splits the data into chunks. You optimize on the first chunk (in-sample), test on the next chunk (out-of-sample), then slide the window forward and repeat. The out-of-sample results give you a much more realistic picture of how the strategy will perform on unseen data.

I ran Walk-Forward on the top performers from the mass backtest. The results were humbling:

  • Multi-Timeframe (Sharpe 1.50 in backtest) — couldn't be evaluated. Only 2 trades total, not enough data for meaningful folds.
  • EMA Crossover — Held up reasonably well. Out-of-sample returns were lower but still positive. The simplicity of the strategy helps — fewer parameters mean less room for overfitting.
  • MACD — Scored A- grade with a robustness ratio of 0.46. The optimal Walk-Forward parameters (15/30/9) differed from the defaults, which tells you the optimization actually found something. Out-of-sample max drawdown was -18.7%, much more conservative than the in-sample results.

The robustness ratio is the key metric here. It's the ratio of out-of-sample to in-sample performance. A ratio of 0.46 means you keep about half the performance when moving to unseen data. That's realistic. If the ratio is above 0.8, you probably haven't tested enough out-of-sample periods. If it's below 0.2, the strategy is likely overfitted.

What About Machine Learning?

I tested 6 ML strategies: XGBoost, Random Forest, LSTM, LSTM+XGBoost Ensemble, DQN (reinforcement learning), and PPO. All on the same BTC/USDT daily data.

The result? Zero trades. Every single ML model either couldn't converge on meaningful features or produced signals so uncertain that they never crossed the confidence threshold.

This makes sense if you think about it. Daily candles for one crypto pair give you roughly 1,000 data points over 3 years. That's nothing for a model with hundreds or thousands of parameters. You'd need minute-level data across multiple pairs with engineered features to give ML a fair shot — and even then, crypto's non-stationarity makes it a brutal domain.

I left the ML strategies in the codebase for experimentation, but for actual trading? Stick with the simple stuff.

The Live Trading Bot

This is where it gets real. The live bot connects to an exchange, checks for signals once per hour (daily candle strategy), and places actual orders.

Configuration

Everything is controlled through environment variables. Copy .env.example to .env:

# Exchange API credentials
MEXC_API_KEY=your_api_key_here
MEXC_SECRET=your_secret_here

# What to trade
TRADING_SYMBOL=BTC/USDT
TRADING_AMOUNT_USDT=1

# Strategy: ema_crossover or macd
STRATEGY=ema_crossover

# Safety first
DRY_RUN=true
CONFIRM_LIVE_TRADING=no
Enter fullscreen mode Exit fullscreen mode

You need API keys from your exchange. Go to API Management, create a key with Read + Trade permissions, and keep withdrawal disabled.

The Exchange Client

The bot talks to the exchange through a MexcClient wrapper around ccxt:

class MexcClient:
    def __init__(self, config: Config) -> None:
        self._config = config
        self._exchange = ccxt.mexc({
            "apiKey": config.api_key,
            "secret": config.secret,
            "enableRateLimit": True,
        })

    def fetch_ohlcv(self, symbol, timeframe="1h", limit=100):
        raw = self._exchange.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit)
        df = pd.DataFrame(raw, columns=["timestamp", "open", "high", "low", "close", "volume"])
        df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
        return df.set_index("timestamp")

    def create_market_buy_order(self, amount, symbol=None):
        if self._config.dry_run:
            logger.info("[DRY_RUN] Market BUY %s: amount=%.8f (not executed)", symbol, amount)
            return {"symbol": symbol, "side": "buy", "dry_run": True}
        return dict(self._exchange.create_market_buy_order(symbol, amount))
Enter fullscreen mode Exit fullscreen mode

The dry_run flag is critical. When DRY_RUN=true, the bot goes through the entire cycle — fetching candles, calculating signals, deciding to buy or sell — but skips the actual order. You see exactly what it would do without risking money.

Signal-to-Order Bridge

This was one of the trickier parts to get right. The strategy produces a signal: 1 (buy), -1 (sell), or 0 (hold). But the action depends on your current position:

class SignalToOrderBridge:
    """
    FLAT + signal 1  → BUY
    LONG + signal -1 → SELL
    LONG + signal 1  → HOLD (already long)
    FLAT + signal -1 → HOLD (no shorting)
    """

    def determine_action(self, signal: int) -> ActionResult:
        if signal == 0:
            return ActionResult(action=OrderAction.HOLD, reason="Signal is hold")

        if self._position == PositionState.FLAT and signal == 1:
            return ActionResult(action=OrderAction.BUY, reason="Buy signal, no position")

        if self._position == PositionState.LONG and signal == -1:
            return ActionResult(action=OrderAction.SELL, reason="Sell signal, closing long")

        return ActionResult(action=OrderAction.HOLD, reason="No valid action")
Enter fullscreen mode Exit fullscreen mode

The bridge persists its state to a JSON file, so if the bot crashes and restarts, it knows whether you're currently holding or flat. Without this, a restart could trigger a duplicate buy.

The EMA Crossover Strategy

The strategy itself is straightforward. Two exponential moving averages — fast (12 periods) and slow (26 periods). When the fast crosses above the slow, buy. When it crosses below, sell.

class EmaLiveStrategy:
    def generate_signal_detailed(self, ohlcv_data):
        close = ohlcv_data["close"]
        fast_ema = close.ewm(span=self._fast_period, adjust=False).mean()
        slow_ema = close.ewm(span=self._slow_period, adjust=False).mean()

        current_fast = float(fast_ema.iloc[-1])
        current_slow = float(slow_ema.iloc[-1])
        prev_fast = float(fast_ema.iloc[-2])
        prev_slow = float(slow_ema.iloc[-2])

        # Crossover detection
        if current_fast > current_slow and prev_fast <= prev_slow:
            return EmaSignalResult(signal=1, ...)   # BUY
        if current_fast < current_slow and prev_fast >= prev_slow:
            return EmaSignalResult(signal=-1, ...)  # SELL
        return EmaSignalResult(signal=0, ...)       # HOLD
Enter fullscreen mode Exit fullscreen mode

You need the previous bar's values to detect a crossover — it's the transition from "fast below slow" to "fast above slow" that matters, not just the current state. If you only check fast > slow, you'd get a buy signal every single bar while the fast EMA is above the slow one.

MACD — The Walk-Forward Winner

The second strategy is MACD with parameters tuned through Walk-Forward analysis. The default parameters (15/30/9) came from splitting the data into training and validation folds, optimizing on training, and validating on unseen data. MACD scored an A- grade with a robustness ratio of 0.46 — meaning the out-of-sample performance was about 46% of the in-sample performance. That's actually pretty good for a simple indicator strategy.

class MacdLiveStrategy:
    def generate_signal_detailed(self, ohlcv_data):
        close = ohlcv_data["close"]
        fast_ema = close.ewm(span=self._fast_period, adjust=False).mean()
        slow_ema = close.ewm(span=self._slow_period, adjust=False).mean()

        macd_line = fast_ema - slow_ema
        signal_line = macd_line.ewm(span=self._signal_period, adjust=False).mean()

        # Crossover: MACD crosses above Signal → BUY
        if current_macd > current_signal and prev_macd <= prev_signal:
            return MacdSignalResult(signal=1, ...)
        # Crossover: MACD crosses below Signal → SELL
        if current_macd < current_signal and prev_macd >= prev_signal:
            return MacdSignalResult(signal=-1, ...)
        return MacdSignalResult(signal=0, ...)
Enter fullscreen mode Exit fullscreen mode

Switch between strategies with one env variable: STRATEGY=macd.

The Main Loop

The bot's main loop ties everything together. Each cycle: sync state, check circuit breaker, fetch candles, check stop loss, generate signal, execute order.

def _run_cycle(client, strategy, pair, config, stop_loss_manager):
    # 1. Make sure local state matches exchange reality
    _sync_position_state(client, pair)

    # 2. Circuit breaker check — bail if we've lost too much
    if not _check_circuit_breaker(pair):
        return

    # 3. Fetch latest candles
    ohlcv = client.fetch_ohlcv(pair.symbol, timeframe="1d", limit=50)

    # 4. Price anomaly check (>30% move = skip)
    if pair.circuit_breaker.check_price_anomaly(current_price, last_price):
        return

    # 5. Check stop loss before processing new signals
    if _check_stop_loss(client, pair, config, stop_loss_manager, ohlcv):
        return  # position was closed

    # 6. Generate signal and act
    signal_result = strategy.generate_signal_detailed(ohlcv)
    action_result = pair.bridge.determine_action(signal_result.signal)

    if action_result.action == OrderAction.BUY:
        _execute_buy(client, pair, config, stop_loss_manager, ohlcv)
    elif action_result.action == OrderAction.SELL:
        _execute_sell(client, pair, config, reason="signal")
Enter fullscreen mode Exit fullscreen mode

The order matters. State sync first, because everything else depends on knowing your actual position. Circuit breaker second, because there's no point analyzing signals if trading is paused. Stop loss third, because a triggered stop should close the position before a new signal can open another one.

The loop runs once per hour (configurable). For daily candle strategies, that means 24 checks per day — probably more than you need, but it keeps stop losses responsive and means the infrastructure is already there if you switch to shorter timeframes later.

Risk Management

This is the part that separates a toy project from something you can actually run overnight without anxiety.

Dual Stop Loss (ATR + Hard)

Every position gets two stop losses. The first is ATR-based — a dynamic level calculated from recent price volatility:

class StopLossManager:
    def calculate_stop_loss(self, entry_price, atr_value, direction):
        atr_stop_distance = self._atr_multiplier * atr_value  # 2.0 × ATR
        atr_stop = entry_price - atr_stop_distance             # for longs
        hard_stop = entry_price * (1 - self._hard_stop_pct)    # 5% hard limit
        return max(atr_stop, hard_stop)
Enter fullscreen mode Exit fullscreen mode

The ATR stop adapts to market conditions — wider in volatile markets, tighter in calm ones. The hard stop at 5% is a backstop. Whichever is higher (closer to entry) wins. As price moves in your favor, a trailing stop follows it up.

On top of the software stop, the bot places a server-side stop-loss order on the exchange. If your bot crashes, the exchange will still close your position at the hard stop price. Belt and suspenders.

Circuit Breaker

Three levels of automatic shutdown, all based on cumulative losses relative to your initial balance:

Level 1 (Daily):  Loss ≥ 3%  → No new trades until tomorrow
Level 2 (Weekly): Loss ≥ 7%  → No new trades until next Monday
Level 3 (Monthly): Loss ≥ 15% → Full stop. Manual reset required.
Enter fullscreen mode Exit fullscreen mode

The circuit breaker also watches for price anomalies — if price moves more than 30% between candles, it skips the cycle entirely. Flash crashes and bad data are more common than you'd think.

Position State Sync

Here's a subtle one: your bot's local state can drift from the exchange's reality. Maybe the bot recorded a buy but the order failed. Maybe you manually sold on the exchange UI. Every cycle, the bot checks the exchange balance and corrects its local state:

def _sync_position_state(client, pair):
    base_total = float(balance_data.get("total", {}).get(base_currency, 0))
    has_position = base_total >= 0.001  # ignore dust

    if has_position and not local_is_long:
        pair.bridge.update_position(PositionState.LONG)  # correct to LONG
    elif not has_position and local_is_long:
        pair.bridge.update_position(PositionState.FLAT)  # correct to FLAT
Enter fullscreen mode Exit fullscreen mode

Without this, the bot could think it's flat when it actually has an open position — and then buy again. Or think it's long when the position was already closed — and miss the next entry.

Graceful Shutdown

When you stop the bot (Ctrl+C or SIGTERM), it automatically closes all open positions before exiting:

def run_bot():
    # ... main loop ...

    # On shutdown: close everything
    _close_all_positions_on_shutdown(client, pairs, config)
    logger.info("Bot shutdown gracefully")
Enter fullscreen mode Exit fullscreen mode

If a shutdown sell fails, the bot writes an emergency message to a dashboard file. You'll know about it.

Going Live

The deployment sequence has three stages, and you should actually follow them. I know it's tempting to skip ahead.

Stage 1: Dry run. DRY_RUN=true. The bot logs everything — what it would buy, what it would sell, where it would set stops — without touching your money. Run it for a few days. Make sure the signals make sense.

python -m src.live.main
Enter fullscreen mode Exit fullscreen mode

Stage 2: Tiny amount. DRY_RUN=false, CONFIRM_LIVE_TRADING=yes, TRADING_AMOUNT_USDT=1. Yes, one dollar. The bot requires both flags to be set before it will place real orders. This catches things that dry run can't — order minimums, API permission issues, balance calculation rounding.

Stage 3: Real money. Increase TRADING_AMOUNT_USDT gradually. The circuit breaker protects you, but start small and scale up as you gain confidence.

Multi-Pair Trading

The bot supports trading multiple pairs simultaneously with independent state per pair:

TRADING_SYMBOLS=BTC/USDT,ETH/USDT,SOL/USDT
TRADING_AMOUNTS=0.5,0.3,0.2
TRADING_AMOUNT_USDT=100
Enter fullscreen mode Exit fullscreen mode

This allocates $50 to BTC, $30 to ETH, and $20 to SOL. Each pair gets its own circuit breaker, stop loss state, and position tracker. A bad trade on ETH won't affect your BTC position.

What I Got Wrong

A few things bit me during development:

Dust amounts. After selling, you're often left with tiny residual balances (like 38 satoshi of BTC). The first version of the position sync code saw this as "has position" and refused to buy again. The fix: treat anything below 0.001 units as dust and ignore it.

State persistence across crashes. Early versions didn't save the circuit breaker state. If the bot crashed and restarted after hitting a daily limit, it would forget the limit and keep trading. Now everything persists to JSON files.

Stop loss on restart. If the bot restarts while holding a position, it needs to reconstruct the stop loss level. But the ATR value from the original entry is gone. The solution: save the entry price to disk, and on restart, calculate a conservative hard stop at 5% below entry until the next candle provides a fresh ATR value.

What I'd Do Differently

If I were starting over:

  1. Use WebSocket instead of polling. The bot checks for signals every hour. For a daily strategy that's fine, but for shorter timeframes you'd want real-time data streaming.

  2. Add more exchange connectors. The codebase is tightly coupled to one exchange's API in places. Abstracting the exchange layer more cleanly would make it easier to swap in a different one.

  3. Separate the backtester from the live bot completely. They're in the same repo for convenience, but in production I'd want them in separate deployments with the strategy code as a shared package.

Lessons

After running 50 strategies through backtests and deploying the top ones live:

Simple strategies outperform complex ones. EMA Crossover and MACD — two of the oldest technical indicators — ranked in the top 5. Machine learning strategies (XGBoost, LSTM, DQN) produced zero trades because they couldn't generate reliable signals on daily crypto data.

Backtesting is not enough. Walk-Forward analysis caught several strategies that looked great in-sample but fell apart on unseen data. If you skip this step, you're probably just trading noise.

Risk management is the actual product. The strategy is maybe 20% of the code. Stop losses, circuit breakers, state synchronization, graceful shutdown — that's where I spent most of my time, and honestly it's the part that actually keeps you from losing money.

Start with $1. I'm not kidding. The moment real money hits the exchange, every assumption you had about how things work gets tested. Order minimums, fee calculations, timing weirdness — I found all of them at $1, which is a lot better than finding them at $100.

Get Started

I'm planning to open-source the full codebase soon. Once it's up, you'll be able to clone it, run a backtest, and see what you get.

If you need an exchange account, MEXC is what I'd recommend for getting started — zero spot maker fees, solid API, and low minimums. Signing up through that link supports this project's continued development.

cd crypto-backtest-engine
pip install -e ".[dev]"

# Download data
python scripts/download_data.py --symbols BTCUSDT --timeframes 1d --start-date 2023-01-01

# Run your first backtest
python scripts/run_backtest.py --strategy ema_crossover --symbol BTCUSDT --timeframe 1d --generate-report
Enter fullscreen mode Exit fullscreen mode

This project will be released under the MIT License. Cryptocurrency trading involves risk. Don't trade money you can't afford to lose.

Top comments (0)