Perpetual Futures Risk Engines in Rust: Margin Netting, Trailing-Stop State Machines, and L2 Execution
What happens under the hood of a perpetual futures risk engine when an autonomous bot places protective stops, ratchets trailing anchors, and nets margin against open positions — with lessons and upstream patches from krakenfx/kraken-cli.
When you build a spot matching engine, the core invariant is straightforward: a buyer must hold enough quote currency to pay for the bid, and a seller must hold enough base asset to deliver the ask.
Perpetual futures engines are a different beast entirely. Every order interacts with a live margin state machine: positions carry directional exposure (Long or Short), leverage amplifies notional value against collateral, and conditional orders (stop, take-profit, trailing-stop) sit dormant until a reference price stream (last, mark, or index) crosses a trigger boundary.
Recently, while building an autonomous Rust execution bot on top of Kraken's open-source kraken-cli (after previously contributing configurable market-order slippage simulation in PR #24), I stress-tested the local Futures Paper risk engine with real-time Level-2 orderbook signals and automated stop ratcheting. Along the way, I uncovered and patched three subtle state-machine bugs in the Rust engine:
- Opposite-Side Position Margin Netting (PR #56): Protective
stop,take-profit, andtrailing-stoporders were unconditionally reserving full initial margin (notional / leverage) even when reducing or closing an existing opposite-side position. - Trailing-Stop Anchor Drift on Order Amendments (PR #54): Amending
stop_priceviaedit-ordermutated the trigger price without re-anchoringtrailing_anchor, causing the very next market tick to overwrite the trader's tightened stop. - Triggered Order Observability for Autonomous Agents (PR #55): Exposing
stop_priceandtrigger_signalinordersandorder-statusso execution loops can closed-loop verify their protective stops.
In this deep dive, we'll walk through the mathematics and Rust implementation of perpetual futures margin netting, trailing-stop state machines, and Level-2 orderbook imbalance execution.
1. The Margin Netting Invariant: Why Protective Stops Shouldn't Double-Charge Collateral
Suppose a trader initializes a futures account with $10,000 in collateral and opens a Short position on PF_XBTUSD at $82,800 with leverage:
Now the trader (or an autonomous risk loop) places a protective Buy Stop order for at stop_price = $85,000 to cap downside risk if Bitcoin rallies.
The Naive Bug: Unconditional Margin Reservation
If place_triggered_order treats every conditional order as standalone new exposure:
// ❌ BUG: Unconditionally charges full initial margin on protective stopslet notional = params.size * eff_price;let required_margin = notional / leverage;let available = self.available_margin(&HashMap::new());if required_margin > available {return Err(KrakenError::Validation(format!("Insufficient margin: order requires ${required_margin:.2}, available ${available:.2}")));}self.used_margin += required_margin;
Under this logic, placing a protective buy stop at $85,000 () reserves an additional $850.00 of margin—bringing used_margin to $1,678.00 for a position whose maximum exposure never exceeds ! Worse, a trader using high leverage who is near full margin utilization would be rejected with Insufficient margin when trying to place a stop-loss to reduce their risk.
The Correct Invariant: Position-Aware Margin Delta
When an order on symbol has side , its execution first closes up to of the existing position (which releases margin rather than consuming it). Only the excess size that flips the position into the opposite direction requires new initial margin:
In kraken-cli, resting limit orders already used a helper called compute_margin_delta that implemented this exact netting rule. Updating place_triggered_order (PR #56) unified both code paths:
// ✅ FIXED (PR #56): Net triggered order margin against opposite-side positionslet eff_price = params.price.unwrap_or(stop_price);let required_margin = self.compute_margin_delta(params, eff_price, leverage);let available = self.available_margin(&HashMap::new());if required_margin > available {return Err(KrakenError::Validation(format!("Insufficient margin: order requires ${required_margin:.2}, available ${available:.2}")));}
Where compute_margin_delta evaluates the net exposure change:
fn compute_margin_delta(&self,params: &PlaceOrderParams,exec_price: f64,leverage: f64,) -> f64 {if params.reduce_only {return 0.0;}let pos_key = params.symbol.to_uppercase();
2. Trailing-Stop State Machines: The Stale trailing_anchor Trap
A trailing-stop order is a two-variable state machine per open order:
trailing_anchor(): tracks the most favorable extreme price observed since the order was placed.stop_price(): the active trigger boundary derived from and the trail deviation (either percent or quote currency).
For a protective Sell Trailing-Stop (protecting a Long position), as market price rises:
When , the stop triggers and executes.
What Happens When an Execution Bot Ratchets stop_price via edit-order?
In quantitative strategies, it is common to tighten a stop aggressively once a position reaches a profit milestone (for instance, locking in break-even or ratcheting a trailing stop closer to mark price before a macro event).
Here is what FuturesPaperState::edit_order originally did when a user or bot called kraken futures paper edit-order --order-id FP-00001 --stop-price 88000:
// ❌ BUG: Updates stop_price, but leaves trailing_anchor at its old value!if let Some(new_stop) = stop_price {if new_stop <= 0.0 || !new_stop.is_finite() {return Err(KrakenError::Validation("Stop price must be a positive finite number.".into(),));}order.stop_price = Some(new_stop);}
Trace what happens on the very next price tick in evaluate_orders:
- Suppose the order started with
trailing_anchor = Some(89_000.0)and a trail (stop_price = 84_550.0). - The trader calls
edit-order --stop-price 88000to lock in gains at $88,000. order.stop_pricebecomesSome(88_000.0), butorder.trailing_anchoris stillSome(89_000.0).- One second later, a market tick arrives at $88,800.
evaluate_orderscomputesnew_anchor = anchor.max(88_800.0) = 89_000.0, and then unconditionally overwritesorder.stop_priceback to:
The trader's $88,000 stop amendment silently vanished on the next tick!
Synchronizing trailing_anchor inside edit_order when order.order_type == FuturesOrderType::TrailingStop (PR #54) restores state-machine consistency:
// ✅ FIXED (PR #54): Synchronize trailing_anchor when stop_price is edited on a trailing-stopif let Some(new_stop) = stop_price {if new_stop <= 0.0 || !new_stop.is_finite() {return Err(KrakenError::Validation("Stop price must be a positive finite number.".into(),));}order.stop_price = Some(new_stop);if order.order_type == FuturesOrderType::TrailingStop {order.trailing_anchor = Some(new_stop);}}
3. Closed-Loop Agent Observability (stop_price and trigger_signal)
An autonomous trading agent should never be stateful-blind—it should treat the exchange (or paper engine) as the single source of truth on every cycle.
When querying kraken futures paper orders -o json or kraken futures paper order-status <ID> -o json, the CLI serialized id, symbol, side, type, size, price, status, and cli_ord_id, but omitted stop_price and trigger_signal. Because conditional orders (stop, take-profit, trailing-stop) have price: None, an external Rust bot inspecting open orders saw:
{
"id": "FP-00003",
"price": null,
"side": "long",
"size": 0.1,
"status": "open",
"symbol": "PF_XBTUSD",
"type": "trailing-stop"
}Without stop_price in the JSON output, the bot could not tell whether its stop was sitting at $86,000 or $82,000 without reading internal state files on disk. Surfacing stop_price and trigger_signal across both JSON and table outputs (PR #55) completed the closed-loop control surface:
{
"id": "FP-00003",
"price": null,
"side": "long",
"size": 0.1,
"status": "open",
"stop_price": 86000.0,
"symbol": "PF_XBTUSD",
"trigger_signal": "last",
"type": "trailing-stop"
}4. Putting It Together: L2 Orderbook Imbalance Execution in Rust
With those three engine invariants in place, we can build a clean, self-contained Rust execution loop (kraken-profit-bot) that combines:
- Top-10 Level-2 Orderbook Imbalance () to detect institutional bid absorption vs. ask supply walls:
- Spread & Mark-to-Last Basis Filters: Reject entries when the bid-ask spread exceeds
max_spread_bpsor when last price diverges sharply against mark price. - Automated Protective Stop Attachment & Ratcheting: Every open position is paired with an opposite-side
trailing-stop(netted via PR #56), inspected on every cycle (via PR #55), and ratcheted viaedit-order(via PR #54) once unrealized move exceedsratchet_trigger_pct.
let bid_vol = sum_top10("bids");let ask_vol = sum_top10("asks");let total_vol = bid_vol + ask_vol;let obi = if total_vol > 0.0 {(bid_vol - ask_vol) / total_vol} else {0.0};if spread_bps <= args.max_spread_bps {if obi >= args.obi_threshold && basis_bps >= -1.5 {recommended_side = Some("long");} else if obi <= -args.obi_threshold && basis_bps <= 1.5 {recommended_side = Some("short");}}
Here is a live cycle running against Kraken's real-time perpetual futures feeds:
=== Kraken Rust Profit Bot ===
Watching: PF_XBTUSD, PF_ETHUSD, PF_SOLUSD | Leverage: 5x | OBI threshold: 0.22
[2026-10-21 03:13:39 UTC] Equity=$10010.71 | PnL=$+10.71 (+0.11%) | AvailMargin=$9182.74 | Positions=1 | OpenOrders=1
[POSITION] PF_XBTUSD SHORT size=0.1 entry=$82799.00 mark=$82654.14 uPnL=$+14.49 (+0.17%)
[SCAN] PF_XBTUSD bid=$82659.00 ask=$82660.00 OBI=+0.92 spread=0.12bps -> bullish L2 absorption
[SCAN] PF_ETHUSD bid=$2445.60 ask=$2445.70 OBI=+0.26 spread=0.41bps -> bullish L2 absorption
[SIGNAL ENTRY] BUY 2.816 PF_ETHUSD @ ~$2445.70 (5x) + trailing-stop @ $2431.03
[SCAN] PF_SOLUSD bid=$106.03 ask=$106.04 OBI=+0.21 spread=0.94bps -> neutralKey Takeaways for Exchange & Financial Systems Engineers
- Test simulation engines with closed-loop bots, not just unit tests. Unit tests rarely combine an open leveraged short, an opposite-side trailing-stop placement, a manual
edit-orderstop ratchet, and a subsequent non-monotonic price tick. A real execution loop hits all four in its first 60 seconds. - Every order mutation must preserve multi-field invariants. Whenever an order type carries derived state (like
stop_priceandtrailing_anchor), mutating one field in an amendment handler without updating the other creates a latent state-machine bug. - Risk-reducing orders must never be blocked by initial-margin checks. In any leveraged derivatives engine, an opposite-side order whose size does not exceed the existing position reduces risk and must net to zero additional initial margin.