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 thelink_id/link_kindfields the base RFC left as forward-compat open question #5.
The base RFC (Stop-Loss / Take-Profit) explicitly lists OCO / bracket orders as a non-goal, but was written to not preclude them:
link_id
should
be
added
now
"(cheap) so
brackets
don't
need
a
later
wire
break."
Untriggered
conditionals
with
a
durable
store, idempotent
single-fire,
restart
reload,
and
reject-at-trigger
observability
— the
exact
primitives
a
bracket's
protective
legs
need.
STOP_LIMIT
and
TAKE_PROFIT_LIMIT
already
fire
in
opposite
trigger directions
for
the
same
side,
which
is
precisely
the
TP/SL
pairing
a bracket
wants
around
a
position.
What is still missing for brackets is coordination between orders:
This RFC adds exactly those three things and nothing else.
TAKE_PROFIT_LIMIT
and
STOP_LIMIT
protecting
the
filled
position.
Suspended)
until
a parent
fills,
then
arm
—
for
a
bracket,
TP+SL
arm
on
entry
fill,
sized
to the
cumulative
filled
quantity.
link_id
is unaffected;
the
base
RFC's
single-conditional
flow
is
a
bracket
group
of
size
1.
STOP_LIMIT
/
TAKE_PROFIT_LIMIT
from the
base
RFC.
No
trailing,
no
stop-market
(those
remain
the
base
RFC's
future work;
a
bracket
inherits
them
for
free
once
they
land).
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.
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):
OrderState
(rs/sdk/src/types/trading.rs:362):
add
Suspended ("SUSPENDED")
—
an
OTO
child
accepted
and
durable
but
not
yet
armed
(not even
evaluating
its
trigger).
Distinct
from
the
base
RFC's
Untriggered,
which means
armed
and
watching
its
trigger.
Bracket
child
lifecycle
is Suspended → Untriggered → (Triggered → …)
for
the
SL/TP
legs.
Order
/
OrderDetails:
add
link_id,
link_kind.
OrderGatewayEvent:
add
Armed
(suspended
→
untriggered/working,
i.e.
the OTO
handoff
fired)
and
LinkedCancel
(this
order
was
canceled
because
a sibling
terminated,
carrying
the
sibling's
id)
so
a
client
can
distinguish
an OCO
auto-cancel
from
a
user
cancel.
OrderRejectReason
(rs/sdk/src/types/trading.rs:517):
add InvalidBracket
(legs
inconsistent
—
see
validation)
and LinkGroupNotFound
(a
link_id
referencing
an
unknown/closed
group).
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.
The protective legs must protect the position that actually gets established, which the entry may build up in partial fills. Rule:
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.
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).
LinkedCancel)
—
nothing
to
protect.
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.
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:
resolving
flag
(per
the
base
RFC's
untriggered→triggering
flip,
lifted
to the
group):
the
first
leg
to
win
the
flip
proceeds;
the
loser
is
canceled.
At most
one
protective
leg
ever
reaches
the
book
per
unit
of
position.
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.
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:
resolving
flag
is
persisted
so
a
crash
between
"SL
filled"
and "cancel
TP"
replays
the
cancel
idempotently
rather
than
leaving
an
orphan
TP.
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):
resolving
/
armed
flags
— a
double
tick
or
a
replayed
reload
cannot
double-arm,
double-cancel,
or double-size.
SL < ref < TP
geometry
against
the
current
mark.
Suspended
→
Armed
→
Untriggered
→
filled/canceled
transitions,
and
mark
an OCO
auto-cancel
distinctly
(LinkedCancel)
from
a
user
cancel.
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).
resolving
flag,
restart
reload.
Behind
a
flag.
(Bare
OCO
pairs
are testable
before
brackets
exist.)
PlaceBracketRequest,
geometry
checks)
and group-level
margin.
btnl-order-gateway.
| # | 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 |
MaxOpenOrdersExceeded
count
the
group
as
1
or
3, and
do
suspended
children
count
against
open-order
caps
at
all?
(Extends base-RFC
open
question
on
conditional
caps.)
clord_id
scheme
for
group
members.
Deterministic
per-leg
ids
(base-RFC crash-dedup)
must
also
encode
group
membership
for
reload
—
settle
the
id
format (e.g.
{link_id}.{leg})
so
reload
can
reconstruct
the
group
without
a
join.