Skip to main content

Risk Controls

All of rocky-bot's risk machinery lives in three mechanisms: RiskCaps (threshold config), CircuitBreaker (runtime trip), and the Position-Cap Gate (strategy-layer pre-emptive avoidance). This page also covers the backend's LEVERAGE_V1 = 10 constant — the key fix for the margin-leak saga.

If you haven't read the strategy details, see Strategy Loops first.


1. RiskCaps — threshold config

Source: rocky_bot/risk.py

@dataclass
class RiskCaps:
max_loss_usdc: float = 50.0 # cumulative PnL loss → CB trips
max_notional_usdc: float = 200.0 # per-account notional cap (overridden to 150 in main)
max_leverage: int = 10 # (currently only recorded, not enforced)
api_errors_to_trip: int = 5 # consecutive API errors → CB trips
pause_seconds: int = 60 # cooldown duration after trip
feed_stale_seconds: int = 10 # Binance feed-stale window

One RiskCaps instance per account, injected by main.py:

# rocky_bot/main.py
circuits = {
acc.id: CircuitBreaker(RiskCaps(max_notional_usdc=150.0))
for acc in accounts
}

max_notional_usdc=150.0 is overridden in main.py (default 200 was too loose). This number directly caps each account's max position size (150 USDC notional value) and is the key parameter for the funnel to operate stably.


2. CircuitBreaker — runtime trip

Source: rocky_bot/risk.py

Each CircuitBreaker maintains:

class CircuitBreaker:
caps: RiskCaps
_consecutive_errors: int
_cumulative_pnl: float # cumulative wallet PnL (real-time delta)
_opened_at: float | None # trip timestamp; None = not tripped
_open_reason: str | None
_last_wallet: float | None # last seen wallet, used to compute delta

2.1 Three trip conditions

TriggerConditionAction
API error streak_consecutive_errors >= api_errors_to_tripTrip for pause_seconds
Cumulative loss_cumulative_pnl <= -max_loss_usdcTrip permanently (until external reset)
Feed staleness(strategy decides) feed.mid() raises StaleFeedErrorSkip this iter but don't count

2.2 Behavior after trip

async def iterate_once(self):
if self.circuit.is_open():
return # ← every strategy loop checks this first
...

The first thing every iteration does is check the CB. If open, return immediately — wait for next iter (pause_seconds later the CB recovers and the next iter passes).

2.3 State update points

self.circuit.record_api_success() # after every successful API call, clear _consecutive_errors
self.circuit.record_api_error() # after every failed API call, +1
self.circuit.update_wallet(usdc) # after balance() each iter, updates _cumulative_pnl
self.circuit.record_realised_pnl() # (not called from bot, reserved)

Example from ladder.py:

balances = await self.client.balance()
self.circuit.record_api_success()
for b in balances:
if b["asset"] == "USDC":
try:
self.circuit.update_wallet(float(b["balance"]))
except (KeyError, ValueError):
pass
break

3. Position-Cap Gate — strategy-layer avoidance

Not all risks can wait for the CB to trip — CB is the after-the-fact emergency brake, while the position cap is the active pre-emptive avoider.

3.1 Why we need it

Historical lesson (full saga in Deployment & Operations § 5-round saga):

Earlier ladder versions had no position-cap check. Every fill caused the ladder to quote another same-side order → one-sided inventory accumulated → wallet margin exhausted → -2010 insufficient balance flooded → no orders could place → empty book.

Within 30 minutes a single account's locked margin went from $0 to $98 (near the wallet ceiling). The CB couldn't even trip in time (no API errors, just backend rejecting new orders).

3.2 The fix: pre-place would-be check

All three strategies (ladder / anchor / taker) do this check before place_order:

positions = await self.client.position_risk(symbol=binance_sym)
pos_amt = positions[0]["positionAmt"]
mark = positions[0]["markPrice"]

would_be = pos_amt + sign(side) * qty
if abs(would_be * mark) > caps.max_notional_usdc:
# cancel existing same-side order (so it doesn't fill and add more)
if live_order: await self.client.cancel_order(order_id=live_order["orderId"])
return # don't place

3.3 Key details

  • Gate runs before cancel-replace — judge cap first, then drift, so you don't "cancel an old one then immediately place a same-size new one"
  • Opposite-side always allowed — the cap only restricts adding; reducing is always free, letting opposing orders / takers shrink the position
  • Re-read positionRisk every iter — position may have changed between iters (taker or other maker filled against it)

3.4 Per-strategy behavior on gate trigger

StrategyAction when gated
LadderCancel current same-side order + return (skip this iter)
AnchorCancel only that side's order + skip that side; the other side proceeds normally
TakerFlip side to the reducing direction (not skip — actively reverses)

4. LEVERAGE_V1 — backend fix

Strictly not part of the bot, but without this fix, all bot-side caps were defeated.

4.1 Symptom

In backend apply_trade_matched (the fill ledger applier):

let leverage = (notional / order_margin).round_dp(0); // old version

Derives leverage from notional and original order_margin. Failure modes:

  • After partial fills, order_margin has been proportionally released
  • Price drift makes fill price differ from placement price
  • Combined, notional / order_margin could round to 7, 9, 11 — non-10 integers

Result: apply_margin_recompute computed new_locked with the wrong leverage, which didn't match what decrement_with_margin_release released as order margin → accounts.locked got a phantom increment per trade ($1–3).

4.2 Diagnostic data

After deploying an invariant logger, 956 violations were collected in 30 minutes. 180 trades caused diff to go up (never down). pos_sum values contained the 142857 recurring pattern (decimal of 1/7).

4.3 The fix

// services/internal-ledger/src/apply.rs
const LEVERAGE_V1: u32 = 10;
let taker_leverage = LEVERAGE_V1;
let maker_leverage = LEVERAGE_V1;

4 lines to replace 12 lines of derivation. Premise: the whole stack above hardcodes lev=10 (api-gateway/routes_orders.rs:96: leverage: 10, // default until /v1/leverage endpoint lands), so this is a true single source of truth.

4.4 Verification

After deploy, 5-min smoke + 30-min monitor:

  • 0 invariant violations
  • max_locked = $27.31 (within cap)
  • over_80 == 0
  • -2010 count == 0
  • After 63 minutes max_locked stayed stable

Full 5-round margin-leak investigation: see rocky.interface/docs/superpowers/specs/2026-05-25-leverage-derivation-fix-design.md.


5. All risk config — quick reference

ParamLocationCurrent valueEffect
max_notional_usdcmain.py (override)150Per-account notional cap
max_loss_usdcRiskCaps default50Cumulative loss trip threshold
api_errors_to_tripRiskCaps default5API error count trip threshold
pause_secondsRiskCaps default60CB cooldown
LEVERAGE_V1apply.rs (backend)10Matching layer's locked-margin leverage
TAKER_AGGRESSIONtaker.py0.005 (50 bps)How far taker crosses
DRIFT_BPS (ladder)ladder.py0.0002 (2 bps)Live-order drift threshold
DRIFT_BPS (anchor)anchor.py0.0001 (1 bps)Same, anchor more sensitive
interval_s (ladder)ladder.py3.0Main loop period
interval_s (anchor)anchor.py2.0Same
base_interval_s (taker)taker.py30.0Same