Skip to main content

Strategy Loops

Each account in rocky-bot corresponds to one strategy instance × symbol. The three strategy classes share a similar loop skeleton, diverging only at the "compute target / decide whether to place" step.

Source: rocky_bot/strategies/{ladder,anchor,taker}.py. If you're unfamiliar with account roles, read Overview first.


1. Shared loop skeleton

All three follow:

async def run(self):
await asyncio.sleep(random.uniform(0, self.interval_s)) # stagger startup
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))

Key points:

  • Random startup delay — 30 accounts boot at once; stagger to avoid bursting the backend at start
  • Try/except per iter — a single failed iter doesn't kill the loop; counts one API error toward the CircuitBreaker
  • Cadence jitter±jitter keeps 30 accounts from synchronizing onto the same wall-clock slot

Each strategy's iterate_once() is the core logic — covered separately below.


2. LadderMakerLoop (24 accounts)

Source: rocky_bot/strategies/ladder.py

2.1 Core idea

Each ladder instance does one thing: keep one un-filled limit order alive at one fixed bps offset, on one fixed side (BUY or SELL).

Example mm-l1-buy-05bps account:

  • BTC-PERP: always have one BUY LIMIT around mid - 5 bps
  • ETH-PERP: same

2.2 Key logic

async def iterate_once(self):
if self.circuit.is_open():
return # CircuitBreaker skip
mid = self.feed.mid(self.symbol) # Binance bookTicker mid
target = mid * (1 + sign * offset_bps/10000)

balance, positions = await self.client.balance(), \
await self.client.position_risk(symbol=binance_sym)
# ↑ used both for CircuitBreaker.wallet update and the next step's
# 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 # at cap, don't add more

# CANCEL-REPLACE-OR-PLACE
live = find_open_order(side == self.side)
if live:
drift = |live.price - target| / target
if drift <= 2 bps:
return # in-place, do nothing
cancel(live) # drifted too far, cancel

place_order(side, qty, price=target) # place new order

2.3 Parameters

ParamDefaultMeaning
interval_s3.0Main loop period
jitter_s1.0± jitter
DRIFT_BPS0.0002 (2 bps)If current order price is further than this from target, re-quote
qtyaccounts.py::qty_for returns it by layerL1=0.0005 BTC, L2=0.001, L3=0.002
offset_bps5..100From the account id suffix (e.g. 05bps)

2.4 The position-cap gate matters

Historical lesson: earlier versions had no gate, leading to: every fill triggered another ladder quote → one-sided position accumulating → wallet margin exhausted → -2010 insufficient balance flood.

With the gate:

  • If filling the new order would push |position| > cap, don't place
  • Also cancel any existing same-side order (to prevent it from being taken and adding more)
  • Opposite-side quoting is unaffected — position can always reduce

History and rationale: Risk Controls.


3. AnchorMakerLoop (1 account)

Source: rocky_bot/strategies/anchor.py

3.1 Core idea

The single anchor account (mm-anchor) is responsible for tightening the inside spread. Each iteration quotes both sides:

BUY at mid - 1 bps
SELL at mid + 1 bps

So other ladder/taker observers see "a 2-bps best bid-ask".

3.2 Key logic

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 = ...

# check both sides of 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 # this side at cap, skip

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 Key differences from Ladder

DimensionLadderMakerLoopAnchorMakerLoop
Quotes per iteration1 (only own side)2 (both BUY and SELL)
Position-cap gateOne-sidedEach side judged independently
interval_s3.02.0 (faster, has to track mid closely)
DRIFT_BPS2 bps1 bps (more sensitive)
qtyLayer-based 0.0005~0.002 BTCFixed 0.0003 BTC (smaller — bears two-sided risk)

3.4 Why only 1 anchor

  • Multiple anchors would cancel each other at the same price level, wasting API quota
  • A single anchor is easy to tune (quota usage, risk exposure)
  • L1 ladders provide "next tightest" depth at 5 bps; one anchor at 1 bps is plenty

4. TakerLoop (5 accounts)

Source: rocky_bot/strategies/taker.py

4.1 Core idea

Doesn't quote — actively crosses 50 bps past mid to force a fill. Once every 30s ± 10s.

Side decision per iteration:

  • If current position notional > max_notional_usdc cap → opposite side (force reduce)
  • Otherwise random
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" # force reduce
else:
side = random.choice(["BUY", "SELL"]) # random

qty = TAKE_QTY_BY_SYMBOL[self.symbol]
price = mid * (1 ± 50 bps) # aggressive

# cancel any previous unfilled taker orders (avoid locking margin)
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 Parameters

ParamDefaultMeaning
base_interval_s30.0Main loop period
jitter_s10.0± jitter
TAKER_AGGRESSION0.005 (50 bps)How far past mid is "aggressive"
qty0.0005 BTC / 0.01 ETHSame as L1 ladder — guarantees it consumes one ladder fill

4.3 Why 5 takers

  • 5 × 2 symbols = 10 taker tasks, each ~30s → on average a fill every ~3s
  • Too few → long stretches with no fill activity
  • Too many → ladders get drained too fast

4.4 Counterparty relationship with ladder

Ladders quote at 5–100 bps; takers cross at ±50 bps:

  • Taker BUY → consumes L1+L2 SELLs (5–30 bps range)
  • Taker SELL → consumes L1+L2 BUYs
  • L3 (50–100 bps) is occasionally hit (when mid drifted during the taker iter)

This structure ensures inner ladders (L1) fill most often and outer ladders (L3) rarely fill — matches the "funnel" shape.


5. main.py coordination

Source: rocky_bot/main.py

async def _main_async(settings):
base_url, accounts = load_accounts(settings.keys_path) # 30-row .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}]",
))

# Wait for SIGINT/SIGTERM
await stop_event.wait()
for t in tasks:
t.cancel()
feed.close()
await asyncio.gather(*tasks, return_exceptions=True)

Notes:

  • Each account gets its own RockyClient + CircuitBreaker, fully isolated
  • Main loop waits on signal.SIGINT/SIGTERM (triggered by systemctl --user stop rocky-bot) for graceful shutdown
  • task name includes role + account_id + symbol — easy to search in journalctl

6. Next