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 —
±jitterkeeps 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
| Param | Default | Meaning |
|---|---|---|
interval_s | 3.0 | Main loop period |
jitter_s | 1.0 | ± jitter |
DRIFT_BPS | 0.0002 (2 bps) | If current order price is further than this from target, re-quote |
qty | accounts.py::qty_for returns it by layer | L1=0.0005 BTC, L2=0.001, L3=0.002 |
offset_bps | 5..100 | From 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
| Dimension | LadderMakerLoop | AnchorMakerLoop |
|---|---|---|
| Quotes per iteration | 1 (only own side) | 2 (both BUY and SELL) |
| Position-cap gate | One-sided | Each side judged independently |
interval_s | 3.0 | 2.0 (faster, has to track mid closely) |
DRIFT_BPS | 2 bps | 1 bps (more sensitive) |
qty | Layer-based 0.0005~0.002 BTC | Fixed 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_usdccap → 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
| Param | Default | Meaning |
|---|---|---|
base_interval_s | 30.0 | Main loop period |
jitter_s | 10.0 | ± jitter |
TAKER_AGGRESSION | 0.005 (50 bps) | How far past mid is "aggressive" |
qty | 0.0005 BTC / 0.01 ETH | Same 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 bysystemctl --user stop rocky-bot) for graceful shutdown - task name includes role + account_id + symbol — easy to search in
journalctl
6. Next
- Risk controls — full CircuitBreaker / RiskCaps / position cap / LEVERAGE_V1 rules
- Deployment & operations — how to mint 30 accounts in one shot + go live