rustrade_execution/order/bracket.rs
1//! Bracket order types for the [`BracketOrderClient`](crate::client::BracketOrderClient) trait.
2//!
3//! A bracket order consists of three linked orders:
4//! 1. **Entry**: Limit order to enter the position
5//! 2. **Take Profit**: Limit order to exit at profit target
6//! 3. **Stop Loss**: Stop (or stop-limit) order to exit at loss limit
7//!
8//! When either exit leg fills, the exchange automatically cancels the other.
9
10use crate::order::{Order, OrderEvent, OrderKey, TimeInForce};
11use derive_more::Constructor;
12use rust_decimal::Decimal;
13use rustrade_instrument::{Side, exchange::ExchangeId, instrument::name::InstrumentNameExchange};
14use serde::{Deserialize, Serialize};
15
16use super::{id::StrategyId, state::UnindexedOrderState};
17
18/// Request parameters for opening a bracket order.
19///
20/// Contains the common fields needed by all exchanges that support bracket orders.
21/// Exchange-specific behavior is documented per field.
22///
23/// # Price Ordering
24///
25/// For a **Buy** bracket: `stop_loss_price < entry_price < take_profit_price`
26/// For a **Sell** bracket: `take_profit_price < entry_price < stop_loss_price`
27#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Constructor)]
28pub struct RequestOpenBracket {
29 /// Buy or Sell for the entry order. Exit legs use the opposite side.
30 pub side: Side,
31 /// Number of shares/contracts for all three legs.
32 pub quantity: Decimal,
33 /// Entry limit price.
34 pub entry_price: Decimal,
35 /// Take-profit limit price.
36 pub take_profit_price: Decimal,
37 /// Stop-loss trigger price.
38 pub stop_loss_price: Decimal,
39 /// Optional stop-loss limit price. When `Some`, the stop-loss becomes a stop-limit order.
40 ///
41 /// | Exchange | Behavior |
42 /// |----------|----------|
43 /// | Alpaca | Used — creates stop-limit SL leg |
44 /// | IBKR | Ignored — SL is always a stop (market) order |
45 pub stop_loss_limit_price: Option<Decimal>,
46 /// Time-in-force for all three legs.
47 ///
48 /// **Note:** Most exchanges restrict bracket orders to `Day` or `GoodUntilCancelled`.
49 pub time_in_force: TimeInForce,
50}
51
52/// Bracket order request: entry + take-profit + stop-loss.
53///
54/// This is an [`OrderEvent`] with [`RequestOpenBracket`] as the state, providing
55/// the standard `key` (exchange, instrument, strategy, client order ID) plus
56/// bracket-specific parameters.
57///
58/// # Example
59///
60/// ```ignore
61/// use rustrade_execution::order::bracket::{BracketOrderRequest, RequestOpenBracket};
62/// use rustrade_execution::order::{OrderKey, TimeInForce};
63/// use rustrade_execution::order::id::{ClientOrderId, StrategyId};
64/// use rustrade_instrument::{Side, exchange::ExchangeId, instrument::name::InstrumentNameExchange};
65/// use rust_decimal_macros::dec;
66///
67/// let request = BracketOrderRequest {
68/// key: OrderKey::new(
69/// ExchangeId::AlpacaBroker,
70/// InstrumentNameExchange::from("AAPL"),
71/// StrategyId::new("momentum"),
72/// ClientOrderId::new("bracket-001"),
73/// ),
74/// state: RequestOpenBracket::new(
75/// Side::Buy,
76/// dec!(10),
77/// dec!(150.00), // entry
78/// dec!(160.00), // take profit
79/// dec!(145.00), // stop loss
80/// None, // no stop-limit for SL
81/// TimeInForce::GoodUntilCancelled { post_only: false },
82/// ),
83/// };
84/// ```
85pub type BracketOrderRequest<ExchangeKey = ExchangeId, InstrumentKey = InstrumentNameExchange> =
86 OrderEvent<RequestOpenBracket, ExchangeKey, InstrumentKey>;
87
88/// Result of bracket order placement.
89///
90/// Contains the parent order and optionally the child legs. The `Option` types
91/// document API divergence between exchanges:
92///
93/// | Exchange | `take_profit` | `stop_loss` | Reason |
94/// |----------|---------------|-------------|--------|
95/// | IBKR | `Some(...)` | `Some(...)` | Returns all three orders immediately |
96/// | Alpaca | `None` | `None` | Child legs created server-side; use `fetch_open_orders` |
97///
98/// # Invariants
99///
100/// - Either all orders are `Active(Open)` or all are `Inactive` (placement failed), except that
101/// a leg of a failed placement whose fate at the venue is unknown is `Active(Open)`, so the
102/// account stream can resolve it. See
103/// [`BracketOrderClient::open_bracket_order`](crate::client::BracketOrderClient::open_bracket_order).
104/// - Child legs are either both `Some` (exchange returns legs immediately, e.g. IBKR)
105/// or both `None` (exchange creates legs server-side, e.g. Alpaca). Asymmetric leg
106/// presence is not supported — no current exchange returns one leg but not the other,
107/// so [`with_all_legs`](Self::with_all_legs) and [`parent_only`](Self::parent_only)
108/// are the only public constructors.
109#[non_exhaustive]
110#[derive(Debug, Clone)]
111pub struct BracketOrderResult {
112 /// Parent (entry) order.
113 pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
114 /// Take-profit order (opposite side, limit).
115 ///
116 /// `None` when the exchange creates legs server-side (Alpaca).
117 /// `Some` when the exchange returns legs immediately (IBKR).
118 pub take_profit: Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>,
119 /// Stop-loss order (opposite side, stop or stop-limit).
120 ///
121 /// `None` when the exchange creates legs server-side (Alpaca).
122 /// `Some` when the exchange returns legs immediately (IBKR).
123 pub stop_loss: Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>,
124}
125
126impl BracketOrderResult {
127 /// Create a result with all three legs present.
128 ///
129 /// Use for exchanges that return all orders immediately (e.g., IBKR).
130 pub fn with_all_legs(
131 parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
132 take_profit: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
133 stop_loss: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
134 ) -> Self {
135 Self {
136 parent,
137 take_profit: Some(take_profit),
138 stop_loss: Some(stop_loss),
139 }
140 }
141
142 /// Create a result with only the parent order.
143 ///
144 /// Use for exchanges that create child legs server-side (e.g., Alpaca).
145 pub fn parent_only(
146 parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
147 ) -> Self {
148 Self {
149 parent,
150 take_profit: None,
151 stop_loss: None,
152 }
153 }
154
155 /// Returns `true` if all child legs are present.
156 pub fn has_all_legs(&self) -> bool {
157 self.take_profit.is_some() && self.stop_loss.is_some()
158 }
159
160 /// Returns `true` if the parent order placement failed.
161 ///
162 /// Checking only the parent is sufficient because of the struct invariant:
163 /// either all orders are active or all are inactive. A failed parent implies
164 /// failed legs (or no legs returned, in the case of Alpaca).
165 pub fn is_failed(&self) -> bool {
166 self.parent.state.is_failed()
167 }
168}
169
170/// Builder for creating [`BracketOrderRequest`] with a fluent API.
171///
172/// # Example
173///
174/// ```ignore
175/// use rustrade_execution::order::bracket::BracketOrderRequestBuilder;
176/// use rustrade_execution::order::id::{ClientOrderId, StrategyId};
177/// use rustrade_instrument::{Side, exchange::ExchangeId, instrument::name::InstrumentNameExchange};
178/// use rust_decimal_macros::dec;
179///
180/// let instrument = InstrumentNameExchange::from("AAPL");
181/// let request = BracketOrderRequestBuilder::new(
182/// ExchangeId::AlpacaBroker,
183/// &instrument,
184/// StrategyId::new("momentum"),
185/// ClientOrderId::new("bracket-001"),
186/// )
187/// .side(Side::Buy)
188/// .quantity(dec!(10))
189/// .entry_price(dec!(150.00))
190/// .take_profit_price(dec!(160.00))
191/// .stop_loss_price(dec!(145.00))
192/// .build();
193/// ```
194#[derive(Debug, Clone)]
195#[must_use = "builder does nothing unless .build() or .try_build() is called"]
196pub struct BracketOrderRequestBuilder<
197 ExchangeKey = ExchangeId,
198 InstrumentKey = InstrumentNameExchange,
199> {
200 key: OrderKey<ExchangeKey, InstrumentKey>,
201 side: Option<Side>,
202 quantity: Option<Decimal>,
203 entry_price: Option<Decimal>,
204 take_profit_price: Option<Decimal>,
205 stop_loss_price: Option<Decimal>,
206 stop_loss_limit_price: Option<Decimal>,
207 time_in_force: TimeInForce,
208}
209
210impl<ExchangeKey, InstrumentKey> BracketOrderRequestBuilder<ExchangeKey, InstrumentKey>
211where
212 ExchangeKey: Clone,
213 InstrumentKey: Clone,
214{
215 /// Create a new builder with the given order key components.
216 pub fn new(
217 exchange: ExchangeKey,
218 instrument: InstrumentKey,
219 strategy: StrategyId,
220 cid: super::id::ClientOrderId,
221 ) -> Self {
222 Self {
223 key: OrderKey::new(exchange, instrument, strategy, cid),
224 side: None,
225 quantity: None,
226 entry_price: None,
227 take_profit_price: None,
228 stop_loss_price: None,
229 stop_loss_limit_price: None,
230 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
231 }
232 }
233
234 /// Set the order side (Buy or Sell).
235 pub fn side(mut self, side: Side) -> Self {
236 self.side = Some(side);
237 self
238 }
239
240 /// Set the quantity for all legs.
241 pub fn quantity(mut self, quantity: Decimal) -> Self {
242 self.quantity = Some(quantity);
243 self
244 }
245
246 /// Set the entry limit price.
247 pub fn entry_price(mut self, price: Decimal) -> Self {
248 self.entry_price = Some(price);
249 self
250 }
251
252 /// Set the take-profit limit price.
253 pub fn take_profit_price(mut self, price: Decimal) -> Self {
254 self.take_profit_price = Some(price);
255 self
256 }
257
258 /// Set the stop-loss trigger price.
259 pub fn stop_loss_price(mut self, price: Decimal) -> Self {
260 self.stop_loss_price = Some(price);
261 self
262 }
263
264 /// Set the stop-loss limit price (creates stop-limit SL on supporting exchanges).
265 pub fn stop_loss_limit_price(mut self, price: Decimal) -> Self {
266 self.stop_loss_limit_price = Some(price);
267 self
268 }
269
270 /// Set the time-in-force for all legs.
271 pub fn time_in_force(mut self, tif: TimeInForce) -> Self {
272 self.time_in_force = tif;
273 self
274 }
275
276 /// Build the bracket order request.
277 ///
278 /// # Panics
279 ///
280 /// Panics if any required field is missing: `side`, `quantity`, `entry_price`,
281 /// `take_profit_price`, or `stop_loss_price`.
282 #[track_caller]
283 #[allow(clippy::expect_used)] // Panic is intentional per doc contract
284 pub fn build(self) -> BracketOrderRequest<ExchangeKey, InstrumentKey> {
285 BracketOrderRequest {
286 key: self.key,
287 state: RequestOpenBracket {
288 side: self.side.expect("side is required"),
289 quantity: self.quantity.expect("quantity is required"),
290 entry_price: self.entry_price.expect("entry_price is required"),
291 take_profit_price: self
292 .take_profit_price
293 .expect("take_profit_price is required"),
294 stop_loss_price: self.stop_loss_price.expect("stop_loss_price is required"),
295 stop_loss_limit_price: self.stop_loss_limit_price,
296 time_in_force: self.time_in_force,
297 },
298 }
299 }
300
301 /// Try to build the bracket order request, returning `None` if any required field is missing.
302 pub fn try_build(self) -> Option<BracketOrderRequest<ExchangeKey, InstrumentKey>> {
303 Some(BracketOrderRequest {
304 key: self.key,
305 state: RequestOpenBracket {
306 side: self.side?,
307 quantity: self.quantity?,
308 entry_price: self.entry_price?,
309 take_profit_price: self.take_profit_price?,
310 stop_loss_price: self.stop_loss_price?,
311 stop_loss_limit_price: self.stop_loss_limit_price,
312 time_in_force: self.time_in_force,
313 },
314 })
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::order::id::ClientOrderId;
322 use rust_decimal_macros::dec;
323
324 #[test]
325 fn test_request_open_bracket_new() {
326 let req = RequestOpenBracket::new(
327 Side::Buy,
328 dec!(100),
329 dec!(150.00),
330 dec!(160.00),
331 dec!(145.00),
332 None,
333 TimeInForce::GoodUntilCancelled { post_only: false },
334 );
335
336 assert_eq!(req.side, Side::Buy);
337 assert_eq!(req.quantity, dec!(100));
338 assert_eq!(req.entry_price, dec!(150.00));
339 assert_eq!(req.take_profit_price, dec!(160.00));
340 assert_eq!(req.stop_loss_price, dec!(145.00));
341 assert!(req.stop_loss_limit_price.is_none());
342 }
343
344 #[test]
345 fn test_request_open_bracket_with_stop_limit() {
346 let req = RequestOpenBracket::new(
347 Side::Buy,
348 dec!(100),
349 dec!(150.00),
350 dec!(160.00),
351 dec!(145.00),
352 Some(dec!(144.00)),
353 TimeInForce::GoodUntilEndOfDay,
354 );
355
356 assert_eq!(req.stop_loss_limit_price, Some(dec!(144.00)));
357 }
358
359 #[test]
360 fn test_bracket_order_request_builder() {
361 let instrument = InstrumentNameExchange::from("AAPL");
362 let request = BracketOrderRequestBuilder::new(
363 ExchangeId::AlpacaBroker,
364 instrument.clone(),
365 StrategyId::new("test"),
366 ClientOrderId::new("bracket-001"),
367 )
368 .side(Side::Buy)
369 .quantity(dec!(10))
370 .entry_price(dec!(150.00))
371 .take_profit_price(dec!(160.00))
372 .stop_loss_price(dec!(145.00))
373 .build();
374
375 assert_eq!(request.key.exchange, ExchangeId::AlpacaBroker);
376 assert_eq!(request.key.instrument, instrument);
377 assert_eq!(request.state.side, Side::Buy);
378 assert_eq!(request.state.quantity, dec!(10));
379 }
380
381 #[test]
382 fn test_bracket_order_request_builder_try_build_missing_field() {
383 let instrument = InstrumentNameExchange::from("AAPL");
384 let result = BracketOrderRequestBuilder::new(
385 ExchangeId::AlpacaBroker,
386 instrument,
387 StrategyId::new("test"),
388 ClientOrderId::new("bracket-001"),
389 )
390 .side(Side::Buy)
391 .quantity(dec!(10))
392 // missing entry_price
393 .take_profit_price(dec!(160.00))
394 .stop_loss_price(dec!(145.00))
395 .try_build();
396
397 assert!(result.is_none());
398 }
399}