Skip to main content

rustrade_execution/client/ibkr/
order.rs

1use crate::order::{
2    Order as RustradeOrder, OrderKind, TimeInForce, TrailingOffsetType,
3    id::{ClientOrderId, StrategyId},
4    state::UnindexedOrderState,
5};
6use fnv::FnvHashMap;
7use ibapi::orders::{Action, OcaType, Order, TimeInForce as IbTimeInForce, order_builder};
8use parking_lot::{Mutex, RwLock};
9use rust_decimal::Decimal;
10use rustrade_instrument::{Side, exchange::ExchangeId, instrument::name::InstrumentNameExchange};
11use std::{sync::Arc, time::Instant};
12
13// ============================================================================
14// Bracket Order Types
15// ============================================================================
16
17/// Request to place a bracket order (entry + take-profit + stop-loss).
18///
19/// A bracket order consists of three linked orders:
20/// 1. **Entry**: Limit order to enter the position
21/// 2. **Take Profit**: Limit order to exit at profit target
22/// 3. **Stop Loss**: Stop order to exit at loss limit
23///
24/// The entry order is submitted with `transmit=false`, holding all orders until
25/// the stop-loss (with `transmit=true`) triggers atomic submission of all three.
26///
27/// Take-profit and stop-loss are linked via OCA (One-Cancels-All) group, so when
28/// one fills, IB automatically cancels the other.
29#[derive(Debug, Clone)]
30pub struct BracketOrderRequest {
31    /// Instrument to trade.
32    pub instrument: InstrumentNameExchange,
33    /// Strategy identifier for order correlation.
34    pub strategy: StrategyId,
35    /// Client order ID for the parent (entry) order.
36    /// Child orders will have IDs derived from this (parent_cid + "_tp", parent_cid + "_sl").
37    pub parent_cid: ClientOrderId,
38    /// Buy or Sell for the entry order (exits use opposite side).
39    pub side: Side,
40    /// Number of shares/contracts.
41    pub quantity: Decimal,
42    /// Entry limit price.
43    pub entry_price: Decimal,
44    /// Take profit limit price.
45    pub take_profit_price: Decimal,
46    /// Stop loss trigger price.
47    pub stop_loss_price: Decimal,
48    /// Time-in-force for all three orders.
49    pub time_in_force: TimeInForce,
50}
51
52/// Result of placing a bracket order.
53///
54/// Contains the three orders with their states. Use the `ClientOrderId` from each
55/// order's key to cancel individual legs or correlate stream events.
56///
57/// # Invariant
58///
59/// Either all three orders are `Active(Open)` or all three are `Inactive`.
60/// Partial success (some active, some inactive) is prevented by the all-or-nothing
61/// error handling in `open_bracket_order`.
62#[derive(Debug, Clone)]
63pub struct BracketOrderResult {
64    /// Parent (entry) order.
65    pub parent: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
66    /// Take profit order (opposite side, limit).
67    pub take_profit: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
68    /// Stop loss order (opposite side, stop).
69    pub stop_loss: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
70}
71
72// ============================================================================
73// Order Context
74// ============================================================================
75
76/// Order context stored when placing orders.
77///
78/// IB's `OrderStatus` callback doesn't include instrument/side/price/kind,
79/// so we store this context at order placement time to reconstruct full
80/// `Order` structs from status updates.
81///
82/// # Invariant
83///
84/// This data is immutable after order placement. IB doesn't support in-flight
85/// order modification — amendments are cancel+replace (new order ID, new entry).
86#[derive(Debug, Clone)]
87pub struct OrderContext {
88    pub instrument: InstrumentNameExchange,
89    pub side: Side,
90    /// Limit price. `None` for Market/Stop/TrailingStop orders.
91    pub price: Option<Decimal>,
92    pub quantity: Decimal,
93    pub kind: OrderKind,
94    pub time_in_force: TimeInForce,
95}
96
97/// Bidirectional mapping between rustrade ClientOrderId and IB order IDs.
98///
99/// IB uses `i32` order IDs from a sequence. Barter uses `ClientOrderId` (SmolStr).
100/// This map maintains the bidirectional relationship and stores order context
101/// for reconstructing full `Order` structs from `OrderStatus` callbacks.
102#[derive(Debug, Clone)]
103pub struct OrderIdMap {
104    inner: Arc<RwLock<OrderIdMapInner>>,
105}
106
107#[derive(Debug, Default)]
108struct OrderIdMapInner {
109    cid_to_ib: FnvHashMap<ClientOrderId, i32>,
110    /// Merged map: IB order ID → (ClientOrderId, OrderContext, registration time) for single-lookup on hot path.
111    /// The Instant tracks when the order was registered, enabling age-based cleanup.
112    ib_to_entry: FnvHashMap<i32, (ClientOrderId, OrderContext, Instant)>,
113}
114
115impl OrderIdMap {
116    pub fn new() -> Self {
117        Self {
118            inner: Arc::new(RwLock::new(OrderIdMapInner::default())),
119        }
120    }
121
122    /// Register a mapping between ClientOrderId and IB order ID with order context.
123    pub fn register(&self, client_id: ClientOrderId, ib_id: i32, context: OrderContext) {
124        let mut inner = self.inner.write();
125        inner.cid_to_ib.insert(client_id.clone(), ib_id);
126        inner
127            .ib_to_entry
128            .insert(ib_id, (client_id, context, Instant::now()));
129    }
130
131    /// Look up IB order ID by ClientOrderId.
132    pub fn get_ib_id(&self, client_id: &ClientOrderId) -> Option<i32> {
133        self.inner.read().cid_to_ib.get(client_id).copied()
134    }
135
136    /// Look up ClientOrderId by IB order ID.
137    pub fn get_client_id(&self, ib_id: i32) -> Option<ClientOrderId> {
138        self.inner
139            .read()
140            .ib_to_entry
141            .get(&ib_id)
142            .map(|(cid, _, _)| cid.clone())
143    }
144
145    /// Look up ClientOrderId and OrderContext together by IB order ID (single lookup).
146    pub fn get_client_id_and_context(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
147        self.inner
148            .read()
149            .ib_to_entry
150            .get(&ib_id)
151            .map(|(cid, ctx, _)| (cid.clone(), ctx.clone()))
152    }
153
154    /// Remove mapping and return context in a single write lock acquisition.
155    ///
156    /// Use this for terminal status events (Cancelled/Inactive) to avoid the
157    /// read-then-write pattern of `get_client_id_and_context` + `remove_by_ib_id`.
158    pub fn remove_and_get_context(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
159        let mut inner = self.inner.write();
160        if let Some((client_id, ctx, _)) = inner.ib_to_entry.remove(&ib_id) {
161            inner.cid_to_ib.remove(&client_id);
162            Some((client_id, ctx))
163        } else {
164            None
165        }
166    }
167
168    /// Remove a mapping by IB order ID (used when order is fully filled/cancelled).
169    pub fn remove_by_ib_id(&self, ib_id: i32) -> Option<ClientOrderId> {
170        let mut inner = self.inner.write();
171        if let Some((client_id, _, _)) = inner.ib_to_entry.remove(&ib_id) {
172            inner.cid_to_ib.remove(&client_id);
173            Some(client_id)
174        } else {
175            None
176        }
177    }
178
179    /// Clear order ID mappings older than the given duration.
180    ///
181    /// Returns the number of cleared entries.
182    ///
183    /// # Why This Is Needed
184    ///
185    /// IB does not guarantee event ordering between `OrderStatus("Filled")` and
186    /// `ExecutionData`/`CommissionReport`. For fast-filling orders (especially
187    /// market orders), execution data may arrive AFTER the filled status — or
188    /// the filled status may not arrive at all. Removing mappings on terminal
189    /// status would cause data loss.
190    ///
191    /// Instead, call this method periodically to clean up old mappings. A
192    /// reasonable interval is 5-10 minutes with a max_age of 1 hour.
193    pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
194        let mut inner = self.inner.write();
195        let before = inner.ib_to_entry.len();
196
197        // Collect IB IDs to remove (can't mutate while iterating)
198        let stale_ids: Vec<i32> = inner
199            .ib_to_entry
200            .iter()
201            .filter(|(_, (_, _, registered_at))| registered_at.elapsed() >= max_age)
202            .map(|(ib_id, _)| *ib_id)
203            .collect();
204
205        for ib_id in stale_ids {
206            if let Some((client_id, _, _)) = inner.ib_to_entry.remove(&ib_id) {
207                inner.cid_to_ib.remove(&client_id);
208            }
209        }
210
211        before - inner.ib_to_entry.len()
212    }
213
214    /// Number of active mappings.
215    pub fn len(&self) -> usize {
216        self.inner.read().cid_to_ib.len()
217    }
218
219    /// Check if map is empty.
220    pub fn is_empty(&self) -> bool {
221        self.inner.read().cid_to_ib.is_empty()
222    }
223}
224
225impl Default for OrderIdMap {
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231/// Tracks IB order IDs with pending cancel requests.
232///
233/// Used to differentiate user-initiated cancellation from time-based expiration.
234/// IBKR sends `"Cancelled"` status for both cases; this map tracks which cancels
235/// were user-initiated via `cancel_order()`.
236#[derive(Debug, Clone)]
237pub struct PendingCancels {
238    inner: Arc<Mutex<FnvHashMap<i32, Instant>>>,
239}
240
241impl PendingCancels {
242    pub fn new() -> Self {
243        Self {
244            inner: Arc::new(Mutex::new(FnvHashMap::with_capacity_and_hasher(
245                8,
246                Default::default(),
247            ))),
248        }
249    }
250
251    /// Record a pending cancel request for the given IB order ID.
252    pub fn insert(&self, ib_id: i32) {
253        self.inner.lock().insert(ib_id, Instant::now());
254    }
255
256    /// Check if a cancel was user-initiated and remove from tracking.
257    ///
258    /// Returns `true` if the order ID was in the pending set (user-initiated cancel).
259    #[must_use]
260    pub fn remove(&self, ib_id: i32) -> bool {
261        self.inner.lock().remove(&ib_id).is_some()
262    }
263
264    /// Clear entries older than the given duration.
265    ///
266    /// Returns the number of cleared entries. Call periodically to prevent
267    /// memory leaks from orphaned cancel requests (e.g., network issues).
268    #[must_use]
269    pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
270        let mut map = self.inner.lock();
271        let before = map.len();
272        map.retain(|_, registered_at| registered_at.elapsed() < max_age);
273        before - map.len()
274    }
275
276    /// Number of pending cancel requests being tracked.
277    #[must_use]
278    pub fn len(&self) -> usize {
279        self.inner.lock().len()
280    }
281
282    /// Check if there are no pending cancel requests.
283    #[must_use]
284    pub fn is_empty(&self) -> bool {
285        self.inner.lock().is_empty()
286    }
287}
288
289impl Default for PendingCancels {
290    fn default() -> Self {
291        Self::new()
292    }
293}
294
295/// Convert rustrade Side to IB Action.
296pub(crate) fn side_to_action(side: rustrade_instrument::Side) -> Action {
297    match side {
298        rustrade_instrument::Side::Buy => Action::Buy,
299        rustrade_instrument::Side::Sell => Action::Sell,
300    }
301}
302
303/// Error when mapping rustrade order types to IB.
304#[derive(Debug, Clone, PartialEq, Eq)]
305pub enum OrderMappingError {
306    PostOnlyNotSupported,
307    /// Price conversion to f64 failed (overflow or invalid decimal).
308    InvalidPrice(String),
309    /// Limit price required for this order type but was None.
310    MissingLimitPrice(OrderKind),
311    /// Trailing offset type not supported by IBKR.
312    UnsupportedOffsetType(TrailingOffsetType),
313    /// Order kind not supported by IBKR (e.g., TakeProfit).
314    UnsupportedOrderKind(OrderKind),
315    /// AtClose TIF only valid with Market or Limit orders (becomes MOC/LOC).
316    /// Stop orders cannot be combined with at-close execution.
317    UnsupportedOrderKindForAtClose(OrderKind),
318    /// AtClose TIF reached `time_in_force_to_ib` directly. AtClose changes the
319    /// order TYPE (MOC/LOC), not just the TIF; callers must route through
320    /// `build_ib_order` which handles the type promotion.
321    AtCloseRequiresOrderTypeChange,
322}
323
324impl std::fmt::Display for OrderMappingError {
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        match self {
327            Self::PostOnlyNotSupported => write!(f, "post_only not supported by IB"),
328            Self::InvalidPrice(p) => write!(f, "invalid price for f64 conversion: {p}"),
329            Self::MissingLimitPrice(k) => {
330                write!(f, "limit price required for {k} but was None")
331            }
332            Self::UnsupportedOffsetType(t) => {
333                write!(f, "trailing offset type {t:?} not supported by IBKR")
334            }
335            Self::UnsupportedOrderKind(k) => {
336                write!(f, "order kind {k} not supported by IBKR")
337            }
338            Self::UnsupportedOrderKindForAtClose(k) => {
339                write!(
340                    f,
341                    "AtClose TIF only valid with Market or Limit orders, got {k}"
342                )
343            }
344            Self::AtCloseRequiresOrderTypeChange => write!(
345                f,
346                "AtClose TIF must be routed through build_ib_order, which promotes \
347                 the order to MOC/LOC; time_in_force_to_ib cannot map it directly"
348            ),
349        }
350    }
351}
352
353impl std::error::Error for OrderMappingError {}
354
355/// Convert rustrade TimeInForce to IB TimeInForce.
356///
357/// Returns [`OrderMappingError::AtCloseRequiresOrderTypeChange`] for
358/// [`TimeInForce::AtClose`]: AtClose changes the order TYPE (MOC/LOC), not
359/// just the TIF, and is handled by [`build_ib_order`] which promotes the
360/// order type. Callers needing AtClose support should route through
361/// [`build_ib_order`] rather than this helper.
362pub fn time_in_force_to_ib(tif: &TimeInForce) -> Result<IbTimeInForce, OrderMappingError> {
363    match tif {
364        TimeInForce::GoodUntilCancelled { post_only } => {
365            if *post_only {
366                Err(OrderMappingError::PostOnlyNotSupported)
367            } else {
368                Ok(IbTimeInForce::GoodTilCanceled)
369            }
370        }
371        TimeInForce::GoodUntilEndOfDay => Ok(IbTimeInForce::Day),
372        TimeInForce::FillOrKill => Ok(IbTimeInForce::FillOrKill),
373        TimeInForce::ImmediateOrCancel => Ok(IbTimeInForce::ImmediateOrCancel),
374        TimeInForce::GoodTillDate { .. } => Ok(IbTimeInForce::GoodTilDate),
375        TimeInForce::AtOpen => Ok(IbTimeInForce::OnOpen),
376        // AtClose changes the order TYPE to MOC/LOC, not just the TIF.
377        // `build_ib_order` intercepts AtClose; surfacing Err here lets other
378        // callers (e.g. the bracket-order path) reject gracefully.
379        TimeInForce::AtClose => Err(OrderMappingError::AtCloseRequiresOrderTypeChange),
380    }
381}
382
383/// Convert a Decimal to f64, returning an error if conversion fails.
384fn decimal_to_f64(value: rust_decimal::Decimal) -> Result<f64, OrderMappingError> {
385    value.try_into().or_else(|_| {
386        value
387            .to_string()
388            .parse()
389            .map_err(|_| OrderMappingError::InvalidPrice(value.to_string()))
390    })
391}
392
393/// Extract required limit price from Option, returning error if None.
394fn require_limit_price(
395    price: Option<rust_decimal::Decimal>,
396    kind: OrderKind,
397) -> Result<f64, OrderMappingError> {
398    match price {
399        Some(p) => decimal_to_f64(p),
400        None => Err(OrderMappingError::MissingLimitPrice(kind)),
401    }
402}
403
404/// Build an IB Order from rustrade order parameters.
405///
406/// # Order Type Mapping
407///
408/// - `Market` → IB "MKT"
409/// - `Limit` → IB "LMT" with `limit_price`
410/// - `Stop` → IB "STP" with `aux_price` as trigger
411/// - `StopLimit` → IB "STP LMT" with `aux_price` as trigger, `limit_price` from Order.price
412/// - `TrailingStop` (Percentage) → IB "TRAIL" with `trailing_percent`
413/// - `TrailingStop` (Absolute) → IB "TRAIL" with `aux_price`
414/// - `TrailingStopLimit` (Absolute) → IB "TRAIL LIMIT" with `aux_price`, `limit_price_offset`
415/// - `TrailingStopLimit` (Percentage) → IB "TRAIL LIMIT" with `trailing_percent`, `limit_price_offset`
416/// - `TakeProfit` → `Err(UnsupportedOrderKind)` (IBKR has no native TP; use bracket orders)
417/// - `TakeProfitLimit` → `Err(UnsupportedOrderKind)` (IBKR has no native TP; use bracket orders)
418/// - `BasisPoints` offset type → Error (not supported by IBKR)
419///
420/// # Price Requirements
421///
422/// - `Market`, `Stop`, `TrailingStop`: `price` should be `None` (ignored if provided)
423/// - `Limit`, `StopLimit`, `TrailingStopLimit`: `price` must be `Some(limit_price)`
424///
425/// # Time-in-Force Special Cases
426///
427/// - `AtClose` + `Market` → IB "MOC" (Market-on-Close)
428/// - `AtClose` + `Limit` → IB "LOC" (Limit-on-Close)
429/// - `AtClose` + Stop/Trailing → Error (not supported by IBKR)
430/// - `GoodTillDate` → Sets both TIF and `good_till_date` field
431pub fn build_ib_order(
432    side: rustrade_instrument::Side,
433    quantity: f64,
434    kind: &OrderKind,
435    price: Option<rust_decimal::Decimal>,
436    tif: &TimeInForce,
437) -> Result<Order, OrderMappingError> {
438    let action = side_to_action(side);
439
440    // Handle AtClose specially - it changes the order TYPE, not just TIF
441    if matches!(tif, TimeInForce::AtClose) {
442        return build_at_close_order(action, quantity, kind, price);
443    }
444
445    let tif_ib = time_in_force_to_ib(tif)?;
446
447    let mut order = match kind {
448        OrderKind::Market => order_builder::market_order(action, quantity),
449
450        OrderKind::Limit => {
451            let price_f64 = require_limit_price(price, *kind)?;
452            order_builder::limit_order(action, quantity, price_f64)
453        }
454
455        OrderKind::Stop { trigger_price } => {
456            let trigger_f64 = decimal_to_f64(*trigger_price)?;
457            order_builder::stop(action, quantity, trigger_f64)
458        }
459
460        OrderKind::StopLimit { trigger_price } => {
461            let limit_f64 = require_limit_price(price, *kind)?;
462            let trigger_f64 = decimal_to_f64(*trigger_price)?;
463            order_builder::stop_limit(action, quantity, limit_f64, trigger_f64)
464        }
465
466        OrderKind::TrailingStop {
467            offset,
468            offset_type,
469        } => {
470            let offset_f64 = decimal_to_f64(*offset)?;
471            match offset_type {
472                TrailingOffsetType::Percentage => {
473                    // Use the builder for percentage-based trailing stop.
474                    // trail_stop_price is None, letting IB derive it from market price.
475                    Order {
476                        action,
477                        order_type: "TRAIL".to_owned(),
478                        total_quantity: quantity,
479                        trailing_percent: Some(offset_f64),
480                        trail_stop_price: None,
481                        ..Order::default()
482                    }
483                }
484                TrailingOffsetType::Absolute => {
485                    // Manual construction for absolute trailing stop.
486                    // aux_price holds the trailing amount.
487                    Order {
488                        action,
489                        order_type: "TRAIL".to_owned(),
490                        total_quantity: quantity,
491                        aux_price: Some(offset_f64),
492                        trail_stop_price: None,
493                        ..Order::default()
494                    }
495                }
496                TrailingOffsetType::BasisPoints => {
497                    return Err(OrderMappingError::UnsupportedOffsetType(
498                        TrailingOffsetType::BasisPoints,
499                    ));
500                }
501            }
502        }
503
504        OrderKind::TrailingStopLimit {
505            offset,
506            offset_type,
507            limit_offset,
508        } => {
509            let offset_f64 = decimal_to_f64(*offset)?;
510            let limit_offset_f64 = decimal_to_f64(*limit_offset)?;
511            match offset_type {
512                TrailingOffsetType::Absolute => {
513                    // Use builder for absolute trailing stop-limit.
514                    // aux_price = trailing_amount, limit_price_offset = limit offset from stop.
515                    order_builder::trailing_stop_limit(
516                        action,
517                        quantity,
518                        limit_offset_f64,
519                        offset_f64,
520                        0.0, // trail_stop_price: let IB derive from market
521                    )
522                }
523                TrailingOffsetType::Percentage => {
524                    // Manual construction for percentage-based trailing stop-limit.
525                    Order {
526                        action,
527                        order_type: "TRAIL LIMIT".to_owned(),
528                        total_quantity: quantity,
529                        trailing_percent: Some(offset_f64),
530                        limit_price_offset: Some(limit_offset_f64),
531                        trail_stop_price: None,
532                        ..Order::default()
533                    }
534                }
535                TrailingOffsetType::BasisPoints => {
536                    return Err(OrderMappingError::UnsupportedOffsetType(
537                        TrailingOffsetType::BasisPoints,
538                    ));
539                }
540            }
541        }
542
543        // IBKR does not have native take-profit order types. Callers should use
544        // bracket orders or manage TP logic at the strategy level.
545        OrderKind::TakeProfit { .. } | OrderKind::TakeProfitLimit { .. } => {
546            return Err(OrderMappingError::UnsupportedOrderKind(*kind));
547        }
548    };
549
550    order.tif = tif_ib;
551
552    // Handle GoodTillDate: set the good_till_date string field
553    if let TimeInForce::GoodTillDate { expiry } = tif {
554        order.good_till_date = format_gtd_datetime(expiry);
555    }
556
557    Ok(order)
558}
559
560/// Format a DateTime<Utc> for IB's good_till_date field.
561///
562/// IB accepts format: "yyyyMMdd HH:mm:ss" with optional timezone suffix.
563/// We use UTC format "yyyyMMdd-HH:mm:ss" which IB interprets as UTC.
564fn format_gtd_datetime(dt: &chrono::DateTime<chrono::Utc>) -> String {
565    dt.format("%Y%m%d-%H:%M:%S").to_string()
566}
567
568/// Build an at-close order (MOC or LOC).
569///
570/// Only Market and Limit orders can be combined with at-close execution.
571/// Stop orders cannot execute at close due to IBKR's order model constraints.
572fn build_at_close_order(
573    action: Action,
574    quantity: f64,
575    kind: &OrderKind,
576    price: Option<rust_decimal::Decimal>,
577) -> Result<Order, OrderMappingError> {
578    match kind {
579        OrderKind::Market => Ok(order_builder::market_on_close(action, quantity)),
580        OrderKind::Limit => {
581            let price_f64 = require_limit_price(price, *kind)?;
582            Ok(order_builder::limit_on_close(action, quantity, price_f64))
583        }
584        // Stop orders cannot be combined with at-close execution
585        _ => Err(OrderMappingError::UnsupportedOrderKindForAtClose(*kind)),
586    }
587}
588
589/// Build a bracket order with proper OCA linking.
590///
591/// ibapi's `order_builder::bracket_order()` sets `parent_id` on child orders (TP/SL
592/// wait for entry fill) but does NOT set `oca_group` or `oca_type`. Without OCA
593/// linking, if take-profit fills, stop-loss stays open — dangerous position exposure.
594///
595/// This function wraps `bracket_order()` and adds OCA group linking so that when
596/// either child order fills, the other is automatically cancelled by IB.
597///
598/// # Arguments
599///
600/// * `parent_order_id` - The IB order ID for the parent (entry) order
601/// * `action` - Buy or Sell for the entry order (children use opposite action)
602/// * `quantity` - Number of shares/contracts
603/// * `limit_price` - Entry limit price
604/// * `take_profit_price` - Take profit limit price (above entry for Buy, below for Sell)
605/// * `stop_loss_price` - Stop loss trigger price (below entry for Buy, above for Sell)
606///
607/// # Returns
608///
609/// Vec of 3 orders: `[parent, take_profit, stop_loss]` with:
610/// - `parent_id` set on children (IB's bracket linkage)
611/// - `oca_group` set on children (OCA linkage between TP and SL)
612/// - `oca_type = CancelWithBlock` (safest: one at a time, cancel remaining)
613/// - `transmit` flags: parent=false, TP=false, SL=true (atomic submission)
614///
615/// # Order ID Convention
616///
617/// ibapi's `bracket_order()` assigns consecutive IDs: parent=N, TP=N+1, SL=N+2.
618/// Caller must ensure these IDs are reserved atomically via `allocate_order_id_range(3)`.
619pub fn build_ib_bracket_with_oca(
620    parent_order_id: i32,
621    action: Action,
622    quantity: f64,
623    limit_price: f64,
624    take_profit_price: f64,
625    stop_loss_price: f64,
626    tif: IbTimeInForce,
627) -> Vec<Order> {
628    let mut orders = order_builder::bracket_order(
629        parent_order_id,
630        action,
631        quantity,
632        limit_price,
633        take_profit_price,
634        stop_loss_price,
635    );
636    assert_eq!(
637        orders.len(),
638        3,
639        "ibapi bracket_order must return exactly 3 orders (parent, TP, SL)"
640    );
641
642    // ibapi's bracket_order() defaults all legs to TimeInForce::Day; override
643    // with the caller-specified TIF so it actually reaches the wire.
644    orders[0].tif = tif.clone();
645    orders[1].tif = tif.clone();
646    orders[2].tif = tif;
647
648    // Link TP and SL via OCA group so one cancels the other.
649    // CancelWithBlock provides overfill protection by routing only one child at a time.
650    let oca_group = format!("bracket_{}", parent_order_id);
651    orders[1].oca_group = oca_group.clone();
652    orders[1].oca_type = OcaType::CancelWithBlock;
653    orders[2].oca_group = oca_group;
654    orders[2].oca_type = OcaType::CancelWithBlock;
655
656    orders
657}
658
659#[cfg(test)]
660#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
661mod tests {
662    use super::*;
663    use rust_decimal::Decimal;
664
665    fn test_context() -> OrderContext {
666        OrderContext {
667            instrument: rustrade_instrument::instrument::name::InstrumentNameExchange::from("AAPL"),
668            side: Side::Buy,
669            price: Some(Decimal::from(150)),
670            quantity: Decimal::from(100),
671            kind: OrderKind::Limit,
672            time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
673        }
674    }
675
676    #[test]
677    fn test_order_id_map_basic() {
678        let map = OrderIdMap::new();
679        let cid = ClientOrderId::new("order-123");
680        let ctx = test_context();
681
682        map.register(cid.clone(), 42, ctx.clone());
683
684        assert_eq!(map.get_ib_id(&cid), Some(42));
685        assert_eq!(map.get_client_id(42), Some(cid.clone()));
686        assert_eq!(map.len(), 1);
687
688        let (retrieved_cid, retrieved_ctx) = map.get_client_id_and_context(42).unwrap();
689        assert_eq!(retrieved_cid, cid);
690        assert_eq!(retrieved_ctx.side, Side::Buy);
691        assert_eq!(retrieved_ctx.price, Some(Decimal::from(150)));
692    }
693
694    #[test]
695    fn test_order_id_map_remove() {
696        let map = OrderIdMap::new();
697        let cid = ClientOrderId::new("order-456");
698
699        map.register(cid.clone(), 100, test_context());
700        assert_eq!(map.len(), 1);
701
702        let removed = map.remove_by_ib_id(100);
703        assert_eq!(removed, Some(cid.clone()));
704        assert!(map.is_empty());
705        assert!(map.get_ib_id(&cid).is_none());
706        assert!(map.get_client_id(100).is_none());
707        assert!(map.get_client_id_and_context(100).is_none());
708    }
709
710    #[test]
711    fn test_side_conversion() {
712        assert!(matches!(
713            side_to_action(rustrade_instrument::Side::Buy),
714            Action::Buy
715        ));
716        assert!(matches!(
717            side_to_action(rustrade_instrument::Side::Sell),
718            Action::Sell
719        ));
720    }
721
722    #[test]
723    fn test_time_in_force_conversion() {
724        assert_eq!(
725            time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: false }),
726            Ok(IbTimeInForce::GoodTilCanceled)
727        );
728
729        assert!(matches!(
730            time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: true }),
731            Err(OrderMappingError::PostOnlyNotSupported)
732        ));
733
734        assert_eq!(
735            time_in_force_to_ib(&TimeInForce::GoodUntilEndOfDay),
736            Ok(IbTimeInForce::Day)
737        );
738        assert_eq!(
739            time_in_force_to_ib(&TimeInForce::FillOrKill),
740            Ok(IbTimeInForce::FillOrKill)
741        );
742        assert_eq!(
743            time_in_force_to_ib(&TimeInForce::ImmediateOrCancel),
744            Ok(IbTimeInForce::ImmediateOrCancel)
745        );
746    }
747
748    #[test]
749    fn test_time_in_force_conversion_good_till_date() {
750        use chrono::{TimeZone, Utc};
751
752        let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
753        assert_eq!(
754            time_in_force_to_ib(&TimeInForce::GoodTillDate { expiry }),
755            Ok(IbTimeInForce::GoodTilDate)
756        );
757    }
758
759    #[test]
760    fn test_time_in_force_conversion_at_open() {
761        assert_eq!(
762            time_in_force_to_ib(&TimeInForce::AtOpen),
763            Ok(IbTimeInForce::OnOpen)
764        );
765    }
766
767    #[test]
768    fn test_time_in_force_conversion_at_close_returns_err() {
769        // AtClose changes the order TYPE (MOC/LOC), so callers must route
770        // through build_ib_order. Other callers (e.g. bracket orders) get a
771        // graceful Err that they can surface as an order rejection.
772        assert_eq!(
773            time_in_force_to_ib(&TimeInForce::AtClose),
774            Err(OrderMappingError::AtCloseRequiresOrderTypeChange)
775        );
776    }
777
778    #[test]
779    fn test_build_market_order() {
780        let order = build_ib_order(
781            rustrade_instrument::Side::Buy,
782            100.0,
783            &OrderKind::Market,
784            None, // Market orders have no limit price
785            &TimeInForce::GoodUntilEndOfDay,
786        )
787        .unwrap();
788
789        assert_eq!(order.action, Action::Buy);
790        assert_eq!(order.total_quantity, 100.0);
791        assert_eq!(order.order_type, "MKT");
792    }
793
794    #[test]
795    fn test_build_limit_order() {
796        let order = build_ib_order(
797            rustrade_instrument::Side::Sell,
798            50.0,
799            &OrderKind::Limit,
800            Some(Decimal::try_from(150.5).unwrap()),
801            &TimeInForce::GoodUntilCancelled { post_only: false },
802        )
803        .unwrap();
804
805        assert_eq!(order.action, Action::Sell);
806        assert_eq!(order.total_quantity, 50.0);
807        assert_eq!(order.order_type, "LMT");
808    }
809
810    #[test]
811    fn test_order_id_map_remove_and_get_context() {
812        let map = OrderIdMap::new();
813        let cid = ClientOrderId::new("order-789");
814        let ctx = test_context();
815
816        map.register(cid.clone(), 50, ctx);
817        assert_eq!(map.len(), 1);
818
819        // Remove and get context in single operation
820        let result = map.remove_and_get_context(50);
821        assert!(result.is_some());
822        let (retrieved_cid, retrieved_ctx) = result.unwrap();
823        assert_eq!(retrieved_cid, cid);
824        assert_eq!(retrieved_ctx.side, Side::Buy);
825
826        // Map should be empty now
827        assert!(map.is_empty());
828        assert!(map.get_client_id(50).is_none());
829        assert!(map.get_ib_id(&cid).is_none());
830
831        // Second removal returns None
832        assert!(map.remove_and_get_context(50).is_none());
833    }
834
835    #[test]
836    fn test_order_id_map_clear_stale() {
837        use std::time::Duration;
838
839        let map = OrderIdMap::new();
840
841        // Register orders
842        map.register(ClientOrderId::new("old-1"), 1, test_context());
843        map.register(ClientOrderId::new("old-2"), 2, test_context());
844
845        // With zero max_age, all entries are stale
846        let cleared = map.clear_stale(Duration::ZERO);
847        assert_eq!(cleared, 2);
848        assert!(map.is_empty());
849
850        // Register new orders
851        map.register(ClientOrderId::new("new-1"), 10, test_context());
852        map.register(ClientOrderId::new("new-2"), 20, test_context());
853
854        // With large max_age, nothing is stale
855        let cleared = map.clear_stale(Duration::from_secs(3600));
856        assert_eq!(cleared, 0);
857        assert_eq!(map.len(), 2);
858    }
859
860    #[test]
861    fn test_pending_cancels_insert_remove() {
862        let cancels = PendingCancels::new();
863        assert!(cancels.is_empty());
864
865        // Insert a cancel request
866        cancels.insert(42);
867        assert_eq!(cancels.len(), 1);
868        assert!(!cancels.is_empty());
869
870        // Remove returns true for tracked ID
871        assert!(cancels.remove(42));
872        assert!(cancels.is_empty());
873
874        // Remove returns false for untracked ID
875        assert!(!cancels.remove(42));
876        assert!(!cancels.remove(999));
877    }
878
879    #[test]
880    fn test_pending_cancels_multiple() {
881        let cancels = PendingCancels::new();
882
883        cancels.insert(1);
884        cancels.insert(2);
885        cancels.insert(3);
886        assert_eq!(cancels.len(), 3);
887
888        // Remove middle one
889        assert!(cancels.remove(2));
890        assert_eq!(cancels.len(), 2);
891
892        // Other IDs still tracked
893        assert!(cancels.remove(1));
894        assert!(cancels.remove(3));
895        assert!(cancels.is_empty());
896    }
897
898    #[test]
899    fn test_pending_cancels_clear_stale() {
900        use std::time::Duration;
901
902        let cancels = PendingCancels::new();
903
904        cancels.insert(1);
905        cancels.insert(2);
906
907        // With zero max_age, all entries are stale
908        let cleared = cancels.clear_stale(Duration::ZERO);
909        assert_eq!(cleared, 2);
910        assert!(cancels.is_empty());
911
912        // Insert new ones
913        cancels.insert(10);
914        cancels.insert(20);
915
916        // With large max_age, nothing is stale
917        let cleared = cancels.clear_stale(Duration::from_secs(3600));
918        assert_eq!(cleared, 0);
919        assert_eq!(cancels.len(), 2);
920    }
921
922    #[test]
923    fn test_pending_cancels_duplicate_insert() {
924        let cancels = PendingCancels::new();
925
926        cancels.insert(42);
927        cancels.insert(42);
928
929        // HashMap deduplicates by ID; second insert updates timestamp
930        assert_eq!(cancels.len(), 1);
931        assert!(cancels.remove(42));
932        assert!(cancels.is_empty());
933    }
934
935    #[test]
936    fn test_build_stop_order() {
937        let order = build_ib_order(
938            rustrade_instrument::Side::Sell,
939            100.0,
940            &OrderKind::Stop {
941                trigger_price: Decimal::from(45),
942            },
943            None, // Stop orders have no limit price
944            &TimeInForce::GoodUntilCancelled { post_only: false },
945        )
946        .unwrap();
947
948        assert_eq!(order.action, Action::Sell);
949        assert_eq!(order.total_quantity, 100.0);
950        assert_eq!(order.order_type, "STP");
951        assert_eq!(order.aux_price, Some(45.0));
952        assert_eq!(order.limit_price, None);
953    }
954
955    #[test]
956    fn test_build_stop_limit_order() {
957        let order = build_ib_order(
958            rustrade_instrument::Side::Sell,
959            100.0,
960            &OrderKind::StopLimit {
961                trigger_price: Decimal::from(44),
962            },
963            Some(Decimal::from(45)), // limit price
964            &TimeInForce::GoodUntilCancelled { post_only: false },
965        )
966        .unwrap();
967
968        assert_eq!(order.action, Action::Sell);
969        assert_eq!(order.total_quantity, 100.0);
970        assert_eq!(order.order_type, "STP LMT");
971        assert_eq!(order.aux_price, Some(44.0)); // trigger/stop price
972        assert_eq!(order.limit_price, Some(45.0)); // limit price
973    }
974
975    #[test]
976    fn test_build_trailing_stop_percentage() {
977        let order = build_ib_order(
978            rustrade_instrument::Side::Sell,
979            100.0,
980            &OrderKind::TrailingStop {
981                offset: Decimal::from(5),
982                offset_type: TrailingOffsetType::Percentage,
983            },
984            None, // Trailing stop orders have no limit price
985            &TimeInForce::GoodUntilCancelled { post_only: false },
986        )
987        .unwrap();
988
989        assert_eq!(order.action, Action::Sell);
990        assert_eq!(order.total_quantity, 100.0);
991        assert_eq!(order.order_type, "TRAIL");
992        assert_eq!(order.trailing_percent, Some(5.0));
993        assert_eq!(order.aux_price, None); // percentage uses trailing_percent, not aux_price
994        assert_eq!(order.trail_stop_price, None); // let IB derive from market
995    }
996
997    #[test]
998    fn test_build_trailing_stop_absolute() {
999        let order = build_ib_order(
1000            rustrade_instrument::Side::Sell,
1001            100.0,
1002            &OrderKind::TrailingStop {
1003                offset: Decimal::from(2),
1004                offset_type: TrailingOffsetType::Absolute,
1005            },
1006            None, // Trailing stop orders have no limit price
1007            &TimeInForce::GoodUntilCancelled { post_only: false },
1008        )
1009        .unwrap();
1010
1011        assert_eq!(order.action, Action::Sell);
1012        assert_eq!(order.total_quantity, 100.0);
1013        assert_eq!(order.order_type, "TRAIL");
1014        assert_eq!(order.aux_price, Some(2.0)); // absolute uses aux_price
1015        assert_eq!(order.trailing_percent, None);
1016        assert_eq!(order.trail_stop_price, None);
1017    }
1018
1019    #[test]
1020    fn test_build_trailing_stop_limit_absolute() {
1021        let order = build_ib_order(
1022            rustrade_instrument::Side::Sell,
1023            100.0,
1024            &OrderKind::TrailingStopLimit {
1025                offset: Decimal::from(2),
1026                offset_type: TrailingOffsetType::Absolute,
1027                limit_offset: Decimal::try_from(0.5).unwrap(),
1028            },
1029            None, // Trailing stop limit uses limit_offset, not a fixed price
1030            &TimeInForce::GoodUntilCancelled { post_only: false },
1031        )
1032        .unwrap();
1033
1034        assert_eq!(order.action, Action::Sell);
1035        assert_eq!(order.total_quantity, 100.0);
1036        assert_eq!(order.order_type, "TRAIL LIMIT");
1037        assert_eq!(order.aux_price, Some(2.0)); // trailing amount
1038        assert_eq!(order.limit_price_offset, Some(0.5)); // limit offset from stop
1039        assert_eq!(order.trailing_percent, None);
1040    }
1041
1042    #[test]
1043    fn test_build_trailing_stop_limit_percentage() {
1044        let order = build_ib_order(
1045            rustrade_instrument::Side::Sell,
1046            100.0,
1047            &OrderKind::TrailingStopLimit {
1048                offset: Decimal::from(5),
1049                offset_type: TrailingOffsetType::Percentage,
1050                limit_offset: Decimal::try_from(0.5).unwrap(),
1051            },
1052            None, // Trailing stop limit uses limit_offset, not a fixed price
1053            &TimeInForce::GoodUntilCancelled { post_only: false },
1054        )
1055        .unwrap();
1056
1057        assert_eq!(order.action, Action::Sell);
1058        assert_eq!(order.total_quantity, 100.0);
1059        assert_eq!(order.order_type, "TRAIL LIMIT");
1060        assert_eq!(order.trailing_percent, Some(5.0));
1061        assert_eq!(order.limit_price_offset, Some(0.5));
1062        assert_eq!(order.aux_price, None); // percentage doesn't use aux_price
1063    }
1064
1065    #[test]
1066    fn test_build_trailing_stop_basis_points_unsupported() {
1067        let result = build_ib_order(
1068            rustrade_instrument::Side::Sell,
1069            100.0,
1070            &OrderKind::TrailingStop {
1071                offset: Decimal::from(50), // 50 basis points
1072                offset_type: TrailingOffsetType::BasisPoints,
1073            },
1074            None, // Trailing stop orders have no limit price
1075            &TimeInForce::GoodUntilCancelled { post_only: false },
1076        );
1077
1078        assert!(matches!(
1079            result,
1080            Err(OrderMappingError::UnsupportedOffsetType(
1081                TrailingOffsetType::BasisPoints
1082            ))
1083        ));
1084    }
1085
1086    #[test]
1087    fn test_build_trailing_stop_limit_basis_points_unsupported() {
1088        let result = build_ib_order(
1089            rustrade_instrument::Side::Sell,
1090            100.0,
1091            &OrderKind::TrailingStopLimit {
1092                offset: Decimal::from(50),
1093                offset_type: TrailingOffsetType::BasisPoints,
1094                limit_offset: Decimal::try_from(0.5).unwrap(),
1095            },
1096            None, // Trailing stop limit uses limit_offset, not a fixed price
1097            &TimeInForce::GoodUntilCancelled { post_only: false },
1098        );
1099
1100        assert!(matches!(
1101            result,
1102            Err(OrderMappingError::UnsupportedOffsetType(
1103                TrailingOffsetType::BasisPoints
1104            ))
1105        ));
1106    }
1107
1108    #[test]
1109    fn test_build_take_profit_unsupported() {
1110        let result = build_ib_order(
1111            rustrade_instrument::Side::Sell,
1112            100.0,
1113            &OrderKind::TakeProfit {
1114                trigger_price: Decimal::from(150),
1115            },
1116            None,
1117            &TimeInForce::GoodUntilCancelled { post_only: false },
1118        );
1119
1120        assert!(matches!(
1121            result,
1122            Err(OrderMappingError::UnsupportedOrderKind(
1123                OrderKind::TakeProfit { .. }
1124            ))
1125        ));
1126    }
1127
1128    #[test]
1129    fn test_build_take_profit_limit_unsupported() {
1130        let result = build_ib_order(
1131            rustrade_instrument::Side::Sell,
1132            100.0,
1133            &OrderKind::TakeProfitLimit {
1134                trigger_price: Decimal::from(150),
1135            },
1136            None,
1137            &TimeInForce::GoodUntilCancelled { post_only: false },
1138        );
1139
1140        assert!(matches!(
1141            result,
1142            Err(OrderMappingError::UnsupportedOrderKind(
1143                OrderKind::TakeProfitLimit { .. }
1144            ))
1145        ));
1146    }
1147
1148    // =========================================================================
1149    // Extended Time-in-Force Tests (Phase 6)
1150    // =========================================================================
1151
1152    #[test]
1153    fn test_build_market_order_at_open() {
1154        let order = build_ib_order(
1155            rustrade_instrument::Side::Buy,
1156            100.0,
1157            &OrderKind::Market,
1158            None, // Market orders have no limit price
1159            &TimeInForce::AtOpen,
1160        )
1161        .unwrap();
1162
1163        assert_eq!(order.action, Action::Buy);
1164        assert_eq!(order.total_quantity, 100.0);
1165        assert_eq!(order.order_type, "MKT");
1166        assert_eq!(order.tif, IbTimeInForce::OnOpen);
1167    }
1168
1169    #[test]
1170    fn test_build_limit_order_at_open() {
1171        let order = build_ib_order(
1172            rustrade_instrument::Side::Sell,
1173            50.0,
1174            &OrderKind::Limit,
1175            Some(Decimal::from(150)),
1176            &TimeInForce::AtOpen,
1177        )
1178        .unwrap();
1179
1180        assert_eq!(order.action, Action::Sell);
1181        assert_eq!(order.total_quantity, 50.0);
1182        assert_eq!(order.order_type, "LMT");
1183        assert_eq!(order.limit_price, Some(150.0));
1184        assert_eq!(order.tif, IbTimeInForce::OnOpen);
1185    }
1186
1187    #[test]
1188    fn test_build_stop_order_at_open() {
1189        // Stop orders can use AtOpen TIF
1190        let order = build_ib_order(
1191            rustrade_instrument::Side::Sell,
1192            100.0,
1193            &OrderKind::Stop {
1194                trigger_price: Decimal::from(45),
1195            },
1196            None, // Stop orders have no limit price
1197            &TimeInForce::AtOpen,
1198        )
1199        .unwrap();
1200
1201        assert_eq!(order.order_type, "STP");
1202        assert_eq!(order.aux_price, Some(45.0));
1203        assert_eq!(order.tif, IbTimeInForce::OnOpen);
1204    }
1205
1206    #[test]
1207    fn test_build_market_on_close() {
1208        let order = build_ib_order(
1209            rustrade_instrument::Side::Buy,
1210            100.0,
1211            &OrderKind::Market,
1212            None, // Market orders have no limit price
1213            &TimeInForce::AtClose,
1214        )
1215        .unwrap();
1216
1217        assert_eq!(order.action, Action::Buy);
1218        assert_eq!(order.total_quantity, 100.0);
1219        assert_eq!(order.order_type, "MOC");
1220    }
1221
1222    #[test]
1223    fn test_build_limit_on_close() {
1224        let order = build_ib_order(
1225            rustrade_instrument::Side::Sell,
1226            50.0,
1227            &OrderKind::Limit,
1228            Some(Decimal::from(150)),
1229            &TimeInForce::AtClose,
1230        )
1231        .unwrap();
1232
1233        assert_eq!(order.action, Action::Sell);
1234        assert_eq!(order.total_quantity, 50.0);
1235        assert_eq!(order.order_type, "LOC");
1236        assert_eq!(order.limit_price, Some(150.0));
1237    }
1238
1239    #[test]
1240    fn test_build_stop_at_close_unsupported() {
1241        let result = build_ib_order(
1242            rustrade_instrument::Side::Sell,
1243            100.0,
1244            &OrderKind::Stop {
1245                trigger_price: Decimal::from(45),
1246            },
1247            None, // Stop orders have no limit price
1248            &TimeInForce::AtClose,
1249        );
1250
1251        assert!(matches!(
1252            result,
1253            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
1254                OrderKind::Stop { .. }
1255            ))
1256        ));
1257    }
1258
1259    #[test]
1260    fn test_build_stop_limit_at_close_unsupported() {
1261        let result = build_ib_order(
1262            rustrade_instrument::Side::Sell,
1263            100.0,
1264            &OrderKind::StopLimit {
1265                trigger_price: Decimal::from(44),
1266            },
1267            Some(Decimal::from(45)),
1268            &TimeInForce::AtClose,
1269        );
1270
1271        assert!(matches!(
1272            result,
1273            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
1274                OrderKind::StopLimit { .. }
1275            ))
1276        ));
1277    }
1278
1279    #[test]
1280    fn test_build_trailing_stop_at_close_unsupported() {
1281        let result = build_ib_order(
1282            rustrade_instrument::Side::Sell,
1283            100.0,
1284            &OrderKind::TrailingStop {
1285                offset: Decimal::from(5),
1286                offset_type: TrailingOffsetType::Percentage,
1287            },
1288            None, // Trailing stop orders have no limit price
1289            &TimeInForce::AtClose,
1290        );
1291
1292        assert!(matches!(
1293            result,
1294            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
1295                OrderKind::TrailingStop { .. }
1296            ))
1297        ));
1298    }
1299
1300    #[test]
1301    fn test_build_trailing_stop_limit_at_close_unsupported() {
1302        let result = build_ib_order(
1303            rustrade_instrument::Side::Sell,
1304            100.0,
1305            &OrderKind::TrailingStopLimit {
1306                offset: Decimal::from(5),
1307                offset_type: TrailingOffsetType::Absolute,
1308                limit_offset: Decimal::from(1),
1309            },
1310            None, // Trailing stop limit uses limit_offset, not a fixed price
1311            &TimeInForce::AtClose,
1312        );
1313
1314        assert!(matches!(
1315            result,
1316            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
1317                OrderKind::TrailingStopLimit { .. }
1318            ))
1319        ));
1320    }
1321
1322    #[test]
1323    fn test_build_good_till_date_order() {
1324        use chrono::{TimeZone, Utc};
1325
1326        let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
1327        let order = build_ib_order(
1328            rustrade_instrument::Side::Buy,
1329            100.0,
1330            &OrderKind::Limit,
1331            Some(Decimal::from(150)),
1332            &TimeInForce::GoodTillDate { expiry },
1333        )
1334        .unwrap();
1335
1336        assert_eq!(order.action, Action::Buy);
1337        assert_eq!(order.total_quantity, 100.0);
1338        assert_eq!(order.order_type, "LMT");
1339        assert_eq!(order.tif, IbTimeInForce::GoodTilDate);
1340        assert_eq!(order.good_till_date, "20250630-23:59:59");
1341    }
1342
1343    #[test]
1344    fn test_format_gtd_datetime() {
1345        use chrono::{TimeZone, Utc};
1346
1347        let dt = Utc.with_ymd_and_hms(2024, 12, 25, 14, 30, 0).unwrap();
1348        assert_eq!(format_gtd_datetime(&dt), "20241225-14:30:00");
1349
1350        let dt2 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
1351        assert_eq!(format_gtd_datetime(&dt2), "20250101-00:00:00");
1352    }
1353
1354    // =========================================================================
1355    // Bracket Order Tests (Phase 3)
1356    // =========================================================================
1357
1358    #[test]
1359    fn test_build_ib_bracket_with_oca_sets_oca_fields() {
1360        let orders = build_ib_bracket_with_oca(
1361            1000,
1362            Action::Buy,
1363            10.0,
1364            150.0,
1365            160.0,
1366            140.0,
1367            IbTimeInForce::Day,
1368        );
1369
1370        assert_eq!(orders.len(), 3);
1371
1372        // OCA group must be non-empty on both child orders
1373        assert!(!orders[1].oca_group.is_empty());
1374        assert_eq!(orders[1].oca_group, orders[2].oca_group);
1375
1376        // Both children must use CancelWithBlock
1377        assert_eq!(orders[1].oca_type, OcaType::CancelWithBlock);
1378        assert_eq!(orders[2].oca_type, OcaType::CancelWithBlock);
1379
1380        // Parent must NOT be in OCA group (only children are linked)
1381        assert!(orders[0].oca_group.is_empty());
1382        assert_eq!(orders[0].oca_type, OcaType::None);
1383    }
1384
1385    #[test]
1386    fn test_build_ib_bracket_with_oca_parent_id_linkage() {
1387        let orders = build_ib_bracket_with_oca(
1388            1000,
1389            Action::Buy,
1390            10.0,
1391            150.0,
1392            160.0,
1393            140.0,
1394            IbTimeInForce::Day,
1395        );
1396
1397        // Children must reference parent via parent_id
1398        assert_eq!(orders[1].parent_id, 1000); // TP waits for parent fill
1399        assert_eq!(orders[2].parent_id, 1000); // SL waits for parent fill
1400
1401        // Parent has no parent
1402        assert_eq!(orders[0].parent_id, 0);
1403    }
1404
1405    #[test]
1406    fn test_build_ib_bracket_with_oca_transmit_flags() {
1407        let orders = build_ib_bracket_with_oca(
1408            1000,
1409            Action::Buy,
1410            10.0,
1411            150.0,
1412            160.0,
1413            140.0,
1414            IbTimeInForce::Day,
1415        );
1416
1417        // Only the last order triggers transmission of all three
1418        assert!(!orders[0].transmit); // Parent: don't transmit yet
1419        assert!(!orders[1].transmit); // TP: don't transmit yet
1420        assert!(orders[2].transmit); // SL: transmit all
1421    }
1422
1423    #[test]
1424    fn test_build_ib_bracket_with_oca_order_ids_are_consecutive() {
1425        let orders = build_ib_bracket_with_oca(
1426            500,
1427            Action::Sell,
1428            5.0,
1429            100.0,
1430            90.0,
1431            110.0,
1432            IbTimeInForce::Day,
1433        );
1434
1435        assert_eq!(orders[0].order_id, 500); // Parent
1436        assert_eq!(orders[1].order_id, 501); // Take profit
1437        assert_eq!(orders[2].order_id, 502); // Stop loss
1438    }
1439
1440    #[test]
1441    fn test_build_ib_bracket_with_oca_group_name_contains_parent_id() {
1442        let orders =
1443            build_ib_bracket_with_oca(42, Action::Buy, 1.0, 10.0, 12.0, 8.0, IbTimeInForce::Day);
1444
1445        assert!(orders[1].oca_group.contains("42"));
1446        assert!(orders[2].oca_group.contains("42"));
1447    }
1448
1449    #[test]
1450    fn test_build_ib_bracket_with_oca_order_types() {
1451        let orders = build_ib_bracket_with_oca(
1452            1000,
1453            Action::Buy,
1454            100.0,
1455            150.0,
1456            160.0,
1457            140.0,
1458            IbTimeInForce::Day,
1459        );
1460
1461        // Parent is limit order
1462        assert_eq!(orders[0].order_type, "LMT");
1463        assert_eq!(orders[0].limit_price, Some(150.0));
1464
1465        // Take profit is limit order
1466        assert_eq!(orders[1].order_type, "LMT");
1467        assert_eq!(orders[1].limit_price, Some(160.0));
1468
1469        // Stop loss is stop order
1470        assert_eq!(orders[2].order_type, "STP");
1471        assert_eq!(orders[2].aux_price, Some(140.0));
1472    }
1473
1474    #[test]
1475    fn test_build_ib_bracket_with_oca_actions_reversed_for_children() {
1476        // Buy bracket: entry=Buy, exits=Sell
1477        let buy_orders =
1478            build_ib_bracket_with_oca(100, Action::Buy, 10.0, 50.0, 55.0, 45.0, IbTimeInForce::Day);
1479        assert_eq!(buy_orders[0].action, Action::Buy);
1480        assert_eq!(buy_orders[1].action, Action::Sell);
1481        assert_eq!(buy_orders[2].action, Action::Sell);
1482
1483        // Sell bracket: entry=Sell, exits=Buy
1484        let sell_orders = build_ib_bracket_with_oca(
1485            200,
1486            Action::Sell,
1487            10.0,
1488            50.0,
1489            45.0,
1490            55.0,
1491            IbTimeInForce::Day,
1492        );
1493        assert_eq!(sell_orders[0].action, Action::Sell);
1494        assert_eq!(sell_orders[1].action, Action::Buy);
1495        assert_eq!(sell_orders[2].action, Action::Buy);
1496    }
1497
1498    #[test]
1499    fn test_build_ib_bracket_with_oca_applies_tif_to_all_legs() {
1500        let orders = build_ib_bracket_with_oca(
1501            1000,
1502            Action::Buy,
1503            10.0,
1504            150.0,
1505            160.0,
1506            140.0,
1507            IbTimeInForce::GoodTilCanceled,
1508        );
1509
1510        assert_eq!(orders[0].tif, IbTimeInForce::GoodTilCanceled);
1511        assert_eq!(orders[1].tif, IbTimeInForce::GoodTilCanceled);
1512        assert_eq!(orders[2].tif, IbTimeInForce::GoodTilCanceled);
1513    }
1514}