RFC: Stop-Loss / Take-Profit Orders (STOP_LIMIT & TAKE_PROFIT_LIMIT)

Date: 2026-07-16 Status: Draft for review Author: (you) Related: Post-only reprice (A-4076), Liquidation engine design (docs/plans/2026-05-14-liquidation-engine-design.md)

TL;DR. Add two client order types STOP_LIMIT (stop-loss) and TAKE_PROFIT_LIMIT (take-profit). Both are conditional limit orders: a resting trigger price that, once crossed by a reference price, submits a plain limit order onto the book. They differ only in trigger direction relative to the reference price. v1 evaluates triggers in an AX-side trigger engine (uniform across venues, supports both directions, durable across restart) rather than passing native stop orders to the venue.

Background

The client order surface today knows only Limit and Market. The wire request PlaceOrderRequest (rs/sdk/src/protocol/order_gateway.rs:162-202) carries symbol, side, quantity, price, time_in_force (a string, not a typed enum), post_only, self_trade_prevention, account_id no order type field and no trigger/stop price. Order and OrderState (rs/sdk/src/types/trading.rs:252-277, :362) likewise have no conditional state; OrderState is Pending | Accepted | PartiallyFilled | Filled | Canceled | Rejected | Expired | Replaced | DoneForDay | Unknown nothing that represents "resting, waiting for a trigger."

Three relevant facts about what already exists:

  1. The GUI already stubs the two types. OrderType in gui/packages/app/data/types/orderTypes.ts:169-175 enumerates StopLossLimit = 'stop_loss_limit' and TakeProfitLimit = 'take_profit_limit', but the order form only renders Limit/Market tabs and nothing is wired to the gateway. So the client vocabulary is half-built and the backend is empty.

  2. EP3 supports native stop orders. The venue OrderAttributes (rs/ep3/src/protocol/connamara.ep3.orders.v1beta1.rs) carries stop_price: i64 (tag 9) and trigger_method: ConditionTriggerMethod (tag 35), with OrderType::{Stop(3), StopLimit(4)}. ConditionTriggerMethod is Undefined | LastPrice | SettlementPrice (:987-994). So the EP3/Bermuda edition could delegate stop triggering to the matching engine. But (a) EP3 exposes Stop/StopLimit, whose trigger direction is the adverse/stop direction take-profit's favorable-direction trigger has no obvious native type; and (b) the AIEx/Bitnomial edition routes through btnl-order-gateway (BTP), whose native trigger support is unconfirmed. Relying on native triggers would give us two divergent implementations and no take-profit on either.

  3. Orders are not durably persisted in Postgres. db/postgres/1.sql has no orders table; open orders live in gateway session state and analytics land in ClickHouse. A resting conditional order can sit untriggered for hours or days, so v1 must add a durable store for untriggered conditionals they have to survive a gateway restart (see Lifecycle & correctness).

Goals

Non-Goals

Design

Concept: one conditional, two directions

STOP_LIMIT and TAKE_PROFIT_LIMIT are the same machine trigger price + limit order emitted on trigger differing only in the direction the reference price must cross the trigger:

Type Side Fires when reference price Typical use
STOP_LIMIT Sell ref <= trigger (falls to/through) protect a long
STOP_LIMIT Buy ref >= trigger (rises to/through) protect a short
TAKE_PROFIT_LIMIT Sell ref >= trigger (rises to/through) bank a long's gain
TAKE_PROFIT_LIMIT Buy ref <= trigger (falls to/through) bank a short's gain

The trigger direction is fully determined by (order_type, side) the client does not send it separately. On fire, the engine submits a limit order at price with the order's side, quantity, time_in_force, and self_trade_prevention.

Reference price

Trigger evaluation reads a single reference price per symbol. Default = mark/index price (perps already publish this via the index/underlying feed), which resists thin-book manipulation; last trade price is offered as an option, mirroring EP3's ConditionTriggerMethod::{SettlementPrice, LastPrice} vocabulary so the field maps cleanly if we ever delegate to the venue.

pub enum TriggerMethod {
    /// Mark / index price (default). Maps to EP3 SettlementPrice.
    Mark,
    /// Last trade price. Maps to EP3 LastPrice.
    Last,
}

Wire changes (SDK, additive)

PlaceOrderRequest (rs/sdk/src/protocol/order_gateway.rs:162) gains three optional fields. Absent order_type Limit, so every existing client is unchanged:

/// Order type. Absent ⇒ Limit (back-compat).
#[serde(rename = "ot", default)]
pub order_type: OrderType,          // Limit | Market | StopLimit | TakeProfitLimit
/// Trigger (stop) price. Required iff order_type ∈ {StopLimit, TakeProfitLimit}.
#[serde(rename = "tp", skip_serializing_if = "Option::is_none")]
pub trigger_price: Option<Decimal>,
/// Reference price for the trigger. Absent ⇒ Mark.
#[serde(rename = "tm", default)]
pub trigger_method: TriggerMethod,

OrderType is introduced as a typed enum in the SDK (today the concept is implicit) closed-set discriminator, per the repo's typed-DB-discriminator rule.

Downstream additive changes:

Trigger engine (the crux)

v1 evaluates triggers inside AX, not at the venue. A trigger task:

  1. Holds untriggered conditionals in memory, keyed by symbol, with a durable backing store (below).
  2. Subscribes to the reference-price stream per symbol (mark or last).
  3. On each price update, fires every conditional whose direction predicate is now satisfied, in one pass, and submits the follow-on limit order through the normal order path reusing margin check (rs/order-gateway/src/check_margin.rs), recon, and reporting with zero new downstream code.

Placement policy. If the trigger is already crossed at submit time (e.g. a sell-stop whose trigger is at/above current mark), reject with TriggerAlreadyCrossed rather than firing instantly an already-crossed conditional almost always means the client wanted a plain limit and fat-fingered the type. (Alternative "fire immediately" is noted under Open questions.)

Margin policy. Two checkpoints:

Where should this live?

The trigger task needs three things: the reference-price stream, durable conditional state, and the ability to inject a PlaceOrder into the authorized order path with the original session's account context. That points at the order-gateway, which already owns per-session open-order state and the place/margin/submit path. Proposal: a resident trigger task inside order-gateway (and its btnl-order-gateway twin) rather than a standalone service keeps the whole order lifecycle in one crate and avoids a second authorization hop. trade-engine2 (which sees marks/fills) is the alternative home; rejected because it would need a new privileged order-injection path back into the gateway.

Lifecycle & correctness

Per the repo rule for anything tied to session state / order lifecycle / risk controls, these paths are in-scope for tests before rollout:

GUI

Wire the existing OrderType.StopLossLimit / OrderType.TakeProfitLimit (gui/packages/app/data/types/orderTypes.ts:172-173) into the order-form tabs, add a trigger-price input and a mark/last selector, and render the UNTRIGGERED state (plus the Triggered transition) in the open-orders blotter.

Rollout

  1. This RFC + SDK wire types (OrderType, trigger_price, trigger_method, OrderState::Untriggered, new reject reasons, Triggered event) additive, back-compat, no behavior yet.
  2. Trigger engine + durable store in order-gateway (EP3/Bermuda edition): evaluation, firing, idempotency, restart reload. Behind a flag.
  3. Margin policy at placement + at trigger; reject-at-trigger observability.
  4. GUI: order-form tabs, trigger input, blotter states.
  5. AIEx/Bitnomial edition: same engine in btnl-order-gateway.
  6. (Future, non-goal) stop-market / take-profit-market; trailing; OCO/brackets.

Alternatives considered

Decisions

# Decision Rationale
1 Two client types STOP_LIMIT + TAKE_PROFIT_LIMIT, one engine, direction from (type, side) Client never sends trigger direction; less surface to get wrong
2 AX-side trigger engine, not native venue stop Uniform across editions + both directions; native has no take-profit
3 Engine lives in order-gateway (+ btnl twin) Reuses session/account context + margin/submit path; single auth hop
4 Default reference = mark/index; last optional Manipulation-resistant; maps to EP3 ConditionTriggerMethod
5 Margin estimated at placement, checked (not reserved) at trigger; reject-at-trigger is observable Don't strand capital on resting stops; never silently drop
6 Untriggered conditionals are durable + reloaded on restart Must survive crash; re-evaluate on reload
7 Persist untriggered on disconnect by default; COD only if opted in A stop you can't watch is when you need it most
8 Already-crossed at placement reject (TriggerAlreadyCrossed) Almost always a mis-selected type
9 Wire changes additive; absent order_type Limit Zero-change back-compat for existing clients

Open questions