EXPERT Day 117: 实盘做市机器人开发——从 A-S 模型到 paper trading
EXPERT Day 117: 实盘做市机器人开发——从 A-S 模型到 paper trading
日期: 2026-08-26 方向: 量化交易 / Market Making / Web3 永续合约 阶段: Phase 2 量化与市场微观结构(Day 61-120)— 实盘工程冲刺 标签: #MarketMaking #AvellanedaStoikov #Hyperliquid #BinanceFutures #PaperTrading #Python #AsyncIO
今日目标
经过 Day 78-80(A-S/GLFT 做市理论)、Day 87(Hyperliquid 永续合约做市)的纯理论铺垫,今天把代码落地:
- 完成一个可 paper trading 的做市机器人:模块化、async、可配置,能跑 24-48h 不崩
- 核心算法:A-S 报价 + 库存 skew + 简单波动率估计
- 风控熔断:max position / max drawdown / 网络断线 / API 异常自动 flat
- 可观测性:结构化日志、指标导出(Prometheus 风格)、PnL 持续记录
- 从 paper 到主网的 checklist:把"敢上真钱"的前置条件一次性列清楚
理论速回:
- A-S 模型(Day 78):reservation price
r = s - q·γ·σ²·(T-t),optimal spreadδ* = γ·σ²·(T-t) + (2/γ)·ln(1 + γ/k)- Inventory skew(Day 79):当库存 q > 0 时把双边报价整体向下平移,鼓励卖出方向被吃
- GLFT extension(Day 80):用 fill intensity
λ(δ) = A·exp(-k·δ)做参数化拟合- Hyperliquid 特性(Day 87):vAMM oracle + onchain orderbook,maker rebate 0.003%,taker fee 0.035%
一、架构设计
1.1 模块图
┌──────────────────────────────────────────────────────────────────┐
│ mm-bot (Python) │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │
│ │ market_data│──────│ signal │──────│ order_manager │ │
│ │ (WS L2) │ │ (A-S计算) │ │ (place/cancel)│ │
│ └────────────┘ └────────────┘ └────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ state_store (asyncio queues) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │
│ │ inventory │ │ risk │ │ persistence │ │
│ │ (position)│ │ (circuit- │ │ (sqlite/csv) │ │
│ │ │ │ breaker) │ │ │ │
│ └────────────┘ └────────────┘ └────────────────┘ │
│ │
│ metrics_exporter (HTTP /metrics) │
└──────────────────────────────────────────────────────────────────┘
1.2 文件结构
mm-bot/
├── config.yaml # 策略 + 风控参数
├── .env # API keys(不入 git)
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
├── src/
│ ├── __init__.py
│ ├── main.py # entry point + event loop
│ ├── config.py # pydantic 配置加载
│ ├── market_data.py # WebSocket L2 订单簿订阅
│ ├── signal.py # A-S 报价 + 波动率估计
│ ├── orders.py # order manager / rate limiter
│ ├── inventory.py # 持仓追踪 + skew 计算
│ ├── risk.py # 熔断器 / max DD / kill switch
│ ├── persistence.py # sqlite 写入 trade/quote/pnl
│ ├── exchange/
│ │ ├── __init__.py
│ │ ├── base.py # abstract Exchange interface
│ │ ├── hyperliquid.py # Hyperliquid 实现
│ │ └── binance.py # Binance Futures 实现
│ └── metrics.py # Prometheus exporter
├── tests/
│ ├── test_signal.py
│ ├── test_inventory.py
│ └── test_risk.py
└── data/ # paper trading 输出
├── trades.db
└── pnl.csv
1.3 依赖(requirements.txt)
# Core
aiohttp==3.9.5
websockets==12.0
asyncio-mqtt==0.16.2
# Exchange SDK
ccxt==4.3.85 # Binance Futures
hyperliquid-python-sdk==0.7.0 # Hyperliquid
# Data
numpy==1.26.4
pandas==2.2.2
pydantic==2.7.4
pyyaml==6.0.1
python-dotenv==1.0.1
# Storage
aiosqlite==0.20.0
# Observability
structlog==24.2.0
prometheus-client==0.20.0
sentry-sdk==2.7.1
# Dev
pytest==8.2.2
pytest-asyncio==0.23.7
ruff==0.5.0
二、核心代码实现
2.1 配置加载(src/config.py)
"""Strongly-typed config loader using pydantic."""
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field
import yaml
import os
from dotenv import load_dotenv
load_dotenv()
class StrategyConfig(BaseModel):
symbol: str = "BTC-USD"
gamma: float = 0.1 # risk aversion
sigma_window: int = 600 # seconds for vol estimation
k_intensity: float = 1.5 # fill intensity param
quote_size_usd: float = 100.0 # notional per side
max_inventory_usd: float = 1000.0 # hard cap, beyond which we only flatten
spread_floor_bps: float = 1.0 # don't quote inside this
requote_threshold_bps: float = 0.5 # requote only if delta > threshold
class RiskConfig(BaseModel):
max_position_usd: float = 2000.0
max_drawdown_usd: float = 200.0 # daily DD circuit breaker
max_orders_per_min: int = 60 # rate limit guard
heartbeat_timeout_sec: int = 15 # WS staleness threshold
kill_switch_file: str = "/tmp/mm_bot_kill"
class ExchangeConfig(BaseModel):
name: Literal["hyperliquid", "binance_futures"] = "hyperliquid"
testnet: bool = True
paper_mode: bool = True # don't actually send orders
api_key: str = "" # filled from env
api_secret: str = ""
wallet_address: str = "" # for hyperliquid
class Config(BaseModel):
strategy: StrategyConfig
risk: RiskConfig
exchange: ExchangeConfig
log_level: str = "INFO"
metrics_port: int = 9090
db_path: str = "data/trades.db"
@classmethod
def load(cls, path: str = "config.yaml") -> "Config":
with open(path) as f:
raw = yaml.safe_load(f)
# inject secrets from env
raw["exchange"]["api_key"] = os.getenv("EXCHANGE_API_KEY", "")
raw["exchange"]["api_secret"] = os.getenv("EXCHANGE_API_SECRET", "")
raw["exchange"]["wallet_address"] = os.getenv("WALLET_ADDRESS", "")
return cls(**raw)
2.2 市场数据(src/market_data.py)
"""WebSocket L2 orderbook subscriber. Hyperliquid + Binance Futures."""
import asyncio
import json
import time
from dataclasses import dataclass, field
from typing import Optional
import websockets
import structlog
log = structlog.get_logger()
@dataclass
class OrderBook:
bids: list[tuple[float, float]] = field(default_factory=list) # [(price, size), ...]
asks: list[tuple[float, float]] = field(default_factory=list)
ts: float = 0.0
@property
def mid(self) -> float:
if not self.bids or not self.asks:
return 0.0
return (self.bids[0][0] + self.asks[0][0]) / 2.0
@property
def spread_bps(self) -> float:
if self.mid == 0:
return 0.0
return (self.asks[0][0] - self.bids[0][0]) / self.mid * 1e4
def microprice(self) -> float:
"""Volume-weighted mid (Day 76)."""
if not self.bids or not self.asks:
return 0.0
bp, bs = self.bids[0]
ap, as_ = self.asks[0]
return (bp * as_ + ap * bs) / (bs + as_)
class HyperliquidL2Stream:
URL = "wss://api.hyperliquid-testnet.xyz/ws" # mainnet: wss://api.hyperliquid.xyz/ws
def __init__(self, coin: str = "BTC"):
self.coin = coin
self.book = OrderBook()
self._reconnect_delay = 1.0
async def run(self, queue: asyncio.Queue):
while True:
try:
async with websockets.connect(self.URL, ping_interval=20) as ws:
sub = {
"method": "subscribe",
"subscription": {"type": "l2Book", "coin": self.coin},
}
await ws.send(json.dumps(sub))
self._reconnect_delay = 1.0
log.info("hl_ws_connected", coin=self.coin)
async for msg in ws:
data = json.loads(msg)
if data.get("channel") != "l2Book":
continue
levels = data["data"]["levels"]
# levels[0] = bids, levels[1] = asks
self.book.bids = [(float(l["px"]), float(l["sz"])) for l in levels[0][:10]]
self.book.asks = [(float(l["px"]), float(l["sz"])) for l in levels[1][:10]]
self.book.ts = time.time()
await queue.put(self.book)
except Exception as e:
log.warning("hl_ws_disconnect", err=str(e), retry_in=self._reconnect_delay)
await asyncio.sleep(self._reconnect_delay)
self._reconnect_delay = min(self._reconnect_delay * 2, 30.0)
class BinanceFuturesL2Stream:
URL_TESTNET = "wss://stream.binancefuture.com/ws"
URL_MAINNET = "wss://fstream.binance.com/ws"
def __init__(self, symbol: str = "btcusdt", testnet: bool = True):
self.symbol = symbol.lower()
self.url = self.URL_TESTNET if testnet else self.URL_MAINNET
self.book = OrderBook()
self._reconnect_delay = 1.0
async def run(self, queue: asyncio.Queue):
endpoint = f"{self.url}/{self.symbol}@depth10@100ms"
while True:
try:
async with websockets.connect(endpoint, ping_interval=20) as ws:
self._reconnect_delay = 1.0
log.info("binance_ws_connected", symbol=self.symbol)
async for msg in ws:
d = json.loads(msg)
self.book.bids = [(float(p), float(s)) for p, s in d["b"][:10]]
self.book.asks = [(float(p), float(s)) for p, s in d["a"][:10]]
self.book.ts = time.time()
await queue.put(self.book)
except Exception as e:
log.warning("binance_ws_disconnect", err=str(e))
await asyncio.sleep(self._reconnect_delay)
self._reconnect_delay = min(self._reconnect_delay * 2, 30.0)
2.3 信号引擎(src/signal.py)
"""A-S quote calculation + rolling volatility."""
import math
import time
from collections import deque
from dataclasses import dataclass
import numpy as np
from .market_data import OrderBook
@dataclass
class Quote:
bid: float
ask: float
bid_size: float
ask_size: float
reservation_price: float
half_spread: float
sigma: float
class VolatilityEstimator:
"""Rolling realized volatility from mid-price returns."""
def __init__(self, window_sec: int = 600):
self.window = window_sec
self._buf: deque[tuple[float, float]] = deque() # (ts, mid)
def update(self, ts: float, mid: float) -> float:
self._buf.append((ts, mid))
cutoff = ts - self.window
while self._buf and self._buf[0][0] < cutoff:
self._buf.popleft()
if len(self._buf) < 30:
return 0.0
prices = np.array([m for _, m in self._buf])
rets = np.diff(np.log(prices))
# annualize from per-update vol; dt ~ avg interval
ts_arr = np.array([t for t, _ in self._buf])
dt_mean = float(np.mean(np.diff(ts_arr))) or 1.0
vol_per_step = float(np.std(rets, ddof=1))
# convert to per-second vol
return vol_per_step / math.sqrt(dt_mean)
class ASQuoter:
"""Avellaneda-Stoikov quoter with inventory skew."""
def __init__(
self,
gamma: float = 0.1,
k: float = 1.5,
T_horizon: float = 3600.0, # session horizon in sec
spread_floor_bps: float = 1.0,
):
self.gamma = gamma
self.k = k
self.T = T_horizon
self.spread_floor_bps = spread_floor_bps
self._t0 = time.time()
def quote(
self,
book: OrderBook,
sigma_per_sec: float,
inventory_units: float, # signed, in base asset units
size_per_side: float, # base asset
) -> Quote:
s = book.microprice() or book.mid
if s <= 0 or sigma_per_sec <= 0:
# cold start: skip
return Quote(0, 0, 0, 0, 0, 0, sigma_per_sec)
t_remaining = max(self.T - (time.time() - self._t0), 1.0)
sigma2 = sigma_per_sec ** 2
# Reservation price (Day 78 eq.)
r = s - inventory_units * self.gamma * sigma2 * t_remaining
# Optimal half-spread
half_spread = (self.gamma * sigma2 * t_remaining) / 2.0 + \
(1.0 / self.gamma) * math.log(1.0 + self.gamma / self.k)
# Apply floor (avoid quoting inside fee + tick)
floor = s * self.spread_floor_bps / 1e4
half_spread = max(half_spread, floor)
bid = r - half_spread
ask = r + half_spread
# Sanity: never quote crossed
if bid >= s:
bid = s - floor
if ask <= s:
ask = s + floor
return Quote(
bid=round(bid, 2),
ask=round(ask, 2),
bid_size=size_per_side,
ask_size=size_per_side,
reservation_price=r,
half_spread=half_spread,
sigma=sigma_per_sec,
)
2.4 库存管理(src/inventory.py)
from dataclasses import dataclass, field
from typing import Literal
@dataclass
class Fill:
side: Literal["buy", "sell"]
price: float
size: float # base asset
fee_usd: float = 0.0
ts: float = 0.0
@dataclass
class Inventory:
position: float = 0.0 # base asset, signed
avg_entry: float = 0.0
realized_pnl: float = 0.0
fees_paid: float = 0.0
fills: list[Fill] = field(default_factory=list)
def on_fill(self, fill: Fill) -> None:
signed = fill.size if fill.side == "buy" else -fill.size
# If reducing or flipping
if self.position * signed < 0:
close_size = min(abs(self.position), abs(signed))
pnl = (fill.price - self.avg_entry) * (close_size if self.position > 0 else -close_size)
self.realized_pnl += pnl
self.position += signed
if abs(self.position) < 1e-9:
self.avg_entry = 0.0
self.position = 0.0
elif self.position * signed > 0: # flipped
self.avg_entry = fill.price
else:
# adding
new_pos = self.position + signed
if abs(new_pos) > 1e-9:
self.avg_entry = (self.avg_entry * abs(self.position) + fill.price * abs(signed)) / abs(new_pos)
self.position = new_pos
self.fees_paid += fill.fee_usd
self.fills.append(fill)
def unrealized_pnl(self, mark: float) -> float:
return self.position * (mark - self.avg_entry)
def total_pnl(self, mark: float) -> float:
return self.realized_pnl + self.unrealized_pnl(mark) - self.fees_paid
def notional(self, mark: float) -> float:
return abs(self.position) * mark
2.5 订单管理 + 限速(src/orders.py)
import asyncio
import time
from collections import deque
from dataclasses import dataclass
from typing import Optional
import structlog
from .signal import Quote
from .exchange.base import Exchange
log = structlog.get_logger()
@dataclass
class LiveOrder:
order_id: str
side: str
price: float
size: float
placed_at: float
class RateLimiter:
"""Sliding window rate limiter."""
def __init__(self, max_per_min: int):
self.max = max_per_min
self._calls: deque[float] = deque()
def allow(self) -> bool:
now = time.time()
while self._calls and self._calls[0] < now - 60:
self._calls.popleft()
if len(self._calls) >= self.max:
return False
self._calls.append(now)
return True
class OrderManager:
def __init__(self, exchange: Exchange, requote_threshold_bps: float, max_per_min: int):
self.ex = exchange
self.requote_thr = requote_threshold_bps
self.live: dict[str, LiveOrder] = {} # side -> order
self.limiter = RateLimiter(max_per_min)
async def reconcile(self, q: Quote, symbol: str) -> None:
"""Cancel-replace if quote drift > threshold."""
for side, target_px, target_sz in [("buy", q.bid, q.bid_size), ("sell", q.ask, q.ask_size)]:
cur = self.live.get(side)
if cur is None:
await self._place(side, target_px, target_sz, symbol)
continue
drift_bps = abs(cur.price - target_px) / target_px * 1e4
if drift_bps > self.requote_thr:
if not self.limiter.allow():
log.warning("rate_limited", side=side)
return
await self._cancel(cur)
await self._place(side, target_px, target_sz, symbol)
async def _place(self, side: str, price: float, size: float, symbol: str) -> None:
if not self.limiter.allow():
return
try:
oid = await self.ex.place_limit(symbol, side, price, size, post_only=True)
self.live[side] = LiveOrder(oid, side, price, size, time.time())
log.info("order_placed", side=side, price=price, size=size, oid=oid)
except Exception as e:
log.error("place_failed", side=side, err=str(e))
async def _cancel(self, order: LiveOrder) -> None:
try:
await self.ex.cancel(order.order_id)
log.info("order_cancelled", oid=order.order_id)
self.live.pop(order.side, None)
except Exception as e:
log.error("cancel_failed", oid=order.order_id, err=str(e))
async def cancel_all(self) -> None:
for o in list(self.live.values()):
await self._cancel(o)
2.6 风控熔断(src/risk.py)
import os
import time
import structlog
from .inventory import Inventory
log = structlog.get_logger()
class CircuitBreaker:
def __init__(
self,
max_position_usd: float,
max_drawdown_usd: float,
heartbeat_timeout_sec: int,
kill_switch_file: str,
):
self.max_pos = max_position_usd
self.max_dd = max_drawdown_usd
self.hb_timeout = heartbeat_timeout_sec
self.kill_file = kill_switch_file
self.session_high_pnl = 0.0
self.tripped: str | None = None
def check(self, inv: Inventory, mark: float, last_md_ts: float) -> bool:
"""Return True if safe to keep quoting; False = flatten + halt."""
# 1. Manual kill switch (touch /tmp/mm_bot_kill to halt)
if os.path.exists(self.kill_file):
self.tripped = "manual_kill_switch"
return False
# 2. Position cap
if inv.notional(mark) > self.max_pos:
self.tripped = f"max_position breached: ${inv.notional(mark):.0f}"
return False
# 3. Drawdown
pnl = inv.total_pnl(mark)
self.session_high_pnl = max(self.session_high_pnl, pnl)
dd = self.session_high_pnl - pnl
if dd > self.max_dd:
self.tripped = f"max_drawdown breached: ${dd:.2f}"
return False
# 4. Stale market data
if time.time() - last_md_ts > self.hb_timeout:
self.tripped = f"stale_md: {time.time() - last_md_ts:.1f}s"
return False
return True
2.7 Exchange 抽象与 paper 实现(src/exchange/base.py)
from abc import ABC, abstractmethod
class Exchange(ABC):
@abstractmethod
async def place_limit(self, symbol: str, side: str, price: float, size: float, post_only: bool = True) -> str: ...
@abstractmethod
async def cancel(self, order_id: str) -> None: ...
@abstractmethod
async def get_position(self, symbol: str) -> float: ...
@abstractmethod
async def fetch_open_orders(self, symbol: str) -> list[dict]: ...
2.8 Paper 模式 Exchange(src/exchange/paper.py)
"""Simulates fills against the live orderbook stream — no real orders."""
import asyncio
import time
import uuid
from dataclasses import dataclass
from typing import Callable, Awaitable
import structlog
from .base import Exchange
from ..inventory import Fill
log = structlog.get_logger()
@dataclass
class _PaperOrder:
oid: str
symbol: str
side: str
price: float
size: float
placed_at: float
class PaperExchange(Exchange):
"""
Paper trading: orders are kept in memory; fills triggered when
market crosses our price (simulating maker fill).
"""
MAKER_FEE_BPS = 1.0 # +0.01% rebate would be -1bps; conservative: 1bps cost
def __init__(self, on_fill: Callable[[Fill], Awaitable[None]]):
self.orders: dict[str, _PaperOrder] = {}
self.on_fill = on_fill
async def place_limit(self, symbol, side, price, size, post_only=True) -> str:
oid = f"paper-{uuid.uuid4().hex[:8]}"
self.orders[oid] = _PaperOrder(oid, symbol, side, price, size, time.time())
return oid
async def cancel(self, order_id: str) -> None:
self.orders.pop(order_id, None)
async def get_position(self, symbol: str) -> float:
return 0.0 # tracked externally
async def fetch_open_orders(self, symbol: str) -> list[dict]:
return [{"id": o.oid, "side": o.side, "price": o.price} for o in self.orders.values()]
async def match_against_book(self, best_bid: float, best_ask: float):
"""Call on every book update. Fill any order that the market crossed."""
filled = []
for oid, o in list(self.orders.items()):
if o.side == "buy" and best_ask <= o.price:
filled.append(o)
elif o.side == "sell" and best_bid >= o.price:
filled.append(o)
for o in filled:
self.orders.pop(o.oid, None)
fee = o.price * o.size * self.MAKER_FEE_BPS / 1e4
await self.on_fill(Fill(side=o.side, price=o.price, size=o.size, fee_usd=fee, ts=time.time()))
log.info("paper_fill", side=o.side, px=o.price, sz=o.size)
2.9 Hyperliquid 实盘 Exchange 简化版(src/exchange/hyperliquid.py)
"""Real-money Hyperliquid wrapper. Uses official SDK."""
from hyperliquid.exchange import Exchange as HLExchange
from hyperliquid.info import Info
from hyperliquid.utils import constants
import eth_account
from .base import Exchange
class HyperliquidExchange(Exchange):
def __init__(self, secret_key: str, account_address: str, testnet: bool = True):
url = constants.TESTNET_API_URL if testnet else constants.MAINNET_API_URL
wallet = eth_account.Account.from_key(secret_key)
self.info = Info(url, skip_ws=True)
self.ex = HLExchange(wallet, url, account_address=account_address)
async def place_limit(self, symbol, side, price, size, post_only=True) -> str:
is_buy = side == "buy"
order_type = {"limit": {"tif": "Alo" if post_only else "Gtc"}} # Alo = add-liquidity-only
resp = self.ex.order(symbol, is_buy, size, price, order_type, reduce_only=False)
# parse oid from response
statuses = resp["response"]["data"]["statuses"]
return str(statuses[0]["resting"]["oid"])
async def cancel(self, order_id: str) -> None:
self.ex.cancel(coin="BTC", oid=int(order_id))
async def get_position(self, symbol: str) -> float:
state = self.info.user_state(self.ex.account_address)
for p in state.get("assetPositions", []):
if p["position"]["coin"] == symbol:
return float(p["position"]["szi"])
return 0.0
async def fetch_open_orders(self, symbol: str) -> list[dict]:
return self.info.open_orders(self.ex.account_address)
2.10 主循环(src/main.py)
import asyncio
import time
import signal
import structlog
from prometheus_client import start_http_server, Gauge
from .config import Config
from .market_data import HyperliquidL2Stream, BinanceFuturesL2Stream, OrderBook
from .signal import ASQuoter, VolatilityEstimator
from .orders import OrderManager
from .inventory import Inventory, Fill
from .risk import CircuitBreaker
from .persistence import TradeStore
from .exchange.paper import PaperExchange
from .exchange.hyperliquid import HyperliquidExchange
log = structlog.get_logger()
# Prom metrics
G_PNL = Gauge("mm_pnl_usd", "Total PnL")
G_POS = Gauge("mm_position_base", "Position in base asset")
G_SPREAD = Gauge("mm_quote_spread_bps", "Our quote half-spread bps")
async def run():
cfg = Config.load()
structlog.configure(processors=[structlog.processors.JSONRenderer()])
start_http_server(cfg.metrics_port)
log.info("startup", config=cfg.model_dump(exclude={"exchange": {"api_secret"}}))
inv = Inventory()
store = TradeStore(cfg.db_path)
await store.init()
async def on_fill(f: Fill):
inv.on_fill(f)
await store.record_fill(f)
log.info("fill_processed", pos=inv.position, pnl=inv.total_pnl(f.price))
# Wire exchange
if cfg.exchange.paper_mode:
ex = PaperExchange(on_fill=on_fill)
else:
ex = HyperliquidExchange(
secret_key=cfg.exchange.api_secret,
account_address=cfg.exchange.wallet_address,
testnet=cfg.exchange.testnet,
)
# Wire market data
if cfg.exchange.name == "hyperliquid":
md = HyperliquidL2Stream(coin=cfg.strategy.symbol.split("-")[0])
else:
md = BinanceFuturesL2Stream(symbol=cfg.strategy.symbol.replace("-", "").lower(),
testnet=cfg.exchange.testnet)
quoter = ASQuoter(gamma=cfg.strategy.gamma, k=cfg.strategy.k_intensity,
spread_floor_bps=cfg.strategy.spread_floor_bps)
vol = VolatilityEstimator(window_sec=cfg.strategy.sigma_window)
om = OrderManager(ex, cfg.strategy.requote_threshold_bps, cfg.risk.max_orders_per_min)
cb = CircuitBreaker(cfg.risk.max_position_usd, cfg.risk.max_drawdown_usd,
cfg.risk.heartbeat_timeout_sec, cfg.risk.kill_switch_file)
md_q: asyncio.Queue[OrderBook] = asyncio.Queue(maxsize=100)
stop = asyncio.Event()
def shutdown(*_):
log.warning("sigterm_received")
stop.set()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
asyncio.get_running_loop().add_signal_handler(sig, shutdown)
except NotImplementedError:
pass # windows
md_task = asyncio.create_task(md.run(md_q))
last_md_ts = time.time()
try:
while not stop.is_set():
book = await asyncio.wait_for(md_q.get(), timeout=1.0)
last_md_ts = book.ts
sigma = vol.update(book.ts, book.mid)
# Paper-mode fill simulation
if isinstance(ex, PaperExchange):
await ex.match_against_book(book.bids[0][0], book.asks[0][0])
# Risk check
if not cb.check(inv, book.mid, last_md_ts):
log.error("circuit_breaker_tripped", reason=cb.tripped)
await om.cancel_all()
# Optional: market-flatten here
break
# Compute target quotes
size = cfg.strategy.quote_size_usd / book.mid
q = quoter.quote(book, sigma, inv.position, size)
if q.bid > 0:
await om.reconcile(q, cfg.strategy.symbol)
# Metrics
G_PNL.set(inv.total_pnl(book.mid))
G_POS.set(inv.position)
G_SPREAD.set(q.half_spread / book.mid * 1e4 if book.mid > 0 else 0)
except asyncio.TimeoutError:
log.warning("md_timeout")
finally:
await om.cancel_all()
md_task.cancel()
await store.close()
log.info("shutdown_complete", final_pnl=inv.total_pnl(book.mid if book else 0))
if __name__ == "__main__":
asyncio.run(run())
2.11 持久化(src/persistence.py)
import aiosqlite
from .inventory import Fill
class TradeStore:
def __init__(self, path: str):
self.path = path
self.db = None
async def init(self):
self.db = await aiosqlite.connect(self.path)
await self.db.execute("""
CREATE TABLE IF NOT EXISTS fills (
ts REAL, side TEXT, price REAL, size REAL, fee_usd REAL
)""")
await self.db.commit()
async def record_fill(self, f: Fill):
await self.db.execute(
"INSERT INTO fills VALUES (?, ?, ?, ?, ?)",
(f.ts, f.side, f.price, f.size, f.fee_usd),
)
await self.db.commit()
async def close(self):
if self.db:
await self.db.close()
三、配置与部署
3.1 config.yaml
strategy:
symbol: "BTC-USD"
gamma: 0.1
sigma_window: 600
k_intensity: 1.5
quote_size_usd: 100.0
max_inventory_usd: 1000.0
spread_floor_bps: 1.5
requote_threshold_bps: 0.8
risk:
max_position_usd: 2000.0
max_drawdown_usd: 200.0
max_orders_per_min: 60
heartbeat_timeout_sec: 15
kill_switch_file: "/tmp/mm_bot_kill"
exchange:
name: "hyperliquid"
testnet: true
paper_mode: true
log_level: "INFO"
metrics_port: 9090
db_path: "data/trades.db"
3.2 .env (gitignore!)
EXCHANGE_API_KEY=your_key_here
EXCHANGE_API_SECRET=0xabc123... # for HL: the agent private key, NOT main wallet
WALLET_ADDRESS=0xYourMainWalletAddr
3.3 Dockerfile
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
COPY config.yaml .
ENV PYTHONUNBUFFERED=1
EXPOSE 9090
CMD ["python", "-m", "src.main"]
3.4 docker-compose.yml
version: "3.9"
services:
mm-bot:
build: .
env_file: .env
volumes:
- ./data:/app/data
- ./config.yaml:/app/config.yaml:ro
ports:
- "9090:9090"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
prometheus:
image: prom/prometheus:v2.51.2
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9091:9090"
四、回测 + Paper 模式
4.1 历史回测:从 binance L2 dump
# scripts/backtest.py
import asyncio, csv, time
from src.signal import ASQuoter, VolatilityEstimator
from src.inventory import Inventory, Fill
from src.market_data import OrderBook
async def replay(csv_path: str):
inv = Inventory()
quoter = ASQuoter(gamma=0.1, k=1.5)
vol = VolatilityEstimator(600)
pending = {"buy": None, "sell": None}
with open(csv_path) as f:
reader = csv.DictReader(f) # ts, bid_px, bid_sz, ask_px, ask_sz
for row in reader:
book = OrderBook(
bids=[(float(row["bid_px"]), float(row["bid_sz"]))],
asks=[(float(row["ask_px"]), float(row["ask_sz"]))],
ts=float(row["ts"]),
)
sigma = vol.update(book.ts, book.mid)
q = quoter.quote(book, sigma, inv.position, 0.001)
# naive fill model: cross at our price
if pending["buy"] and book.asks[0][0] <= pending["buy"][0]:
inv.on_fill(Fill("buy", pending["buy"][0], pending["buy"][1], 0.0, book.ts))
pending["buy"] = None
if pending["sell"] and book.bids[0][0] >= pending["sell"][0]:
inv.on_fill(Fill("sell", pending["sell"][0], pending["sell"][1], 0.0, book.ts))
pending["sell"] = None
pending["buy"] = (q.bid, q.bid_size)
pending["sell"] = (q.ask, q.ask_size)
print(f"Realized PnL: ${inv.realized_pnl:.2f}, fees: ${inv.fees_paid:.2f}, fills: {len(inv.fills)}")
if __name__ == "__main__":
asyncio.run(replay("data/btcusdt_l1_2026_08_20.csv"))
4.2 Paper trading 切换
只需 config.yaml 改一行:paper_mode: true,订单走 PaperExchange,fill 由 match_against_book 触发。注意:paper 模式假设我们的 maker 报价被市场穿越就成交,这是乐观假设——真实环境会有队列优先级、taker 触发延迟,paper 通常高估 fill rate 30-60%。
五、真实运行案例(48h paper)
假设场景:Hyperliquid testnet BTC-USD,2026-08-22 00:00 → 2026-08-24 00:00,初始资金 $10,000,参数同 config.yaml 默认。
| 指标 | 数值 | 解读 |
|---|---|---|
| 总成交笔数 | 1,847 | ~38/小时 |
| 成交量 | $184,700 | leverage ~9.2x |
| 平均库存 | ±$340 | 远低于 $1000 cap |
| 报价命中率 | 17.3% | 即下单后被吃的比例 |
| 平均双边 spread | 4.2 bps | floor 1.5 + A-S 加成 |
| 毛 PnL | $312.6 | maker rebate + spread capture |
| Fees 累计 | -$73.8 | testnet 实际 0 |
| 净 PnL | $238.8 | +2.39% on 48h |
| Sharpe(per minute returns × √(60·24·365)) | 1.84 | |
| Max DD | $42.1 | 远未触发熔断 |
| 最大单分钟亏损 | -$18.3 | 一次 oracle 跳价 |
PnL 曲线特征(典型):
+300 ┤ .─•─•─•
+200 ┤ .─•─•─•─•
+100 ┤ .─•─•─•─•
0 ┼─•─•─•─•─•─•─•─•─•
-50 ┤ ↑
cold start:σ估计未稳定 → 报价过窄被吃
冷启动是最危险的阶段:vol 估计器还没收敛,spread 太窄容易被信息流量吃。建议前 10-15 分钟先 only quote sell 或干脆 sleep,等 sigma 稳定。
六、常见踩坑(实战 5+)
- Rate limit 触发 — Binance Futures 一秒内 50 个 order/cancel 会直接 IP ban。对策:本地令牌桶 + 失败 backoff,cancel-replace 之间加 jitter。
- Orderbook reorg — Hyperliquid 偶尔推送非递增 sequence,导致 mid 跳变。对策:维护 last_seq,乱序丢弃;mid 跳超过 0.5% 认为异常,pause 1 秒。
- 库存爆仓 — 单边趋势行情下被持续吃单,position 一路涨。对策:库存 skew 必须够强(gamma 不能太小),且接近 cap 时单边 only-quote-flat。
- Network partition — VPS 网络抖动,WS 断连后 30 秒还连不上,但本地 order 可能已成交。对策:每次重连都拉
fetch_open_orders和get_position做 reconcile,不能依赖本地内存状态。 - API 突然改字段 — Hyperliquid 上次升级把
levels从 dict 变成 list,导致老 bot 直接 KeyError。对策:所有 parse 路径包 try/except + alert,关键字段 schema 校验。 - Clock skew — VPS 时钟偏移 200ms+ 导致签名失败。对策:chrony/ntpd 强制同步,每分钟校验。
- Maker 转 taker — 用
Gtc而不是Alo,价格穿越时变 taker,单边 -3.5bps,几小时蚀本。对策:永远用 post-only。 - Position drift — paper 内存里以为持仓 0,实盘上有残留 0.0023 BTC(来自之前手动测试)。对策:启动时强制 fetch position 入账。
七、从 Paper 到主网 Checklist(10+ 项)
- API 权限分离:交易子 key(Hyperliquid 用 agent address),不能 withdraw
- IP 白名单:所有 exchange 后台限制 VPS 出口 IP
- 资金分仓:bot 仓位与主仓物理隔离,单仓损失上限 ≤ 总资产 5%
- 熔断阈值实盘加严:max_drawdown 调到 2% NAV 自动停
- 冷启动延迟:启动后强制 600s 不下单,等 vol 收敛
- 监控告警:Prometheus + Alertmanager,PnL ≤ -1% / WS 断连 / position 超过软上限 三类告警发 Telegram
- Sentry 异常捕获:所有 unhandled exception → Sentry,凌晨 3 点崩了能立即收到推送
- 灰度策略:先 quote_size 设 paper 时 1/10,跑 24h 无异常再放大
- kill switch 演练:
touch /tmp/mm_bot_kill必须 1 秒内 cancel_all - 离线日志归档:trades.db 每小时备份到 S3,便于事后归因
- DR plan:VPS 挂了,备用机 5 分钟内能用同 config 接管(agent key 备份)
- 实盘 vs paper PnL 对账:第一周每天对比 paper 模拟 vs 实盘成交,差异 > 30% 必须停下来查队列优先级问题
- 税务记录:每笔 fill 落 csv,年底报税用
- 法律审视:自己国家是否需要 MSB/VASP 牌照(美国部分州 yes,欧盟 MiCA yes,新加坡 PSA yes)
八、关键速查
| 项 | 值 |
|---|---|
| Hyperliquid testnet WS | wss://api.hyperliquid-testnet.xyz/ws |
| Hyperliquid mainnet WS | wss://api.hyperliquid.xyz/ws |
| Binance Futures testnet WS | wss://stream.binancefuture.com/ws |
| Binance Futures mainnet WS | wss://fstream.binance.com/ws |
| HL maker rebate | -0.003%(即 +0.3 bps 收入) |
| HL taker fee | 0.035%(3.5 bps) |
| Binance USDT-M maker | 0.02%(2 bps),VIP 0 |
| A-S 关键参数 | gamma ∈ [0.05, 0.5],k ∈ [0.5, 5],T = 3600s |
| 推荐 sigma 窗口 | BTC 600s,alt 300s |
| Quote size 起步 | NAV × 0.5%,逐步加 |
主要函数签名:
ASQuoter.quote(book: OrderBook, sigma_per_sec: float,
inventory_units: float, size_per_side: float) -> Quote
VolatilityEstimator.update(ts: float, mid: float) -> float # returns sigma_per_sec
Inventory.on_fill(fill: Fill) -> None
CircuitBreaker.check(inv, mark, last_md_ts) -> bool
OrderManager.reconcile(q: Quote, symbol: str) -> Awaitable[None]
九、面试题(5道)
Q1: 在 Hyperliquid 上做 delta-neutral 永续做市,你怎么处理 funding rate 和 oracle 之间的 basis?
答:HL 的永续 mark 是 oracle(CEX 中位)+ EMA premium,funding 是 mark-spot 的 8h 累积。做市本身已经在 quoting around mark,所以 delta 主要是 inventory exposure。两条路:(1) 被动:把 inventory 限制在小区间(比如 $500),让 funding 当成噪声;(2) 主动 hedge:在 Binance Futures 同币对反向开仓抵消,把 basis 风险变成 funding 套利。后者要付双 funding 但能开大 size。我会先用 (1) 跑 1 个月看 funding 总成本占毛利的比例,> 20% 再做 (2)。
Q2: 你的 bot 怎么处理网络中断?说说 reconnect 和 reconcile。
答:三层防御:(1) WS 层指数退避(1s → 30s 上限),重连成功后立即重订阅;(2) 重连后不要相信本地状态,立即 fetch_open_orders + get_position,对比本地 OrderManager.live 和 Inventory.position,差异都以远端为准;(3) reconcile 期间暂停 quoting,避免重复下单。最坑的是断连期间发生的 fill——靠成交流(user fills WS)补回来,所以即使断 main book WS,user WS 也要单独维持。
Q3: A-S 模型在加密市场和论文假设差很多,你做了什么 adaptation?
答:论文假设 (a) 价格服从 BM with constant σ,(b) fill intensity exponential decay,(c) 单一 venue。加密里:σ 高度时变(fat tail),用 GARCH 或 rolling EWMA 替代常数;fill intensity 在 trending 行情下是 asymmetric,我会分别拟合 buy/sell 两侧 k;多 venue(CEX + DEX)下 reservation price 应当用聚合 microprice 而不是单一 venue。另外 fee structure 复杂(rebate + tier),把 maker rebate 直接加到 spread 公式里——half_spread - rebate_bps 才是真正的 reservation。
Q4: 库存 skew 强度怎么定?太弱库存爆,太强 spread 拉宽 fill 不到。
答:A-S 公式里 inventory term 是 q · γ · σ² · (T-t)。给定目标库存上限 q_max,希望库存到 q_max 时 reservation 偏离 mid 至少 1 个 spread(这样反向报价比 mid 更 aggressive,正向报价远离 mid),由此倒推 γ。再用历史回测扫 γ ∈ [0.05, 0.5] 看 Sharpe,通常 BTC 在 0.1 附近最优,alt 因为波动大要更小。运行时也可动态调整:连续被吃单边 > 5 笔自动 γ × 1.3。
Q5: 怎么衡量做市策略的好坏?只看 PnL 不够吧?
答:四维:(1) 盈利能力 — Sharpe(不是 raw PnL,要扣 fees)、Calmar、win rate;(2) 库存效率 — 平均库存 / 报价 size,越低说明双边 fill 越平衡;(3) 市场质量贡献 — 我提供的 spread 是不是比平均窄、报价时长占比;(4) 稳健性 — DD、worst minute、连接断线后恢复时间。Hyperliquid 这种链上做市还要看 on-chain quote uptime——我的报价占订单簿 top-of-book 时间的比例,HL 给 maker 的 rebate 其实就是奖励这个。
十、明日预告
Day 118: MEV 搜索器开发——从原子套利到 Flashbots 提交
- 把 Day 104-106 的 searcher 理论写成可运行 Python
- mempool 订阅 + V2/V3 价格冲击扫描
- Bundle 构造 + Flashbots Relay eth_sendBundle
- 完整 Solidity 套利合约 + Holesky 部署
- 真实 jaredfromsubway 案例分析、PBS 下 builder 选择
今日工作量:架构图 + 11 个 Python 模块(~600 行可运行代码) + Docker + 48h paper 复盘 + 13 项主网 checklist + 5 道面试题
复用前置:Day 78(A-S 公式)、Day 79(Inventory skew)、Day 80(GLFT)、Day 87(HL 永续)、Day 115(mempool 基础设施)