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).
101/// Partial success is prevented by all-or-nothing error handling in implementations.
102/// - Child legs are either both `Some` (exchange returns legs immediately, e.g. IBKR)
103/// or both `None` (exchange creates legs server-side, e.g. Alpaca). Asymmetric leg
104/// presence is not supported — no current exchange returns one leg but not the other,
105/// so [`with_all_legs`](Self::with_all_legs) and [`parent_only`](Self::parent_only)
106/// are the only public constructors.
107#[non_exhaustive]
108#[derive(Debug, Clone)]
109pub struct BracketOrderResult {
110 /// Parent (entry) order.
111 pub parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
112 /// Take-profit order (opposite side, limit).
113 ///
114 /// `None` when the exchange creates legs server-side (Alpaca).
115 /// `Some` when the exchange returns legs immediately (IBKR).
116 pub take_profit: Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>,
117 /// Stop-loss order (opposite side, stop or stop-limit).
118 ///
119 /// `None` when the exchange creates legs server-side (Alpaca).
120 /// `Some` when the exchange returns legs immediately (IBKR).
121 pub stop_loss: Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>>,
122}
123
124impl BracketOrderResult {
125 /// Create a result with all three legs present.
126 ///
127 /// Use for exchanges that return all orders immediately (e.g., IBKR).
128 pub fn with_all_legs(
129 parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
130 take_profit: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
131 stop_loss: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
132 ) -> Self {
133 Self {
134 parent,
135 take_profit: Some(take_profit),
136 stop_loss: Some(stop_loss),
137 }
138 }
139
140 /// Create a result with only the parent order.
141 ///
142 /// Use for exchanges that create child legs server-side (e.g., Alpaca).
143 pub fn parent_only(
144 parent: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
145 ) -> Self {
146 Self {
147 parent,
148 take_profit: None,
149 stop_loss: None,
150 }
151 }
152
153 /// Returns `true` if all child legs are present.
154 pub fn has_all_legs(&self) -> bool {
155 self.take_profit.is_some() && self.stop_loss.is_some()
156 }
157
158 /// Returns `true` if the parent order placement failed.
159 ///
160 /// Checking only the parent is sufficient because of the struct invariant:
161 /// either all orders are active or all are inactive. A failed parent implies
162 /// failed legs (or no legs returned, in the case of Alpaca).
163 pub fn is_failed(&self) -> bool {
164 self.parent.state.is_failed()
165 }
166}
167
168/// Builder for creating [`BracketOrderRequest`] with a fluent API.
169///
170/// # Example
171///
172/// ```ignore
173/// use rustrade_execution::order::bracket::BracketOrderRequestBuilder;
174/// use rustrade_execution::order::id::{ClientOrderId, StrategyId};
175/// use rustrade_instrument::{Side, exchange::ExchangeId, instrument::name::InstrumentNameExchange};
176/// use rust_decimal_macros::dec;
177///
178/// let instrument = InstrumentNameExchange::from("AAPL");
179/// let request = BracketOrderRequestBuilder::new(
180/// ExchangeId::AlpacaBroker,
181/// &instrument,
182/// StrategyId::new("momentum"),
183/// ClientOrderId::new("bracket-001"),
184/// )
185/// .side(Side::Buy)
186/// .quantity(dec!(10))
187/// .entry_price(dec!(150.00))
188/// .take_profit_price(dec!(160.00))
189/// .stop_loss_price(dec!(145.00))
190/// .build();
191/// ```
192#[derive(Debug, Clone)]
193#[must_use = "builder does nothing unless .build() or .try_build() is called"]
194pub struct BracketOrderRequestBuilder<
195 ExchangeKey = ExchangeId,
196 InstrumentKey = InstrumentNameExchange,
197> {
198 key: OrderKey<ExchangeKey, InstrumentKey>,
199 side: Option<Side>,
200 quantity: Option<Decimal>,
201 entry_price: Option<Decimal>,
202 take_profit_price: Option<Decimal>,
203 stop_loss_price: Option<Decimal>,
204 stop_loss_limit_price: Option<Decimal>,
205 time_in_force: TimeInForce,
206}
207
208impl<ExchangeKey, InstrumentKey> BracketOrderRequestBuilder<ExchangeKey, InstrumentKey>
209where
210 ExchangeKey: Clone,
211 InstrumentKey: Clone,
212{
213 /// Create a new builder with the given order key components.
214 pub fn new(
215 exchange: ExchangeKey,
216 instrument: InstrumentKey,
217 strategy: StrategyId,
218 cid: super::id::ClientOrderId,
219 ) -> Self {
220 Self {
221 key: OrderKey::new(exchange, instrument, strategy, cid),
222 side: None,
223 quantity: None,
224 entry_price: None,
225 take_profit_price: None,
226 stop_loss_price: None,
227 stop_loss_limit_price: None,
228 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
229 }
230 }
231
232 /// Set the order side (Buy or Sell).
233 pub fn side(mut self, side: Side) -> Self {
234 self.side = Some(side);
235 self
236 }
237
238 /// Set the quantity for all legs.
239 pub fn quantity(mut self, quantity: Decimal) -> Self {
240 self.quantity = Some(quantity);
241 self
242 }
243
244 /// Set the entry limit price.
245 pub fn entry_price(mut self, price: Decimal) -> Self {
246 self.entry_price = Some(price);
247 self
248 }
249
250 /// Set the take-profit limit price.
251 pub fn take_profit_price(mut self, price: Decimal) -> Self {
252 self.take_profit_price = Some(price);
253 self
254 }
255
256 /// Set the stop-loss trigger price.
257 pub fn stop_loss_price(mut self, price: Decimal) -> Self {
258 self.stop_loss_price = Some(price);
259 self
260 }
261
262 /// Set the stop-loss limit price (creates stop-limit SL on supporting exchanges).
263 pub fn stop_loss_limit_price(mut self, price: Decimal) -> Self {
264 self.stop_loss_limit_price = Some(price);
265 self
266 }
267
268 /// Set the time-in-force for all legs.
269 pub fn time_in_force(mut self, tif: TimeInForce) -> Self {
270 self.time_in_force = tif;
271 self
272 }
273
274 /// Build the bracket order request.
275 ///
276 /// # Panics
277 ///
278 /// Panics if any required field is missing: `side`, `quantity`, `entry_price`,
279 /// `take_profit_price`, or `stop_loss_price`.
280 #[track_caller]
281 #[allow(clippy::expect_used)] // Panic is intentional per doc contract
282 pub fn build(self) -> BracketOrderRequest<ExchangeKey, InstrumentKey> {
283 BracketOrderRequest {
284 key: self.key,
285 state: RequestOpenBracket {
286 side: self.side.expect("side is required"),
287 quantity: self.quantity.expect("quantity is required"),
288 entry_price: self.entry_price.expect("entry_price is required"),
289 take_profit_price: self
290 .take_profit_price
291 .expect("take_profit_price is required"),
292 stop_loss_price: self.stop_loss_price.expect("stop_loss_price is required"),
293 stop_loss_limit_price: self.stop_loss_limit_price,
294 time_in_force: self.time_in_force,
295 },
296 }
297 }
298
299 /// Try to build the bracket order request, returning `None` if any required field is missing.
300 pub fn try_build(self) -> Option<BracketOrderRequest<ExchangeKey, InstrumentKey>> {
301 Some(BracketOrderRequest {
302 key: self.key,
303 state: RequestOpenBracket {
304 side: self.side?,
305 quantity: self.quantity?,
306 entry_price: self.entry_price?,
307 take_profit_price: self.take_profit_price?,
308 stop_loss_price: self.stop_loss_price?,
309 stop_loss_limit_price: self.stop_loss_limit_price,
310 time_in_force: self.time_in_force,
311 },
312 })
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::order::id::ClientOrderId;
320 use rust_decimal_macros::dec;
321
322 #[test]
323 fn test_request_open_bracket_new() {
324 let req = RequestOpenBracket::new(
325 Side::Buy,
326 dec!(100),
327 dec!(150.00),
328 dec!(160.00),
329 dec!(145.00),
330 None,
331 TimeInForce::GoodUntilCancelled { post_only: false },
332 );
333
334 assert_eq!(req.side, Side::Buy);
335 assert_eq!(req.quantity, dec!(100));
336 assert_eq!(req.entry_price, dec!(150.00));
337 assert_eq!(req.take_profit_price, dec!(160.00));
338 assert_eq!(req.stop_loss_price, dec!(145.00));
339 assert!(req.stop_loss_limit_price.is_none());
340 }
341
342 #[test]
343 fn test_request_open_bracket_with_stop_limit() {
344 let req = RequestOpenBracket::new(
345 Side::Buy,
346 dec!(100),
347 dec!(150.00),
348 dec!(160.00),
349 dec!(145.00),
350 Some(dec!(144.00)),
351 TimeInForce::GoodUntilEndOfDay,
352 );
353
354 assert_eq!(req.stop_loss_limit_price, Some(dec!(144.00)));
355 }
356
357 #[test]
358 fn test_bracket_order_request_builder() {
359 let instrument = InstrumentNameExchange::from("AAPL");
360 let request = BracketOrderRequestBuilder::new(
361 ExchangeId::AlpacaBroker,
362 instrument.clone(),
363 StrategyId::new("test"),
364 ClientOrderId::new("bracket-001"),
365 )
366 .side(Side::Buy)
367 .quantity(dec!(10))
368 .entry_price(dec!(150.00))
369 .take_profit_price(dec!(160.00))
370 .stop_loss_price(dec!(145.00))
371 .build();
372
373 assert_eq!(request.key.exchange, ExchangeId::AlpacaBroker);
374 assert_eq!(request.key.instrument, instrument);
375 assert_eq!(request.state.side, Side::Buy);
376 assert_eq!(request.state.quantity, dec!(10));
377 }
378
379 #[test]
380 fn test_bracket_order_request_builder_try_build_missing_field() {
381 let instrument = InstrumentNameExchange::from("AAPL");
382 let result = BracketOrderRequestBuilder::new(
383 ExchangeId::AlpacaBroker,
384 instrument,
385 StrategyId::new("test"),
386 ClientOrderId::new("bracket-001"),
387 )
388 .side(Side::Buy)
389 .quantity(dec!(10))
390 // missing entry_price
391 .take_profit_price(dec!(160.00))
392 .stop_loss_price(dec!(145.00))
393 .try_build();
394
395 assert!(result.is_none());
396 }
397}