策略详解
每个账号在 rocky-bot 内对应一个策略实例 × 每个 symbol。三种策略类继承相似的循环骨架,只在"算 target / 判断要不要 place"这一步分化。
源码:
rocky_bot/strategies/{ladder,anchor,taker}.py。如果不熟悉账号分布,请先看 做市机器人概览。
一、共同循环骨架
三种策略都遵循:
async def run(self):
await asyncio.sleep(random.uniform(0, self.interval_s)) # 启动错峰
while True:
try:
await self.iterate_once()
except Exception as e:
log.warning("... iteration failed: %s", e)
self.circuit.record_api_error()
delay = self.interval_s + (random.random() * 2 - 1) * self.jitter_s
await asyncio.sleep(max(0.5, delay))
关键点:
- 启动时随机延迟:30 账号同时启动,错峰避免对 backend 的瞬时压力 spike
- 每轮 try/except:单次 iter 失败不杀死循环,记一次 API error 给 CircuitBreaker
- 间隔抖动:cadence 加 ±jitter 防止 30 个账号刚好同步抢同一个时间片
每个策略的 iterate_once() 是核心逻辑,下面分别讲。
二、LadderMakerLoop(24 账号)
源码:rocky_bot/strategies/ladder.py
2.1 核心思想
每个 ladder 实例只做一件事:在一个固定的 bps offset 上、一个固定的 side(BUY 或 SELL)上、维护一个未成交的限价单。
例如 mm-l1-buy-05bps 账号:
- BTC-PERP:始终保持有一个 BUY LIMIT 在
mid - 5 bps附近 - ETH-PERP:同上
2.2 关键逻辑
async def iterate_once(self):
if self.circuit.is_open():
return # CircuitBreaker 跳过
mid = self.feed.mid(self.symbol) # Binance bookTicker 的中价
target = mid * (1 + sign * offset_bps/10000)
balance, positions = await self.client.balance(), \
await self.client.position_risk(symbol=binance_sym)
# ↑ 同时拿来更新 CircuitBreaker.wallet 与下一步的 position-cap check
# POSITION-CAP GATE
would_be = pos_amt + sign(side) * qty
if abs(would_be * mark) > caps.max_notional_usdc:
cancel_existing_same_side_order(); return # 至 cap,不再加仓
# CANCEL-REPLACE-OR-PLACE
live = find_open_order(side == self.side)
if live:
drift = |live.price - target| / target
if drift <= 2 bps:
return # 现有挂单还在容差内,啥也不做
cancel(live) # 漂太远,撤掉
place_order(side, qty, price=target) # 挂新单
2.3 配置参数
| 参数 | 默认值 | 说明 |
|---|---|---|
interval_s | 3.0 | 主循环周期 |
jitter_s | 1.0 | ± 抖动 |
DRIFT_BPS | 0.0002 (2 bps) | 现有单价与目标价差超过这个就 re-quote |
qty | 由 accounts.py::qty_for 按层级返回 | L1=0.0005 BTC, L2=0.001, L3=0.002 |
offset_bps | 5..100 | 来自账号 ID 后缀(如 05bps) |
2.4 position-cap gate 的关键点
历史教训:早期版本没有这个 gate,导致每笔成交后 ladder 继续挂单 → 持仓单边累积 → wallet margin 被吃光 → -2010 insufficient balance 爆刷。
加上 gate 后:
- 如果新挂单成交会让
|position| > cap,直接不挂 - 同时把当前已存在的同侧挂 单 cancel 掉(避免别人 take 它继续加仓)
- 对手方向不受限——总能 reduce position
详细历史 + 决策见 风控与杠杆。
三、AnchorMakerLoop(1 账号)
源码:rocky_bot/strategies/anchor.py
3.1 核心思想
只有一个 anchor 账号(mm-anchor),承担收紧 inside spread 的责任。每轮同时报双边:
BUY at mid - 1 bps
SELL at mid + 1 bps
让其他 ladder/taker 看到一个"窄到 2 bps"的最佳买卖差。
3.2 关键逻辑
async def iterate_once(self):
mid = self.feed.mid(self.symbol)
target_bid = mid * (1 - 1bps)
target_ask = mid * (1 + 1bps)
positions = await self.client.position_risk(symbol=binance_sym)
pos_amt, mark, cap = ...
# 同时检查两边的 open orders
live_bid, live_ask = find_open_orders()
async def ensure(target_price, side, live):
# PER-SIDE POSITION-CAP GATE
would_be = pos_amt + sign(side) * qty
if abs(would_be * mark) > cap:
cancel(live); return # 这一侧到 cap,不挂
if live and |live.price - target| / target > 1bps:
cancel(live)
if not live:
place(side, qty, target)
await ensure(target_bid, "BUY", live_bid)
await ensure(target_ask, "SELL", live_ask)
3.3 与 Ladder 的关键差异
| 维度 | LadderMakerLoop | AnchorMakerLoop |
|---|---|---|
| 单 iteration 报价数 | 1(仅自己的 side) | 2(同时报 BUY + SELL) |
| Position-cap gate | 一侧 | 每侧独立判断 |
interval_s | 3.0 | 2.0(更快,因为要紧贴 mid) |
DRIFT_BPS | 2 bps | 1 bps(更敏感) |
qty | 按层级 0.0005~0.002 BTC | 固定 0.0003 BTC(更小,因为承担双向风险) |
3.4 为什么只 1 个 anchor
- 多个 anchor 互相 cancel 抢同一个价位,浪费 API quota
- 单 anchor 容易调参(quota 占用、风险敞口)
- L1 ladder 在 5 bps 提供"次紧"深度,1 个 anchor 在 1 bps 提供"最紧"已经足够
四、TakerLoop(5 账号)
源码:rocky_bot/strategies/taker.py
4.1 核心思想
不挂单,主动 cross 50 bps 过 mid 强行触发成交。每 30s ± 10s 一次。
每次决定 side 的逻辑:
- 如果当前持仓的名义值超过
max_notional_usdccap → 取与持仓反向的 side(强制减仓) - 否则随机一边
async def iterate_once(self):
mid = self.feed.mid(self.symbol)
positions = await self.client.position_risk(symbol=binance_sym)
pos_amt, mark = ...
notional = |pos_amt * mark|
if notional > caps.max_notional_usdc:
side = "SELL" if pos_amt > 0 else "BUY" # 强制减仓
else:
side = random.choice(["BUY", "SELL"]) # 随机两侧
qty = TAKE_QTY_BY_SYMBOL[self.symbol]
price = mid * (1 ± 50 bps) # 激进价
# 撤掉之前所有未成交 taker 单(防止积压锁仓)
for o in await self.client.open_orders(symbol):
await self.client.cancel_order(order_id=o["orderId"])
await self.client.place_order(
symbol=symbol, side=side, order_type="LIMIT",
quantity=qty, price=price,
)
4.2 参数
| 参数 | 默认值 | 说明 |
|---|---|---|
base_interval_s | 30.0 | 主循环周期 |
jitter_s | 10.0 | ± 抖动 |
TAKER_AGGRESSION | 0.005 (50 bps) | 跨过 mid 多少 bps 算"激进" |
qty | 0.0005 BTC / 0.01 ETH | 与 L1 ladder 持平,确保能成交一份 ladder |
4.3 为什么 5 个 taker
- 5 × 2 symbols = 10 个 taker 任务,每个 ~30s 一次 = 平均 3s 一笔成交
- 太少会出现"长时间无成交"的盘面
- 太多会过快吃光 ladder 流动性
4.4 与 ladder 的对手关系
ladder 挂在 5-100 bps,taker 报在 ±50 bps:
- taker BUY → 吃 L1+L2 的 SELL(5-30 bps 范围)
- taker SELL → 吃 L1+L2 的 BUY
- L3 的 50-100 bps 偶尔被吃到(如果 taker 路上 mid 移动)
这种结构让 inner ladder(L1)成交最频繁,outer ladder(L3)几乎不成交,与"漏斗"形态匹配。
五、main.py 的协调
源码:rocky_bot/main.py
async def _main_async(settings):
base_url, accounts = load_accounts(settings.keys_path) # 30 行的 .keys.json
feed = BinanceFeed(SYMBOLS)
circuits = {acc.id: CircuitBreaker(RiskCaps(max_notional_usdc=150.0)) for acc in accounts}
clients = {acc.id: RockyClient(httpc_for(acc), acc.key, acc.secret) for acc in accounts}
tasks = [asyncio.create_task(feed.run(), name="binance_feed")]
for acc in accounts:
for sym in SYMBOLS:
loop_cls = {ladder: LadderMakerLoop, anchor: AnchorMakerLoop, taker: TakerLoop}[acc.role]
tasks.append(asyncio.create_task(
loop_cls(client=clients[acc.id], feed=feed, symbol=sym, ..., circuit=circuits[acc.id]).run(),
name=f"{acc.role}[{acc.id},{sym}]",
))
# 等 SIGINT/SIGTERM
await stop_event.wait()
for t in tasks:
t.cancel()
feed.close()
await asyncio.gather(*tasks, return_exceptions=True)
要点:
- 每个账号一个 RockyClient + CircuitBreaker,彼此隔离
- 主进程靠
signal.SIGINT/SIGTERM触发stop_event,systemdsystemctl --user stop rocky-bot实现优雅退出 - task name 包含 role + account_id + symbol,方便
journalctl排查