Learn how to implement a profitable grid trading strategy for cryptocurrency trading bots. This comprehensive guide covers the mathematical foundation, a concrete profit calculation, a fully commented Python implementation, order management, and optimization techniques for automated Bitcoin trading.
Series context: This is part 3 of the trading-bot build series. Start with the Python development environment, then configure keys in the Binance API configuration and authentication guide. After this article, validate with the backtesting framework, practice via paper trading, and harden the bot with risk management and logging.
Prerequisite: Have working Binance API keys and a passing connection test from the Binance API configuration and authentication guide (testnet first). Without correct permissions and IP whitelist settings, grid order placement will fail even if the strategy code is fine.
What is Grid Trading Strategy?
Grid trading is a systematic approach that places buy and sell orders at predetermined intervals above and below a base price, creating a "grid" of orders. This strategy profits from market volatility by capturing small price movements in both directions.
How Grid Trading Works
Imagine Bitcoin trading at $65,000. A grid trading bot would:
- Set buy orders below current price: $64,675, $64,350, $64,025...
- Set sell orders above current price: $65,325, $65,650, $65,975...
- When an order fills: Immediately place a new order on the opposite side
- Capture profit: Each complete buy-sell cycle generates profit
Grid Trading Advantages
- ✅ Market neutral: Profits in sideways markets
- ✅ Passive income: Works 24/7 without monitoring
- ✅ Risk-controlled: Predefined position sizes
- ✅ Scalable: Works across multiple trading pairs
- ✅ Backtestable: Easy to validate historically
Grid Trading Risks
- ⚠️ Strong trends: Can accumulate losing positions
- ⚠️ Range-bound requirement: Needs volatile but ranging markets
- ⚠️ Capital intensive: Requires funds for multiple orders
- ⚠️ Slippage costs: Frequent trading increases fees
Mathematical Foundation
Grid Spacing Calculation
The key to profitable grid trading is optimal spacing between orders:
# Basic grid spacing formula
grid_spacing = current_price * spacing_percentage / 100
# Example: 0.5% spacing at $65,000
grid_spacing = 65000 * 0.5 / 100 = $325
# Buy levels: $64,675, $64,350, $64,025...
# Sell levels: $65,325, $65,650, $65,975...Arithmetic vs. Geometric Grid Spacing
There are two ways to lay out the ladder and they diverge quickly over a wide range. An arithmetic grid uses a fixed currency step (every level is $325 apart). A geometric grid uses a fixed percentage step (every level is 0.5% apart). The formulas above are geometric, because percentage spacing keeps profit per completed cycle constant no matter where price sits inside the range.
from typing import List
def arithmetic_levels(lower: float, upper: float, levels: int) -> List[float]:
"""Fixed currency step: (upper - lower) / levels."""
step = (upper - lower) / levels
return [lower + step * i for i in range(levels + 1)]
def geometric_levels(lower: float, upper: float, levels: int) -> List[float]:
"""Fixed percentage step: (upper / lower) ** (1 / levels)."""
ratio = (upper / lower) ** (1 / levels)
return [lower * ratio ** i for i in range(levels + 1)]
lower, upper, levels = 58_000, 72_000, 10
arith = arithmetic_levels(lower, upper, levels)
geo = geometric_levels(lower, upper, levels)
print(f"arithmetic step: ${(upper - lower) / levels:,.0f} at every level")
print(f" bottom of range: {(arith[1] / arith[0] - 1) * 100:.2f}%") # ≈ 2.41%
print(f" top of range: {(arith[-1] / arith[-2] - 1) * 100:.2f}%") # ≈ 1.98%
step_pct = ((upper / lower) ** (1 / levels) - 1) * 100
print(f"geometric step: {step_pct:.2f}% at every level") # ≈ 2.19%The arithmetic grid earns 2.41% per cycle near $58,000 but only 1.98% near $72,000 — the same dollar step is worth less as price rises. The geometric grid earns 2.19% everywhere.
| Aspect | Arithmetic grid | Geometric grid |
|---|---|---|
| Step definition | Fixed amount ($1,400) | Fixed ratio (2.19%) |
| Profit per cycle | Varies with price level | Constant percentage |
| Level density | Even in currency terms | Denser at the bottom |
| Best for | Narrow ranges, stablecoin pairs | Wide ranges, volatile crypto |
| Watch out for | Top levels can drop under break-even | Bottom levels tie up more inventory |
Use geometric spacing for BTC and ETH grids that span more than roughly 10% of price. Use arithmetic spacing when the range is tight enough that the difference is noise — a USDT/USDC or stablecoin-pair grid, for example.
Profit Per Grid Level
Each completed grid level generates predictable profit. The short calculation below is the building block; the full capital and 30-day scenario follows in the worked example.
# Profit calculation
buy_price = 64675
sell_price = 65325
quantity = 0.001 BTC
trading_fee = 0.001 # 0.1% per trade
gross_profit = (sell_price - buy_price) * quantity
total_fees = (buy_price + sell_price) * quantity * trading_fee
net_profit = gross_profit - total_fees
# Example result: $0.52 profit per 0.001 BTC cycleBreak-Even Spacing: The Minimum That Beats Fees
Every completed cycle pays the fee twice — once on the buy, once on the sell. There is therefore a hard floor below which a grid cannot be profitable, no matter how many times it cycles. With f as the fee rate per side, the break-even spacing s is:
s > 2f / (1 - f)Run the numbers against the fee tier you actually pay:
def break_even_spacing(fee_rate: float) -> float:
"""Smallest spacing where a completed cycle still nets more than zero."""
return (2 * fee_rate) / (1 - fee_rate)
def min_viable_spacing(fee_rate: float, margin: float = 3.0) -> float:
"""Break-even times a safety margin — fills are neither free nor instant."""
return break_even_spacing(fee_rate) * margin
tiers = [
("Standard spot 0.100%", 0.0010),
("With BNB discount 0.075%", 0.00075),
("VIP 1 maker 0.090%", 0.0009),
("VIP 4 maker 0.020%", 0.0002),
]
for label, fee in tiers:
print(f"{label}: break-even {break_even_spacing(fee) * 100:.3f}% "
f"| use at least {min_viable_spacing(fee) * 100:.2f}%")
# Standard spot 0.100%: break-even 0.200% | use at least 0.60%
# With BNB discount 0.075%: break-even 0.150% | use at least 0.45%
# VIP 1 maker 0.090%: break-even 0.180% | use at least 0.54%
# VIP 4 maker 0.020%: break-even 0.040% | use at least 0.12%The 0.5% spacing used in the worked example clears the standard-fee break-even by about 2.5×, which is why it still nets roughly $0.20 per cycle. Anything under 0.25% at standard spot fees is a fee-generation machine rather than a strategy: the grid will trade constantly and hand most of the spread to the exchange.
Optimal Grid Parameters
| Market Condition | Grid Spacing | Grid Levels | Capital Allocation |
|---|---|---|---|
| High Volatility | 0.3% - 0.5% | 15-20 levels | 5% per level |
| Medium Volatility | 0.5% - 1.0% | 10-15 levels | 7% per level |
| Low Volatility | 0.2% - 0.3% | 20-30 levels | 3% per level |
Worked Example: Parameters and Expected Profit
Abstract formulas are useful, but a full scenario makes the economics concrete. Below is a realistic BTCUSDT grid with fixed parameters, capital requirements, one complete buy→sell cycle, and a 30-day projection.
Scenario Parameters
| Parameter | Value | Notes |
|---|---|---|
| Center price | $65,000 | BTCUSDT spot mid |
| Grid spacing | 0.5% ($325) | Medium-volatility setting |
| Levels | 10 buy / 10 sell | 20 open orders at start |
| Order size | 0.001 BTC | ≈ $65 notional per fill |
| Fee rate | 0.10% per side | Spot taker; maker is cheaper |
| Starting capital | $1,000 USDT + 0.01 BTC | USDT for buys, BTC for sells |
Capital Requirement
# USDT reserved for the 10 buy ladders (approx. using center price)
# More precise: sum(price_i * qty) for each buy level
required_usdt = sum(
65000 * (1 - 0.005 * i) * 0.001
for i in range(1, 11)
)
# ≈ $632.12 USDT locked in buy orders
# BTC reserved for the 10 sell ladders
required_btc = 10 * 0.001 # = 0.01 BTC
# ≈ $650 of BTC inventory at $65,000
# Total capital at risk ≈ $632.12 + $650 ≈ $1,282.12With only $1,000 USDT you can still run the grid if you fund fewer buy levels (for example 7–8) or reduce size to 0.0008 BTC. Undersized capital is the most common reason initial order placement fails.
One Complete Buy→Sell Cycle
- Buy fills at level 1: price = $65,000 × (1 − 0.005) = $64,675, qty = 0.001 BTC
- Bot places sell one spacing above the fill: $64,675 × 1.005 = $65,000.59 (≈ center)
- Sell fills; the cycle is complete and profit is locked in
# Fee-aware profit for one cycle
buy_price = 64675.00
sell_price = 64675.00 * 1.005 # 65000.5875
qty = 0.001
fee = 0.001 # 0.1% each side
gross = (sell_price - buy_price) * qty
# gross = 325.5875 * 0.001 = $0.3256
fees = (buy_price + sell_price) * qty * fee
# fees ≈ 129675.59 * 0.001 * 0.001 = $0.1297
net = gross - fees
# net ≈ $0.1959 per completed cycle at 0.001 BTC
# At maker fees (0.1% → 0.02% each side) net rises to ≈ $0.30
# Scaling qty to 0.01 BTC multiplies net by 10 → ≈ $1.96 / cycle30-Day Projection (Sideways Market)
Assume a ranging market that completes 8 full cycles per day (typical for 0.5% spacing on BTC in moderate volatility):
| Metric | Result |
|---|---|
| Cycles / day | 8 |
| Net / cycle (0.001 BTC, 0.1% fees) | ≈ $0.20 |
| Daily net | ≈ $1.57 |
| 30-day net (fee-aware) | ≈ $47 on ~$1,282 deployed (~3.7%) |
| Same setup, maker fees 0.02% | ≈ $72 / 30 days (~5.6%) |
Important: These numbers assume a range-bound market. A strong one-way trend pauses cycle completion, ties up inventory, and can produce unrealized drawdown until price mean-reverts. Always validate assumptions with the crypto bot backtesting framework before sizing up.
Grid Trading Strategy Implementation
Complete Commented Grid Strategy Example
The full production class below handles order books, risk checks, and status reporting. Start here with a self-contained, heavily commented version that shows only the essential grid loop: build levels, place orders, react to fills, and book fee-aware profit.
"""
Minimal but complete grid strategy — every line explained.
Use this to understand the mechanics before wiring the full class.
"""
from dataclasses import dataclass
from typing import Dict, List, Optional
@dataclass
class GridLevel:
"""One limit order in the grid ladder."""
price: float
quantity: float
side: str # 'buy' or 'sell'
order_id: Optional[str] = None
status: str = 'pending' # pending | filled | cancelled
class SimpleGridStrategy:
"""
Core idea:
1) Place buys below center and sells above center.
2) When a buy fills, immediately place a sell one spacing higher.
3) When a sell fills, immediately place a buy one spacing lower.
Each completed buy→sell (or sell→buy) pair realizes net profit after fees.
"""
def __init__(
self,
center_price: float,
spacing: float = 0.005, # 0.5% between levels
levels_each_side: int = 10,
qty: float = 0.001, # BTC per order
fee_rate: float = 0.001, # 0.1% per fill
):
self.center = center_price
self.spacing = spacing
self.levels = levels_each_side
self.qty = qty
self.fee_rate = fee_rate
self.active: Dict[str, GridLevel] = {}
self.realized_profit = 0.0
self.completed_cycles = 0
self._next_id = 1
def build_grid(self) -> List[GridLevel]:
"""Create the initial ladder around the center price."""
grid: List[GridLevel] = []
# Buys step DOWN from center: center * (1 - spacing), (1 - 2*spacing), ...
for i in range(1, self.levels + 1):
price = self.center * (1 - self.spacing * i)
grid.append(GridLevel(price=price, quantity=self.qty, side='buy'))
# Sells step UP from center: center * (1 + spacing), (1 + 2*spacing), ...
for i in range(1, self.levels + 1):
price = self.center * (1 + self.spacing * i)
grid.append(GridLevel(price=price, quantity=self.qty, side='sell'))
return grid
def place_order(self, level: GridLevel) -> str:
"""
Stand-in for exchange.place_limit_order(...).
Returns a fake order id so we can track fills locally.
"""
order_id = f'ord-{self._next_id}'
self._next_id += 1
level.order_id = order_id
level.status = 'pending'
self.active[order_id] = level
return order_id
def on_fill(self, order_id: str) -> Optional[GridLevel]:
"""
Called when the exchange reports a fill.
Places the opposite order one spacing away and books profit on buy fills
(sell is the closing leg of the long cycle in this simplified model).
"""
filled = self.active.pop(order_id, None)
if filled is None:
return None
filled.status = 'filled'
if filled.side == 'buy':
# Lock in the exit: sell one grid step above the buy
new_price = filled.price * (1 + self.spacing)
new_side = 'sell'
# Expected gross before the sell actually fills
gross = (new_price - filled.price) * filled.quantity
fees = (filled.price + new_price) * filled.quantity * self.fee_rate
# We book the fee-adjusted expectation when the closing order is placed;
# the full class waits until both legs are confirmed.
self.realized_profit += gross - fees
self.completed_cycles += 1
else:
# Sell filled → re-arm a buy one step below
new_price = filled.price * (1 - self.spacing)
new_side = 'buy'
replacement = GridLevel(
price=new_price,
quantity=filled.quantity,
side=new_side,
)
self.place_order(replacement)
return replacement
# --- Demo: same numbers as the worked example ---
if __name__ == '__main__':
bot = SimpleGridStrategy(center_price=65000, spacing=0.005, qty=0.001)
levels = bot.build_grid()
# Place every initial order
for level in levels:
bot.place_order(level)
print(f'Active orders: {len(bot.active)}') # 20
# Simulate the nearest buy filling (level 1 @ $64,675)
buy_id = next(
oid for oid, lvl in bot.active.items()
if lvl.side == 'buy' and abs(lvl.price - 64675) < 1
)
bot.on_fill(buy_id)
print(f'Cycles: {bot.completed_cycles}') # 1
print(f'Realized profit: ${bot.realized_profit:.4f}') # ≈ 0.1959
print(f'Active orders: {len(bot.active)}') # still 20 (1 removed, 1 added)Once this loop is clear, drop it into the exchange-connected class below (same spacing math, same opposite-order rule) and keep using testnet keys from the Binance API configuration and authentication guide.
Core Strategy Class
Create src/strategies/grid_trading.py:
import time
import logging
import math
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from src.exchanges.binance_client import BinanceClient
from src.config.binance_config import BinanceConfig
@dataclass
class GridLevel:
"""Represents a single grid level"""
price: float
quantity: float
side: str # 'buy' or 'sell'
order_id: Optional[str] = None
status: str = 'pending' # pending, filled, cancelled
filled_time: Optional[float] = None
@dataclass
class GridConfig:
"""Grid trading configuration"""
symbol: str = 'BTCUSDT'
grid_spacing: float = 0.005 # 0.5%
num_grids_up: int = 10
num_grids_down: int = 10
base_order_size: float = 0.001 # BTC
max_position_size: float = 0.1 # Maximum total position
stop_loss_percentage: float = 0.05 # 5% stop loss
take_profit_percentage: float = 0.02 # 2% take profit overall
class GridTradingStrategy:
"""
Advanced Grid Trading Strategy Implementation
Features:
- Dynamic grid adjustment
- Risk management
- Profit tracking
- Order management
"""
def __init__(self, client: BinanceClient, config: GridConfig):
self.client = client
self.config = config
self.logger = logging.getLogger(__name__)
# Strategy state
self.grid_levels: List[GridLevel] = []
self.active_orders: Dict[str, GridLevel] = {}
self.filled_orders: List[GridLevel] = []
# Performance tracking
self.total_profit = 0.0
self.total_trades = 0
self.start_time = time.time()
self.start_balance = 0.0
# Grid center price
self.center_price = 0.0
self.last_update_time = 0.0
def initialize_strategy(self) -> bool:
"""Initialize the grid trading strategy"""
try:
# Get current price
ticker = self.client.get_ticker(self.config.symbol)
if not ticker['success']:
self.logger.error(f"Failed to get ticker: {ticker['error']}")
return False
self.center_price = ticker['price']
self.logger.info(f"Initialized grid around ${self.center_price:,.2f}")
# Get starting balance
account = self.client.get_account_info()
if account['success']:
usdt_balance = account['balances'].get('USDT', {}).get('free', 0)
self.start_balance = usdt_balance
self.logger.info(f"Starting USDT balance: ${usdt_balance:,.2f}")
# Generate initial grid
self._generate_grid_levels()
# Place initial orders
return self._place_initial_orders()
except Exception as e:
self.logger.error(f"Strategy initialization failed: {e}")
return False
def _generate_grid_levels(self):
"""Generate grid levels around center price"""
self.grid_levels.clear()
# Generate buy levels (below center price)
for i in range(1, self.config.num_grids_down + 1):
price = self.center_price * (1 - self.config.grid_spacing * i)
grid_level = GridLevel(
price=price,
quantity=self.config.base_order_size,
side='buy'
)
self.grid_levels.append(grid_level)
# Generate sell levels (above center price)
for i in range(1, self.config.num_grids_up + 1):
price = self.center_price * (1 + self.config.grid_spacing * i)
grid_level = GridLevel(
price=price,
quantity=self.config.base_order_size,
side='sell'
)
self.grid_levels.append(grid_level)
self.logger.info(f"Generated {len(self.grid_levels)} grid levels")
# Log grid levels for debugging
buy_levels = [g for g in self.grid_levels if g.side == 'buy']
sell_levels = [g for g in self.grid_levels if g.side == 'sell']
self.logger.info(f"Buy levels: {len(buy_levels)} orders from "
f"${min(g.price for g in buy_levels):,.2f} to "
f"${max(g.price for g in buy_levels):,.2f}")
self.logger.info(f"Sell levels: {len(sell_levels)} orders from "
f"${min(g.price for g in sell_levels):,.2f} to "
f"${max(g.price for g in sell_levels):,.2f}")
def _place_initial_orders(self) -> bool:
"""Place all initial grid orders"""
success_count = 0
for grid_level in self.grid_levels:
try:
# Place limit order
result = self.client.place_limit_order(
symbol=self.config.symbol,
side=grid_level.side,
amount=grid_level.quantity,
price=grid_level.price
)
if result['success']:
grid_level.order_id = result['order_id']
grid_level.status = 'pending'
self.active_orders[result['order_id']] = grid_level
success_count += 1
self.logger.debug(f"Placed {grid_level.side} order: "
f"{grid_level.quantity} at ${grid_level.price:,.2f}")
else:
self.logger.error(f"Failed to place order: {result['error']}")
grid_level.status = 'failed'
# Rate limiting
time.sleep(0.1)
except Exception as e:
self.logger.error(f"Error placing order: {e}")
grid_level.status = 'failed'
self.logger.info(f"Successfully placed {success_count}/{len(self.grid_levels)} orders")
return success_count > 0
def update_strategy(self) -> Dict:
"""Main strategy update loop"""
try:
# Check for filled orders
filled_orders = self._check_filled_orders()
# Process filled orders
for order in filled_orders:
self._process_filled_order(order)
# Update performance metrics
performance = self._calculate_performance()
# Check risk limits
if self._check_risk_limits():
self.logger.warning("Risk limits exceeded - consider stopping strategy")
self.last_update_time = time.time()
return {
'success': True,
'filled_orders': len(filled_orders),
'active_orders': len(self.active_orders),
'total_profit': self.total_profit,
'performance': performance
}
except Exception as e:
self.logger.error(f"Strategy update failed: {e}")
return {'success': False, 'error': str(e)}
def _check_filled_orders(self) -> List[GridLevel]:
"""Check which orders have been filled"""
filled_orders = []
try:
# Get current open orders
open_orders_result = self.client.get_open_orders(self.config.symbol)
if not open_orders_result['success']:
self.logger.error(f"Failed to get open orders: {open_orders_result['error']}")
return filled_orders
open_order_ids = {order['id'] for order in open_orders_result['orders']}
# Check which of our orders are no longer open (i.e., filled)
for order_id, grid_level in list(self.active_orders.items()):
if order_id not in open_order_ids:
# Order was filled
grid_level.status = 'filled'
grid_level.filled_time = time.time()
filled_orders.append(grid_level)
# Remove from active orders
del self.active_orders[order_id]
# Add to filled orders
self.filled_orders.append(grid_level)
self.logger.info(f"Order filled: {grid_level.side} "
f"{grid_level.quantity} at ${grid_level.price:,.2f}")
return filled_orders
except Exception as e:
self.logger.error(f"Error checking filled orders: {e}")
return filled_orders
def _process_filled_order(self, filled_order: GridLevel):
"""Process a filled order and place corresponding opposite order"""
try:
# Calculate new order parameters
if filled_order.side == 'buy':
# Buy order filled, place sell order one level up
new_price = filled_order.price * (1 + self.config.grid_spacing)
new_side = 'sell'
else:
# Sell order filled, place buy order one level down
new_price = filled_order.price * (1 - self.config.grid_spacing)
new_side = 'buy'
# Create new grid level
new_grid_level = GridLevel(
price=new_price,
quantity=filled_order.quantity,
side=new_side
)
# Place new order
result = self.client.place_limit_order(
symbol=self.config.symbol,
side=new_side,
amount=filled_order.quantity,
price=new_price
)
if result['success']:
new_grid_level.order_id = result['order_id']
new_grid_level.status = 'pending'
self.active_orders[result['order_id']] = new_grid_level
# Calculate profit for this trade cycle
if filled_order.side == 'buy':
# We bought and now placed sell order
expected_profit = (new_price - filled_order.price) * filled_order.quantity
# Subtract trading fees (0.1% per trade)
trading_fees = (filled_order.price + new_price) * filled_order.quantity * 0.001
net_profit = expected_profit - trading_fees
self.total_profit += net_profit
self.total_trades += 1
self.logger.info(f"Trade cycle: Buy ${filled_order.price:,.2f} → "
f"Sell ${new_price:,.2f} = ${net_profit:.2f} profit")
self.logger.info(f"Placed new {new_side} order: "
f"{new_grid_level.quantity} at ${new_price:,.2f}")
else:
self.logger.error(f"Failed to place new order: {result['error']}")
except Exception as e:
self.logger.error(f"Error processing filled order: {e}")
def _calculate_performance(self) -> Dict:
"""Calculate strategy performance metrics"""
try:
current_time = time.time()
runtime_hours = (current_time - self.start_time) / 3600
# Get current account balance
account = self.client.get_account_info()
current_balance = 0.0
if account['success']:
current_balance = account['balances'].get('USDT', {}).get('free', 0)
# Calculate returns
balance_change = current_balance - self.start_balance
profit_percentage = (balance_change / self.start_balance * 100) if self.start_balance > 0 else 0
# Calculate rates
trades_per_hour = self.total_trades / runtime_hours if runtime_hours > 0 else 0
profit_per_hour = self.total_profit / runtime_hours if runtime_hours > 0 else 0
return {
'runtime_hours': runtime_hours,
'total_trades': self.total_trades,
'total_profit': self.total_profit,
'balance_change': balance_change,
'profit_percentage': profit_percentage,
'trades_per_hour': trades_per_hour,
'profit_per_hour': profit_per_hour,
'active_orders': len(self.active_orders),
'avg_profit_per_trade': self.total_profit / self.total_trades if self.total_trades > 0 else 0
}
except Exception as e:
self.logger.error(f"Error calculating performance: {e}")
return {}
def _check_risk_limits(self) -> bool:
"""Check if risk limits are exceeded"""
try:
# Check total position size
total_position = sum(g.quantity for g in self.active_orders.values() if g.side == 'buy')
if total_position > self.config.max_position_size:
self.logger.warning(f"Position size {total_position} exceeds limit {self.config.max_position_size}")
return True
# Check drawdown
account = self.client.get_account_info()
if account['success']:
current_balance = account['balances'].get('USDT', {}).get('free', 0)
drawdown = (self.start_balance - current_balance) / self.start_balance
if drawdown > self.config.stop_loss_percentage:
self.logger.warning(f"Drawdown {drawdown:.2%} exceeds stop loss {self.config.stop_loss_percentage:.2%}")
return True
return False
except Exception as e:
self.logger.error(f"Error checking risk limits: {e}")
return False
def stop_strategy(self) -> bool:
"""Stop the strategy and cancel all open orders"""
try:
cancelled_count = 0
for order_id, grid_level in list(self.active_orders.items()):
result = self.client.cancel_order(order_id, self.config.symbol)
if result['success']:
cancelled_count += 1
grid_level.status = 'cancelled'
del self.active_orders[order_id]
self.logger.info(f"Cancelled order: {order_id}")
else:
self.logger.error(f"Failed to cancel order {order_id}: {result['error']}")
self.logger.info(f"Strategy stopped. Cancelled {cancelled_count} orders.")
# Final performance report
final_performance = self._calculate_performance()
self.logger.info(f"Final performance: {final_performance}")
return True
except Exception as e:
self.logger.error(f"Error stopping strategy: {e}")
return False
def get_status(self) -> Dict:
"""Get current strategy status"""
return {
'center_price': self.center_price,
'grid_levels': len(self.grid_levels),
'active_orders': len(self.active_orders),
'filled_orders': len(self.filled_orders),
'total_profit': self.total_profit,
'total_trades': self.total_trades,
'last_update': self.last_update_time,
'performance': self._calculate_performance()
}Strategy Runner and Main Loop
Strategy Execution Manager
Create src/grid_trading_bot.py:
#!/usr/bin/env python3
"""
Grid Trading Bot - Main execution script
"""
import time
import signal
import sys
import logging
from datetime import datetime
from src.exchanges.binance_client import BinanceClient
from src.strategies.grid_trading import GridTradingStrategy, GridConfig
from src.config.binance_config import BinanceConfig
class GridTradingBot:
"""Main grid trading bot controller"""
def __init__(self):
self.setup_logging()
self.logger = logging.getLogger(__name__)
# Initialize components
self.config = BinanceConfig()
self.client = BinanceClient(self.config)
# Grid strategy configuration
self.grid_config = GridConfig(
symbol='BTCUSDT',
grid_spacing=0.005, # 0.5%
num_grids_up=10,
num_grids_down=10,
base_order_size=0.001, # 0.001 BTC per order
max_position_size=0.1, # Maximum 0.1 BTC position
stop_loss_percentage=0.05, # 5% stop loss
take_profit_percentage=0.02 # 2% take profit
)
self.strategy = GridTradingStrategy(self.client, self.grid_config)
self.running = False
# Setup signal handlers for graceful shutdown
signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler)
def setup_logging(self):
"""Configure logging for the bot"""
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('data/logs/grid_trading_bot.log'),
logging.StreamHandler(sys.stdout)
]
)
def signal_handler(self, signum, frame):
"""Handle shutdown signals gracefully"""
self.logger.info(f"Received signal {signum}. Shutting down gracefully...")
self.running = False
def pre_flight_checks(self) -> bool:
"""Perform pre-flight checks before starting"""
self.logger.info("Performing pre-flight checks...")
# Test API connection
if not self.client.test_connection():
self.logger.error("❌ API connection failed")
return False
# Check account balance
account = self.client.get_account_info()
if not account['success']:
self.logger.error(f"❌ Cannot access account: {account['error']}")
return False
usdt_balance = account['balances'].get('USDT', {}).get('free', 0)
btc_balance = account['balances'].get('BTC', {}).get('free', 0)
self.logger.info(f"💰 Account balances:")
self.logger.info(f" USDT: ${usdt_balance:,.2f}")
self.logger.info(f" BTC: {btc_balance:.6f}")
# Calculate required capital
ticker = self.client.get_ticker(self.grid_config.symbol)
if not ticker['success']:
self.logger.error("❌ Cannot get current price")
return False
current_price = ticker['price']
required_usdt = (current_price * self.grid_config.base_order_size *
self.grid_config.num_grids_down)
required_btc = (self.grid_config.base_order_size *
self.grid_config.num_grids_up)
self.logger.info(f"📊 Capital requirements:")
self.logger.info(f" Required USDT: ${required_usdt:,.2f}")
self.logger.info(f" Required BTC: {required_btc:.6f}")
# Check sufficient balance
if usdt_balance < required_usdt:
self.logger.error(f"❌ Insufficient USDT balance")
return False
if btc_balance < required_btc:
self.logger.warning(f"⚠️ Low BTC balance - some sell orders may fail")
# Check if testnet
if self.config.TESTNET:
self.logger.info("🧪 Running on TESTNET - safe for testing")
else:
self.logger.warning("🔴 LIVE TRADING MODE - real money at risk!")
response = input("Type 'CONFIRM' to proceed with live trading: ")
if response != 'CONFIRM':
self.logger.info("Live trading cancelled by user")
return False
self.logger.info("✅ All pre-flight checks passed")
return True
def run(self):
"""Main bot execution loop"""
try:
self.logger.info("🚀 Starting Grid Trading Bot")
# Pre-flight checks
if not self.pre_flight_checks():
self.logger.error("Pre-flight checks failed. Exiting.")
return False
# Initialize strategy
if not self.strategy.initialize_strategy():
self.logger.error("Strategy initialization failed. Exiting.")
return False
self.running = True
self.logger.info("🎯 Grid trading strategy is now active")
# Main execution loop
loop_count = 0
last_status_time = time.time()
while self.running:
try:
# Update strategy
update_result = self.strategy.update_strategy()
if update_result['success']:
loop_count += 1
# Log filled orders
if update_result['filled_orders'] > 0:
self.logger.info(f"📈 {update_result['filled_orders']} orders filled")
# Periodic status report (every 5 minutes)
current_time = time.time()
if current_time - last_status_time > 300: # 5 minutes
self._log_status_report()
last_status_time = current_time
else:
self.logger.error(f"Strategy update failed: {update_result.get('error')}")
# Sleep before next iteration
time.sleep(30) # Update every 30 seconds
except KeyboardInterrupt:
self.logger.info("Keyboard interrupt received")
break
except Exception as e:
self.logger.error(f"Error in main loop: {e}")
time.sleep(60) # Wait longer on errors
# Graceful shutdown
self.logger.info("🛑 Shutting down grid trading bot")
self.strategy.stop_strategy()
return True
except Exception as e:
self.logger.error(f"Critical error in bot execution: {e}")
return False
def _log_status_report(self):
"""Log periodic status report"""
try:
status = self.strategy.get_status()
performance = status['performance']
self.logger.info("📊 === STATUS REPORT ===")
self.logger.info(f"Runtime: {performance.get('runtime_hours', 0):.1f} hours")
self.logger.info(f"Total trades: {performance.get('total_trades', 0)}")
self.logger.info(f"Total profit: ${performance.get('total_profit', 0):.2f}")
self.logger.info(f"Profit percentage: {performance.get('profit_percentage', 0):.2f}%")
self.logger.info(f"Active orders: {status['active_orders']}")
self.logger.info(f"Trades per hour: {performance.get('trades_per_hour', 0):.1f}")
self.logger.info(f"Profit per hour: ${performance.get('profit_per_hour', 0):.2f}")
# Get current price for reference
ticker = self.client.get_ticker(self.grid_config.symbol)
if ticker['success']:
self.logger.info(f"Current BTC price: ${ticker['price']:,.2f}")
self.logger.info("========================")
except Exception as e:
self.logger.error(f"Error generating status report: {e}")
def main():
"""Main entry point"""
bot = GridTradingBot()
success = bot.run()
if success:
print("✅ Grid trading bot completed successfully")
sys.exit(0)
else:
print("❌ Grid trading bot failed")
sys.exit(1)
if __name__ == "__main__":
main()Grid Trading Parameter Reference
Every grid bot failure traces back to one of a small set of parameters. This is the complete list the strategy class reads, what each one controls, and the failure you see when it is wrong.
| Parameter | Default | Typical range | What it controls / failure mode |
|---|---|---|---|
symbol |
BTCUSDT |
High-volume spot pairs | Trading pair. Thin books mean partial fills and slippage that eat the grid margin. |
mode |
neutral |
neutral / long / short | Which side of the ladder is placed. Short grids require futures — see below. |
grid_spacing |
0.005 |
0.003 – 0.01 | Distance between levels. Below break-even the grid loses on every cycle; too wide and it never fills. |
num_grids_down |
10 |
5 – 30 | Buy levels below center. Each one needs quote currency reserved for it. |
num_grids_up |
10 |
5 – 30 | Sell levels above center. On spot each one needs base-asset inventory you already hold. |
base_order_size |
0.001 |
Above MIN_NOTIONAL |
Quantity per order. Under the symbol minimum, every order is rejected with -1013. |
lower_price / upper_price |
derived | ATR-based, see below | Range bounds. Price outside the range stops cycling and leaves inventory parked. |
max_position_size |
0.1 |
≥ full buy ladder | Hard cap on accumulated base asset. Too low and the grid halts mid-ladder in a downtrend. |
stop_loss_percentage |
0.05 |
0.03 – 0.15 | Unrealized loss on average entry that triggers a stop-out and cancels the grid. |
take_profit_percentage |
0.02 |
0.01 – 0.05 | Total account profit that closes the grid and books the run. |
fee_rate |
0.001 |
0.0002 – 0.001 | Fee per side. Drives break-even spacing, so a wrong value silently invalidates the whole plan. |
poll_interval |
5 |
2 – 15 seconds | Seconds between fill checks. Too aggressive and you hit Binance API rate limits. |
Encode the constraints in the config object itself so a bad combination fails at startup instead of at the first rejected order:
from dataclasses import dataclass
from typing import Optional
@dataclass
class GridConfig:
"""Every knob the grid bot reads, with production-safe defaults."""
symbol: str = 'BTCUSDT'
mode: str = 'neutral' # neutral | long | short
grid_spacing: float = 0.005 # 0.5% between levels
num_grids_up: int = 10
num_grids_down: int = 10
base_order_size: float = 0.001 # base asset per order
lower_price: Optional[float] = None # None = derive from spacing * levels
upper_price: Optional[float] = None
max_position_size: float = 0.1
stop_loss_percentage: float = 0.05
take_profit_percentage: float = 0.02
fee_rate: float = 0.001
poll_interval: int = 5
def __post_init__(self) -> None:
"""Fail fast: a bad grid should never reach the exchange."""
floor = break_even_spacing(self.fee_rate)
if self.grid_spacing <= floor:
raise ValueError(
f"spacing {self.grid_spacing:.4f} does not clear fees "
f"(break-even {floor:.4f})"
)
if self.mode not in {'neutral', 'long', 'short'}:
raise ValueError(f"unknown mode: {self.mode}")
if self.num_grids_up < 1 or self.num_grids_down < 1:
raise ValueError("need at least one level on each side")
if self.base_order_size <= 0:
raise ValueError("base_order_size must be positive")
if self.max_position_size < self.base_order_size * self.num_grids_down:
raise ValueError(
"max_position_size cannot cover a full downside fill sequence"
)
def required_quote(self, center_price: float) -> float:
"""Quote currency (USDT) locked by the buy ladder."""
return sum(
center_price * (1 - self.grid_spacing * i) * self.base_order_size
for i in range(1, self.num_grids_down + 1)
)
def required_base(self) -> float:
"""Base currency (BTC) inventory the sell ladder needs."""
return self.base_order_size * self.num_grids_up
cfg = GridConfig()
print(f"USDT locked in buy ladder: ${cfg.required_quote(65_000):,.2f}") # ≈ $632.12
print(f"BTC needed for sell ladder: {cfg.required_base():.4f}") # 0.0100Those two numbers are the capital check from the worked example, now computed instead of estimated. Run it before funding an account: undersized capital is the single most common reason initial grid placement fails.
Grid Range: Bounds, Re-Centering and Stop-Out
A grid only earns while price stays inside its range. Deciding the bounds up front — and deciding what happens when price leaves them — is what separates a bot that survives a trend from one that quietly accumulates a losing position.
Derive the range from realized volatility rather than a round number. Average True Range on the 4-hour candle is a reasonable default:
import math
from typing import Tuple
def range_from_atr(center: float, atr: float, multiple: float = 3.0) -> Tuple[float, float]:
"""Bounds that cover roughly `multiple` ATRs in each direction."""
return center - atr * multiple, center + atr * multiple
def levels_for_range(lower: float, upper: float, spacing: float) -> int:
"""How many geometric levels fit between the bounds."""
return math.floor(math.log(upper / lower) / math.log(1 + spacing))
center, atr_4h = 65_000, 900 # BTCUSDT, ATR(14) on the 4h candle
lower, upper = range_from_atr(center, atr_4h)
print(f"range: ${lower:,.0f} – ${upper:,.0f}") # $62,300 – $67,700
print(f"levels: {levels_for_range(lower, upper, 0.005)}") # 16 → 8 per sideThen enforce the range on every poll, before the grid replaces any order:
class RangeGuard:
"""Decide what the bot does when price leaves the configured range."""
def __init__(self, lower: float, upper: float,
stop_loss_pct: float = 0.05, on_exit: str = 'halt'):
self.lower = lower
self.upper = upper
self.stop_loss_pct = stop_loss_pct
self.on_exit = on_exit # 'halt' | 'recenter'
def check(self, price: float, avg_entry: float = 0.0) -> str:
"""Returns 'run' | 'halt' | 'recenter' | 'stop_out'."""
# Hard stop first: unrealized loss on accumulated inventory.
if avg_entry and price < avg_entry * (1 - self.stop_loss_pct):
return 'stop_out'
if self.lower <= price <= self.upper:
return 'run'
return self.on_exit
guard = RangeGuard(lower, upper, stop_loss_pct=0.05)
action = guard.check(price=61_800, avg_entry=64_100)
# 'halt' — below the range but inside the stop-loss band
action = guard.check(price=60_500, avg_entry=64_100)
# 'stop_out' — 5.6% under average entry| Situation | What it means | Recommended action |
|---|---|---|
| Price above upper bound | Sell ladder is exhausted, inventory sold out, bot sits in quote currency | Halt and re-center at the new price, or wait for mean reversion |
| Price below lower bound | Buy ladder is exhausted, full inventory held at a losing average | Halt new buys; re-centering here doubles down on a downtrend |
| Stop-out triggered | Unrealized loss exceeds stop_loss_percentage |
Cancel all open orders, flatten or hold deliberately, then review parameters |
| Range too wide | Levels rarely fill, capital sits idle | Reduce ATR multiple or tighten spacing |
| Range too narrow | Price exits within hours, constant halting | Increase ATR multiple, or switch pairs |
Re-centering is tempting and dangerous: it works in a market that oscillates around a new level and compounds losses in one that keeps trending. Default to halt, and add the kill switches and alerting from the risk management and logging guide before you let a bot re-center unattended.
Grid Modes: Neutral, Long and Short (Spot vs. Futures)
The same ladder logic supports three modes. Which ones are available depends on whether you run spot or futures.
| Mode | Ladder placed | Market | Use when | Main risk |
|---|---|---|---|---|
| Neutral | Buys below and sells above center | Spot or futures | You expect a range and already hold base-asset inventory | Trend in either direction exits the range |
| Long | Buys below center only, sells placed after fills | Spot or futures | You want to accumulate and sell into strength | Accumulating a falling asset with no floor |
| Short | Sells above center only, buys placed after fills | Futures only | You expect a range with a downward bias | Unbounded loss in a rally; liquidation |
def build_ladder(center: float, cfg: GridConfig) -> list:
"""Level layout per mode. Spot can only run 'neutral' or 'long'."""
levels = []
if cfg.mode in ('neutral', 'long'):
for i in range(1, cfg.num_grids_down + 1):
levels.append(('buy', center * (1 - cfg.grid_spacing * i)))
if cfg.mode in ('neutral', 'short'):
for i in range(1, cfg.num_grids_up + 1):
levels.append(('sell', center * (1 + cfg.grid_spacing * i)))
return sorted(levels, key=lambda level: level[1])
for side, price in build_ladder(65_000, GridConfig(mode='long')):
print(f"{side:4} @ ${price:,.2f}")
# buy @ $61,750.00 ... buy @ $64,675.00 (10 levels, no pre-placed sells)On spot, a neutral grid needs the base asset in hand — the sell ladder cannot be placed against inventory you do not own. A long grid avoids that requirement and is the right starting mode for a first live run. Short grids require a futures account, carry liquidation risk that spot does not, and should never be a first deployment. If a directional market is what you actually expect, a grid is the wrong tool: see crypto trading bot strategies beyond grid for DCA, mean reversion, and trend following.
Configuration and Parameter Optimization
Dynamic Grid Spacing
Implement adaptive grid spacing based on market volatility:
import numpy as np
from typing import List
class VolatilityBasedGridding:
"""Adjust grid spacing based on market volatility"""
def __init__(self, base_spacing: float = 0.005):
self.base_spacing = base_spacing
self.price_history: List[float] = []
def calculate_volatility(self, prices: List[float], window: int = 24) -> float:
"""Calculate rolling volatility"""
if len(prices) < window:
return self.base_spacing
recent_prices = prices[-window:]
returns = np.diff(np.log(recent_prices))
volatility = np.std(returns) * np.sqrt(24) # 24 hours
return volatility
def get_optimal_spacing(self, current_price: float) -> float:
"""Get optimal grid spacing based on current volatility"""
self.price_history.append(current_price)
# Keep only last 100 price points
if len(self.price_history) > 100:
self.price_history = self.price_history[-100:]
volatility = self.calculate_volatility(self.price_history)
# Adjust spacing: higher volatility = wider spacing
if volatility > 0.03: # High volatility
return self.base_spacing * 1.5
elif volatility < 0.01: # Low volatility
return self.base_spacing * 0.7
else:
return self.base_spacingMulti-Timeframe Analysis
Enhanced strategy with trend detection:
class TrendAwareGrid:
"""Grid trading with trend awareness"""
def __init__(self, client: BinanceClient):
self.client = client
def detect_trend(self, symbol: str, timeframes: List[str] = ['1h', '4h', '1d']) -> str:
"""Detect overall market trend"""
trend_signals = []
for timeframe in timeframes:
try:
# Get historical data
klines = self.client.client.fetch_ohlcv(symbol, timeframe, limit=50)
closes = [kline[4] for kline in klines] # Closing prices
# Simple moving averages
sma_20 = np.mean(closes[-20:])
sma_50 = np.mean(closes[-50:])
current_price = closes[-1]
# Trend determination
if current_price > sma_20 > sma_50:
trend_signals.append('bullish')
elif current_price < sma_20 < sma_50:
trend_signals.append('bearish')
else:
trend_signals.append('sideways')
except Exception as e:
print(f"Error analyzing {timeframe}: {e}")
trend_signals.append('sideways')
# Aggregate trend signals
bullish_count = trend_signals.count('bullish')
bearish_count = trend_signals.count('bearish')
if bullish_count > bearish_count:
return 'bullish'
elif bearish_count > bullish_count:
return 'bearish'
else:
return 'sideways'
def adjust_grid_for_trend(self, config: GridConfig, trend: str) -> GridConfig:
"""Adjust grid parameters based on trend"""
if trend == 'bullish':
# More buy orders, fewer sell orders
config.num_grids_down = int(config.num_grids_down * 1.5)
config.num_grids_up = int(config.num_grids_up * 0.7)
elif trend == 'bearish':
# Fewer buy orders, more sell orders
config.num_grids_down = int(config.num_grids_down * 0.7)
config.num_grids_up = int(config.num_grids_up * 1.5)
return configBacktesting Your Grid Strategy
The snippet below is enough to sanity-check spacing and fee drag on sample prices. For production-grade metrics (Sharpe, drawdown curves, walk-forward splits), continue with the dedicated crypto trading bot backtesting framework article.
Simple Backtesting Framework
Create src/backtesting/grid_backtest.py:
import pandas as pd
import numpy as np
from typing import Dict, List, Tuple
from datetime import datetime, timedelta
class GridBacktester:
"""Backtest grid trading strategy on historical data"""
def __init__(self, grid_spacing: float = 0.005, base_order_size: float = 0.001):
self.grid_spacing = grid_spacing
self.base_order_size = base_order_size
self.trading_fee = 0.001 # 0.1% per trade
def run_backtest(self, price_data: pd.DataFrame,
start_balance: float = 1000) -> Dict:
"""Run backtest on historical price data"""
# Initialize backtest state
balance = start_balance
btc_holdings = 0.0
trades = []
grid_levels = []
# Performance tracking
max_balance = start_balance
max_drawdown = 0.0
for index, row in price_data.iterrows():
current_price = row['close']
timestamp = row['timestamp'] if 'timestamp' in row else index
# First iteration - set up initial grid
if not grid_levels:
grid_levels = self._create_initial_grid(current_price)
continue
# Check for filled orders
filled_orders = []
remaining_levels = []
for level in grid_levels:
if level['side'] == 'buy' and current_price <= level['price']:
# Buy order filled
if balance >= level['price'] * level['quantity']:
cost = level['price'] * level['quantity']
fee = cost * self.trading_fee
balance -= (cost + fee)
btc_holdings += level['quantity']
trades.append({
'timestamp': timestamp,
'side': 'buy',
'price': level['price'],
'quantity': level['quantity'],
'cost': cost,
'fee': fee,
'balance': balance,
'btc_holdings': btc_holdings
})
filled_orders.append(level)
elif level['side'] == 'sell' and current_price >= level['price']:
# Sell order filled
if btc_holdings >= level['quantity']:
revenue = level['price'] * level['quantity']
fee = revenue * self.trading_fee
balance += (revenue - fee)
btc_holdings -= level['quantity']
trades.append({
'timestamp': timestamp,
'side': 'sell',
'price': level['price'],
'quantity': level['quantity'],
'revenue': revenue,
'fee': fee,
'balance': balance,
'btc_holdings': btc_holdings
})
filled_orders.append(level)
else:
# Order not filled, keep in grid
remaining_levels.append(level)
# Update grid levels
grid_levels = remaining_levels
# Place new orders for filled positions
for filled_order in filled_orders:
new_level = self._create_opposite_order(filled_order, current_price)
if new_level:
grid_levels.append(new_level)
# Update performance metrics
total_value = balance + (btc_holdings * current_price)
max_balance = max(max_balance, total_value)
drawdown = (max_balance - total_value) / max_balance
max_drawdown = max(max_drawdown, drawdown)
# Calculate final results
final_price = price_data.iloc[-1]['close']
final_value = balance + (btc_holdings * final_price)
total_return = (final_value - start_balance) / start_balance
total_trades = len(trades)
return {
'start_balance': start_balance,
'final_balance': final_value,
'total_return': total_return,
'total_return_pct': total_return * 100,
'max_drawdown': max_drawdown * 100,
'total_trades': total_trades,
'trades': trades,
'final_btc_holdings': btc_holdings,
'avg_trades_per_day': total_trades / len(price_data) if len(price_data) > 0 else 0
}
def _create_initial_grid(self, center_price: float) -> List[Dict]:
"""Create initial grid around center price"""
grid_levels = []
# Create buy levels below center price
for i in range(1, 11): # 10 levels down
price = center_price * (1 - self.grid_spacing * i)
grid_levels.append({
'side': 'buy',
'price': price,
'quantity': self.base_order_size
})
# Create sell levels above center price
for i in range(1, 11): # 10 levels up
price = center_price * (1 + self.grid_spacing * i)
grid_levels.append({
'side': 'sell',
'price': price,
'quantity': self.base_order_size
})
return grid_levels
def _create_opposite_order(self, filled_order: Dict, current_price: float) -> Dict:
"""Create opposite order after one is filled"""
if filled_order['side'] == 'buy':
# Create sell order one level up
new_price = filled_order['price'] * (1 + self.grid_spacing)
return {
'side': 'sell',
'price': new_price,
'quantity': filled_order['quantity']
}
else:
# Create buy order one level down
new_price = filled_order['price'] * (1 - self.grid_spacing)
return {
'side': 'buy',
'price': new_price,
'quantity': filled_order['quantity']
}
# Example usage
def run_grid_backtest_example():
"""Example of running grid strategy backtest"""
# Load historical data (you would get this from the Binance API configuration and authentication)
# For demo, create sample data
dates = pd.date_range(start='2024-01-01', end='2024-01-31', freq='H')
np.random.seed(42)
# Simulate Bitcoin price movement
initial_price = 45000
returns = np.random.normal(0, 0.02, len(dates)) # 2% hourly volatility
prices = [initial_price]
for ret in returns[1:]:
prices.append(prices[-1] * (1 + ret))
price_data = pd.DataFrame({
'timestamp': dates,
'close': prices
})
# Run backtest
backtester = GridBacktester(grid_spacing=0.005, base_order_size=0.001)
results = backtester.run_backtest(price_data, start_balance=1000)
print("=== GRID TRADING BACKTEST RESULTS ===")
print(f"Start Balance: ${results['start_balance']:,.2f}")
print(f"Final Balance: ${results['final_balance']:,.2f}")
print(f"Total Return: {results['total_return_pct']:.2f}%")
print(f"Max Drawdown: {results['max_drawdown']:.2f}%")
print(f"Total Trades: {results['total_trades']}")
print(f"Avg Trades/Day: {results['avg_trades_per_day']:.1f}")
return results
if __name__ == "__main__":
run_grid_backtest_example()Running Your Grid Trading Bot
Before starting the bot loop below, confirm testnet authentication from the Binance API configuration and authentication guide — grid orders fail immediately if keys, permissions, or IP whitelist do not match the environment.
Step-by-Step Deployment
Prefer this path: testnet orders → historical backtest → paper trading → small live size with risk limits and logging.
- Test on Binance Testnet first:
# In your .env file BINANCE_TESTNET=true BINANCE_API_KEY=your_testnet_api_key BINANCE_SECRET_KEY=your_testnet_secret_key - Run the bot:
python src/grid_trading_bot.py - Monitor performance:
tail -f data/logs/grid_trading_bot.log - Switch to live trading:
# Change .env to live credentials BINANCE_TESTNET=false BINANCE_API_KEY=your_live_api_key BINANCE_SECRET_KEY=your_live_secret_key
Expected Performance
Based on historical data, a well-tuned grid trading strategy can achieve:
- 📈 Annual returns: 15-35% in sideways markets
- 📊 Trade frequency: 10-50 trades per day
- 💰 Profit per trade: $0.50-$2.00 per 0.001 BTC
- 📉 Maximum drawdown: 5-15% in normal conditions
Optimization and Advanced Features
Parameter Optimization
Test different grid spacings to find optimal parameters:
def optimize_grid_parameters():
"""Find optimal grid spacing through backtesting"""
spacings = [0.002, 0.003, 0.005, 0.007, 0.01] # Different spacings to test
results = []
for spacing in spacings:
backtester = GridBacktester(grid_spacing=spacing)
result = backtester.run_backtest(price_data, start_balance=1000)
result['grid_spacing'] = spacing
results.append(result)
# Find best spacing by return/drawdown ratio
best_spacing = max(results, key=lambda x: x['total_return'] / (x['max_drawdown'] + 0.01))
print(f"Optimal grid spacing: {best_spacing['grid_spacing']:.3f}")
print(f"Return: {best_spacing['total_return_pct']:.2f}%")
print(f"Max Drawdown: {best_spacing['max_drawdown']:.2f}%")
return best_spacingExchange Filters: LOT_SIZE and MIN_NOTIONAL
Most grid bots fail at order placement — not in strategy logic — because Binance rejects prices or quantities that violate symbol filters. Every spot pair exposes LOT_SIZE (step size), PRICE_FILTER (tick size), and MIN_NOTIONAL (minimum order value). Your grid must round to these rules before calling the API.
Create src/exchanges/symbol_filters.py:
import math
from typing import Any, Dict
def parse_filters(market: Dict[str, Any]) -> Dict[str, float]:
"""Extract LOT_SIZE, PRICE_FILTER, MIN_NOTIONAL from ccxt market info."""
out = {
'step_size': float(market['precision']['amount']),
'tick_size': float(market['precision']['price']),
'min_qty': 0.0,
'min_notional': 0.0,
}
for f in market.get('info', {}).get('filters', []):
if f['filterType'] == 'LOT_SIZE':
out['step_size'] = float(f['stepSize'])
out['min_qty'] = float(f['minQty'])
elif f['filterType'] == 'PRICE_FILTER':
out['tick_size'] = float(f['tickSize'])
elif f['filterType'] == 'MIN_NOTIONAL':
out['min_notional'] = float(f['minNotional'])
return out
def round_step(value: float, step: float) -> float:
"""Round down to exchange step (never round up — avoids rejections)."""
if step <= 0:
return value
precision = max(0, int(round(-math.log10(step))))
return math.floor(value / step) * step if step < 1 else round(
math.floor(value / step) * step, precision
)
def normalize_order(price: float, qty: float, filters: Dict[str, float]) -> tuple[float, float]:
"""Return (price, qty) that pass Binance filters."""
price = round_step(price, filters['tick_size'])
qty = round_step(qty, filters['step_size'])
if qty < filters['min_qty']:
qty = filters['min_qty']
if price * qty < filters['min_notional']:
qty = round_step(filters['min_notional'] / price, filters['step_size'])
return price, qty
# Example for BTCUSDT @ ,000, 0.001 BTC target
filters = {'step_size': 0.00001, 'tick_size': 0.01, 'min_qty': 0.00001, 'min_notional': 5.0}
price, qty = normalize_order(64675.123, 0.001, filters)
# price=64675.12, qty=0.001 — passes MIN_NOTIONAL (.68 > )Wire this into _place_initial_orders() and _process_filled_order() so every limit order is normalized. Skipping this step produces cryptic -1013 Filter failure or -1111 Precision is over the maximum errors.
Debug Script: Verify Grid Orders Before Going Live
Run this standalone script after configuring keys in the Binance API guide. It prints the grid ladder, capital requirements, and a dry-run validation — without placing orders.
Create scripts/debug_grid.py:
#!/usr/bin/env python3
"""Dry-run grid validation — no orders placed."""
import os
import ccxt
from dotenv import load_dotenv
load_dotenv()
def main():
exchange = ccxt.binance({
'apiKey': os.getenv('BINANCE_API_KEY'),
'secret': os.getenv('BINANCE_SECRET_KEY'),
'enableRateLimit': True,
'options': {'defaultType': 'spot'},
})
if os.getenv('BINANCE_TESTNET', 'true').lower() == 'true':
exchange.set_sandbox_mode(True)
symbol = 'BTC/USDT'
spacing = 0.005
levels = 10
qty = 0.001
exchange.load_markets()
ticker = exchange.fetch_ticker(symbol)
center = ticker['last']
market = exchange.market(symbol)
print(f"Symbol: {symbol}")
print(f"Center: ${center:,.2f}")
print(f"Spacing: {spacing*100:.1f}% | Levels/side: {levels} | Qty: {qty} BTC\n")
usdt_needed = 0.0
btc_needed = levels * qty
print("BUY levels:")
for i in range(1, levels + 1):
price = center * (1 - spacing * i)
cost = price * qty
usdt_needed += cost
print(f" L{i}: ${price:,.2f} × {qty} = ${cost:.2f}")
print("\nSELL levels:")
for i in range(1, levels + 1):
price = center * (1 + spacing * i)
print(f" L{i}: ${price:,.2f} × {qty}")
balance = exchange.fetch_balance()
usdt_free = balance.get('USDT', {}).get('free', 0)
btc_free = balance.get('BTC', {}).get('free', 0)
print(f"\nRequired: ${usdt_needed:,.2f} USDT + {btc_needed:.4f} BTC")
print(f"Available: ${usdt_free:,.2f} USDT + {btc_free:.4f} BTC")
if usdt_free < usdt_needed:
print("❌ Insufficient USDT — reduce levels or order size")
elif btc_free < btc_needed:
print("⚠️ Low BTC — some sell orders may fail")
else:
print("✅ Capital check passed")
# Test API permissions (read-only)
try:
exchange.fetch_open_orders(symbol)
print("✅ API read + open orders permission OK")
except Exception as e:
print(f"❌ API error: {e}")
if __name__ == '__main__':
main()Expected output on testnet with sufficient balance:
Symbol: BTC/USDT
Center: ,000.00
Spacing: 0.5% | Levels/side: 10 | Qty: 0.001 BTC
Required: 3.75 USDT + 0.0100 BTC
Available: ,000.00 USDT + 1.0000 BTC
✅ Capital check passed
✅ API read + open orders permission OKIf you see Invalid API-key, IP, or permissions, return to the authentication guide and verify testnet keys, Spot Trading permission, and IP whitelist settings.
Order Fill Polling with ccxt
The production class above checks open orders to detect fills. Here is the same pattern in minimal form — useful when debugging why the grid stops replacing orders:
def poll_filled_orders(exchange, symbol: str, tracked_ids: set) -> list:
"""Return order IDs that were tracked but are no longer open (= filled or cancelled)."""
open_ids = {o['id'] for o in exchange.fetch_open_orders(symbol)}
filled = [oid for oid in tracked_ids if oid not in open_ids]
return filled
def confirm_fill(exchange, symbol: str, order_id: str) -> dict | None:
"""Fetch order status — distinguish FILLED from CANCELED."""
order = exchange.fetch_order(order_id, symbol)
if order['status'] == 'closed' and order['filled'] > 0:
return order
return NoneAlways confirm status == 'closed' and filled > 0 before placing the opposite grid leg. A cancelled order should re-arm the same level, not advance the grid.
Common Issues and Solutions
Binance API Error Codes
Problem: Orders fail with -2010, -1013, or -1021
Solution: Map errors to fixes:
| Error | Meaning | Fix |
|---|---|---|
-2010 |
Insufficient balance | Reduce grid levels or run debug_grid.py |
-1013 |
Filter failure (LOT_SIZE / MIN_NOTIONAL) | Use normalize_order() before placing |
-1021 |
Timestamp outside recvWindow | Sync system clock: sudo ntpdate pool.ntp.org |
-2015 |
Invalid API key / IP | Revisit API configuration |
Wrap order placement to log the full error payload:
try:
order = exchange.create_limit_buy_order(symbol, qty, price)
except ccxt.ExchangeError as e:
print(f"Order rejected: {e}")
# ccxt includes the raw Binance code in str(e)For a complete error reference across API, orders, and runtime issues, see the dedicated crypto trading bot troubleshooting guide.
Grid Stops Replacing Orders After First Fill
Problem: Bot logs a fill but no opposite order appears
Solution: The fill-detection loop likely treats cancelled orders as fills. Use confirm_fill() (above) and only call _process_filled_order() when order['status'] == 'closed' and filled > 0.
Testnet vs Live Key Mismatch
Problem: Invalid API-key despite correct-looking keys
Solution: Testnet keys only work with exchange.set_sandbox_mode(True). Live keys fail on testnet and vice versa. Double-check BINANCE_TESTNET=true in .env matches the key type you generated.
Grid Spacing Too Narrow
Problem: High fees, constant rebalancing
Solution: Increase spacing to 0.7-1.0% and reduce grid levels
Insufficient Capital
Problem: Orders fail due to insufficient balance
Solution: Reduce order sizes or number of grid levels
Strong Trending Markets
Problem: Accumulating losing positions
Solution: Implement trend detection and pause grid in strong trends
Frequently Asked Questions
Is grid trading actually profitable?
In a range-bound market, yes — modestly and predictably. The worked example above nets roughly $47 over 30 days on about $1,282 deployed (~3.7%) at standard spot fees, or about $72 (~5.6%) at maker fees. In a trending market the same grid stops completing cycles and holds inventory at an unrealized loss. Grid trading converts volatility into income; it does not predict direction.
How much capital do I need to run a grid bot?
Enough to fund every level plus the exchange minimum per order. Binance spot enforces a MIN_NOTIONAL of about $5 per order, so a 20-level grid needs at least $100 in play — but that leaves no room for spacing or inventory. A realistic floor is $500–$1,000; the required_quote() and required_base() helpers in the parameter reference compute the exact number for your settings.
What is the best grid spacing for Bitcoin?
0.3%–1.0% for BTCUSDT, depending on volatility, with 0.5% a reasonable default. The hard constraint is the break-even formula: at standard 0.10% fees, anything under 0.20% loses money on every cycle and anything under 0.60% has very little margin for error. Validate the choice on historical data with the backtesting framework rather than guessing.
How many grid levels should I use?
Enough to cover the range at your chosen spacing, which is what levels_for_range() computes — typically 8–15 per side. More levels mean more cycles but more locked capital; fewer levels mean idle capital when price moves only slightly. Levels beyond the range bounds are dead weight.
Grid trading vs. DCA — which is better?
They solve different problems. DCA buys on a schedule regardless of price and suits accumulation with a bullish thesis. Grid trading buys and sells around a center price and suits sideways markets with no directional view. Many bots run DCA as the base position and a grid on top of it; the strategies beyond grid guide implements DCA, mean reversion, and trend following in the same framework.
Can a grid bot lose money?
Yes, in three ways: a sustained trend that leaves inventory parked below the range, spacing set too tight so fees exceed the captured spread, and stop-outs that realize an unrealized loss. The first is the most common. Range bounds, a stop-out rule, and position limits from the risk management guide are what keep those failures bounded.
Does grid trading work on futures?
It works, and it adds liquidation risk. Futures unlock the short and neutral-with-leverage modes that spot cannot offer, but a grid that holds inventory through a trend can be liquidated before price mean-reverts. Run spot until the strategy is proven, keep leverage at 1–2× if you move to futures, and size positions so a full ladder fill stays well clear of the liquidation price.
Which pairs work best for grid trading?
High-volume pairs with genuine two-way movement: BTCUSDT, ETHUSDT, and the larger altcoin majors. Thin books cause partial fills and slippage that eat the grid margin, and low-volatility pairs never trigger enough cycles to cover fees. Check 30-day realized volatility and order-book depth before committing capital to a new pair.
Does the bot need to run 24/7?
Yes. A grid only earns while its orders are live, and a bot that dies mid-ladder leaves open orders and inventory unmanaged. Run it on a VPS with a whitelisted IP (see Binance API configuration and authentication), persist grid state to disk so a restart resumes instead of re-placing everything, and alert on process exit.
Next Steps
Your grid trading strategy is now implemented and ready for testing. You should already have completed:
- Python setup for crypto trading bots
- Binance API configuration and authentication
- Grid trading strategy (this article)
Continue through the trading-bot series:
- Crypto bot backtesting framework — validate spacing, fees, and drawdown on historical data
- Paper trading implementation — bridge from backtest to live without risking capital
- Risk management and logging — position limits, kill switches, and production logs
- Overview of the full build path: How to build a cryptocurrency trading bot
Always test thoroughly on testnet before using real money! If authentication or order placement fails, revisit Binance API configuration (testnet vs live keys, Spot Trading permission, IP whitelist). Grid trading can be highly profitable in the right market conditions, but requires careful parameter tuning and risk management.
Next up: build a professional backtesting framework to validate this grid strategy on historical data before risking real capital. Then move to paper trading and risk management.
Comments