Strategy Tools

Grid Trading Bots: How AI Agents
Profit from Sideways Markets

March 6, 2026 Purple Flea Team 11 min read

Most trading strategies assume price goes somewhere: up for trend-followers, down for short-sellers. Grid trading assumes something more realistic: price oscillates. In a ranging market, an asset bounces between a floor and ceiling. Grid bots exploit every bounce — buying on dips, selling on pops, collecting the bid-ask spread on each completed cycle.

For AI agents, grid trading is a natural fit. It requires no directional prediction, no fundamental analysis, no news interpretation. The agent deploys a mechanical system, monitors fills, places replacement orders, and accumulates small profits at scale. This article covers the mechanics, the math, the optimal spacing calculation, and a complete Python GridBot class using the Purple Flea Trading API.

How Grid Trading Works

The concept is straightforward. You define a price range — say $2,800 to $3,400 for ETH/USDC — and divide it into N equally-spaced levels. At each level, you place a buy order below current price and a sell order above. When any buy fills, you immediately place a sell at the next level up. When any sell fills, you immediately place a buy at the next level down.

Every completed buy-sell cycle generates profit equal to the grid spacing minus trading fees. If the asset oscillates continuously within your range, each grid level fires multiple times per day. The more oscillations, the more profit.

Grid layout — ETH/USDC, $3,000 range, 10 levels, $60 spacing
$3,300
SELL order
$3,240
SELL order
$3,180
SELL order
$3,120
SELL order
$3,060
▶ Current price ~$3,060
$3,000
BUY order
$2,940
BUY order
$2,880
BUY order
$2,820
BUY order

When price drops to $3,000, the BUY fills. The bot immediately places a SELL at $3,060. When price rises back to $3,060, the SELL fills. Net profit per cycle: $60 minus fees. Repeat across 9 active grid levels, multiple times per day.

Spacing Calculation: The Core Math

Grid spacing determines profitability. Too narrow: fees eat the profit. Too wide: fewer cycles per day, lower total return. The optimal spacing depends on asset volatility, trading fees, and desired capital allocation.

Python — Grid spacing calculator
from decimal import Decimal
import math


def calculate_grid_params(
    lower_price: Decimal,
    upper_price: Decimal,
    capital_usdc: Decimal,
    trading_fee_pct: Decimal = Decimal("0.001"),  # 0.1% per side
    min_profit_per_cycle_pct: Decimal = Decimal("0.003"),
) -> dict:
    """
    Calculate optimal grid parameters given a price range and capital.

    Returns: num_levels, spacing, capital_per_level, expected_profit_per_cycle
    """
    price_range = upper_price - lower_price
    midpoint    = (upper_price + lower_price) / 2

    # Minimum spacing to be profitable after fees (both sides)
    min_spacing_pct = (trading_fee_pct * 2) + min_profit_per_cycle_pct
    min_spacing_abs = midpoint * min_spacing_pct

    # Number of levels that fit with minimum spacing
    num_levels = int(price_range / min_spacing_abs)
    num_levels = max(num_levels, 4)   # at least 4 levels
    num_levels = min(num_levels, 50)  # cap at 50 orders (API limits)

    actual_spacing = price_range / num_levels
    capital_per_level = capital_usdc / num_levels

    # Profit per completed cycle (buy + sell = 1 cycle)
    profit_per_cycle_usdc = (actual_spacing / midpoint) * capital_per_level
    fee_per_cycle_usdc    = capital_per_level * trading_fee_pct * 2
    net_per_cycle_usdc    = profit_per_cycle_usdc - fee_per_cycle_usdc

    return {
        "num_levels":           num_levels,
        "spacing":              round(actual_spacing, 2),
        "spacing_pct":          round(actual_spacing / midpoint * 100, 3),
        "capital_per_level":    round(capital_per_level, 2),
        "net_per_cycle_usdc":   round(net_per_cycle_usdc, 4),
        "breakeven_cycles_day": 0,  # depends on volatility
    }


# Example: ETH/USDC, $2,700 to $3,600, $10,000 capital
params = calculate_grid_params(
    lower_price=Decimal("2700"),
    upper_price=Decimal("3600"),
    capital_usdc=Decimal("10000"),
)
print(params)
# {num_levels: 18, spacing: 50.0, spacing_pct: 1.587,
#  capital_per_level: 555.56, net_per_cycle_usdc: 6.89}

With 18 levels and $6.89 net profit per completed cycle, 5 cycles per day across the grid yields approximately $34.45/day on $10,000 capital — roughly 0.34% daily return, or 125% annualized (assuming consistent ranging conditions).

When Grid Trading Fails

Grid trading loses money when price trends strongly outside your defined range. If you set a $2,700–$3,600 range and ETH breaks to $4,500, your sell orders exhaust and you hold only USDC — missing the upside. Set ranges wide enough to absorb expected volatility, or implement dynamic range extension.

Purple Flea Trading API Integration

The Purple Flea Trading API provides limit order placement, order status polling, and cancellation — the three operations a grid bot needs. With 275+ markets and a referral rate of 20% on trading fees your bot generates, it is a natural execution venue for AI agents.

Key API endpoints for grid trading:

Operation Endpoint Notes
Place limit orderPOST /api/v1/ordersside, price, qty, market
Get order statusGET /api/v1/orders/:idopen | filled | cancelled
Cancel orderDELETE /api/v1/orders/:idfor range rebalancing
List open ordersGET /api/v1/orders?status=openpaginated, max 100
Get tickerGET /api/v1/markets/:market/tickerlast, bid, ask

Full Python GridBot Class

Python — GridBot class with Purple Flea Trading API
import time, os, logging
from decimal import Decimal
from typing import Optional
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("gridbot")

TRADING_BASE = "https://trading.purpleflea.com"


class GridBot:
    """
    Grid trading bot for Purple Flea Trading API.

    Maintains a set of buy and sell limit orders across evenly-spaced
    price levels. When a fill is detected, replaces it with a counter-order
    one level in the opposite direction.
    """

    def __init__(
        self,
        api_key: str,
        market: str,
        lower_price: Decimal,
        upper_price: Decimal,
        num_levels: int,
        capital_usdc: Decimal,
        poll_interval: float = 5.0,
    ):
        self.api_key       = api_key
        self.market        = market
        self.lower         = lower_price
        self.upper         = upper_price
        self.num_levels    = num_levels
        self.capital       = capital_usdc
        self.poll_interval = poll_interval
        self.session       = requests.Session()
        self.session.headers["Authorization"] = f"Bearer {api_key}"
        self.session.headers["Content-Type"]  = "application/json"

        # spacing between grid levels
        self.spacing = (upper_price - lower_price) / (num_levels - 1)
        self.levels  = [
            lower_price + self.spacing * i
            for i in range(num_levels)
        ]
        self.qty_per_level = (capital_usdc / num_levels) / (
            (upper_price + lower_price) / 2
        )

        # order_id -> {side, price, level_idx}
        self.open_orders: dict = {}
        self.filled_count = 0
        self.total_profit  = Decimal("0")

    def _post(self, path: str, data: dict) -> dict:
        r = self.session.post(f"{TRADING_BASE}{path}", json=data, timeout=10)
        r.raise_for_status()
        return r.json()

    def _get(self, path: str, params: dict = None) -> dict:
        r = self.session.get(f"{TRADING_BASE}{path}", params=params, timeout=10)
        r.raise_for_status()
        return r.json()

    def _delete(self, path: str) -> None:
        r = self.session.delete(f"{TRADING_BASE}{path}", timeout=10)
        r.raise_for_status()

    def get_current_price(self) -> Decimal:
        data = self._get(f"/api/v1/markets/{self.market}/ticker")
        return Decimal(str(data["last"]))

    def place_order(self, side: str, price: Decimal, level_idx: int) -> Optional[str]:
        """Place a limit order and track it. Returns order_id or None on error."""
        try:
            resp = self._post("/api/v1/orders", {
                "market": self.market,
                "side":   side,
                "type":   "limit",
                "price":  str(round(price, 2)),
                "qty":    str(round(self.qty_per_level, 6)),
            })
            order_id = resp["id"]
            self.open_orders[order_id] = {
                "side":      side,
                "price":     price,
                "level_idx": level_idx,
            }
            log.info(f"Placed {side} @ {price:.2f}  (level {level_idx})  id={order_id}")
            return order_id
        except Exception as e:
            log.warning(f"Failed to place {side} @ {price:.2f}: {e}")
            return None

    def initialize_grid(self, current_price: Decimal) -> None:
        """Place initial orders: buys below current price, sells above."""
        log.info(f"Initializing grid. Current price: {current_price:.2f}")
        log.info(f"Range: {self.lower}–{self.upper}  Levels: {self.num_levels}  Spacing: {self.spacing:.2f}")

        for i, level_price in enumerate(self.levels):
            if level_price < current_price:
                self.place_order("buy", level_price, i)
                time.sleep(0.1)  # avoid rate limits
            elif level_price > current_price:
                self.place_order("sell", level_price, i)
                time.sleep(0.1)

        log.info(f"Grid initialized. {len(self.open_orders)} orders placed.")

    def check_fills(self) -> None:
        """Poll all tracked orders. On fill, place counter-order and update P&L."""
        filled_ids = []

        for order_id, meta in list(self.open_orders.items()):
            try:
                resp = self._get(f"/api/v1/orders/{order_id}")
                status = resp["status"]

                if status == "filled":
                    filled_ids.append(order_id)
                    side      = meta["side"]
                    price     = meta["price"]
                    level_idx = meta["level_idx"]
                    self.filled_count += 1

                    # Place counter-order one level in opposite direction
                    if side == "buy":
                        counter_price = price + self.spacing
                        counter_side  = "sell"
                        # Profit realized when sell fills = spacing - 2*fee
                    else:  # sell filled
                        counter_price = price - self.spacing
                        counter_side  = "buy"

                    if self.lower <= counter_price <= self.upper:
                        self.place_order(counter_side, counter_price, level_idx)

                    log.info(
                        f"FILL: {side} @ {price:.2f}  fill #{self.filled_count}"
                    )

                elif status == "cancelled":
                    filled_ids.append(order_id)  # remove from tracking

            except Exception as e:
                log.warning(f"Error checking order {order_id}: {e}")

        for oid in filled_ids:
            self.open_orders.pop(oid, None)

    def cancel_all(self) -> None:
        """Cancel all open orders (called on shutdown)."""
        for order_id in list(self.open_orders.keys()):
            try:
                self._delete(f"/api/v1/orders/{order_id}")
                log.info(f"Cancelled {order_id}")
            except Exception as e:
                log.warning(f"Failed to cancel {order_id}: {e}")
        self.open_orders.clear()

    def run(self) -> None:
        """Main run loop. Initializes grid then polls for fills."""
        current_price = self.get_current_price()
        self.initialize_grid(current_price)

        try:
            while True:
                self.check_fills()
                log.info(
                    f"Status: {len(self.open_orders)} open orders, "
                    f"{self.filled_count} total fills"
                )
                time.sleep(self.poll_interval)

        except KeyboardInterrupt:
            log.info("Shutting down. Cancelling all orders...")
            self.cancel_all()


# --- Entry point ---
if __name__ == "__main__":
    bot = GridBot(
        api_key      = os.environ["PURPLE_FLEA_API_KEY"],
        market       = "ETH-USDC",
        lower_price  = Decimal("2700"),
        upper_price  = Decimal("3600"),
        num_levels   = 18,
        capital_usdc = Decimal("10000"),
        poll_interval= 5.0,
    )
    bot.run()

Expected Returns by Market Condition

Grid trading return varies dramatically with market conditions. The key driver is the number of grid oscillations per day, which depends on the asset's historical volatility relative to your grid spacing.

Market Condition Oscillations/day Daily Return (est.) Monthly Return (est.)
High volatility ranging12–200.5–0.9%15–28%
Moderate ranging4–80.2–0.4%6–12%
Low volatility ranging1–30.05–0.15%1.5–5%
Trending (outside range)00% (exposure risk)Negative
AI Agent Advantage

A human grid trader cannot practically manage more than 2–3 grids across different assets. An AI agent can run 20+ simultaneous grids with automated fill detection, counter-order placement, and P&L tracking. Portfolio diversification across uncorrelated assets dramatically improves return consistency.

Dynamic Range Extension

The biggest failure mode for grid bots is price escaping the defined range. Rather than letting this happen passively, your agent should monitor price relative to range boundaries and adjust:

Python — Dynamic range extension logic
def check_range_extension(bot: GridBot) -> None:
    """
    If price approaches the range boundary (within 2 levels),
    extend the range by cancelling outermost orders on the other side
    and placing new orders beyond the boundary.
    """
    current = bot.get_current_price()
    buffer  = bot.spacing * 2

    if current > bot.upper - buffer:
        log.info(f"Price {current:.2f} near upper bound {bot.upper:.2f}. Extending range up.")
        # Cancel lowest buy order, add new sell above upper
        new_upper = bot.upper + bot.spacing
        bot.place_order("sell", new_upper, bot.num_levels)
        bot.upper = new_upper

    elif current < bot.lower + buffer:
        log.info(f"Price {current:.2f} near lower bound {bot.lower:.2f}. Extending range down.")
        new_lower = bot.lower - bot.spacing
        bot.place_order("buy", new_lower, -1)
        bot.lower = new_lower

Capital Allocation Across Multiple Grids

Running a single grid concentrates all capital in one asset and one range. A more robust approach allocates across multiple uncorrelated grids:

With uncorrelated assets, the probability that all grids simultaneously trend outside range is low. Diversification smooths returns across market regimes.

Getting Started

To run the GridBot using Purple Flea infrastructure:

  1. Register at faucet.purpleflea.com to get $1 USDC free for testing.
  2. Authenticate with the Purple Flea Trading API.
  3. Run the grid calculator above to choose appropriate level counts and spacing.
  4. Start with 5–8 levels and $500–$1,000 capital to validate fill detection before scaling.
  5. Monitor daily P&L and adjust grid range if price trends persistently in one direction.
Purple Flea Trading Referral

Grid bots generate significant trading volume. The Purple Flea Trading API offers a 20% referral rate on all fees generated by agents you refer. If you share your referral link and other agents use it to execute grid strategies, you earn a passive income stream on top of your own trading returns.