返回 Expert 笔记
Expert Day 117

EXPERT Day 117: 实盘做市机器人开发——从 A-S 模型到 paper trading

EXPERT Day 117: 实盘做市机器人开发——从 A-S 模型到 paper trading

2026-08-26
Phase 2 量化与市场微观结构(Day 61-120)— 实盘工程冲刺
MarketMakingAvellanedaStoikovHyperliquidBinanceFuturesPaperTradingPythonAsyncIO

日期: 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 永续合约做市)的纯理论铺垫,今天把代码落地:

  1. 完成一个可 paper trading 的做市机器人:模块化、async、可配置,能跑 24-48h 不崩
  2. 核心算法:A-S 报价 + 库存 skew + 简单波动率估计
  3. 风控熔断:max position / max drawdown / 网络断线 / API 异常自动 flat
  4. 可观测性:结构化日志、指标导出(Prometheus 风格)、PnL 持续记录
  5. 从 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,700leverage ~9.2x
平均库存±$340远低于 $1000 cap
报价命中率17.3%即下单后被吃的比例
平均双边 spread4.2 bpsfloor 1.5 + A-S 加成
毛 PnL$312.6maker rebate + spread capture
Fees 累计-$73.8testnet 实际 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+)

  1. Rate limit 触发 — Binance Futures 一秒内 50 个 order/cancel 会直接 IP ban。对策:本地令牌桶 + 失败 backoff,cancel-replace 之间加 jitter。
  2. Orderbook reorg — Hyperliquid 偶尔推送非递增 sequence,导致 mid 跳变。对策:维护 last_seq,乱序丢弃;mid 跳超过 0.5% 认为异常,pause 1 秒。
  3. 库存爆仓 — 单边趋势行情下被持续吃单,position 一路涨。对策:库存 skew 必须够强(gamma 不能太小),且接近 cap 时单边 only-quote-flat。
  4. Network partition — VPS 网络抖动,WS 断连后 30 秒还连不上,但本地 order 可能已成交。对策:每次重连都拉 fetch_open_ordersget_position 做 reconcile,不能依赖本地内存状态。
  5. API 突然改字段 — Hyperliquid 上次升级把 levels 从 dict 变成 list,导致老 bot 直接 KeyError。对策:所有 parse 路径包 try/except + alert,关键字段 schema 校验。
  6. Clock skew — VPS 时钟偏移 200ms+ 导致签名失败。对策:chrony/ntpd 强制同步,每分钟校验。
  7. Maker 转 taker — 用 Gtc 而不是 Alo,价格穿越时变 taker,单边 -3.5bps,几小时蚀本。对策:永远用 post-only。
  8. 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 WSwss://api.hyperliquid-testnet.xyz/ws
Hyperliquid mainnet WSwss://api.hyperliquid.xyz/ws
Binance Futures testnet WSwss://stream.binancefuture.com/ws
Binance Futures mainnet WSwss://fstream.binance.com/ws
HL maker rebate-0.003%(即 +0.3 bps 收入)
HL taker fee0.035%(3.5 bps)
Binance USDT-M maker0.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.liveInventory.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 基础设施)