3606e7f92e
New strategies (strategies/nt/):
- GridMMNT: symmetric limit order grid around mid-price, captures spread from
oscillation. Simulates fills from candle high/low. Rebuilds grid every 20 bars.
- CompositeMMNT: weighted ensemble of OBI (30%) + A-S inventory skew (40%) +
Hurst/VPIN (30%). Votes: +1 long, -1 short, 0 neutral. Entry |score| > 0.5.
- IcebergNT: volume spike detection for whale accumulation. Dual-mode:
candle proxy (volume > avg*2.5, >= 3 consecutive same-direction) and
L2 wall detection (single level > avg*3). Exit on stop-loss/time/spike-fade.
Fixed strategies:
- Hurst/VPIN VBT: added proper VPIN proxy from candle volume (buy_vol when close
> open, sell_vol when close < open). 50-bar rolling VPIN window. Signal:
H>0.55 AND VPIN>0.25 AND |direction|>0.05. Exit: H<0.45 or direction flips.
- Hurst/VPIN paper trader: added HurstVPINLive integration (was missing entirely)
- A-S VBT: replaced placeholder spread filter with proper A-S simulation using
reservation price formula (mid - q*gamma*sigma^2*tau), inventory tracking
- A-S NT formula: fixed to standard: mid - q*gamma*sigma^2*tau (was scaled by
notional and gamma_scale improperly)
- Iceberg VBT: new volume spike detection replacing the old trend proxy
Registry: all 7 strategies now ✅ (pairs, hurst_vpin, as_mm, obi, grid_mm,
composite_mm, iceberg)
VBT backtest results (500 BTC 1h bars):
pairs: -2.81% 13 trades 38% win
hurst_vpin: -0.77% 1 trade (VPIN now active, very selective)
as_mm: -16.38% 73 trades 29% win
obi: -7.19% 15 trades 7% win
grid_mm: -4.79% 22 trades 33% win
iceberg: 0 trades (threshold strict for 1h BTC data)
169 lines
6.3 KiB
Python
169 lines
6.3 KiB
Python
"""
|
|
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
|