← 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 →