""" Grid Market Making — structural spread capture without directional signal. Places a symmetric grid of limit orders above and below the current mid-price. When a buy fills, a sell is immediately placed one grid level above. When a sell fills, a buy is placed one grid level below. Captures the spread repeatedly during ranging / oscillating markets. Architecture: - Backtest: simulate fills from candle high/low ranges - Live/Paper: submit real POST-ONLY limit orders and manage order lifecycle Grid params: - grid_levels: number of levels on each side (default 10) - grid_spacing_pct: spacing between levels as % of price (default 0.1%) - order_size: fixed size per grid level (default 0.001 BTC) - rebalance_every: recenter grid every N bars (default 20) - maker_fee: fee for limit orders """ from __future__ import annotations import logging from collections import deque from typing import Any import numpy as np from nautilus_trader.model.data import Bar from nautilus_trader.model.enums import OrderSide from framework.base_strategy import BaseHlStrategy from framework.config import StrategyConfig logger = logging.getLogger(__name__) class GridMMNT(BaseHlStrategy): """Grid market making — places symmetrical buy/sell grid around mid-price.""" def __init__(self, config: StrategyConfig): super().__init__(config) self._grid_levels = config.params.get("grid_levels", 10) self._grid_spacing_pct = config.params.get("grid_spacing_pct", 0.001) # 0.1% self._rebalance_every = config.params.get("rebalance_every", 20) self._maker_fee = config.maker_fee # Virtual order book: {price: {"side": "BUY"/"SELL", "size": float, "filled": bool}} self._grid: dict[float, dict] = {} self._fills: list[dict] = [] self._inventory: float = 0.0 self._cumulative_pnl: float = 0.0 self._bar_count: int = 0 self._last_mid: float = 0.0 # ── Core logic ───────────────────────────────────────────── def on_bar(self, bar: Bar): self._bar_count += 1 mid = float(bar.close) high = float(bar.high) low = float(bar.low) # Initialise or rebalance grid if not self._grid or self._bar_count % self._rebalance_every == 0: self._build_grid(mid) # Check fills: compare candle range against grid levels filled_buys = [] filled_sells = [] for price, order in self._grid.items(): if order["filled"]: continue if order["side"] == "BUY" and low <= price: order["filled"] = True filled_buys.append((price, order)) elif order["side"] == "SELL" and high >= price: order["filled"] = True filled_sells.append((price, order)) # Process fills for px, order in filled_buys: self._inventory += order["size"] self._cumulative_pnl -= order["size"] * px * self._maker_fee # Place matching sell one grid level up sell_px = px * (1 + self._grid_spacing_pct) self._grid[sell_px] = {"side": "SELL", "size": order["size"], "filled": False} self._fills.append({ "side": "BUY", "price": round(px, 1), "size": order["size"], "fee": round(order["size"] * px * self._maker_fee, 6), "bar": self._bar_count, }) for px, order in filled_sells: # Profit = spread capture minus fees spread_pnl = order["size"] * px * self._grid_spacing_pct fee = order["size"] * px * self._maker_fee self._inventory -= order["size"] self._cumulative_pnl += spread_pnl - fee # Place matching buy one grid level down buy_px = px * (1 - self._grid_spacing_pct) self._grid[buy_px] = {"side": "BUY", "size": order["size"], "filled": False} self._fills.append({ "side": "SELL", "price": round(px, 1), "size": order["size"], "pnl": round(spread_pnl - fee, 6), "bar": self._bar_count, }) self._last_mid = mid def _build_grid(self, mid: float): """Rebuild grid from scratch around current mid price.""" self._grid.clear() size = self._cfg.order_size spacing = self._grid_spacing_pct for i in range(1, self._grid_levels + 1): buy_px = mid * (1 - i * spacing) sell_px = mid * (1 + i * spacing) self._grid[round(buy_px, 6)] = {"side": "BUY", "size": size, "filled": False} self._grid[round(sell_px, 6)] = {"side": "SELL", "size": size, "filled": False} # ── Signal for paper trader / live ───────────────────────── def compute_signal(self, price: float | None = None, orderbook: dict | None = None) -> dict | None: """Return grid quotes for paper trader / deploy orchestrator. Returns the full grid of bid/ask prices for the execution layer to submit as limit orders. """ if price is None or price <= 0: return None mid = price self._build_grid(mid) bids = [(p, o["size"]) for p, o in sorted(self._grid.items(), reverse=True) if o["side"] == "BUY"] asks = [(p, o["size"]) for p, o in sorted(self._grid.items()) if o["side"] == "SELL"] return { "signal": "GRID", "strength": 1.0, "bids": bids[:5], "asks": asks[:5], "levels": self._grid_levels, "spacing_pct": self._grid_spacing_pct * 100, } # ── Metrics ───────────────────────────────────────────────── @property def pnl(self) -> float: return self._cumulative_pnl @property def total_fills(self) -> int: return len(self._fills) @property def inventory(self) -> float: return self._inventory def handle_signal(self, signal: dict): """Grid MM doesn't use single-side signals — handled by on_bar directly.""" pass