RFC: Bracket Orders (OCO + OTO over STOP_LIMIT / TAKE_PROFIT_LIMIT)

Date: 2026-07-20 Status: Draft for review Author: (you) Related: Stop-Loss / Take-Profit orders (docs/rfc/stop-loss-take-profit.md), Post-only reprice (A-4076), Order-chain lifecycle (docs/rfc/order-chain-lifecycle.md)

TL;DR. Build bracket orders on top of the conditional-order machine from the Stop-Loss / Take-Profit RFC. A bracket is an entry order plus an attached take-profit (TAKE_PROFIT_LIMIT) and stop-loss (STOP_LIMIT) pair that protect the resulting position. Two new primitives compose it: OCO (one-cancels-other TP and SL are linked; a fill/cancel of one cancels the other) and OTO (one-triggers-other the protective pair stays dormant until the entry fills, then arms sized to the actual fill). No new order types, no new trigger engine this RFC adds a link group abstraction, quantity synchronization, and the group lifecycle. Wire changes are the link_id / link_kind fields the base RFC left as forward-compat open question #5.

Background

The base RFC (Stop-Loss / Take-Profit) explicitly lists OCO / bracket orders as a non-goal, but was written to not preclude them:

What is still missing for brackets is coordination between orders:

  1. No linkage. Each order today is independent. Nothing expresses "these two conditionals belong together" or "cancel that one when this one fills."
  2. No conditional entry protection handoff. A bracket's protective legs must not rest on the book (or in the trigger engine) until the entry actually establishes a position, and they must be sized to the fill, not the requested quantity, because the entry may partially fill.
  3. No group-level lifecycle or cancel semantics. Cancel-one-cancels-all, partial-fill quantity propagation, and restart reload of a half-executed bracket have no representation in the base state model.

This RFC adds exactly those three things and nothing else.

Goals

Non-Goals

Design

The two primitives

Everything decomposes into two link relationships. A bracket is their composition.

Primitive Meaning Members
OCO (one-cancels-other) Sibling set; any member reaching a terminal fill or cancel cancels the rest the TP leg + the SL leg
OTO (one-triggers-other) Parent child set; children are Suspended until parent fills, then armed entry (parent) {TP, SL} (OCO child set)
        bracket = OTO( entry → OCO( take_profit, stop_loss ) )

   entry fills ──────────► arm TP & SL, each sized to entry.filled_qty
   TP fills  ──► cancel SL  (OCO)
   SL fires+fills ──► cancel TP  (OCO)

The base RFC's lone conditional is the degenerate case: an OCO/OTO group of one member, link_id = None.

Wire changes (SDK, additive)

Realizes base-RFC open question #5. PlaceOrderRequest (rs/sdk/src/protocol/order_gateway.rs:162) gains two optional fields on top of the base RFC's order_type / trigger_price / trigger_method:

/// Link group id. Orders sharing a link_id are coordinated as a group.
/// Absent ⇒ standalone order (base-RFC behavior, size-1 group).
#[serde(rename = "lid", skip_serializing_if = "Option::is_none")]
pub link_id: Option<LinkId>,
/// This order's role within its link group. Absent ⇒ Standalone.
#[serde(rename = "lk", default)]
pub link_kind: LinkKind,          // Standalone | OcoMember | OtoParent | OtoChild
/// Role of an order inside a link group.
pub enum LinkKind {
    Standalone,
    /// Member of an OCO set: a terminal fill/cancel cancels its siblings.
    OcoMember,
    /// Parent of an OTO relationship: on fill, arms its suspended children.
    OtoParent,
    /// Child of an OTO relationship: Suspended until the parent fills.
    OtoChild,
}

Client-assigned link_id, single request. A bracket is placed as one PlaceBracketRequest carrying the entry plus the two protective legs, so the group is atomic on the wire and partially-accepted brackets can't exist:

pub struct PlaceBracketRequest {
    pub entry: PlaceOrderRequest,            // link_kind = OtoParent
    pub take_profit: PlaceOrderRequest,      // link_kind = OtoChild + OcoMember
    pub stop_loss: PlaceOrderRequest,        // link_kind = OtoChild + OcoMember
    // link_id defaulted/overwritten server-side; all three share it.
}

The generic link_id / link_kind on PlaceOrderRequest remain available for a bare OCO pair (two resting orders, no entry) without a bracket wrapper, but the bracket wrapper is the blessed path so validation lives in one place.

Downstream additive changes (all extend, don't break, the base RFC's model):

Bracket validation (at placement)

The entry side determines the mandatory geometry of the protective legs. For a Buy entry (opening/adding a long), both protective legs are Sell and:

stop_loss.trigger  <  entry.reference  <  take_profit.trigger

For a Sell entry (short), both legs are Buy and the inequalities flip. Reject with InvalidBracket if: leg sides don't oppose the entry; a protective trigger is on the wrong side of the entry reference; TP and SL sit on the same side of the reference; or leg quantities don't match the entry quantity (they are sized from the fill, so the request-time quantity must equal the entry quantity see below). This is a pure, synchronous check; no book state needed.

OTO arming and fill-sized protection (the crux)

The protective legs must protect the position that actually gets established, which the entry may build up in partial fills. Rule:

  1. Entry rests / works normally (Untriggered never applies to the entry; it's a plain Limit/Market). TP and SL sit in Suspended in the durable store not subscribed to the trigger feed, consuming no evaluation work.
  2. On each entry fill event (partial or full), the engine arms/updates both children to the entry's cumulative filled quantity (Order.filled_quantity). First fill: Suspended → Untriggered at that qty. Subsequent fills: amend the armed legs' quantity upward to the new cumulative fill (a quantity replace on a resting conditional no re-trigger).
  3. On entry terminal with zero fill (fully canceled / rejected / expired): the suspended children are canceled (LinkedCancel) nothing to protect.
  4. On entry terminal after partial fill (e.g. DoneForDay with 3 of 10 filled): children are armed/left at the filled 3, not the requested 10. The unfilled remainder simply never existed as a position.

This makes the protection exactly position-sized at all times and removes the classic bracket bug where a partially-filled entry leaves an over-sized stop that, when it fires, flips you net short instead of flat.

OCO cancel semantics

The TP and SL legs form an OCO set. The first of them to reach a terminal fill or a user cancel causes the engine to cancel the sibling with LinkedCancel. Concretely, when the SL fires and its follow-on limit fills (fully or partially closing the position), the TP is canceled; symmetrically for TP. Two correctness hazards, handled explicitly:

Where this lives

Same home as the base RFC: a resident coordinator in order-gateway (and its btnl-order-gateway twin), extending the base RFC's trigger task rather than a new service. The link group is state the gateway already is the natural owner of it sees entry fills (session order state), owns the trigger engine that arms children, and holds the authorized submit path for follow-on orders. No second service, no new auth hop, consistent with base-RFC decision #3.

Durable store

Extends the base RFC's conditional_orders durable store with group linkage. Leaning Postgres (base-RFC open question) a new link_groups row plus a link_id / link_kind / link_role_state column set on the conditional/order rows. What must survive restart mid-bracket:

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 (superset of the base RFC's list, adding the group-coordination cases):

GUI

Rollout

  1. This RFC + SDK wire types (link_id, link_kind, LinkKind, OrderState::Suspended, Armed / LinkedCancel events, PlaceBracketRequest, new reject reasons) additive, back-compat, no behavior yet. Depends on the base Stop-Loss / Take-Profit RFC shipping first (its order types + trigger engine + durable store are prerequisites).
  2. OCO primitive in order-gateway: link group state, sibling-cancel on terminal, resolving flag, restart reload. Behind a flag. (Bare OCO pairs are testable before brackets exist.)
  3. OTO + fill-sized arming: suspended children, arm-on-fill, cumulative-fill quantity amend, entry-terminal reconciliation.
  4. Bracket wrapper + validation (PlaceBracketRequest, geometry checks) and group-level margin.
  5. GUI: bracket ticket, grouped blotter rows, group cancel.
  6. AIEx/Bitnomial edition: same coordinator in btnl-order-gateway.
  7. (Future) attach-to-existing-position brackets; scale-in / multi-level groups; bracket over stop-market entries once the base RFC's market variants land.

Alternatives considered

Decisions

# Decision Rationale
1 Two primitives OCO (sibling-cancel) + OTO (parent-arms-children); bracket = their composition Minimal surface; brackets fall out of two reusable rules
2 Reuse base-RFC STOP_LIMIT / TAKE_PROFIT_LIMIT + trigger engine; no new execution Brackets are orchestration, not a new order machine
3 Protective legs sized to cumulative entry fill, amended per fill Protection always position-sized; kills the over-sized-stop flip bug
4 OTO children rest in new Suspended state, not evaluating triggers, until parent fills No wasted engine/feed work; clear distinct state from Untriggered
5 Bracket placed as one atomic PlaceBracketRequest; server assigns shared link_id No partially-accepted brackets; validation in one place
6 Coordinator lives in order-gateway (+ btnl twin), extends base trigger task Owns fills + trigger engine + submit path; no new service/auth hop
7 Group is one unit for cancel & COD; never cancel a lone leg Never disconnect into an unprotected fill or orphaned stop
8 Partial protective fill trims the sibling's qty, doesn't cancel it Keeps protecting the still-open remainder
9 Per-group atomic resolving/armed flags, persisted At-most-once arm/cancel/size across ticks and restarts
10 Wire additive; absent link_id standalone (base-RFC behavior) Zero-change back-compat; base RFC is a size-1 group

Open questions