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).
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}