← Back to Blog
STRATEGY

Grid Trading Bot: Build a Self-Replenishing Grid Bot for LMEX in Python

May 29, 2026 · 8 min read · LMEX.AI

Building a grid trading bot is a useful exercise even if you never deploy one for serious money. The challenges, order management, state recovery, partial fills, exchange API quirks, are foundational for any algorithmic trading project. This article walks through implementing a working grid bot for LMEX in Python, from order placement to recovery logic.


The components


A complete grid trading bot has five main components:


1. **Configuration**: grid parameters (range, spacing, capital per level)

2. **Order placement**: initial grid setup and order management

3. **Fill handler**: react to fills by placing opposite-side orders

4. **State persistence**: track open orders, filled levels, total inventory

5. **Recovery logic**: handle restarts and reconnections without losing state


The configuration is simple. The other four are where most bots fail.


Configuration


A reasonable starting configuration for BTC-PERP:


from dataclasses import dataclass

@dataclass
class GridConfig:
    symbol: str = 'BTC-PERP'
    lower_price: float = 55000
    upper_price: float = 65000
    num_levels: int = 20
    capital_per_level: float = 500  # USD value per buy order
    
    @property
    def spacing(self):
        return (self.upper_price - self.lower_price) / (self.num_levels - 1)
    
    @property
    def levels(self):
        return [self.lower_price + i * self.spacing for i in range(self.num_levels)]

This creates a 20-level grid from \$55k to \$65k with \$500 worth of BTC at each level. Spacing is \$526 per level.


Initial grid placement


Place buy orders below current price, sell orders above. Skip the level closest to current price (no point in placing a stop right where you are):


import ccxt

exchange = ccxt.lmex({'apiKey': '...', 'secret': '...'})

def place_initial_grid(config: GridConfig):
    current_price = exchange.fetch_ticker(config.symbol)['last']
    orders = []
    
    for level_price in config.levels:
        # Skip levels too close to current price
        if abs(level_price - current_price) < config.spacing / 2:
            continue
        
        qty = config.capital_per_level / level_price
        
        if level_price < current_price:
            order = exchange.create_order(
                config.symbol, 'limit', 'buy', qty, level_price,
                {'postOnly': True}
            )
        else:
            order = exchange.create_order(
                config.symbol, 'limit', 'sell', qty, level_price,
                {'postOnly': True}
            )
        
        orders.append({
            'orderId': order['id'],
            'level': level_price,
            'side': order['side'],
            'qty': qty,
        })
    
    return orders

The `postOnly: True` flag is critical. It ensures the orders provide liquidity rather than take it, earning maker rebates and avoiding accidental crosses.


Fill handler


When an order fills, place the opposite-side order at the adjacent level:


def handle_fill(filled_order, config: GridConfig):
    filled_level = filled_order['level']
    filled_side = filled_order['side']
    
    if filled_side == 'buy':
        # Place sell at the level above
        new_level = filled_level + config.spacing
        if new_level <= config.upper_price:
            qty = filled_order['qty']
            order = exchange.create_order(
                config.symbol, 'limit', 'sell', qty, new_level,
                {'postOnly': True}
            )
            return {'orderId': order['id'], 'level': new_level, 'side': 'sell', 'qty': qty}
    
    elif filled_side == 'sell':
        # Place buy at the level below
        new_level = filled_level - config.spacing
        if new_level >= config.lower_price:
            qty = filled_order['qty']
            order = exchange.create_order(
                config.symbol, 'limit', 'buy', qty, new_level,
                {'postOnly': True}
            )
            return {'orderId': order['id'], 'level': new_level, 'side': 'buy', 'qty': qty}
    
    return None

This is the core of grid trading. Buy fills → sell orders one level up. Sell fills → buy orders one level down.


State persistence


Without persistent state, a restart wipes out your knowledge of which orders are placed and at what levels. Use a simple SQLite database:


import sqlite3
from datetime import datetime

class GridState:
    def __init__(self, db_path='grid_state.db'):
        self.conn = sqlite3.connect(db_path)
        self.conn.execute('''
            CREATE TABLE IF NOT EXISTS orders (
                order_id TEXT PRIMARY KEY,
                level REAL,
                side TEXT,
                qty REAL,
                status TEXT,
                created_at TEXT
            )
        ''')
        self.conn.commit()
    
    def add_order(self, order_id, level, side, qty):
        self.conn.execute(
            'INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?)',
            (order_id, level, side, qty, 'open', datetime.utcnow().isoformat())
        )
        self.conn.commit()
    
    def mark_filled(self, order_id):
        self.conn.execute(
            "UPDATE orders SET status = 'filled' WHERE order_id = ?",
            (order_id,)
        )
        self.conn.commit()
    
    def get_open_orders(self):
        cursor = self.conn.execute(
            "SELECT * FROM orders WHERE status = 'open'"
        )
        return cursor.fetchall()

This gives you crash recovery, on restart, read open orders from the database and resume tracking them.


Recovery logic


When the bot restarts after a crash or planned restart, reconcile local state with exchange reality:


def reconcile_state(state: GridState, config: GridConfig):
    # Get local known-open orders
    local_orders = state.get_open_orders()
    local_ids = {o[0] for o in local_orders}
    
    # Get actual open orders from exchange
    actual_orders = exchange.fetch_open_orders(config.symbol)
    actual_ids = {o['id'] for o in actual_orders}
    
    # Find discrepancies
    locally_open_but_not_on_exchange = local_ids - actual_ids
    on_exchange_but_not_in_local = actual_ids - local_ids
    
    # Local says open but exchange doesn't have it = probably filled
    for order_id in locally_open_but_not_on_exchange:
        # Check trade history to confirm
        trades = exchange.fetch_my_trades(config.symbol, limit=200)
        if any(t['order'] == order_id for t in trades):
            state.mark_filled(order_id)
            # Replace with opposite-side order (catch up logic)
    
    # On exchange but not locally = pre-existing orders we don't track
    # Could be from a previous instance or manual placement
    # Decision: leave them alone, log warning
    if on_exchange_but_not_in_local:
        print(f"Warning: untracked orders on exchange: {on_exchange_but_not_in_local}")

This reconciliation step runs every time the bot starts. It's the difference between a bot that recovers cleanly from a 5-minute outage and a bot that needs manual intervention.


What's missing


This skeleton is incomplete. A production bot also needs:


**WebSocket order updates.** Instead of polling REST for fills, subscribe to the order update stream. Lower latency, lower API rate usage.


**Inventory tracking.** Sum position across all filled buys minus all filled sells. Use this for risk management.


**Range monitoring.** When price moves outside the grid, the bot should pause and alert. Continuing to trade with price outside the grid produces poor results.


**Kill switch.** Triggered by drawdown threshold, volatility spike, or manual override. Cancels all orders immediately.


**Logging.** Every order placement, fill, and decision logged with timestamps. Without good logs, debugging is impossible.


These together add another 500-1000 lines of code beyond the skeleton above.


Testing before going live


Run the bot in paper-trading mode for at least 2 weeks before any real money:


1. Implement using LMEX's testnet first

2. Verify orders place and cancel correctly

3. Manually simulate fills to test the fill handler

4. Test recovery by killing the bot mid-operation

5. Run in live conditions but at minimum order size for another week

6. Only then scale up to real position sizes


Most grid bot failures happen in week 1 of live operation. Survive that, you've validated most of the logic.


Frequently Asked Questions


Q: What's the minimum capital to run a grid bot meaningfully?

Around \$5,000-10,000. Below that, transaction costs and minimum order sizes eat too much. Above \$50,000, grid bots can be very capital-efficient.


Q: Should I use REST or WebSocket for order placement?

REST is simpler. WebSocket is faster and uses less rate limit. For a 20-level grid, REST is fine. For 100+ level grids with frequent updates, switch to WebSocket.


Q: How do I handle the bot crashing?

Persistent state in SQLite (or similar), reconciliation logic on startup, alerts when the bot detects state inconsistency. Plan for crashes; they happen.


Q: Can I run multiple grids on different pairs simultaneously?

Yes, recommended. Different pairs trend differently, so a portfolio of grids smooths the combined return. Just be aware of correlation during stress events.


Related Articles


→ Grid Trading: A Complete Strategy Guide
→ Multi-Pair Spread Bot: Liquidity Mining Across LMEX Markets
→ Building a Crypto Perpetuals Trading Bot in Python: Complete Guide
← All ArticlesBuild a Bot →