4) WRIT / Fintech / 2026-10-21

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.

#rust#fintech#trading#distributed-systems#kraken

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:

  1. Opposite-Side Position Margin Netting (PR #56): Protective stop, take-profit, and trailing-stop orders were unconditionally reserving full initial margin (notional / leverage) even when reducing or closing an existing opposite-side position.
  2. Trailing-Stop Anchor Drift on Order Amendments (PR #54): Amending stop_price via edit-order mutated the trigger price without re-anchoring trailing_anchor, causing the very next market tick to overwrite the trader's tightened stop.
  3. Triggered Order Observability for Autonomous Agents (PR #55): Exposing stop_price and trigger_signal in orders and order-status so 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 0.10 BTC0.10\text{ BTC} Short position on PF_XBTUSD at $82,800 with 10×10\times leverage:

Notional=0.10×$82,800=$8,280.00\text{Notional} = 0.10 \times \$82,800 = \$8,280.00 Used Marginposition=$8,280.0010=$828.00\text{Used Margin}_{\text{position}} = \frac{\$8,280.00}{10} = \$828.00

Now the trader (or an autonomous risk loop) places a protective Buy Stop order for 0.10 BTC0.10\text{ BTC} 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 stops
let 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 0.10 BTC0.10\text{ BTC} protective buy stop at $85,000 (10×10\times) reserves an additional $850.00 of margin—bringing used_margin to $1,678.00 for a position whose maximum exposure never exceeds 0.10 BTC0.10\text{ BTC}! 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 SS has side sideorder≠sideposition\text{side}_{\text{order}} \neq \text{side}_{\text{position}}, its execution first closes up to sizeposition\text{size}_{\text{position}} 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:

ΔSizenet=max⁡(0,  sizeorder−sizeopposite_position)\Delta \text{Size}_{\text{net}} = \max\left(0,\; \text{size}_{\text{order}} - \text{size}_{\text{opposite\_position}}\right) ΔMargin=ΔSizenet×PeffectiveLeverage\Delta \text{Margin} = \frac{\Delta \text{Size}_{\text{net}} \times P_{\text{effective}}}{\text{Leverage}}

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 positions
let 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 (AtA_t): tracks the most favorable extreme price observed since the order was placed.
  • stop_price (StS_t): the active trigger boundary derived from AtA_t and the trail deviation δ\delta (either percent or quote currency).

For a protective Sell Trailing-Stop (protecting a Long position), as market price PtP_t rises:

At=max⁡(At−1,  Pt)A_t = \max(A_{t-1},\; P_t) St={At×(1−δ)if unit = percentAt−δif unit = quote_currencyS_t = \begin{cases} A_t \times (1 - \delta) & \text{if unit = percent} \\ A_t - \delta & \text{if unit = quote\_currency} \end{cases}

When Pt≤StP_t \le S_t, 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:

  1. Suppose the order started with trailing_anchor = Some(89_000.0) and a 5%5\% trail (stop_price = 84_550.0).
  2. The trader calls edit-order --stop-price 88000 to lock in gains at $88,000.
  3. order.stop_price becomes Some(88_000.0), but order.trailing_anchor is still Some(89_000.0).
  4. One second later, a market tick arrives at Pt=P_t = $88,800.
  5. evaluate_orders computes new_anchor = anchor.max(88_800.0) = 89_000.0, and then unconditionally overwrites order.stop_price back to: stop_price=89,000×(1−0.05)=$84,550.00\text{stop\_price} = 89,000 \times (1 - 0.05) = \$84,550.00

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-stop
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);
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:

  1. Top-10 Level-2 Orderbook Imbalance (OBI10\text{OBI}_{10}) to detect institutional bid absorption vs. ask supply walls: OBI10=∑i=110Vbid,i−∑i=110Vask,i∑i=110Vbid,i+∑i=110Vask,i∈[−1,+1]\text{OBI}_{10} = \frac{\sum_{i=1}^{10} V_{\text{bid}, i} - \sum_{i=1}^{10} V_{\text{ask}, i}}{\sum_{i=1}^{10} V_{\text{bid}, i} + \sum_{i=1}^{10} V_{\text{ask}, i}} \in [-1, +1]
  2. Spread & Mark-to-Last Basis Filters: Reject entries when the bid-ask spread exceeds max_spread_bps or when last price diverges sharply against mark price.
  3. 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 via edit-order (via PR #54) once unrealized move exceeds ratchet_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 -> neutral

Key Takeaways for Exchange & Financial Systems Engineers

  1. 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-order stop ratchet, and a subsequent non-monotonic price tick. A real execution loop hits all four in its first 60 seconds.
  2. Every order mutation must preserve multi-field invariants. Whenever an order type carries derived state (like stop_price and trailing_anchor), mutating one field in an amendment handler without updating the other creates a latent state-machine bug.
  3. 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.