Skip to main content

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}