Crypto-Native·Spot Trading Mechanics·Intermediate

Spot Grid Trading

Implement an automated spot grid trading bot that algorithmically places a ladder of staggered buy and sell limit orders at regular price intervals within a configured trading range, systematically profiting from natural price oscillations and market microstructure noise in ranging market conditions.

cryptospot-tradingtrading-strategies

Spot Grid Trading — Crypto-Native

Category: Crypto-Native | Subcategory: Spot


What This Notebook Does

Grid trading automates profit-taking from price oscillation within a defined range. By placing buy orders below and sell orders above the current price at fixed intervals, a grid bot continuously buys dips and sells rips — earning the grid spacing as spread on every completed buy-sell pair.

This notebook:

  1. Builds arithmetic and geometric grid constructors
  2. Simulates fill logic as price traverses the grid
  3. Tracks open and realized PnL per grid level
  4. Analyzes performance sensitivity to grid spacing and range
  5. Compares grid trading vs buy-and-hold over the same period
  6. Exports the trade log and performance summary

Grid Trading Mechanics

ConceptDescription
Grid levelA price at which a buy or sell order is placed
Grid spacingPrice distance between adjacent levels
Arithmetic gridEqual dollar spacing between levels
Geometric gridEqual percentage spacing — more natural for crypto
Completed pairOne buy fill + one sell fill at the next level above
Grid profitSpread captured per completed pair = sell_price - buy_price
[1]
!pip install numpy pandas matplotlib seaborn --quiet
[2]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from dataclasses import dataclass, field
from typing import List, Dict, Optional
import warnings

warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top']   = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')
Imports ready.
[3]
# --- Configuration ---
GRID_CENTER      = 50_000.0   # central price (approximate current price)
GRID_RANGE_PCT   = 0.20       # ±20% range around centre
N_LEVELS         = 20         # number of grid levels
ORDER_SIZE_USD   = 500.0      # USD allocated per grid level
SIMULATION_DAYS  = 90         # days to simulate
GRID_TYPE        = 'geometric' # 'arithmetic' or 'geometric'
print('Config ready.')
Config ready.

Section 2 — Grid Construction

[4]
@dataclass
class GridLevel:
    """
    A single level in the trading grid.

    Attributes
    ----------
    price : float  Price at which a buy or sell order sits.
    order_size_btc : float  BTC quantity per fill.
    has_buy_order : bool  Whether a buy order is active at this level.
    has_sell_order : bool  Whether a sell order is active at this level.
    buy_fills : int  Cumulative buy fills at this level.
    sell_fills : int  Cumulative sell fills at this level.
    """
    price: float
    order_size_btc: float
    has_buy_order: bool = True
    has_sell_order: bool = False
    buy_fills: int = 0
    sell_fills: int = 0


def create_arithmetic_grid(
    center: float,
    range_pct: float,
    n_levels: int,
    order_size_usd: float
) -> List[GridLevel]:
    """
    Create a grid with equal dollar spacing between levels.

    Parameters
    ----------
    center : float  Reference price.
    range_pct : float  Half-range as fraction of center (e.g. 0.20 = ±20%).
    n_levels : int  Total number of levels.
    order_size_usd : float  USD value per order.

    Returns
    -------
    List[GridLevel]  Sorted ascending list of grid levels.
    """
    lo = center * (1 - range_pct)
    hi = center * (1 + range_pct)
    prices = np.linspace(lo, hi, n_levels)
    return [GridLevel(price=p, order_size_btc=order_size_usd / p) for p in prices]


def create_geometric_grid(
    center: float,
    range_pct: float,
    n_levels: int,
    order_size_usd: float
) -> List[GridLevel]:
    """
    Create a grid with equal percentage spacing between levels.

    Parameters
    ----------
    center : float  Reference price.
    range_pct : float  Half-range as fraction of center.
    n_levels : int  Total number of levels.
    order_size_usd : float  USD value per order.

    Returns
    -------
    List[GridLevel]  Sorted ascending list of grid levels.

    Notes
    -----
    Geometric spacing is preferred for crypto because percentage moves
    are more natural than dollar moves at high price levels.
    """
    lo = center * (1 - range_pct)
    hi = center * (1 + range_pct)
    prices = np.geomspace(lo, hi, n_levels)
    return [GridLevel(price=p, order_size_btc=order_size_usd / p) for p in prices]


grid = (create_geometric_grid if GRID_TYPE == 'geometric' else create_arithmetic_grid)(
    GRID_CENTER, GRID_RANGE_PCT, N_LEVELS, ORDER_SIZE_USD
)
print(f'Grid type: {GRID_TYPE}')
print(f'Range: ${grid[0].price:,.0f} — ${grid[-1].price:,.0f}')
print(f'Levels: {len(grid)}')
print(f'Spacing: {((grid[1].price / grid[0].price - 1) * 100):.2f}% (between levels 0 and 1)')
Grid type: geometric
Range: $40,000 — $60,000
Levels: 20
Spacing: 2.16% (between levels 0 and 1)

Section 3 — Price Simulation & Grid Backtest

[5]
def generate_synthetic_price_series(
    start: float,
    n_days: int,
    annual_vol: float = 0.60,
    drift: float = 0.0,
    seed: int = 42
) -> np.ndarray:
    """
    Generate a synthetic BTC price series using geometric Brownian motion.

    Parameters
    ----------
    start : float  Starting price.
    n_days : int  Number of daily prices.
    annual_vol : float  Annual volatility (e.g. 0.60 = 60%).
    drift : float  Annual drift term.
    seed : int  Random seed.

    Returns
    -------
    np.ndarray  Daily close prices.
    """
    rng = np.random.default_rng(seed)
    dt  = 1 / 365
    rets = (drift - 0.5 * annual_vol**2) * dt + annual_vol * np.sqrt(dt) * rng.standard_normal(n_days)
    return start * np.exp(np.cumsum(rets))


def simulate_grid_trading(
    grid: List[GridLevel],
    prices: np.ndarray
) -> pd.DataFrame:
    """
    Simulate grid trading by checking price crossings each bar.

    When price crosses a level downward, the buy order at that level fills
    and a sell order is placed one level above. When price crosses upward,
    the sell order fills and a buy order is placed one level below.

    Parameters
    ----------
    grid : List[GridLevel]  Initialized grid levels.
    prices : np.ndarray  Price series (daily or intraday).

    Returns
    -------
    pd.DataFrame
        Trade log with columns: bar, price, side, level_idx,
        fill_price, qty_btc, pnl_usd.
    """
    import copy
    grid = copy.deepcopy(grid)
    # Initialize: set buy orders below current price, sell orders above
    current = prices[0]
    for i, lvl in enumerate(grid):
        lvl.has_buy_order  = (lvl.price < current)
        lvl.has_sell_order = (lvl.price > current)

    trades = []
    prev_price = prices[0]
    cash = 0.0
    btc  = 0.0

    for bar, price in enumerate(prices):
        lo_p = min(price, prev_price)
        hi_p = max(price, prev_price)

        for i, lvl in enumerate(grid):
            # Buy fill: price moved down through this level
            if lvl.has_buy_order and lo_p <= lvl.price <= hi_p:
                cost = lvl.order_size_btc * lvl.price
                cash -= cost
                btc  += lvl.order_size_btc
                lvl.buy_fills += 1
                lvl.has_buy_order  = False
                if i + 1 < len(grid):
                    grid[i + 1].has_sell_order = True  # place sell one level up
                trades.append({'bar': bar, 'price': price, 'side': 'BUY',
                                'fill_price': lvl.price, 'qty_btc': lvl.order_size_btc,
                                'pnl_usd': 0.0})

            # Sell fill: price moved up through this level
            elif lvl.has_sell_order and lo_p <= lvl.price <= hi_p:
                revenue = lvl.order_size_btc * lvl.price
                cash   += revenue
                btc    -= lvl.order_size_btc
                lvl.sell_fills += 1
                lvl.has_sell_order = False
                if i - 1 >= 0:
                    grid[i - 1].has_buy_order = True  # place buy one level down
                # Grid profit = sell_price - buy_price (one level below)
                buy_price = grid[i-1].price if i > 0 else lvl.price
                grid_pnl  = (lvl.price - buy_price) * lvl.order_size_btc
                trades.append({'bar': bar, 'price': price, 'side': 'SELL',
                                'fill_price': lvl.price, 'qty_btc': lvl.order_size_btc,
                                'pnl_usd': grid_pnl})

        prev_price = price

    return pd.DataFrame(trades) if trades else pd.DataFrame(columns=['bar','price','side','fill_price','qty_btc','pnl_usd'])


prices = generate_synthetic_price_series(GRID_CENTER, SIMULATION_DAYS)
trades_df = simulate_grid_trading(grid, prices)
print(f'Total trades: {len(trades_df)}')
print(f'Buy fills: {(trades_df["side"]=="BUY").sum()}, Sell fills: {(trades_df["side"]=="SELL").sum()}')
print(f'Total grid PnL: ${trades_df["pnl_usd"].sum():,.2f}')
Total trades: 57
Buy fills: 29, Sell fills: 28
Total grid PnL: $295.60

Section 4 — Performance Analysis

[6]
def compute_grid_performance(
    trades_df: pd.DataFrame,
    prices: np.ndarray,
    order_size_usd: float,
    n_levels: int
) -> dict:
    """
    Summarize grid trading performance metrics.

    Parameters
    ----------
    trades_df : pd.DataFrame  Trade log from simulate_grid_trading().
    prices : np.ndarray  Price series used in simulation.
    order_size_usd : float  USD per grid order.
    n_levels : int  Number of grid levels.

    Returns
    -------
    dict  Performance metrics.
    """
    total_capital = order_size_usd * n_levels
    grid_pnl      = trades_df['pnl_usd'].sum()
    sell_trades   = trades_df[trades_df['side'] == 'SELL']
    n_completed   = len(sell_trades)  # completed pairs
    price_change  = (prices[-1] / prices[0] - 1) * 100
    bh_pnl        = (prices[-1] - prices[0]) * (total_capital / prices[0])

    return {
        'total_capital_usd':   total_capital,
        'grid_pnl_usd':        round(grid_pnl, 2),
        'grid_return_pct':     round(grid_pnl / total_capital * 100, 2),
        'completed_pairs':     n_completed,
        'avg_pnl_per_pair':    round(grid_pnl / max(n_completed, 1), 2),
        'buy_and_hold_pnl':    round(bh_pnl, 2),
        'price_change_pct':    round(price_change, 2),
    }


perf = compute_grid_performance(trades_df, prices, ORDER_SIZE_USD, N_LEVELS)
print('Grid Performance Summary:')
for k, v in perf.items():
    print(f'  {k}: {v}')
Grid Performance Summary:
  total_capital_usd: 10000.0
  grid_pnl_usd: 295.6
  grid_return_pct: 2.96
  completed_pairs: 28
  avg_pnl_per_pair: 10.56
  buy_and_hold_pnl: -258.22
  price_change_pct: -2.58

Section 5 — Visualization

[7]
def plot_grid_performance(
    trades_df: pd.DataFrame,
    prices: np.ndarray,
    grid: list,
    grid_center: float
) -> None:
    """
    Two-panel visualization: price with grid levels, and cumulative PnL.

    Parameters
    ----------
    trades_df : pd.DataFrame  Trade log.
    prices : np.ndarray  Price series.
    grid : list  List of GridLevel objects.
    grid_center : float  Grid centre price.
    """
    fig, axes = plt.subplots(2, 1, figsize=(14, 10))

    # Panel 1: Price + grid levels + trade markers
    axes[0].plot(prices, color='steelblue', linewidth=1.0, label='Price')
    for lvl in grid:
        axes[0].axhline(lvl.price, color='lightgray', linewidth=0.4, linestyle='-')
    if len(trades_df):
        buys  = trades_df[trades_df['side'] == 'BUY']
        sells = trades_df[trades_df['side'] == 'SELL']
        axes[0].scatter(buys['bar'],  buys['fill_price'],  color='green', s=15, alpha=0.6, label='Buy fills')
        axes[0].scatter(sells['bar'], sells['fill_price'], color='red',   s=15, alpha=0.6, label='Sell fills')
    axes[0].set_ylabel('Price (USD)')
    axes[0].set_title(f'{GRID_TYPE.capitalize()} Grid — {N_LEVELS} levels ±{GRID_RANGE_PCT*100:.0f}% range')
    axes[0].legend()

    # Panel 2: Cumulative PnL
    if len(trades_df):
        cum_pnl = trades_df['pnl_usd'].cumsum()
        axes[1].plot(trades_df['bar'], cum_pnl, color='green', linewidth=1.5, label='Grid PnL')
    axes[1].axhline(0, color='gray', linewidth=0.5, linestyle='--')
    axes[1].set_xlabel('Bar (day)')
    axes[1].set_ylabel('Cumulative PnL (USD)')
    axes[1].set_title('Cumulative Grid Trading PnL (Realized, from completed pairs)')
    axes[1].legend()

    plt.tight_layout()
    plt.show()


plot_grid_performance(trades_df, prices, grid, GRID_CENTER)
cell output

Section 6 — Export

[8]
def export_grid_results(trades_df: pd.DataFrame, perf: dict) -> None:
    """
    Export trade log and performance summary.

    Parameters
    ----------
    trades_df : pd.DataFrame  Trade log.
    perf : dict  Performance metrics dict.
    """
    trades_df.to_csv('grid_trades.csv', index=False)
    pd.DataFrame([perf]).to_csv('grid_performance.csv', index=False)
    print('Exported: grid_trades.csv, grid_performance.csv')


export_grid_results(trades_df, perf)
Exported: grid_trades.csv, grid_performance.csv

Summary & Next Steps

Key Takeaways

  • Grid trading captures oscillation profits regardless of trend direction — it thrives in ranging markets
  • In a strong trend, grid bots accumulate inventory (buying falling prices without sells filling) — capital at risk
  • Geometric spacing is more appropriate for crypto: a 1% move at $60K vs $30K generates different dollar PnL
  • The key parameters are: number of levels, range width, and order size per level
  • Grid PnL is bounded above by the total oscillation captured; it cannot beat buy-and-hold in a strong bull run