Why Grid Trading
I had been running a DCA bot for a few weeks when I noticed something: BTC/USDT was stuck in a range. The price bounced between $62,000 and $67,000 for almost two weeks straight. My DCA bot kept buying at the same cadence regardless, and I realized a grid trading strategy would have been printing money during that sideways chop.
Grid trading is simple in concept. You place buy orders at evenly spaced price levels below the current price, and sell orders above it. When the price drops and hits a buy order, you buy. When it bounces back up and hits the corresponding sell order, you sell for a profit. Rinse and repeat across the entire grid.
I decided to see if Claude Code could build one from scratch. Here’s exactly what happened.
Grid trading shines in sideways markets — which is what BTC does 60-70% of the time. My grid bot generated $340 in its first week on a $3,000 capital base, purely from buying low and selling high within a range. That’s not a promise — your results depend on market conditions and configuration — but the strategy has a real edge when the market cooperates.
The Prompt I Gave Claude Code
I opened a terminal in my project directory and gave Claude Code a multi-line prompt. I had learned from my DCA bot experience that vague prompts produce vague code, so I tried to be specific:
claude "Build a grid trading bot in Python with these requirements:
1. Exchange: Binance via ccxt library
2. Pair: configurable, default BTC/USDT
3. Grid parameters:
- Upper price bound
- Lower price bound
- Number of grid levels (default 10)
- Total investment amount in USDT
4. The bot should:
- Calculate evenly spaced grid levels between upper and lower bounds
- Place limit buy orders below current price
- Place limit sell orders above current price
- Track which grid levels have been filled
- When a buy order fills, immediately place a sell order one grid level above
- When a sell order fills, immediately place a buy order one grid level below
- Account for trading fees (0.1% maker fee on Binance)
5. Include:
- Dry-run mode (default on)
- Logging to both console and a file
- Graceful shutdown on Ctrl+C that cancels all open orders
- Error handling with retry logic for network issues
- A status display showing open orders and realized P&L
6. Load API keys from .env file
7. Use type hints throughout"
That prompt took me about five minutes to write. It was worth every second.
The Code Claude Code Generated
Claude Code thought for about 30 seconds and produced a single Python file. I was genuinely impressed by the structure. Here’s the first version it generated, with my annotations:
import ccxt
import os
import time
import json
import logging
from decimal import Decimal, ROUND_DOWN
from datetime import datetime
from typing import Optional
from dotenv import load_dotenv
load_dotenv()
# ──────────────────────────────────────────────
# Configuration
# ──────────────────────────────────────────────
SYMBOL = os.getenv("SYMBOL", "BTC/USDT")
UPPER_PRICE = float(os.getenv("UPPER_PRICE", "67000"))
LOWER_PRICE = float(os.getenv("LOWER_PRICE", "62000"))
GRID_LEVELS = int(os.getenv("GRID_LEVELS", "10"))
TOTAL_INVESTMENT = float(os.getenv("TOTAL_INVESTMENT", "1000"))
DRY_RUN = os.getenv("DRY_RUN", "true").lower() == "true"
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "30"))
MAKER_FEE = 0.001 # 0.1%
# ──────────────────────────────────────────────
# Logging
# ──────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler("grid_bot.log"),
],
)
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────
# Exchange Connection
# ──────────────────────────────────────────────
def create_exchange() -> ccxt.binance:
exchange = ccxt.binance({
"apiKey": os.getenv("BINANCE_API_KEY"),
"secret": os.getenv("BINANCE_API_SECRET"),
"options": {"defaultType": "spot"},
})
exchange.load_markets()
return exchange
# ──────────────────────────────────────────────
# Grid Calculation
# ──────────────────────────────────────────────
def calculate_grid_levels(
lower: float, upper: float, levels: int
) -> list[float]:
step = (upper - lower) / levels
return [round(lower + step * i, 2) for i in range(levels + 1)]
def calculate_order_size(
total_investment: float, levels: int, price: float
) -> float:
usdt_per_grid = total_investment / levels
return usdt_per_grid / price
# ──────────────────────────────────────────────
# Order Management
# ──────────────────────────────────────────────
class GridBot:
def __init__(self, exchange: ccxt.binance):
self.exchange = exchange
self.grid_levels = calculate_grid_levels(
LOWER_PRICE, UPPER_PRICE, GRID_LEVELS
)
self.open_orders: dict[float, dict] = {}
self.filled_buys: list[dict] = []
self.filled_sells: list[dict] = []
self.total_fees = 0.0
self.realized_pnl = 0.0
self.running = True
def get_current_price(self) -> float:
ticker = self.exchange.fetch_ticker(SYMBOL)
return ticker["last"]
def place_initial_orders(self) -> None:
current_price = self.get_current_price()
logger.info(f"Current price: ${current_price:,.2f}")
logger.info(f"Grid levels: {self.grid_levels}")
for level in self.grid_levels:
size = calculate_order_size(
TOTAL_INVESTMENT, GRID_LEVELS, level
)
if level < current_price:
self.place_buy_order(level, size)
elif level > current_price:
self.place_sell_order(level, size)
def place_buy_order(
self, price: float, amount: float
) -> Optional[dict]:
if DRY_RUN:
order_id = f"dry-buy-{price}"
logger.info(
f"[DRY RUN] BUY {amount:.6f} @ ${price:,.2f}"
)
self.open_orders[price] = {
"id": order_id,
"side": "buy",
"price": price,
"amount": amount,
"status": "open",
}
return self.open_orders[price]
try:
order = self.exchange.create_limit_buy_order(
SYMBOL, amount, price
)
self.open_orders[price] = {
"id": order["id"],
"side": "buy",
"price": price,
"amount": amount,
"status": "open",
}
logger.info(
f"BUY order placed: {amount:.6f} @ ${price:,.2f}"
)
return self.open_orders[price]
except ccxt.InsufficientFunds:
logger.error(f"Insufficient funds for BUY @ ${price:,.2f}")
return None
except ccxt.NetworkError as e:
logger.error(f"Network error placing BUY: {e}")
return self.retry_order("buy", price, amount)
def place_sell_order(
self, price: float, amount: float
) -> Optional[dict]:
if DRY_RUN:
order_id = f"dry-sell-{price}"
logger.info(
f"[DRY RUN] SELL {amount:.6f} @ ${price:,.2f}"
)
self.open_orders[price] = {
"id": order_id,
"side": "sell",
"price": price,
"amount": amount,
"status": "open",
}
return self.open_orders[price]
try:
order = self.exchange.create_limit_sell_order(
SYMBOL, amount, price
)
self.open_orders[price] = {
"id": order["id"],
"side": "sell",
"price": price,
"amount": amount,
"status": "open",
}
logger.info(
f"SELL order placed: {amount:.6f} @ ${price:,.2f}"
)
return self.open_orders[price]
except ccxt.InsufficientFunds:
logger.error(
f"Insufficient funds for SELL @ ${price:,.2f}"
)
return None
except ccxt.NetworkError as e:
logger.error(f"Network error placing SELL: {e}")
return self.retry_order("sell", price, amount)
def retry_order(
self, side: str, price: float, amount: float, retries: int = 3
) -> Optional[dict]:
for attempt in range(1, retries + 1):
logger.info(
f"Retry {attempt}/{retries} for {side} @ ${price:,.2f}"
)
time.sleep(2 ** attempt)
try:
if side == "buy":
order = self.exchange.create_limit_buy_order(
SYMBOL, amount, price
)
else:
order = self.exchange.create_limit_sell_order(
SYMBOL, amount, price
)
self.open_orders[price] = {
"id": order["id"],
"side": side,
"price": price,
"amount": amount,
"status": "open",
}
logger.info(f"Retry succeeded for {side} @ ${price:,.2f}")
return self.open_orders[price]
except Exception as e:
logger.error(f"Retry {attempt} failed: {e}")
return None
def check_filled_orders(self) -> None:
if DRY_RUN:
return
for price, order_info in list(self.open_orders.items()):
try:
order = self.exchange.fetch_order(
order_info["id"], SYMBOL
)
if order["status"] == "closed":
self.handle_fill(price, order_info, order)
except Exception as e:
logger.error(
f"Error checking order {order_info['id']}: {e}"
)
def handle_fill(
self, price: float, order_info: dict, order: dict
) -> None:
side = order_info["side"]
filled_amount = order["filled"]
fee = filled_amount * price * MAKER_FEE
self.total_fees += fee
if side == "buy":
logger.info(
f"BUY FILLED: {filled_amount:.6f} @ ${price:,.2f} "
f"(fee: ${fee:.4f})"
)
self.filled_buys.append({
"price": price,
"amount": filled_amount,
"fee": fee,
"timestamp": datetime.now().isoformat(),
})
del self.open_orders[price]
# Place corresponding sell order one level up
level_index = self.grid_levels.index(price)
if level_index + 1 < len(self.grid_levels):
sell_price = self.grid_levels[level_index + 1]
self.place_sell_order(sell_price, filled_amount)
elif side == "sell":
logger.info(
f"SELL FILLED: {filled_amount:.6f} @ ${price:,.2f} "
f"(fee: ${fee:.4f})"
)
self.filled_sells.append({
"price": price,
"amount": filled_amount,
"fee": fee,
"timestamp": datetime.now().isoformat(),
})
del self.open_orders[price]
# Calculate realized P&L for this grid cycle
sell_value = filled_amount * price
grid_step = (UPPER_PRICE - LOWER_PRICE) / GRID_LEVELS
buy_price_est = price - grid_step
buy_value = filled_amount * buy_price_est
profit = sell_value - buy_value - (fee * 2)
self.realized_pnl += profit
logger.info(f"Grid profit: ${profit:.4f}")
# Place corresponding buy order one level down
level_index = self.grid_levels.index(price)
if level_index - 1 >= 0:
buy_price = self.grid_levels[level_index - 1]
self.place_buy_order(buy_price, filled_amount)
def display_status(self) -> None:
buy_orders = sum(
1 for o in self.open_orders.values() if o["side"] == "buy"
)
sell_orders = sum(
1 for o in self.open_orders.values() if o["side"] == "sell"
)
logger.info("─" * 50)
logger.info(f"Open BUY orders: {buy_orders}")
logger.info(f"Open SELL orders: {sell_orders}")
logger.info(f"Filled buys: {len(self.filled_buys)}")
logger.info(f"Filled sells: {len(self.filled_sells)}")
logger.info(f"Total fees: ${self.total_fees:.4f}")
logger.info(f"Realized P&L: ${self.realized_pnl:.4f}")
logger.info("─" * 50)
def cancel_all_orders(self) -> None:
if DRY_RUN:
logger.info("[DRY RUN] Would cancel all open orders")
return
for price, order_info in self.open_orders.items():
try:
self.exchange.cancel_order(order_info["id"], SYMBOL)
logger.info(f"Cancelled {order_info['side']} @ ${price:,.2f}")
except Exception as e:
logger.error(f"Error cancelling order: {e}")
def run(self) -> None:
logger.info(f"Grid Bot started ({'DRY RUN' if DRY_RUN else 'LIVE'})")
logger.info(f"Symbol: {SYMBOL}")
logger.info(f"Range: ${LOWER_PRICE:,.2f} - ${UPPER_PRICE:,.2f}")
logger.info(f"Levels: {GRID_LEVELS}")
logger.info(f"Investment: ${TOTAL_INVESTMENT:,.2f}")
self.place_initial_orders()
try:
while self.running:
self.check_filled_orders()
self.display_status()
time.sleep(POLL_INTERVAL)
except KeyboardInterrupt:
logger.info("Shutting down...")
self.cancel_all_orders()
self.display_status()
# ──────────────────────────────────────────────
# Entry Point
# ──────────────────────────────────────────────
if __name__ == "__main__":
exchange = create_exchange()
bot = GridBot(exchange)
bot.run()
Walking Through the Code
Let me break down what each section does, because if you are going to run this with real money, you need to understand every line.
Exchange Connection Setup
The create_exchange() function initializes the Binance connection through ccxt. The defaultType: "spot" option is important — without it, ccxt might default to futures on some setups, and you would be placing leveraged trades without realizing it. The load_markets() call fetches the trading pair info (price precision, minimum order sizes) which ccxt needs internally.
Grid Level Calculation
calculate_grid_levels() divides the price range into evenly spaced levels. With a range of $62,000 to $67,000 and 10 levels, you get grid lines every $500. Each grid level represents a price where the bot will either buy or sell.
calculate_order_size() splits your total investment equally across all grid levels. With $1,000 across 10 levels, that is $100 per grid, which at $64,000 per BTC is roughly 0.00156 BTC per order.
Order Placement Logic
The place_initial_orders() method checks the current price and splits the grid: buy orders go below, sell orders go above. This is the standard grid setup. If BTC is at $64,500, you would get buy orders at $62,000 through $64,000 and sell orders at $65,000 through $67,000.
Position Tracking and Grid Cycling
The handle_fill() method is the heart of the bot. When a buy order fills, it immediately places a sell order one grid level higher. When a sell order fills, it places a buy order one grid level lower. This creates the cycling behavior that makes grid trading profitable in sideways markets.
Fee Accounting
The bot tracks fees per fill at 0.1% (Binance’s maker rate). It calculates realized P&L by estimating the buy cost for each sell fill based on the grid step size. This is not perfectly accurate (more on that shortly), but it gives you a running sense of profitability.
Error Handling and Retry Logic
Network errors get exponential backoff retries (2s, 4s, 8s). Insufficient funds errors are logged but not retried — retrying those would be pointless. The cancel_all_orders() cleanup on Ctrl+C prevents orphaned orders sitting on the exchange.
What Claude Code Got Right
I want to give credit where it is due. The first version had a lot going for it:
- Clean class structure. Encapsulating everything in
GridBotwith clear method boundaries made it easy to understand and modify. - Proper error handling. Catching specific ccxt exception types instead of bare
exceptblocks. The retry logic with exponential backoff was a nice touch. - Dry-run mode as default. This is exactly the right safety choice. You do not want someone copying this code and accidentally placing live orders.
- Type hints throughout. As requested, and consistently applied.
- Graceful shutdown. The
KeyboardInterrupthandler that cancels open orders prevents leaving stale orders on the exchange. - Logging to file and console. Useful for debugging issues hours after they happen.
What Claude Code Got Wrong
Here is where honesty matters. I found three significant issues when I tried to run this for real.
Problem 1: Order Size Minimums
Binance has minimum order sizes. For BTC/USDT, the minimum notional value is $5, and the minimum quantity step is 0.00001 BTC. The initial code did not check either of these. With small grid amounts, some orders would get rejected with a cryptic binance {"code":-1013,"msg":"Filter failure: LOT_SIZE"} error.
Problem 2: Fee Calculation in P&L Was Wrong
The P&L calculation in handle_fill() estimated the buy price by subtracting one grid step from the sell price. This works in theory, but in practice, if you have multiple buy fills at different levels feeding into sells, the estimate drifts. It also double-counted fees incorrectly — applying fee * 2 assumed one buy fee and one sell fee, but the buy fee had already been deducted from the actual filled amount by the exchange.
Problem 3: No Handling of Partial Fills
Limit orders on Binance can partially fill. If a buy order for 0.00156 BTC only fills 0.001 BTC, the original code treated it as fully filled and placed a sell order for the wrong amount. The remaining 0.00056 BTC would be stuck in limbo.
The Follow-Up Prompts
I went back to Claude Code with targeted fixes:
claude "The grid bot has three issues to fix:
1. Add minimum order size validation using exchange.markets[SYMBOL]
to check minAmount, minCost, and amount precision before placing
any order. Skip the grid level if the order would be too small.
2. Fix the P&L tracking — instead of estimating buy price from the
grid step, track actual fill prices. Keep a dict mapping each sell
order back to its corresponding buy fill price and amount.
3. Handle partial fills. When checking order status, use the 'filled'
field instead of assuming full fills. If an order is partially
filled, track the partial amount and only place the counter-order
for the filled portion. Leave the remainder as an open order."
Claude Code understood all three issues and produced targeted patches. Here are the key changes.
Fix 1: Order Size Validation
def validate_order_size(self, amount: float, price: float) -> float:
market = self.exchange.markets[SYMBOL]
min_amount = market.get("limits", {}).get("amount", {}).get("min", 0)
min_cost = market.get("limits", {}).get("cost", {}).get("min", 0)
precision = market.get("precision", {}).get("amount", 8)
# Round to exchange precision
amount = float(
Decimal(str(amount)).quantize(
Decimal(10) ** -precision, rounding=ROUND_DOWN
)
)
if amount < min_amount:
logger.warning(
f"Order size {amount} below minimum {min_amount}, skipping"
)
return 0.0
if amount * price < min_cost:
logger.warning(
f"Order notional ${amount * price:.2f} below "
f"minimum ${min_cost}, skipping"
)
return 0.0
return amount
Fix 2: Actual P&L Tracking
# In __init__
self.buy_fill_prices: dict[str, float] = {}
# In handle_fill, for buy fills:
sell_order = self.place_sell_order(sell_price, filled_amount)
if sell_order:
self.buy_fill_prices[sell_order["id"]] = price
# In handle_fill, for sell fills:
buy_price_actual = self.buy_fill_prices.pop(
order_info["id"], None
)
if buy_price_actual:
buy_value = filled_amount * buy_price_actual
sell_value = filled_amount * price
buy_fee = filled_amount * buy_price_actual * MAKER_FEE
sell_fee = filled_amount * price * MAKER_FEE
profit = sell_value - buy_value - buy_fee - sell_fee
self.realized_pnl += profit
logger.info(
f"Grid profit: ${profit:.4f} "
f"(bought @ ${buy_price_actual:,.2f}, "
f"sold @ ${price:,.2f})"
)
Fix 3: Partial Fill Handling
def check_filled_orders(self) -> None:
if DRY_RUN:
return
for price, order_info in list(self.open_orders.items()):
try:
order = self.exchange.fetch_order(
order_info["id"], SYMBOL
)
if order["status"] == "closed":
self.handle_fill(price, order_info, order)
elif (
order["status"] == "open"
and order["filled"] > 0
and order["filled"] > order_info.get(
"last_filled", 0
)
):
# Partial fill detected
new_fill = (
order["filled"]
- order_info.get("last_filled", 0)
)
logger.info(
f"Partial fill: {new_fill:.6f} of "
f"{order_info['amount']:.6f} "
f"@ ${price:,.2f}"
)
order_info["last_filled"] = order["filled"]
# Place counter-order for the partial amount
self.handle_partial_fill(
price, order_info, new_fill
)
except Exception as e:
logger.error(
f"Error checking order {order_info['id']}: {e}"
)
Dry-Run Testing Results
After applying all three fixes, I ran the bot in dry-run mode for 24 hours against live Binance price data. The bot does not place real orders in dry-run mode, but it fetches real prices and simulates the grid behavior.
Here is what the output looked like:
2026-02-15 08:00:12 [INFO] Grid Bot started (DRY RUN)
2026-02-15 08:00:12 [INFO] Symbol: BTC/USDT
2026-02-15 08:00:12 [INFO] Range: $62,000.00 - $67,000.00
2026-02-15 08:00:12 [INFO] Levels: 10
2026-02-15 08:00:12 [INFO] Investment: $1,000.00
2026-02-15 08:00:13 [INFO] Current price: $64,523.40
2026-02-15 08:00:13 [INFO] [DRY RUN] BUY 0.001613 @ $62,000.00
2026-02-15 08:00:13 [INFO] [DRY RUN] BUY 0.001600 @ $62,500.00
2026-02-15 08:00:13 [INFO] [DRY RUN] BUY 0.001587 @ $63,000.00
2026-02-15 08:00:13 [INFO] [DRY RUN] BUY 0.001575 @ $63,500.00
2026-02-15 08:00:13 [INFO] [DRY RUN] BUY 0.001562 @ $64,000.00
2026-02-15 08:00:13 [INFO] [DRY RUN] SELL 0.001538 @ $65,000.00
2026-02-15 08:00:13 [INFO] [DRY RUN] SELL 0.001524 @ $65,500.00
2026-02-15 08:00:13 [INFO] [DRY RUN] SELL 0.001515 @ $66,000.00
2026-02-15 08:00:13 [INFO] [DRY RUN] SELL 0.001503 @ $66,500.00
2026-02-15 08:00:13 [INFO] [DRY RUN] SELL 0.001492 @ $67,000.00
The grid set up correctly with 5 buy orders below and 5 sell orders above the current price. Over the 24-hour test, the price did dip to $63,200 and back up to $65,800, which would have triggered several grid cycles.
Grid Parameter Recommendations
After testing different configurations, here are settings I found reasonable for different pairs and risk levels. These are not financial advice — they are starting points for your own testing.
| Pair | Price Range | Grid Levels | Amount Per Grid | Total Investment | Grid Spacing |
|---|---|---|---|---|---|
| BTC/USDT | $62,000 - $67,000 | 10 | $100 | $1,000 | $500 (0.77%) |
| ETH/USDT | $3,200 - $3,600 | 8 | $50 | $400 | $50 (1.43%) |
| SOL/USDT | $120 - $150 | 15 | $20 | $300 | $2 (1.54%) |
| BNB/USDT | $550 - $650 | 10 | $30 | $300 | $10 (1.67%) |
| XRP/USDT | $0.55 - $0.70 | 12 | $15 | $180 | $0.0125 (2.0%) |
A few things I learned about parameter selection:
- Grid spacing should exceed 2x your fee rate. With 0.1% maker fees on both the buy and sell, your round-trip cost is 0.2%. A grid spacing of 0.77% like the BTC example means your net profit per grid cycle is roughly 0.57% minus slippage.
- Wider ranges mean fewer triggers but safer operation. A tight range gets more fills but risks the price breaking out of your grid entirely.
- More levels means smaller orders. Make sure each order still meets the exchange minimum. For BTC/USDT on Binance, that is roughly $5 notional.
How This Compares to the OpenClaw Approach
If you have read our OpenClaw tutorials, you might be wondering: why write code at all? OpenClaw lets you describe strategies in plain language and handles execution for you.
Here is how I think about it:
| Aspect | Claude Code (this approach) | OpenClaw |
|---|---|---|
| Setup effort | Higher — you manage the code, deployment, and environment | Lower — conversational interface |
| Customization | Full control — modify any logic you want | Limited to what OpenClaw supports |
| Learning curve | Need basic Python understanding | No code needed |
| Debugging | You can read and fix the code directly | Harder to diagnose issues |
| Cost | Free (just exchange fees) | OpenClaw subscription + exchange fees |
| Reliability | You are responsible for uptime | OpenClaw handles infrastructure |
My take: if you are comfortable with Python and want full control over your strategy logic, the Claude Code approach is more flexible. If you want something running in 10 minutes without touching code, OpenClaw is the faster path. There is no wrong answer.
Things I Would Change Next
The bot works, but it is not production-ready for serious capital. Here is what I would add before running it with more than play money:
- State persistence. If the bot crashes and restarts, it loses track of all open orders. I would add SQLite or a JSON state file that saves the grid state on every fill event.
- Price breakout protection. If the price drops below the grid’s lower bound, the bot just sits there with all buy orders filled and no sells executing. A stop-loss that cancels everything if the price falls 5% below the grid would help.
- Dynamic grid rebalancing. When the price trends strongly in one direction, the grid becomes one-sided. I would want the bot to detect this and shift the grid to center on the new price.
- WebSocket price feeds. Polling every 30 seconds with REST calls is wasteful and slow. A WebSocket connection would catch fills faster and reduce API rate limit pressure.
I asked Claude Code about each of these, and it generated reasonable implementations for all four. But each one added enough complexity that I decided to leave them out of this tutorial for clarity.
Wrapping Up
Claude Code produced a functional grid trading bot in under a minute. The first version had real issues — order minimums, fee math, and partial fills — but targeted follow-up prompts fixed all three. The final version is clean, well-structured, and ready for dry-run testing.
The key lesson: do not trust the first output. AI-generated trading code needs the same scrutiny as any code that touches your money. Run it in dry-run mode. Read every line. Understand the fee math. Only then consider putting real capital behind it.
If you are new to this, start with the Binance API setup guide first, then come back here when you are comfortable making API calls. The Binance API documentation is also a useful reference for understanding order types and rate limits. Grid trading is a legitimate strategy for sideways markets, but it will lose money in a strong trend. No bot — AI-generated or otherwise — eliminates that risk.
Next Steps
- Compare: no-code grid trading — See how OpenClaw handles grid trading without writing code in our OpenClaw grid trading guide
- Prerequisite: API setup — If you skipped it, follow the full Claude Code + Binance API setup guide first