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) andTAKE_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.
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:
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.
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.
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).
STOP_LIMIT
(stop-loss)
and TAKE_PROFIT_LIMIT
(take-profit),
each
carrying
a
trigger
price
and
a limit
price.
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.
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,
}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:
OrderState
(rs/sdk/src/types/trading.rs:362):
add
Untriggered ("UNTRIGGERED")
—
resting,
waiting
on
its
trigger.
Terminal
transitions
and #[serde(other)] Unknown
unchanged.
Order
/
OrderDetails:
add
order_type,
trigger_price,
trigger_method so
open-order
snapshots
and
the
LoginResponse
open-orders
replay (:139-149)
round-trip
conditionals.
OrderGatewayEvent:
add
a
Triggered
event
(untriggered
→
working)
so clients
see
the
transition
distinctly
from
the
follow-on
ack.
OrderRejectReason
(rs/sdk/src/types/trading.rs:517):
add InvalidTriggerPrice
(wrong
side
of
reference
/
bad
increment)
and TriggerAlreadyCrossed
(trigger
already
satisfied
at
placement
—
see
policy).
v1 evaluates triggers inside AX, not at the venue. A trigger task:
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:
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.
Per the repo rule for anything tied to session state / order lifecycle / risk controls, these paths are in-scope for tests before rollout:
conditional_orders
table
in
db/postgres/1.sql,
or
gateway-owned durable
state)
and
reloaded
on
startup,
then
the
engine
re-subscribes
to market
data
and
re-evaluates
immediately
(catch
triggers
crossed
while
down).
clord_id
so
a
retry
after
a crash
mid-fire
is
deduped
(DuplicateClientOrderId).
price
may
not
fill,
which
is
inherent
stop-limit
slippage
risk
and
is documented,
not
"fixed"
(stop-market
is
the
future
mitigation).
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.
OrderType,
trigger_price,
trigger_method, OrderState::Untriggered,
new
reject
reasons,
Triggered
event)
—
additive, back-compat,
no
behavior
yet.
btnl-order-gateway.
stop_price
+
trigger_method). Rejected
as
the
v1
mechanism:
EP3
exposes
only
Stop/StopLimit
(adverse direction)
with
no
clear
take-profit
type,
BTP's
native
support
is
unconfirmed, and
margin/lifecycle
semantics
would
diverge
per
venue.
Kept
as
a
future optimization
for
stop-loss
on
EP3
once
take-profit
is
uniform.
The
wire trigger_method
enum
is
deliberately
named
to
map
onto
EP3's
if
we
do.
trade-engine2.
It
already
consumes
marks
and
fills,
but it
has
no
authorized
order-injection
path
back
into
the
gateway;
adding
one duplicates
auth/account-context
logic.
Rejected
in
favor
of
an
order-gateway resident
task.
| # | 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 |
conditional_orders
table
in
db/postgres/1.sql vs.
gateway-owned
durable
state
vs.
ClickHouse.
Leaning
Postgres
for
the
small, mutable,
restart-critical
set.
link_id
worth
adding
to
the
wire
now (cheap)
so
brackets
don't
need
a
later
wire
break?
MaxOpenOrdersExceeded
cover them
or
do
they
need
a
separate
cap?