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, FnvHashSet};
7use ibapi::orders::{
8    Action, OcaType, Order, TimeInForce as IbTimeInForce, builder::BracketPrices, order_builder,
9};
10use parking_lot::{Mutex, RwLock};
11use rust_decimal::Decimal;
12use rustrade_instrument::{Side, exchange::ExchangeId, instrument::name::InstrumentNameExchange};
13use std::{sync::Arc, time::Instant};
14use thiserror::Error;
15
16// ============================================================================
17// Bracket Order Types
18// ============================================================================
19
20/// Request to place a bracket order (entry + take-profit + stop-loss).
21///
22/// A bracket order consists of three linked orders:
23/// 1. **Entry**: Limit order to enter the position
24/// 2. **Take Profit**: Limit order to exit at profit target
25/// 3. **Stop Loss**: Stop order to exit at loss limit
26///
27/// The entry order is submitted with `transmit=false`, holding all orders until
28/// the stop-loss (with `transmit=true`) triggers atomic submission of all three.
29///
30/// Take-profit and stop-loss are linked via OCA (One-Cancels-All) group, so when
31/// one fills, IB automatically cancels the other.
32#[derive(Debug, Clone)]
33pub struct BracketOrderRequest {
34    /// Instrument to trade.
35    pub instrument: InstrumentNameExchange,
36    /// Strategy identifier for order correlation.
37    pub strategy: StrategyId,
38    /// Client order ID for the parent (entry) order.
39    /// Child orders will have IDs derived from this (parent_cid + "_tp", parent_cid + "_sl").
40    pub parent_cid: ClientOrderId,
41    /// Buy or Sell for the entry order (exits use opposite side).
42    pub side: Side,
43    /// Number of shares/contracts.
44    pub quantity: Decimal,
45    /// Entry limit price.
46    pub entry_price: Decimal,
47    /// Take profit limit price.
48    pub take_profit_price: Decimal,
49    /// Stop loss trigger price.
50    pub stop_loss_price: Decimal,
51    /// Time-in-force for all three orders.
52    pub time_in_force: TimeInForce,
53}
54
55/// Result of placing a bracket order.
56///
57/// Contains the three orders with their states. Use the `ClientOrderId` from each
58/// order's key to cancel individual legs or correlate stream events.
59///
60/// # Invariant
61///
62/// Either all three orders are `Active(Open)` or all three are `Inactive`, except
63/// that a leg of a failed placement whose fate at TWS is unknown is `Active(Open)`.
64/// See [`IbkrClient::open_bracket_order`](super::IbkrClient::open_bracket_order).
65#[derive(Debug, Clone)]
66pub struct BracketOrderResult {
67    /// Parent (entry) order.
68    pub parent: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
69    /// Take profit order (opposite side, limit).
70    pub take_profit: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
71    /// Stop loss order (opposite side, stop).
72    pub stop_loss: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
73}
74
75// ============================================================================
76// Order Context
77// ============================================================================
78
79/// Order context stored when placing orders.
80///
81/// IB's `OrderStatus` callback doesn't include instrument/side/price/kind,
82/// so we store this context at order placement time to reconstruct full
83/// `Order` structs from status updates.
84///
85/// # Invariant
86///
87/// This data is immutable after order placement. IB doesn't support in-flight
88/// order modification — amendments are cancel+replace (new order ID, new entry).
89#[derive(Debug, Clone)]
90pub struct OrderContext {
91    pub instrument: InstrumentNameExchange,
92    pub side: Side,
93    /// Limit price. `None` for Market/Stop/TrailingStop orders.
94    pub price: Option<Decimal>,
95    pub quantity: Decimal,
96    pub kind: OrderKind,
97    pub time_in_force: TimeInForce,
98}
99
100/// Bidirectional mapping between rustrade ClientOrderId and IB order IDs.
101///
102/// IB uses `i32` order IDs from a sequence. Barter uses `ClientOrderId` (SmolStr).
103/// This map maintains the bidirectional relationship and stores order context
104/// for reconstructing full `Order` structs from `OrderStatus` callbacks.
105///
106/// # A client order id names one live order at a time
107///
108/// TWS receives a [`ClientOrderId`] only as the order's reference ([`order_ref`]), whose uniqueness
109/// it does not check, so this map is the only place it can be kept.
110/// An id is held from [`register`](Self::register) until its order ends, and registering an id
111/// already held is refused with [`ClientOrderIdInUse`]. Once its order has ended the id may name a
112/// new order.
113///
114/// An order ends here when the account stream reports it `Filled`, `Cancelled` or `Inactive`, or
115/// when [`clear_stale`](Self::clear_stale) reaps it. A `Filled` order releases its id
116/// ([`release_client_id`](Self::release_client_id)) but keeps its IB-id entry, so the executions
117/// and commissions IB may still send for it resolve to the id it was placed under. That entry is
118/// reaped by `clear_stale`.
119///
120/// Every removal is conditional on the id still naming the order removed, so an earlier order
121/// under a reused id cannot take the mapping of the order now under it. Its late executions and
122/// commissions still resolve to the id, carrying its own IB order ID, while a status IB re-sends
123/// for it is dropped by the account stream ([`names_other_order`](Self::names_other_order)).
124#[derive(Debug, Clone)]
125pub struct OrderIdMap {
126    inner: Arc<RwLock<OrderIdMapInner>>,
127}
128
129/// When and how an order came to be tracked by an [`OrderIdMap`].
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub(crate) struct Registration {
132    /// How long ago it was registered or adopted.
133    pub(crate) age: std::time::Duration,
134    /// Whether it was adopted from a listing rather than placed through this client.
135    pub(crate) adopted: bool,
136}
137
138/// Why [`OrderIdMap::adopt`] refused to track a listed order.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
140pub(crate) enum AdoptionRefused {
141    #[error("its client order id names another live order")]
142    ClientOrderIdInUse,
143    #[error("its IB order id names another tracked order")]
144    IbOrderIdTracked,
145}
146
147/// [`OrderIdMap::register`] refused an id because a live order already holds it.
148#[derive(Debug, Clone, PartialEq, Eq, Error)]
149#[error("client order id {0} already names a live IBKR order")]
150pub struct ClientOrderIdInUse(pub ClientOrderId);
151
152#[derive(Debug, Default)]
153struct OrderIdMapInner {
154    /// The live orders: each id held by an order that has not ended.
155    cid_to_ib: FnvHashMap<ClientOrderId, i32>,
156    /// Merged map: IB order ID → (ClientOrderId, OrderContext, registration time) for single-lookup on hot path.
157    /// The Instant tracks when the order was registered, enabling age-based cleanup.
158    ///
159    /// Holds the live orders and the filled ones not yet reaped, which no longer hold their id.
160    ib_to_entry: FnvHashMap<i32, (ClientOrderId, OrderContext, Instant)>,
161    /// The IB order ids of the entries adopted from a listing ([`OrderIdMap::adopt`]) rather than
162    /// placed through this client, whose registration time says nothing of when they were placed.
163    adopted: FnvHashSet<i32>,
164}
165
166impl OrderIdMapInner {
167    /// Removes `ib_id`'s entry, and its id's mapping if that still names `ib_id`.
168    fn remove(&mut self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
169        let (client_id, ctx, _) = self.ib_to_entry.remove(&ib_id)?;
170        self.adopted.remove(&ib_id);
171        self.release(&client_id, ib_id);
172        Some((client_id, ctx))
173    }
174
175    /// Whether `client_id` is held by a live order, or named twice among `orders`: the refusal
176    /// [`OrderIdMap::register_all`] and [`OrderIdMap::adopt`] make.
177    fn in_use<'a>(
178        &self,
179        mut orders: impl Iterator<Item = &'a ClientOrderId>,
180        client_id: &ClientOrderId,
181    ) -> bool {
182        orders.any(|earlier| earlier == client_id) || self.cid_to_ib.contains_key(client_id)
183    }
184
185    fn insert(
186        &mut self,
187        client_id: ClientOrderId,
188        ib_id: i32,
189        context: OrderContext,
190        now: Instant,
191    ) {
192        self.adopted.remove(&ib_id);
193        self.cid_to_ib.insert(client_id.clone(), ib_id);
194        self.ib_to_entry.insert(ib_id, (client_id, context, now));
195    }
196
197    /// Frees `client_id` if it still names `ib_id`, rather than a later order under the same id.
198    fn release(&mut self, client_id: &ClientOrderId, ib_id: i32) {
199        if self.cid_to_ib.get(client_id) == Some(&ib_id) {
200            self.cid_to_ib.remove(client_id);
201        }
202    }
203}
204
205impl OrderIdMap {
206    pub fn new() -> Self {
207        Self {
208            inner: Arc::new(RwLock::new(OrderIdMapInner::default())),
209        }
210    }
211
212    /// Register a mapping between ClientOrderId and IB order ID with order context.
213    ///
214    /// # Errors
215    /// [`ClientOrderIdInUse`] if a live order already holds `client_id`. Nothing is registered.
216    pub fn register(
217        &self,
218        client_id: ClientOrderId,
219        ib_id: i32,
220        context: OrderContext,
221    ) -> Result<(), ClientOrderIdInUse> {
222        self.register_all([(client_id, ib_id, context)])
223    }
224
225    /// Register several orders at once, such as the legs of a bracket: all of them or none.
226    ///
227    /// # Errors
228    /// [`ClientOrderIdInUse`], naming the first id at fault, if a live order already holds one of
229    /// the ids or two of `orders` share one. Nothing is registered.
230    pub fn register_all<const N: usize>(
231        &self,
232        orders: [(ClientOrderId, i32, OrderContext); N],
233    ) -> Result<(), ClientOrderIdInUse> {
234        let mut inner = self.inner.write();
235        for (index, (client_id, _, _)) in orders.iter().enumerate() {
236            if inner.in_use(
237                orders[..index].iter().map(|(earlier, _, _)| earlier),
238                client_id,
239            ) {
240                return Err(ClientOrderIdInUse(client_id.clone()));
241            }
242        }
243        let now = Instant::now();
244        for (client_id, ib_id, context) in orders {
245            inner.insert(client_id, ib_id, context, now);
246        }
247        Ok(())
248    }
249
250    /// Track an order this client did not place, found in IB's listing under `client_id`, the id
251    /// its order reference carries: one placed before a restart, say. Its
252    /// [`registration`](Self::registration) says it was adopted.
253    ///
254    /// # Errors
255    /// [`AdoptionRefused`] if a live order already holds `client_id`, or `ib_id` already names
256    /// an order here. Nothing is tracked.
257    pub(crate) fn adopt(
258        &self,
259        client_id: ClientOrderId,
260        ib_id: i32,
261        context: OrderContext,
262    ) -> Result<(), AdoptionRefused> {
263        let mut inner = self.inner.write();
264        if inner.ib_to_entry.contains_key(&ib_id) {
265            return Err(AdoptionRefused::IbOrderIdTracked);
266        }
267        if inner.in_use(std::iter::empty(), &client_id) {
268            return Err(AdoptionRefused::ClientOrderIdInUse);
269        }
270        inner.insert(client_id, ib_id, context, Instant::now());
271        inner.adopted.insert(ib_id);
272        Ok(())
273    }
274
275    /// Look up the IB order ID of the live order under `client_id`.
276    pub fn get_ib_id(&self, client_id: &ClientOrderId) -> Option<i32> {
277        self.inner.read().cid_to_ib.get(client_id).copied()
278    }
279
280    /// How long ago the live order under `client_id` came to be tracked here, and whether it was
281    /// [adopted](Self::adopt) from a listing rather than placed through this client. A placed
282    /// order is registered just before it is sent, so its age is how long ago it was placed; an
283    /// adopted one's says only that it was open by then.
284    ///
285    /// `None` when no live order holds `client_id`.
286    pub(crate) fn registration(&self, client_id: &ClientOrderId) -> Option<Registration> {
287        let inner = self.inner.read();
288        let ib_id = inner.cid_to_ib.get(client_id)?;
289        let (_, _, registered_at) = inner.ib_to_entry.get(ib_id)?;
290        Some(Registration {
291            age: registered_at.elapsed(),
292            adopted: inner.adopted.contains(ib_id),
293        })
294    }
295
296    /// Whether IB order `ib_id` has an entry: a live order, or a filled one not yet reaped.
297    pub fn contains(&self, ib_id: i32) -> bool {
298        self.inner.read().ib_to_entry.contains_key(&ib_id)
299    }
300
301    /// Look up ClientOrderId by IB order ID.
302    pub fn get_client_id(&self, ib_id: i32) -> Option<ClientOrderId> {
303        self.inner
304            .read()
305            .ib_to_entry
306            .get(&ib_id)
307            .map(|(cid, _, _)| cid.clone())
308    }
309
310    /// Look up ClientOrderId and OrderContext together by IB order ID (single lookup).
311    pub fn get_client_id_and_context(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
312        self.inner
313            .read()
314            .ib_to_entry
315            .get(&ib_id)
316            .map(|(cid, ctx, _)| (cid.clone(), ctx.clone()))
317    }
318
319    /// Whether `client_id` now names a live order other than `ib_id`: the id was freed when `ib_id`
320    /// ended and has since been registered again.
321    pub fn names_other_order(&self, client_id: &ClientOrderId, ib_id: i32) -> bool {
322        self.inner
323            .read()
324            .cid_to_ib
325            .get(client_id)
326            .is_some_and(|live| *live != ib_id)
327    }
328
329    /// Free the id of the filled order `ib_id`, keeping its entry, and return both, in a single
330    /// write lock acquisition.
331    ///
332    /// Use this for a `Filled` status: the id may then name a new order, while the executions and
333    /// commissions IB may still send for this one resolve to it by IB id.
334    pub fn release_client_id(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
335        let mut inner = self.inner.write();
336        let (client_id, ctx, _) = inner.ib_to_entry.get(&ib_id)?;
337        let (client_id, ctx) = (client_id.clone(), ctx.clone());
338        inner.release(&client_id, ib_id);
339        Some((client_id, ctx))
340    }
341
342    /// Remove mapping and return context in a single write lock acquisition.
343    ///
344    /// Use this for terminal status events (Cancelled/Inactive) to avoid the
345    /// read-then-write pattern of `get_client_id_and_context` + `remove_by_ib_id`.
346    /// The id's mapping is removed only if it still names `ib_id`.
347    pub fn remove_and_get_context(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
348        self.inner.write().remove(ib_id)
349    }
350
351    /// Remove a mapping by IB order ID (used when an order failed or was rolled back).
352    ///
353    /// The id's mapping is removed only if it still names `ib_id`.
354    pub fn remove_by_ib_id(&self, ib_id: i32) -> Option<ClientOrderId> {
355        self.inner
356            .write()
357            .remove(ib_id)
358            .map(|(client_id, _)| client_id)
359    }
360
361    /// Clear order ID mappings older than the given duration.
362    ///
363    /// Returns the number of cleared entries.
364    ///
365    /// # Why This Is Needed
366    ///
367    /// IB does not guarantee event ordering between `OrderStatus("Filled")` and
368    /// `ExecutionData`/`CommissionReport`. For fast-filling orders (especially
369    /// market orders), execution data may arrive AFTER the filled status — or
370    /// the filled status may not arrive at all. So a `Filled` status frees the
371    /// order's client id but keeps its IB-id entry, and an order whose terminal
372    /// status never arrives keeps both.
373    ///
374    /// Call this method periodically to clean up old mappings. A
375    /// reasonable interval is 5-10 minutes with a max_age of 1 hour. An order
376    /// still working past `max_age` is cleared too, freeing its id.
377    pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
378        let mut inner = self.inner.write();
379        let before = inner.ib_to_entry.len();
380
381        // Collect IB IDs to remove (can't mutate while iterating)
382        let stale_ids: Vec<i32> = inner
383            .ib_to_entry
384            .iter()
385            .filter(|(_, (_, _, registered_at))| registered_at.elapsed() >= max_age)
386            .map(|(ib_id, _)| *ib_id)
387            .collect();
388
389        for ib_id in stale_ids {
390            inner.remove(ib_id);
391        }
392
393        before - inner.ib_to_entry.len()
394    }
395
396    /// Number of live orders: those holding a client id.
397    pub fn len(&self) -> usize {
398        self.inner.read().cid_to_ib.len()
399    }
400
401    /// Check if no order is live.
402    pub fn is_empty(&self) -> bool {
403        self.inner.read().cid_to_ib.is_empty()
404    }
405}
406
407impl Default for OrderIdMap {
408    fn default() -> Self {
409        Self::new()
410    }
411}
412
413/// Tracks IB order IDs with pending cancel requests.
414///
415/// Used to differentiate user-initiated cancellation from time-based expiration.
416/// IBKR sends `"Cancelled"` status for both cases; this map tracks which cancels
417/// were user-initiated via `cancel_order()`.
418#[derive(Debug, Clone)]
419pub struct PendingCancels {
420    inner: Arc<Mutex<FnvHashMap<i32, Instant>>>,
421}
422
423impl PendingCancels {
424    pub fn new() -> Self {
425        Self {
426            inner: Arc::new(Mutex::new(FnvHashMap::with_capacity_and_hasher(
427                8,
428                Default::default(),
429            ))),
430        }
431    }
432
433    /// Record a pending cancel request for the given IB order ID.
434    pub fn insert(&self, ib_id: i32) {
435        self.inner.lock().insert(ib_id, Instant::now());
436    }
437
438    /// Check if a cancel was user-initiated and remove from tracking.
439    ///
440    /// Returns `true` if the order ID was in the pending set (user-initiated cancel).
441    #[must_use]
442    pub fn remove(&self, ib_id: i32) -> bool {
443        self.inner.lock().remove(&ib_id).is_some()
444    }
445
446    /// Whether a cancel of IB order `ib_id` was requested through this client and is still
447    /// tracked: not yet seen to end the order, nor cleared as stale.
448    pub(crate) fn contains(&self, ib_id: i32) -> bool {
449        self.inner.lock().contains_key(&ib_id)
450    }
451
452    /// Clear entries older than the given duration.
453    ///
454    /// Returns the number of cleared entries. Call periodically to prevent
455    /// memory leaks from orphaned cancel requests (e.g., network issues).
456    #[must_use]
457    pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
458        let mut map = self.inner.lock();
459        let before = map.len();
460        map.retain(|_, registered_at| registered_at.elapsed() < max_age);
461        before - map.len()
462    }
463
464    /// Number of pending cancel requests being tracked.
465    #[must_use]
466    pub fn len(&self) -> usize {
467        self.inner.lock().len()
468    }
469
470    /// Check if there are no pending cancel requests.
471    #[must_use]
472    pub fn is_empty(&self) -> bool {
473        self.inner.lock().is_empty()
474    }
475}
476
477impl Default for PendingCancels {
478    fn default() -> Self {
479        Self::new()
480    }
481}
482
483/// Convert rustrade Side to IB Action.
484pub(crate) fn side_to_action(side: rustrade_instrument::Side) -> Action {
485    match side {
486        rustrade_instrument::Side::Buy => Action::Buy,
487        rustrade_instrument::Side::Sell => Action::Sell,
488    }
489}
490
491/// Convert an IB Action to a rustrade Side. A short sale is a sell.
492pub(crate) fn action_to_side(action: &Action) -> rustrade_instrument::Side {
493    match action {
494        Action::Buy => rustrade_instrument::Side::Buy,
495        Action::Sell | Action::SellShort | Action::SellLong => rustrade_instrument::Side::Sell,
496    }
497}
498
499/// What kind of order an IB order is, as [`build_ib_order`] would have built it.
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub(crate) struct OrderShape {
502    pub(crate) kind: OrderKind,
503    /// The limit price, for the kinds that have one.
504    pub(crate) price: Option<Decimal>,
505    pub(crate) time_in_force: TimeInForce,
506}
507
508/// Read back the shape of an order IB lists, the inverse of [`build_ib_order`].
509///
510/// For an order this client did not place, or no longer tracks, such as one placed before a
511/// restart. A tracked order's shape is the one it was placed with.
512///
513/// # Errors
514/// A description of what does not map: an order type or time in force [`build_ib_order`] never
515/// sends, a price the type needs that IB did not list, or a good-till-date time that does not
516/// parse.
517pub(crate) fn order_shape_from_ib(order: &Order) -> Result<OrderShape, String> {
518    let order_type = order.order_type.as_str();
519    let decimal = |value: Option<f64>, field: &str| -> Result<Decimal, String> {
520        let value = value.ok_or_else(|| format!("{order_type} order without {field}"))?;
521        Decimal::try_from(value).map_err(|e| format!("{order_type} order's {field} {value}: {e}"))
522    };
523
524    let time_in_force = match &order.tif {
525        IbTimeInForce::Day => TimeInForce::GoodUntilEndOfDay,
526        IbTimeInForce::GoodTillCanceled => TimeInForce::GoodUntilCancelled { post_only: false },
527        IbTimeInForce::ImmediateOrCancel => TimeInForce::ImmediateOrCancel,
528        IbTimeInForce::FillOrKill => TimeInForce::FillOrKill,
529        IbTimeInForce::OnOpen => TimeInForce::AtOpen,
530        IbTimeInForce::GoodTillDate => TimeInForce::GoodTillDate {
531            expiry: parse_gtd_datetime(&order.good_till_date).ok_or_else(|| {
532                format!(
533                    "good-till-date time {:?} does not parse",
534                    order.good_till_date
535                )
536            })?,
537        },
538        other => return Err(format!("time in force {other:?}")),
539    };
540
541    // A trailing order is by percentage when IB lists a percentage, else by an amount in
542    // `aux_price`, as `build_ib_order` sends them.
543    let trailing = || -> Result<(Decimal, TrailingOffsetType), String> {
544        match order.trailing_percent {
545            Some(_) => Ok((
546                decimal(order.trailing_percent, "trailing_percent")?,
547                TrailingOffsetType::Percentage,
548            )),
549            None => Ok((
550                decimal(order.aux_price, "aux_price")?,
551                TrailingOffsetType::Absolute,
552            )),
553        }
554    };
555
556    let (kind, price, time_in_force) = match order_type {
557        "MKT" => (OrderKind::Market, None, time_in_force),
558        "LMT" => (
559            OrderKind::Limit,
560            Some(decimal(order.limit_price, "limit_price")?),
561            time_in_force,
562        ),
563        // At-close orders are typed, not timed, at IB (see `build_at_close_order`).
564        "MOC" => (OrderKind::Market, None, TimeInForce::AtClose),
565        "LOC" => (
566            OrderKind::Limit,
567            Some(decimal(order.limit_price, "limit_price")?),
568            TimeInForce::AtClose,
569        ),
570        "STP" => (
571            OrderKind::Stop {
572                trigger_price: decimal(order.aux_price, "aux_price")?,
573            },
574            None,
575            time_in_force,
576        ),
577        "STP LMT" => (
578            OrderKind::StopLimit {
579                trigger_price: decimal(order.aux_price, "aux_price")?,
580            },
581            Some(decimal(order.limit_price, "limit_price")?),
582            time_in_force,
583        ),
584        "TRAIL" => {
585            let (offset, offset_type) = trailing()?;
586            (
587                OrderKind::TrailingStop {
588                    offset,
589                    offset_type,
590                    activation_price: None,
591                },
592                None,
593                time_in_force,
594            )
595        }
596        "TRAIL LIMIT" => {
597            let (offset, offset_type) = trailing()?;
598            let stop_price = decimal(order.trail_stop_price, "trail_stop_price")?;
599            if stop_price <= Decimal::ZERO {
600                return Err(format!(
601                    "{order_type} order's trail_stop_price {stop_price} is not positive"
602                ));
603            }
604            (
605                OrderKind::TrailingStopLimit {
606                    offset,
607                    offset_type,
608                    stop_price,
609                    limit_offset: decimal(order.limit_price_offset, "limit_price_offset")?,
610                },
611                None,
612                time_in_force,
613            )
614        }
615        other => return Err(format!("order type {other:?}")),
616    };
617
618    Ok(OrderShape {
619        kind,
620        price,
621        time_in_force,
622    })
623}
624
625/// Parse IB's good-till-date field: the form [`format_gtd_datetime`] sends, in UTC, or IB's
626/// `yyyyMMdd HH:mm:ss` with an optional zone.
627fn parse_gtd_datetime(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
628    chrono::NaiveDateTime::parse_from_str(value, GTD_FORMAT)
629        .map(|naive| naive.and_utc())
630        .ok()
631        .or_else(|| super::execution::parse_ib_timestamp(value))
632}
633
634/// Error when mapping rustrade order types to IB.
635#[non_exhaustive]
636#[derive(Debug, Clone, PartialEq, Eq)]
637pub enum OrderMappingError {
638    PostOnlyNotSupported,
639    /// Price conversion to f64 failed (overflow or invalid decimal).
640    InvalidPrice(String),
641    /// Limit price required for this order type but was None.
642    MissingLimitPrice(OrderKind),
643    /// A limit price was given for a kind that derives its own, such as `TrailingStopLimit`,
644    /// whose limit follows from its stop and `limit_offset`.
645    UnexpectedLimitPrice(OrderKind),
646    /// The kind's stop price is zero or negative; IB rejects such an order.
647    NonPositiveStopPrice(OrderKind),
648    /// Trailing offset type not supported by IBKR.
649    UnsupportedOffsetType(TrailingOffsetType),
650    /// Order kind not supported by IBKR (e.g., TakeProfit).
651    UnsupportedOrderKind(OrderKind),
652    /// AtClose TIF only valid with Market or Limit orders (becomes MOC/LOC).
653    /// Stop orders cannot be combined with at-close execution.
654    UnsupportedOrderKindForAtClose(OrderKind),
655    /// AtClose TIF reached `time_in_force_to_ib` directly. AtClose changes the
656    /// order TYPE (MOC/LOC), not just the TIF; callers must route through
657    /// `build_ib_order` which handles the type promotion.
658    AtCloseRequiresOrderTypeChange,
659    /// `ibapi` rejected a bracket's prices: one is not finite, or the take profit or stop loss is
660    /// on the wrong side of the entry (for a buy, the take profit must be above the entry and the
661    /// stop loss below; a sell the reverse).
662    InvalidBracketPrices(String),
663    /// The client order id cannot be sent as the order's IB order reference, which carries it:
664    /// IB takes at most [`MAX_ORDER_REF_LEN`] ASCII characters there.
665    InvalidClientOrderId(ClientOrderId),
666}
667
668impl std::fmt::Display for OrderMappingError {
669    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
670        match self {
671            Self::PostOnlyNotSupported => write!(f, "post_only not supported by IB"),
672            Self::InvalidPrice(p) => write!(f, "invalid price for f64 conversion: {p}"),
673            Self::MissingLimitPrice(k) => {
674                write!(f, "limit price required for {k} but was None")
675            }
676            Self::UnexpectedLimitPrice(k) => write!(
677                f,
678                "{k} takes no limit price: it follows from the stop price and limit offset"
679            ),
680            Self::NonPositiveStopPrice(k) => {
681                write!(f, "stop price must be positive for {k}")
682            }
683            Self::UnsupportedOffsetType(t) => {
684                write!(f, "trailing offset type {t:?} not supported by IBKR")
685            }
686            Self::UnsupportedOrderKind(k) => {
687                write!(f, "order kind {k} not supported by IBKR")
688            }
689            Self::UnsupportedOrderKindForAtClose(k) => {
690                write!(
691                    f,
692                    "AtClose TIF only valid with Market or Limit orders, got {k}"
693                )
694            }
695            Self::AtCloseRequiresOrderTypeChange => write!(
696                f,
697                "AtClose TIF must be routed through build_ib_order, which promotes \
698                 the order to MOC/LOC; time_in_force_to_ib cannot map it directly"
699            ),
700            Self::InvalidBracketPrices(e) => write!(f, "invalid bracket prices: {e}"),
701            Self::InvalidClientOrderId(cid) => write!(
702                f,
703                "client order id {cid} is not an IB order reference: at most \
704                 {MAX_ORDER_REF_LEN} ASCII characters"
705            ),
706        }
707    }
708}
709
710/// The longest order reference IB echoes back whole, verified on paper. The client order id
711/// travels there, so it is limited to this.
712pub const MAX_ORDER_REF_LEN: usize = 128;
713
714/// The IB order reference that carries `cid`: the id itself.
715///
716/// IB echoes the reference on every open-order, completed-order and execution report, so an order
717/// can be named by the id it was placed with even by a client that did not place it.
718///
719/// # Errors
720/// [`OrderMappingError::InvalidClientOrderId`] for an id IB would reject (error 10363 for
721/// non-ASCII text) or not echo whole: empty, longer than [`MAX_ORDER_REF_LEN`], or not ASCII.
722pub fn order_ref(cid: &ClientOrderId) -> Result<String, OrderMappingError> {
723    let id = cid.0.as_str();
724    if id.is_empty() || id.len() > MAX_ORDER_REF_LEN || !id.is_ascii() {
725        return Err(OrderMappingError::InvalidClientOrderId(cid.clone()));
726    }
727    Ok(id.to_owned())
728}
729
730impl std::error::Error for OrderMappingError {}
731
732/// Convert rustrade TimeInForce to IB TimeInForce.
733///
734/// Returns [`OrderMappingError::AtCloseRequiresOrderTypeChange`] for
735/// [`TimeInForce::AtClose`]: AtClose changes the order TYPE (MOC/LOC), not
736/// just the TIF, and is handled by [`build_ib_order`] which promotes the
737/// order type. Callers needing AtClose support should route through
738/// [`build_ib_order`] rather than this helper.
739pub fn time_in_force_to_ib(tif: &TimeInForce) -> Result<IbTimeInForce, OrderMappingError> {
740    match tif {
741        TimeInForce::GoodUntilCancelled { post_only } => {
742            if *post_only {
743                Err(OrderMappingError::PostOnlyNotSupported)
744            } else {
745                Ok(IbTimeInForce::GoodTillCanceled)
746            }
747        }
748        TimeInForce::GoodUntilEndOfDay => Ok(IbTimeInForce::Day),
749        TimeInForce::FillOrKill => Ok(IbTimeInForce::FillOrKill),
750        TimeInForce::ImmediateOrCancel => Ok(IbTimeInForce::ImmediateOrCancel),
751        TimeInForce::GoodTillDate { .. } => Ok(IbTimeInForce::GoodTillDate),
752        TimeInForce::AtOpen => Ok(IbTimeInForce::OnOpen),
753        // AtClose changes the order TYPE to MOC/LOC, not just the TIF.
754        // `build_ib_order` intercepts AtClose; surfacing Err here lets other
755        // callers (e.g. the bracket-order path) reject gracefully.
756        TimeInForce::AtClose => Err(OrderMappingError::AtCloseRequiresOrderTypeChange),
757    }
758}
759
760/// Convert a Decimal to f64, returning an error if conversion fails.
761fn decimal_to_f64(value: rust_decimal::Decimal) -> Result<f64, OrderMappingError> {
762    value.try_into().or_else(|_| {
763        value
764            .to_string()
765            .parse()
766            .map_err(|_| OrderMappingError::InvalidPrice(value.to_string()))
767    })
768}
769
770/// Extract required limit price from Option, returning error if None.
771fn require_limit_price(
772    price: Option<rust_decimal::Decimal>,
773    kind: OrderKind,
774) -> Result<f64, OrderMappingError> {
775    match price {
776        Some(p) => decimal_to_f64(p),
777        None => Err(OrderMappingError::MissingLimitPrice(kind)),
778    }
779}
780
781/// Build an IB Order from rustrade order parameters.
782///
783/// # Order Type Mapping
784///
785/// - `Market` → IB "MKT"
786/// - `Limit` → IB "LMT" with `limit_price`
787/// - `Stop` → IB "STP" with `aux_price` as trigger
788/// - `StopLimit` → IB "STP LMT" with `aux_price` as trigger, `limit_price` from Order.price
789/// - `TrailingStop` (Percentage) → IB "TRAIL" with `trailing_percent`
790/// - `TrailingStop` (Absolute) → IB "TRAIL" with `aux_price`
791/// - `TrailingStopLimit` (Absolute) → IB "TRAIL LIMIT" with `aux_price`, `trail_stop_price`,
792///   `limit_price_offset`
793/// - `TrailingStopLimit` (Percentage) → IB "TRAIL LIMIT" with `trailing_percent`,
794///   `trail_stop_price`, `limit_price_offset`
795/// - `TakeProfit` → `Err(UnsupportedOrderKind)` (IBKR has no native TP; use bracket orders)
796/// - `TakeProfitLimit` → `Err(UnsupportedOrderKind)` (IBKR has no native TP; use bracket orders)
797/// - `TrailingStop` with an `activation_price`, and `TrailingStopFixedLimit` →
798///   `Err(UnsupportedOrderKind)` (IBKR has neither)
799/// - `BasisPoints` offset type → Error (not supported by IBKR)
800///
801/// # Price Requirements
802///
803/// - `Market`, `Stop`, `TrailingStop`: `price` should be `None` (ignored if provided)
804/// - `Limit`, `StopLimit`: `price` must be `Some(limit_price)`
805/// - `TrailingStopLimit`: `price` must be `None` (IB derives the limit from the stop and
806///   `limit_offset`), else `Err(UnexpectedLimitPrice)`; `stop_price` must be positive, else
807///   `Err(NonPositiveStopPrice)`. IB requires the initial stop and rejects a zero one.
808///
809/// # Time-in-Force Special Cases
810///
811/// - `AtClose` + `Market` → IB "MOC" (Market-on-Close)
812/// - `AtClose` + `Limit` → IB "LOC" (Limit-on-Close)
813/// - `AtClose` + Stop/Trailing → Error (not supported by IBKR)
814/// - `GoodTillDate` → Sets both TIF and `good_till_date` field
815pub fn build_ib_order(
816    side: rustrade_instrument::Side,
817    quantity: f64,
818    kind: &OrderKind,
819    price: Option<rust_decimal::Decimal>,
820    tif: &TimeInForce,
821) -> Result<Order, OrderMappingError> {
822    let action = side_to_action(side);
823
824    // Handle AtClose specially - it changes the order TYPE, not just TIF
825    if matches!(tif, TimeInForce::AtClose) {
826        return build_at_close_order(action, quantity, kind, price);
827    }
828
829    let tif_ib = time_in_force_to_ib(tif)?;
830
831    let mut order = match kind {
832        OrderKind::Market => order_builder::market_order(action, quantity),
833
834        OrderKind::Limit => {
835            let price_f64 = require_limit_price(price, *kind)?;
836            order_builder::limit_order(action, quantity, price_f64)
837        }
838
839        OrderKind::Stop { trigger_price } => {
840            let trigger_f64 = decimal_to_f64(*trigger_price)?;
841            order_builder::stop(action, quantity, trigger_f64)
842        }
843
844        OrderKind::StopLimit { trigger_price } => {
845            let limit_f64 = require_limit_price(price, *kind)?;
846            let trigger_f64 = decimal_to_f64(*trigger_price)?;
847            // Built by hand: `ibapi` 5 removed `order_builder::stop_limit`, and its fluent
848            // replacement adds nothing our checks above have not done. Same fields as before.
849            Order {
850                action,
851                order_type: "STP LMT".to_owned(),
852                total_quantity: quantity,
853                limit_price: Some(limit_f64),
854                aux_price: Some(trigger_f64),
855                ..Order::default()
856            }
857        }
858
859        OrderKind::TrailingStop {
860            offset,
861            offset_type,
862            activation_price: None,
863        } => {
864            let offset_f64 = decimal_to_f64(*offset)?;
865            match offset_type {
866                TrailingOffsetType::Percentage => {
867                    // Use the builder for percentage-based trailing stop.
868                    // trail_stop_price is None, letting IB derive it from market price.
869                    Order {
870                        action,
871                        order_type: "TRAIL".to_owned(),
872                        total_quantity: quantity,
873                        trailing_percent: Some(offset_f64),
874                        trail_stop_price: None,
875                        ..Order::default()
876                    }
877                }
878                TrailingOffsetType::Absolute => {
879                    // Manual construction for absolute trailing stop.
880                    // aux_price holds the trailing amount.
881                    Order {
882                        action,
883                        order_type: "TRAIL".to_owned(),
884                        total_quantity: quantity,
885                        aux_price: Some(offset_f64),
886                        trail_stop_price: None,
887                        ..Order::default()
888                    }
889                }
890                TrailingOffsetType::BasisPoints => {
891                    return Err(OrderMappingError::UnsupportedOffsetType(
892                        TrailingOffsetType::BasisPoints,
893                    ));
894                }
895            }
896        }
897
898        OrderKind::TrailingStopLimit {
899            offset,
900            offset_type,
901            stop_price,
902            limit_offset,
903        } => {
904            // IB sets the limit from the stop and `limit_price_offset`, so a limit price here
905            // would be ignored; refuse it rather than place something other than was asked.
906            if price.is_some() {
907                return Err(OrderMappingError::UnexpectedLimitPrice(*kind));
908            }
909            // IB refuses a TRAIL LIMIT without an initial stop (error 321), and rejects a zero
910            // one as an invalid price once the order reaches the exchange.
911            if *stop_price <= Decimal::ZERO {
912                return Err(OrderMappingError::NonPositiveStopPrice(*kind));
913            }
914            let offset_f64 = decimal_to_f64(*offset)?;
915            let stop_price_f64 = decimal_to_f64(*stop_price)?;
916            let limit_offset_f64 = decimal_to_f64(*limit_offset)?;
917            // The trail goes in `aux_price` as an amount, or in `trailing_percent`.
918            let (aux_price, trailing_percent) = match offset_type {
919                TrailingOffsetType::Absolute => (Some(offset_f64), None),
920                TrailingOffsetType::Percentage => (None, Some(offset_f64)),
921                TrailingOffsetType::BasisPoints => {
922                    return Err(OrderMappingError::UnsupportedOffsetType(
923                        TrailingOffsetType::BasisPoints,
924                    ));
925                }
926            };
927            Order {
928                action,
929                order_type: "TRAIL LIMIT".to_owned(),
930                total_quantity: quantity,
931                aux_price,
932                trailing_percent,
933                trail_stop_price: Some(stop_price_f64),
934                limit_price_offset: Some(limit_offset_f64),
935                ..Order::default()
936            }
937        }
938
939        // IBKR does not have native take-profit order types. Callers should use
940        // bracket orders or manage TP logic at the strategy level. Its trailing orders take no
941        // activation price, and its trailing limit follows the stop rather than staying fixed.
942        OrderKind::TakeProfit { .. }
943        | OrderKind::TakeProfitLimit { .. }
944        | OrderKind::TrailingStop {
945            activation_price: Some(_),
946            ..
947        }
948        | OrderKind::TrailingStopFixedLimit { .. } => {
949            return Err(OrderMappingError::UnsupportedOrderKind(*kind));
950        }
951    };
952
953    order.tif = tif_ib;
954
955    // Handle GoodTillDate: set the good_till_date string field
956    if let TimeInForce::GoodTillDate { expiry } = tif {
957        order.good_till_date = format_gtd_datetime(expiry);
958    }
959
960    Ok(order)
961}
962
963/// Format a `DateTime<Utc>` for IB's good_till_date field.
964///
965/// IB accepts format: "yyyyMMdd HH:mm:ss" with optional timezone suffix.
966/// We use UTC format "yyyyMMdd-HH:mm:ss" which IB interprets as UTC.
967fn format_gtd_datetime(dt: &chrono::DateTime<chrono::Utc>) -> String {
968    dt.format(GTD_FORMAT).to_string()
969}
970
971/// The good-till-date form this client sends, in UTC.
972const GTD_FORMAT: &str = "%Y%m%d-%H:%M:%S";
973
974/// Build an at-close order (MOC or LOC).
975///
976/// Only Market and Limit orders can be combined with at-close execution.
977/// Stop orders cannot execute at close due to IBKR's order model constraints.
978fn build_at_close_order(
979    action: Action,
980    quantity: f64,
981    kind: &OrderKind,
982    price: Option<rust_decimal::Decimal>,
983) -> Result<Order, OrderMappingError> {
984    match kind {
985        OrderKind::Market => Ok(order_builder::market_on_close(action, quantity)),
986        OrderKind::Limit => {
987            let price_f64 = require_limit_price(price, *kind)?;
988            Ok(order_builder::limit_on_close(action, quantity, price_f64))
989        }
990        // Stop orders cannot be combined with at-close execution
991        _ => Err(OrderMappingError::UnsupportedOrderKindForAtClose(*kind)),
992    }
993}
994
995/// Build a bracket order with proper OCA linking.
996///
997/// ibapi's `order_builder::bracket_order()` sets `parent_id` on child orders (TP/SL
998/// wait for entry fill) but does NOT set `oca_group` or `oca_type`. Without OCA
999/// linking, if take-profit fills, stop-loss stays open — dangerous position exposure.
1000///
1001/// This function wraps `bracket_order()` and adds OCA group linking so that when
1002/// either child order fills, the other is automatically cancelled by IB.
1003///
1004/// # Arguments
1005///
1006/// * `parent_order_id` - The IB order ID for the parent (entry) order
1007/// * `action` - Buy or Sell for the entry order (children use opposite action)
1008/// * `quantity` - Number of shares/contracts
1009/// * `limit_price` - Entry limit price
1010/// * `take_profit_price` - Take profit limit price (above entry for Buy, below for Sell)
1011/// * `stop_loss_price` - Stop loss trigger price (below entry for Buy, above for Sell)
1012///
1013/// # Returns
1014///
1015/// [`OrderMappingError::InvalidBracketPrices`] if `ibapi` rejects the prices: one is not finite,
1016/// or the take profit or stop loss is on the wrong side of the entry. Otherwise a
1017/// Vec of 3 orders: `[parent, take_profit, stop_loss]` with:
1018/// - `parent_id` set on children (IB's bracket linkage)
1019/// - `oca_group` set on children (OCA linkage between TP and SL)
1020/// - `oca_type = CancelWithBlock` (safest: one at a time, cancel remaining)
1021/// - `transmit` flags: parent=false, TP=false, SL=true (atomic submission)
1022///
1023/// # Order ID Convention
1024///
1025/// ibapi's `bracket_order()` assigns consecutive IDs: parent=N, TP=N+1, SL=N+2.
1026/// Caller must ensure these IDs are reserved atomically via `allocate_order_id_range(3)`.
1027pub fn build_ib_bracket_with_oca(
1028    parent_order_id: i32,
1029    action: Action,
1030    quantity: f64,
1031    limit_price: f64,
1032    take_profit_price: f64,
1033    stop_loss_price: f64,
1034    tif: IbTimeInForce,
1035) -> Result<Vec<Order>, OrderMappingError> {
1036    let prices = BracketPrices {
1037        entry: limit_price,
1038        take_profit: take_profit_price,
1039        stop_loss: stop_loss_price,
1040    };
1041    let mut orders = order_builder::bracket_order(parent_order_id, action, quantity, prices)
1042        .map_err(|e| OrderMappingError::InvalidBracketPrices(e.to_string()))?;
1043    assert_eq!(
1044        orders.len(),
1045        3,
1046        "ibapi bracket_order must return exactly 3 orders (parent, TP, SL)"
1047    );
1048
1049    // ibapi's bracket_order() defaults all legs to TimeInForce::Day; override
1050    // with the caller-specified TIF so it actually reaches the wire.
1051    orders[0].tif = tif.clone();
1052    orders[1].tif = tif.clone();
1053    orders[2].tif = tif;
1054
1055    // Link TP and SL via OCA group so one cancels the other.
1056    // CancelWithBlock provides overfill protection by routing only one child at a time.
1057    let oca_group = format!("bracket_{}", parent_order_id);
1058    orders[1].oca_group = oca_group.clone();
1059    orders[1].oca_type = OcaType::CancelWithBlock;
1060    orders[2].oca_group = oca_group;
1061    orders[2].oca_type = OcaType::CancelWithBlock;
1062
1063    Ok(orders)
1064}
1065
1066#[cfg(test)]
1067#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
1068mod tests {
1069    use super::*;
1070    use rust_decimal::Decimal;
1071
1072    fn test_context() -> OrderContext {
1073        OrderContext {
1074            instrument: rustrade_instrument::instrument::name::InstrumentNameExchange::from("AAPL"),
1075            side: Side::Buy,
1076            price: Some(Decimal::from(150)),
1077            quantity: Decimal::from(100),
1078            kind: OrderKind::Limit,
1079            time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1080        }
1081    }
1082
1083    #[test]
1084    fn test_order_id_map_basic() {
1085        let map = OrderIdMap::new();
1086        let cid = ClientOrderId::new("order-123");
1087        let ctx = test_context();
1088
1089        map.register(cid.clone(), 42, ctx.clone()).unwrap();
1090
1091        assert_eq!(map.get_ib_id(&cid), Some(42));
1092        assert_eq!(map.get_client_id(42), Some(cid.clone()));
1093        assert_eq!(map.len(), 1);
1094
1095        let (retrieved_cid, retrieved_ctx) = map.get_client_id_and_context(42).unwrap();
1096        assert_eq!(retrieved_cid, cid);
1097        assert_eq!(retrieved_ctx.side, Side::Buy);
1098        assert_eq!(retrieved_ctx.price, Some(Decimal::from(150)));
1099    }
1100
1101    #[test]
1102    fn test_order_id_map_remove() {
1103        let map = OrderIdMap::new();
1104        let cid = ClientOrderId::new("order-456");
1105
1106        map.register(cid.clone(), 100, test_context()).unwrap();
1107        assert_eq!(map.len(), 1);
1108
1109        let removed = map.remove_by_ib_id(100);
1110        assert_eq!(removed, Some(cid.clone()));
1111        assert!(map.is_empty());
1112        assert!(map.get_ib_id(&cid).is_none());
1113        assert!(map.get_client_id(100).is_none());
1114        assert!(map.get_client_id_and_context(100).is_none());
1115    }
1116
1117    #[test]
1118    fn test_side_conversion() {
1119        assert!(matches!(
1120            side_to_action(rustrade_instrument::Side::Buy),
1121            Action::Buy
1122        ));
1123        assert!(matches!(
1124            side_to_action(rustrade_instrument::Side::Sell),
1125            Action::Sell
1126        ));
1127    }
1128
1129    #[test]
1130    fn test_time_in_force_conversion() {
1131        assert_eq!(
1132            time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: false }),
1133            Ok(IbTimeInForce::GoodTillCanceled)
1134        );
1135
1136        assert!(matches!(
1137            time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: true }),
1138            Err(OrderMappingError::PostOnlyNotSupported)
1139        ));
1140
1141        assert_eq!(
1142            time_in_force_to_ib(&TimeInForce::GoodUntilEndOfDay),
1143            Ok(IbTimeInForce::Day)
1144        );
1145        assert_eq!(
1146            time_in_force_to_ib(&TimeInForce::FillOrKill),
1147            Ok(IbTimeInForce::FillOrKill)
1148        );
1149        assert_eq!(
1150            time_in_force_to_ib(&TimeInForce::ImmediateOrCancel),
1151            Ok(IbTimeInForce::ImmediateOrCancel)
1152        );
1153    }
1154
1155    #[test]
1156    fn test_time_in_force_conversion_good_till_date() {
1157        use chrono::{TimeZone, Utc};
1158
1159        let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
1160        assert_eq!(
1161            time_in_force_to_ib(&TimeInForce::GoodTillDate { expiry }),
1162            Ok(IbTimeInForce::GoodTillDate)
1163        );
1164    }
1165
1166    #[test]
1167    fn test_time_in_force_conversion_at_open() {
1168        assert_eq!(
1169            time_in_force_to_ib(&TimeInForce::AtOpen),
1170            Ok(IbTimeInForce::OnOpen)
1171        );
1172    }
1173
1174    #[test]
1175    fn test_time_in_force_conversion_at_close_returns_err() {
1176        // AtClose changes the order TYPE (MOC/LOC), so callers must route
1177        // through build_ib_order. Other callers (e.g. bracket orders) get a
1178        // graceful Err that they can surface as an order rejection.
1179        assert_eq!(
1180            time_in_force_to_ib(&TimeInForce::AtClose),
1181            Err(OrderMappingError::AtCloseRequiresOrderTypeChange)
1182        );
1183    }
1184
1185    #[test]
1186    fn test_build_market_order() {
1187        let order = build_ib_order(
1188            rustrade_instrument::Side::Buy,
1189            100.0,
1190            &OrderKind::Market,
1191            None, // Market orders have no limit price
1192            &TimeInForce::GoodUntilEndOfDay,
1193        )
1194        .unwrap();
1195
1196        assert_eq!(order.action, Action::Buy);
1197        assert_eq!(order.total_quantity, 100.0);
1198        assert_eq!(order.order_type, "MKT");
1199    }
1200
1201    #[test]
1202    fn test_build_limit_order() {
1203        let order = build_ib_order(
1204            rustrade_instrument::Side::Sell,
1205            50.0,
1206            &OrderKind::Limit,
1207            Some(Decimal::try_from(150.5).unwrap()),
1208            &TimeInForce::GoodUntilCancelled { post_only: false },
1209        )
1210        .unwrap();
1211
1212        assert_eq!(order.action, Action::Sell);
1213        assert_eq!(order.total_quantity, 50.0);
1214        assert_eq!(order.order_type, "LMT");
1215    }
1216
1217    #[test]
1218    fn test_order_id_map_remove_and_get_context() {
1219        let map = OrderIdMap::new();
1220        let cid = ClientOrderId::new("order-789");
1221        let ctx = test_context();
1222
1223        map.register(cid.clone(), 50, ctx).unwrap();
1224        assert_eq!(map.len(), 1);
1225
1226        // Remove and get context in single operation
1227        let result = map.remove_and_get_context(50);
1228        assert!(result.is_some());
1229        let (retrieved_cid, retrieved_ctx) = result.unwrap();
1230        assert_eq!(retrieved_cid, cid);
1231        assert_eq!(retrieved_ctx.side, Side::Buy);
1232
1233        // Map should be empty now
1234        assert!(map.is_empty());
1235        assert!(map.get_client_id(50).is_none());
1236        assert!(map.get_ib_id(&cid).is_none());
1237
1238        // Second removal returns None
1239        assert!(map.remove_and_get_context(50).is_none());
1240    }
1241
1242    #[test]
1243    fn test_order_id_map_clear_stale() {
1244        use std::time::Duration;
1245
1246        let map = OrderIdMap::new();
1247
1248        // Register orders
1249        map.register(ClientOrderId::new("old-1"), 1, test_context())
1250            .unwrap();
1251        map.register(ClientOrderId::new("old-2"), 2, test_context())
1252            .unwrap();
1253
1254        // With zero max_age, all entries are stale
1255        let cleared = map.clear_stale(Duration::ZERO);
1256        assert_eq!(cleared, 2);
1257        assert!(map.is_empty());
1258
1259        // Register new orders
1260        map.register(ClientOrderId::new("new-1"), 10, test_context())
1261            .unwrap();
1262        map.register(ClientOrderId::new("new-2"), 20, test_context())
1263            .unwrap();
1264
1265        // With large max_age, nothing is stale
1266        let cleared = map.clear_stale(Duration::from_secs(3600));
1267        assert_eq!(cleared, 0);
1268        assert_eq!(map.len(), 2);
1269    }
1270
1271    /// A live order's id is refused to a second order, and the first keeps its mapping.
1272    #[test]
1273    fn a_live_orders_id_is_refused_to_a_second_order() {
1274        let map = OrderIdMap::new();
1275        let cid = ClientOrderId::new("taken");
1276        map.register(cid.clone(), 1, test_context()).unwrap();
1277
1278        assert_eq!(
1279            map.register(cid.clone(), 2, test_context()),
1280            Err(ClientOrderIdInUse(cid.clone()))
1281        );
1282        assert_eq!(
1283            map.get_ib_id(&cid),
1284            Some(1),
1285            "a cancel by id still reaches the first"
1286        );
1287        assert!(
1288            map.get_client_id(2).is_none(),
1289            "nothing of the second was registered"
1290        );
1291    }
1292
1293    /// A filled order frees its id at once, while its late executions still resolve to it.
1294    #[test]
1295    fn a_filled_order_frees_its_id_and_keeps_resolving_by_ib_id() {
1296        let map = OrderIdMap::new();
1297        let cid = ClientOrderId::new("reused");
1298        map.register(cid.clone(), 1, test_context()).unwrap();
1299
1300        assert_eq!(
1301            map.release_client_id(1).map(|(id, _)| id),
1302            Some(cid.clone())
1303        );
1304        assert!(map.is_empty(), "no order is live");
1305        assert_eq!(
1306            map.get_client_id(1),
1307            Some(cid.clone()),
1308            "a late execution resolves"
1309        );
1310
1311        // The id is free again.
1312        map.register(cid.clone(), 2, test_context()).unwrap();
1313        assert_eq!(map.get_ib_id(&cid), Some(2));
1314        assert_eq!(map.get_client_id(1), Some(cid.clone()), "and still does");
1315    }
1316
1317    /// An earlier order under a reused id ending, or being reaped, leaves the order now under the
1318    /// id mapped.
1319    #[test]
1320    fn an_earlier_order_ending_leaves_a_reused_ids_mapping() {
1321        let map = OrderIdMap::new();
1322        let cid = ClientOrderId::new("reused");
1323        map.register(cid.clone(), 1, test_context()).unwrap();
1324        let _ = map.release_client_id(1);
1325        map.register(cid.clone(), 2, test_context()).unwrap();
1326
1327        assert!(
1328            map.remove_and_get_context(1).is_some(),
1329            "the first order's entry goes"
1330        );
1331        assert_eq!(map.get_ib_id(&cid), Some(2), "the second keeps the id");
1332        assert!(map.remove_by_ib_id(1).is_none());
1333        assert_eq!(map.get_ib_id(&cid), Some(2));
1334    }
1335
1336    /// Reaping a filled order's entry leaves a reused id's mapping too.
1337    #[test]
1338    fn reaping_a_filled_order_leaves_a_reused_ids_mapping() {
1339        use std::time::Duration;
1340
1341        let map = OrderIdMap::new();
1342        let cid = ClientOrderId::new("reused");
1343        map.register(cid.clone(), 1, test_context()).unwrap();
1344        let _ = map.release_client_id(1);
1345        std::thread::sleep(Duration::from_millis(5));
1346        map.register(cid.clone(), 2, test_context()).unwrap();
1347
1348        assert_eq!(
1349            map.clear_stale(Duration::from_millis(5)),
1350            1,
1351            "only the filled one"
1352        );
1353        assert_eq!(map.get_ib_id(&cid), Some(2));
1354    }
1355
1356    /// Several orders register all or none: an id already live, or one repeated among them,
1357    /// registers none of them.
1358    #[test]
1359    fn register_all_registers_every_order_or_none() {
1360        let map = OrderIdMap::new();
1361        map.register(ClientOrderId::new("live"), 1, test_context())
1362            .unwrap();
1363
1364        let clash = map.register_all([
1365            (ClientOrderId::new("a"), 10, test_context()),
1366            (ClientOrderId::new("live"), 11, test_context()),
1367        ]);
1368        assert_eq!(clash, Err(ClientOrderIdInUse(ClientOrderId::new("live"))));
1369
1370        let repeated = map.register_all([
1371            (ClientOrderId::new("b"), 20, test_context()),
1372            (ClientOrderId::new("b"), 21, test_context()),
1373        ]);
1374        assert_eq!(repeated, Err(ClientOrderIdInUse(ClientOrderId::new("b"))));
1375
1376        assert_eq!(map.len(), 1, "neither registered anything");
1377        assert!(
1378            [10, 11, 20, 21]
1379                .iter()
1380                .all(|ib_id| map.get_client_id(*ib_id).is_none())
1381        );
1382
1383        map.register_all([
1384            (ClientOrderId::new("a"), 10, test_context()),
1385            (ClientOrderId::new("b"), 20, test_context()),
1386        ])
1387        .unwrap();
1388        assert_eq!(map.len(), 3);
1389    }
1390
1391    /// A listed order is adopted under its client order id: tracked, and marked adopted.
1392    #[test]
1393    fn an_adopted_order_is_tracked_and_marked_adopted() {
1394        let map = OrderIdMap::new();
1395        let cid = ClientOrderId::new("listed");
1396
1397        map.adopt(cid.clone(), 7, test_context()).unwrap();
1398
1399        assert_eq!(map.get_ib_id(&cid), Some(7));
1400        assert_eq!(map.get_client_id(7), Some(cid.clone()));
1401        assert!(map.registration(&cid).is_some_and(|r| r.adopted));
1402    }
1403
1404    /// An IB order id that already names a tracked order, live or filled and not yet reaped, is
1405    /// not adopted under another id, and the tracked order is left as it was.
1406    #[test]
1407    fn adopting_an_ib_order_id_already_tracked_is_refused_and_changes_nothing() {
1408        let map = OrderIdMap::new();
1409        let placed = ClientOrderId::new("placed");
1410        let adopted = ClientOrderId::new("adopted");
1411        let filled = ClientOrderId::new("filled");
1412        map.register(placed.clone(), 1, test_context()).unwrap();
1413        map.adopt(adopted.clone(), 2, test_context()).unwrap();
1414        map.register(filled.clone(), 3, test_context()).unwrap();
1415        let _ = map.release_client_id(3);
1416
1417        for ib_id in [1, 2, 3] {
1418            let other = ClientOrderId::new(smol_str::format_smolstr!("other-{ib_id}"));
1419            assert_eq!(
1420                map.adopt(other.clone(), ib_id, test_context()),
1421                Err(AdoptionRefused::IbOrderIdTracked)
1422            );
1423            assert!(map.get_ib_id(&other).is_none(), "{other} was not tracked");
1424        }
1425
1426        assert_eq!(map.get_client_id(1), Some(placed.clone()));
1427        assert_eq!(map.get_client_id(2), Some(adopted.clone()));
1428        assert_eq!(map.get_client_id(3), Some(filled.clone()));
1429        assert!(map.registration(&placed).is_some_and(|r| !r.adopted));
1430        assert!(map.registration(&adopted).is_some_and(|r| r.adopted));
1431        assert!(
1432            map.registration(&filled).is_none(),
1433            "the filled order's id stays free"
1434        );
1435        assert_eq!(map.len(), 2);
1436    }
1437
1438    /// A client order id a live order holds is not adopted for another IB order, and the live
1439    /// order is left as it was.
1440    #[test]
1441    fn adopting_a_client_order_id_a_live_order_holds_is_refused_and_changes_nothing() {
1442        let map = OrderIdMap::new();
1443        let placed = ClientOrderId::new("placed");
1444        let adopted = ClientOrderId::new("adopted");
1445        map.register(placed.clone(), 1, test_context()).unwrap();
1446        map.adopt(adopted.clone(), 2, test_context()).unwrap();
1447        let before = [
1448            map.registration(&placed).map(|r| r.adopted),
1449            map.registration(&adopted).map(|r| r.adopted),
1450        ];
1451
1452        for (cid, ib_id) in [(&placed, 10), (&adopted, 20)] {
1453            assert_eq!(
1454                map.adopt(cid.clone(), ib_id, test_context()),
1455                Err(AdoptionRefused::ClientOrderIdInUse)
1456            );
1457            assert!(!map.contains(ib_id), "IB order {ib_id} was not tracked");
1458        }
1459
1460        assert_eq!(map.get_ib_id(&placed), Some(1));
1461        assert_eq!(map.get_ib_id(&adopted), Some(2));
1462        assert_eq!(
1463            [
1464                map.registration(&placed).map(|r| r.adopted),
1465                map.registration(&adopted).map(|r| r.adopted),
1466            ],
1467            before
1468        );
1469        assert_eq!(map.len(), 2);
1470    }
1471
1472    /// A client order id whose earlier order ended, cancelled or filled, may name an adopted
1473    /// order. A filled order keeps its entry, so its late executions still resolve.
1474    #[test]
1475    fn an_order_is_adopted_under_an_id_whose_earlier_order_ended() {
1476        let map = OrderIdMap::new();
1477        let cancelled = ClientOrderId::new("cancelled");
1478        let filled = ClientOrderId::new("filled");
1479        map.register(cancelled.clone(), 1, test_context()).unwrap();
1480        map.register(filled.clone(), 2, test_context()).unwrap();
1481        let _ = map.remove_and_get_context(1);
1482        let _ = map.release_client_id(2);
1483
1484        map.adopt(cancelled.clone(), 11, test_context()).unwrap();
1485        map.adopt(filled.clone(), 12, test_context()).unwrap();
1486
1487        assert_eq!(map.get_ib_id(&cancelled), Some(11));
1488        assert_eq!(map.get_ib_id(&filled), Some(12));
1489        assert!(map.registration(&cancelled).is_some_and(|r| r.adopted));
1490        assert!(map.registration(&filled).is_some_and(|r| r.adopted));
1491        assert_eq!(
1492            map.get_client_id(2),
1493            Some(filled),
1494            "the filled order's executions resolve"
1495        );
1496    }
1497
1498    #[test]
1499    fn test_pending_cancels_insert_remove() {
1500        let cancels = PendingCancels::new();
1501        assert!(cancels.is_empty());
1502
1503        // Insert a cancel request
1504        cancels.insert(42);
1505        assert_eq!(cancels.len(), 1);
1506        assert!(!cancels.is_empty());
1507
1508        // Remove returns true for tracked ID
1509        assert!(cancels.remove(42));
1510        assert!(cancels.is_empty());
1511
1512        // Remove returns false for untracked ID
1513        assert!(!cancels.remove(42));
1514        assert!(!cancels.remove(999));
1515    }
1516
1517    #[test]
1518    fn test_pending_cancels_multiple() {
1519        let cancels = PendingCancels::new();
1520
1521        cancels.insert(1);
1522        cancels.insert(2);
1523        cancels.insert(3);
1524        assert_eq!(cancels.len(), 3);
1525
1526        // Remove middle one
1527        assert!(cancels.remove(2));
1528        assert_eq!(cancels.len(), 2);
1529
1530        // Other IDs still tracked
1531        assert!(cancels.remove(1));
1532        assert!(cancels.remove(3));
1533        assert!(cancels.is_empty());
1534    }
1535
1536    #[test]
1537    fn test_pending_cancels_clear_stale() {
1538        use std::time::Duration;
1539
1540        let cancels = PendingCancels::new();
1541
1542        cancels.insert(1);
1543        cancels.insert(2);
1544
1545        // With zero max_age, all entries are stale
1546        let cleared = cancels.clear_stale(Duration::ZERO);
1547        assert_eq!(cleared, 2);
1548        assert!(cancels.is_empty());
1549
1550        // Insert new ones
1551        cancels.insert(10);
1552        cancels.insert(20);
1553
1554        // With large max_age, nothing is stale
1555        let cleared = cancels.clear_stale(Duration::from_secs(3600));
1556        assert_eq!(cleared, 0);
1557        assert_eq!(cancels.len(), 2);
1558    }
1559
1560    #[test]
1561    fn test_pending_cancels_duplicate_insert() {
1562        let cancels = PendingCancels::new();
1563
1564        cancels.insert(42);
1565        cancels.insert(42);
1566
1567        // HashMap deduplicates by ID; second insert updates timestamp
1568        assert_eq!(cancels.len(), 1);
1569        assert!(cancels.remove(42));
1570        assert!(cancels.is_empty());
1571    }
1572
1573    #[test]
1574    fn test_build_stop_order() {
1575        let order = build_ib_order(
1576            rustrade_instrument::Side::Sell,
1577            100.0,
1578            &OrderKind::Stop {
1579                trigger_price: Decimal::from(45),
1580            },
1581            None, // Stop orders have no limit price
1582            &TimeInForce::GoodUntilCancelled { post_only: false },
1583        )
1584        .unwrap();
1585
1586        assert_eq!(order.action, Action::Sell);
1587        assert_eq!(order.total_quantity, 100.0);
1588        assert_eq!(order.order_type, "STP");
1589        assert_eq!(order.aux_price, Some(45.0));
1590        assert_eq!(order.limit_price, None);
1591    }
1592
1593    #[test]
1594    fn test_build_stop_limit_order() {
1595        let order = build_ib_order(
1596            rustrade_instrument::Side::Sell,
1597            100.0,
1598            &OrderKind::StopLimit {
1599                trigger_price: Decimal::from(44),
1600            },
1601            Some(Decimal::from(45)), // limit price
1602            &TimeInForce::GoodUntilCancelled { post_only: false },
1603        )
1604        .unwrap();
1605
1606        assert_eq!(order.action, Action::Sell);
1607        assert_eq!(order.total_quantity, 100.0);
1608        assert_eq!(order.order_type, "STP LMT");
1609        assert_eq!(order.aux_price, Some(44.0)); // trigger/stop price
1610        assert_eq!(order.limit_price, Some(45.0)); // limit price
1611    }
1612
1613    #[test]
1614    fn test_build_trailing_stop_percentage() {
1615        let order = build_ib_order(
1616            rustrade_instrument::Side::Sell,
1617            100.0,
1618            &OrderKind::TrailingStop {
1619                offset: Decimal::from(5),
1620                offset_type: TrailingOffsetType::Percentage,
1621                activation_price: None,
1622            },
1623            None, // Trailing stop orders have no limit price
1624            &TimeInForce::GoodUntilCancelled { post_only: false },
1625        )
1626        .unwrap();
1627
1628        assert_eq!(order.action, Action::Sell);
1629        assert_eq!(order.total_quantity, 100.0);
1630        assert_eq!(order.order_type, "TRAIL");
1631        assert_eq!(order.trailing_percent, Some(5.0));
1632        assert_eq!(order.aux_price, None); // percentage uses trailing_percent, not aux_price
1633        assert_eq!(order.trail_stop_price, None); // let IB derive from market
1634    }
1635
1636    #[test]
1637    fn test_build_trailing_stop_absolute() {
1638        let order = build_ib_order(
1639            rustrade_instrument::Side::Sell,
1640            100.0,
1641            &OrderKind::TrailingStop {
1642                offset: Decimal::from(2),
1643                offset_type: TrailingOffsetType::Absolute,
1644                activation_price: None,
1645            },
1646            None, // Trailing stop orders have no limit price
1647            &TimeInForce::GoodUntilCancelled { post_only: false },
1648        )
1649        .unwrap();
1650
1651        assert_eq!(order.action, Action::Sell);
1652        assert_eq!(order.total_quantity, 100.0);
1653        assert_eq!(order.order_type, "TRAIL");
1654        assert_eq!(order.aux_price, Some(2.0)); // absolute uses aux_price
1655        assert_eq!(order.trailing_percent, None);
1656        assert_eq!(order.trail_stop_price, None);
1657    }
1658
1659    #[test]
1660    fn test_build_trailing_stop_limit_absolute() {
1661        let order = build_ib_order(
1662            rustrade_instrument::Side::Sell,
1663            100.0,
1664            &OrderKind::TrailingStopLimit {
1665                offset: Decimal::from(2),
1666                offset_type: TrailingOffsetType::Absolute,
1667                stop_price: Decimal::from(95),
1668                limit_offset: Decimal::try_from(0.5).unwrap(),
1669            },
1670            None, // Trailing stop limit uses limit_offset, not a fixed price
1671            &TimeInForce::GoodUntilCancelled { post_only: false },
1672        )
1673        .unwrap();
1674
1675        assert_eq!(order.action, Action::Sell);
1676        assert_eq!(order.total_quantity, 100.0);
1677        assert_eq!(order.order_type, "TRAIL LIMIT");
1678        assert_eq!(order.aux_price, Some(2.0)); // trailing amount
1679        assert_eq!(order.limit_price_offset, Some(0.5)); // limit offset from stop
1680        assert_eq!(order.trailing_percent, None);
1681        assert_eq!(order.trail_stop_price, Some(95.0)); // initial stop
1682        assert_eq!(order.limit_price, None); // IB derives it from the stop
1683    }
1684
1685    #[test]
1686    fn test_build_trailing_stop_limit_percentage() {
1687        let order = build_ib_order(
1688            rustrade_instrument::Side::Sell,
1689            100.0,
1690            &OrderKind::TrailingStopLimit {
1691                offset: Decimal::from(5),
1692                offset_type: TrailingOffsetType::Percentage,
1693                stop_price: Decimal::from(95),
1694                limit_offset: Decimal::try_from(0.5).unwrap(),
1695            },
1696            None, // Trailing stop limit uses limit_offset, not a fixed price
1697            &TimeInForce::GoodUntilCancelled { post_only: false },
1698        )
1699        .unwrap();
1700
1701        assert_eq!(order.action, Action::Sell);
1702        assert_eq!(order.total_quantity, 100.0);
1703        assert_eq!(order.order_type, "TRAIL LIMIT");
1704        assert_eq!(order.trailing_percent, Some(5.0));
1705        assert_eq!(order.limit_price_offset, Some(0.5));
1706        assert_eq!(order.aux_price, None); // percentage doesn't use aux_price
1707        assert_eq!(order.trail_stop_price, Some(95.0)); // initial stop
1708        assert_eq!(order.limit_price, None); // IB derives it from the stop
1709    }
1710
1711    /// IB refuses a TRAIL LIMIT without a stop, and rejects a zero one; neither is sent.
1712    #[test]
1713    fn test_build_trailing_stop_limit_rejects_non_positive_stop_price() {
1714        for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1715            for stop_price in [Decimal::ZERO, Decimal::from(-1)] {
1716                let kind = OrderKind::TrailingStopLimit {
1717                    offset: Decimal::from(2),
1718                    offset_type,
1719                    stop_price,
1720                    limit_offset: Decimal::from(1),
1721                };
1722                assert_eq!(
1723                    build_ib_order(
1724                        rustrade_instrument::Side::Sell,
1725                        1.0,
1726                        &kind,
1727                        None,
1728                        &TimeInForce::GoodUntilCancelled { post_only: false },
1729                    ),
1730                    Err(OrderMappingError::NonPositiveStopPrice(kind)),
1731                    "{kind}"
1732                );
1733            }
1734        }
1735    }
1736
1737    /// The limit follows from the stop and limit offset, so a limit price is refused, not
1738    /// silently dropped.
1739    #[test]
1740    fn test_build_trailing_stop_limit_rejects_limit_price() {
1741        for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1742            let kind = OrderKind::TrailingStopLimit {
1743                offset: Decimal::from(2),
1744                offset_type,
1745                stop_price: Decimal::from(95),
1746                limit_offset: Decimal::from(1),
1747            };
1748            assert_eq!(
1749                build_ib_order(
1750                    rustrade_instrument::Side::Sell,
1751                    1.0,
1752                    &kind,
1753                    Some(Decimal::from(94)),
1754                    &TimeInForce::GoodUntilCancelled { post_only: false },
1755                ),
1756                Err(OrderMappingError::UnexpectedLimitPrice(kind)),
1757                "{kind}"
1758            );
1759        }
1760    }
1761
1762    #[test]
1763    fn test_build_trailing_stop_basis_points_unsupported() {
1764        let result = build_ib_order(
1765            rustrade_instrument::Side::Sell,
1766            100.0,
1767            &OrderKind::TrailingStop {
1768                offset: Decimal::from(50), // 50 basis points
1769                offset_type: TrailingOffsetType::BasisPoints,
1770                activation_price: None,
1771            },
1772            None, // Trailing stop orders have no limit price
1773            &TimeInForce::GoodUntilCancelled { post_only: false },
1774        );
1775
1776        assert!(matches!(
1777            result,
1778            Err(OrderMappingError::UnsupportedOffsetType(
1779                TrailingOffsetType::BasisPoints
1780            ))
1781        ));
1782    }
1783
1784    #[test]
1785    fn test_build_trailing_stop_limit_basis_points_unsupported() {
1786        let result = build_ib_order(
1787            rustrade_instrument::Side::Sell,
1788            100.0,
1789            &OrderKind::TrailingStopLimit {
1790                offset: Decimal::from(50),
1791                offset_type: TrailingOffsetType::BasisPoints,
1792                stop_price: Decimal::from(95),
1793                limit_offset: Decimal::try_from(0.5).unwrap(),
1794            },
1795            None, // Trailing stop limit uses limit_offset, not a fixed price
1796            &TimeInForce::GoodUntilCancelled { post_only: false },
1797        );
1798
1799        assert!(matches!(
1800            result,
1801            Err(OrderMappingError::UnsupportedOffsetType(
1802                TrailingOffsetType::BasisPoints
1803            ))
1804        ));
1805    }
1806
1807    /// IBKR has no activation price on a trailing stop, and no fixed-limit trailing stop.
1808    #[test]
1809    fn test_build_activated_and_fixed_limit_trailing_stops_unsupported() {
1810        let tif = TimeInForce::GoodUntilCancelled { post_only: false };
1811        for kind in [
1812            OrderKind::TrailingStop {
1813                offset: Decimal::from(2),
1814                offset_type: TrailingOffsetType::Percentage,
1815                activation_price: Some(Decimal::from(150)),
1816            },
1817            OrderKind::TrailingStopFixedLimit {
1818                offset: Decimal::from(2),
1819                offset_type: TrailingOffsetType::Percentage,
1820                activation_price: None,
1821            },
1822        ] {
1823            let result = build_ib_order(
1824                rustrade_instrument::Side::Sell,
1825                100.0,
1826                &kind,
1827                Some(Decimal::from(140)),
1828                &tif,
1829            );
1830            assert!(
1831                matches!(result, Err(OrderMappingError::UnsupportedOrderKind(_))),
1832                "{kind}: {result:?}"
1833            );
1834        }
1835    }
1836
1837    #[test]
1838    fn test_build_take_profit_unsupported() {
1839        let result = build_ib_order(
1840            rustrade_instrument::Side::Sell,
1841            100.0,
1842            &OrderKind::TakeProfit {
1843                trigger_price: Decimal::from(150),
1844            },
1845            None,
1846            &TimeInForce::GoodUntilCancelled { post_only: false },
1847        );
1848
1849        assert!(matches!(
1850            result,
1851            Err(OrderMappingError::UnsupportedOrderKind(
1852                OrderKind::TakeProfit { .. }
1853            ))
1854        ));
1855    }
1856
1857    #[test]
1858    fn test_build_take_profit_limit_unsupported() {
1859        let result = build_ib_order(
1860            rustrade_instrument::Side::Sell,
1861            100.0,
1862            &OrderKind::TakeProfitLimit {
1863                trigger_price: Decimal::from(150),
1864            },
1865            None,
1866            &TimeInForce::GoodUntilCancelled { post_only: false },
1867        );
1868
1869        assert!(matches!(
1870            result,
1871            Err(OrderMappingError::UnsupportedOrderKind(
1872                OrderKind::TakeProfitLimit { .. }
1873            ))
1874        ));
1875    }
1876
1877    // =========================================================================
1878    // Extended Time-in-Force Tests
1879    // =========================================================================
1880
1881    #[test]
1882    fn test_build_market_order_at_open() {
1883        let order = build_ib_order(
1884            rustrade_instrument::Side::Buy,
1885            100.0,
1886            &OrderKind::Market,
1887            None, // Market orders have no limit price
1888            &TimeInForce::AtOpen,
1889        )
1890        .unwrap();
1891
1892        assert_eq!(order.action, Action::Buy);
1893        assert_eq!(order.total_quantity, 100.0);
1894        assert_eq!(order.order_type, "MKT");
1895        assert_eq!(order.tif, IbTimeInForce::OnOpen);
1896    }
1897
1898    #[test]
1899    fn test_build_limit_order_at_open() {
1900        let order = build_ib_order(
1901            rustrade_instrument::Side::Sell,
1902            50.0,
1903            &OrderKind::Limit,
1904            Some(Decimal::from(150)),
1905            &TimeInForce::AtOpen,
1906        )
1907        .unwrap();
1908
1909        assert_eq!(order.action, Action::Sell);
1910        assert_eq!(order.total_quantity, 50.0);
1911        assert_eq!(order.order_type, "LMT");
1912        assert_eq!(order.limit_price, Some(150.0));
1913        assert_eq!(order.tif, IbTimeInForce::OnOpen);
1914    }
1915
1916    #[test]
1917    fn test_build_stop_order_at_open() {
1918        // Stop orders can use AtOpen TIF
1919        let order = build_ib_order(
1920            rustrade_instrument::Side::Sell,
1921            100.0,
1922            &OrderKind::Stop {
1923                trigger_price: Decimal::from(45),
1924            },
1925            None, // Stop orders have no limit price
1926            &TimeInForce::AtOpen,
1927        )
1928        .unwrap();
1929
1930        assert_eq!(order.order_type, "STP");
1931        assert_eq!(order.aux_price, Some(45.0));
1932        assert_eq!(order.tif, IbTimeInForce::OnOpen);
1933    }
1934
1935    #[test]
1936    fn test_build_market_on_close() {
1937        let order = build_ib_order(
1938            rustrade_instrument::Side::Buy,
1939            100.0,
1940            &OrderKind::Market,
1941            None, // Market orders have no limit price
1942            &TimeInForce::AtClose,
1943        )
1944        .unwrap();
1945
1946        assert_eq!(order.action, Action::Buy);
1947        assert_eq!(order.total_quantity, 100.0);
1948        assert_eq!(order.order_type, "MOC");
1949    }
1950
1951    #[test]
1952    fn test_build_limit_on_close() {
1953        let order = build_ib_order(
1954            rustrade_instrument::Side::Sell,
1955            50.0,
1956            &OrderKind::Limit,
1957            Some(Decimal::from(150)),
1958            &TimeInForce::AtClose,
1959        )
1960        .unwrap();
1961
1962        assert_eq!(order.action, Action::Sell);
1963        assert_eq!(order.total_quantity, 50.0);
1964        assert_eq!(order.order_type, "LOC");
1965        assert_eq!(order.limit_price, Some(150.0));
1966    }
1967
1968    #[test]
1969    fn test_build_stop_at_close_unsupported() {
1970        let result = build_ib_order(
1971            rustrade_instrument::Side::Sell,
1972            100.0,
1973            &OrderKind::Stop {
1974                trigger_price: Decimal::from(45),
1975            },
1976            None, // Stop orders have no limit price
1977            &TimeInForce::AtClose,
1978        );
1979
1980        assert!(matches!(
1981            result,
1982            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
1983                OrderKind::Stop { .. }
1984            ))
1985        ));
1986    }
1987
1988    #[test]
1989    fn test_build_stop_limit_at_close_unsupported() {
1990        let result = build_ib_order(
1991            rustrade_instrument::Side::Sell,
1992            100.0,
1993            &OrderKind::StopLimit {
1994                trigger_price: Decimal::from(44),
1995            },
1996            Some(Decimal::from(45)),
1997            &TimeInForce::AtClose,
1998        );
1999
2000        assert!(matches!(
2001            result,
2002            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2003                OrderKind::StopLimit { .. }
2004            ))
2005        ));
2006    }
2007
2008    #[test]
2009    fn test_build_trailing_stop_at_close_unsupported() {
2010        let result = build_ib_order(
2011            rustrade_instrument::Side::Sell,
2012            100.0,
2013            &OrderKind::TrailingStop {
2014                offset: Decimal::from(5),
2015                offset_type: TrailingOffsetType::Percentage,
2016                activation_price: None,
2017            },
2018            None, // Trailing stop orders have no limit price
2019            &TimeInForce::AtClose,
2020        );
2021
2022        assert!(matches!(
2023            result,
2024            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2025                OrderKind::TrailingStop { .. }
2026            ))
2027        ));
2028    }
2029
2030    #[test]
2031    fn test_build_trailing_stop_limit_at_close_unsupported() {
2032        let result = build_ib_order(
2033            rustrade_instrument::Side::Sell,
2034            100.0,
2035            &OrderKind::TrailingStopLimit {
2036                offset: Decimal::from(5),
2037                offset_type: TrailingOffsetType::Absolute,
2038                stop_price: Decimal::from(95),
2039                limit_offset: Decimal::from(1),
2040            },
2041            None, // Trailing stop limit uses limit_offset, not a fixed price
2042            &TimeInForce::AtClose,
2043        );
2044
2045        assert!(matches!(
2046            result,
2047            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2048                OrderKind::TrailingStopLimit { .. }
2049            ))
2050        ));
2051    }
2052
2053    #[test]
2054    fn test_build_good_till_date_order() {
2055        use chrono::{TimeZone, Utc};
2056
2057        let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
2058        let order = build_ib_order(
2059            rustrade_instrument::Side::Buy,
2060            100.0,
2061            &OrderKind::Limit,
2062            Some(Decimal::from(150)),
2063            &TimeInForce::GoodTillDate { expiry },
2064        )
2065        .unwrap();
2066
2067        assert_eq!(order.action, Action::Buy);
2068        assert_eq!(order.total_quantity, 100.0);
2069        assert_eq!(order.order_type, "LMT");
2070        assert_eq!(order.tif, IbTimeInForce::GoodTillDate);
2071        assert_eq!(order.good_till_date, "20250630-23:59:59");
2072    }
2073
2074    #[test]
2075    fn test_format_gtd_datetime() {
2076        use chrono::{TimeZone, Utc};
2077
2078        let dt = Utc.with_ymd_and_hms(2024, 12, 25, 14, 30, 0).unwrap();
2079        assert_eq!(format_gtd_datetime(&dt), "20241225-14:30:00");
2080
2081        let dt2 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
2082        assert_eq!(format_gtd_datetime(&dt2), "20250101-00:00:00");
2083    }
2084
2085    // =========================================================================
2086    // Bracket Order Tests
2087    // =========================================================================
2088
2089    #[test]
2090    fn test_build_ib_bracket_with_oca_sets_oca_fields() {
2091        let orders = build_ib_bracket_with_oca(
2092            1000,
2093            Action::Buy,
2094            10.0,
2095            150.0,
2096            160.0,
2097            140.0,
2098            IbTimeInForce::Day,
2099        )
2100        .unwrap();
2101
2102        assert_eq!(orders.len(), 3);
2103
2104        // OCA group must be non-empty on both child orders
2105        assert!(!orders[1].oca_group.is_empty());
2106        assert_eq!(orders[1].oca_group, orders[2].oca_group);
2107
2108        // Both children must use CancelWithBlock
2109        assert_eq!(orders[1].oca_type, OcaType::CancelWithBlock);
2110        assert_eq!(orders[2].oca_type, OcaType::CancelWithBlock);
2111
2112        // Parent must NOT be in OCA group (only children are linked)
2113        assert!(orders[0].oca_group.is_empty());
2114        assert_eq!(orders[0].oca_type, OcaType::None);
2115    }
2116
2117    #[test]
2118    fn test_build_ib_bracket_with_oca_parent_id_linkage() {
2119        let orders = build_ib_bracket_with_oca(
2120            1000,
2121            Action::Buy,
2122            10.0,
2123            150.0,
2124            160.0,
2125            140.0,
2126            IbTimeInForce::Day,
2127        )
2128        .unwrap();
2129
2130        // Children must reference parent via parent_id
2131        assert_eq!(orders[1].parent_id, 1000); // TP waits for parent fill
2132        assert_eq!(orders[2].parent_id, 1000); // SL waits for parent fill
2133
2134        // Parent has no parent
2135        assert_eq!(orders[0].parent_id, 0);
2136    }
2137
2138    #[test]
2139    fn test_build_ib_bracket_with_oca_transmit_flags() {
2140        let orders = build_ib_bracket_with_oca(
2141            1000,
2142            Action::Buy,
2143            10.0,
2144            150.0,
2145            160.0,
2146            140.0,
2147            IbTimeInForce::Day,
2148        )
2149        .unwrap();
2150
2151        // Only the last order triggers transmission of all three
2152        assert!(!orders[0].transmit); // Parent: don't transmit yet
2153        assert!(!orders[1].transmit); // TP: don't transmit yet
2154        assert!(orders[2].transmit); // SL: transmit all
2155    }
2156
2157    #[test]
2158    fn test_build_ib_bracket_with_oca_order_ids_are_consecutive() {
2159        let orders = build_ib_bracket_with_oca(
2160            500,
2161            Action::Sell,
2162            5.0,
2163            100.0,
2164            90.0,
2165            110.0,
2166            IbTimeInForce::Day,
2167        )
2168        .unwrap();
2169
2170        assert_eq!(orders[0].order_id, 500); // Parent
2171        assert_eq!(orders[1].order_id, 501); // Take profit
2172        assert_eq!(orders[2].order_id, 502); // Stop loss
2173    }
2174
2175    #[test]
2176    fn test_build_ib_bracket_with_oca_rejects_prices_on_the_wrong_side_of_the_entry() {
2177        // A buy's take profit below its entry.
2178        let result =
2179            build_ib_bracket_with_oca(7, Action::Buy, 1.0, 10.0, 9.0, 8.0, IbTimeInForce::Day);
2180        assert!(matches!(
2181            result,
2182            Err(OrderMappingError::InvalidBracketPrices(_))
2183        ));
2184
2185        // A sell's stop loss below its entry.
2186        let result =
2187            build_ib_bracket_with_oca(7, Action::Sell, 1.0, 10.0, 8.0, 9.0, IbTimeInForce::Day);
2188        assert!(matches!(
2189            result,
2190            Err(OrderMappingError::InvalidBracketPrices(_))
2191        ));
2192    }
2193
2194    #[test]
2195    fn test_build_ib_bracket_with_oca_group_name_contains_parent_id() {
2196        let orders =
2197            build_ib_bracket_with_oca(42, Action::Buy, 1.0, 10.0, 12.0, 8.0, IbTimeInForce::Day)
2198                .unwrap();
2199
2200        assert!(orders[1].oca_group.contains("42"));
2201        assert!(orders[2].oca_group.contains("42"));
2202    }
2203
2204    #[test]
2205    fn test_build_ib_bracket_with_oca_order_types() {
2206        let orders = build_ib_bracket_with_oca(
2207            1000,
2208            Action::Buy,
2209            100.0,
2210            150.0,
2211            160.0,
2212            140.0,
2213            IbTimeInForce::Day,
2214        )
2215        .unwrap();
2216
2217        // Parent is limit order
2218        assert_eq!(orders[0].order_type, "LMT");
2219        assert_eq!(orders[0].limit_price, Some(150.0));
2220
2221        // Take profit is limit order
2222        assert_eq!(orders[1].order_type, "LMT");
2223        assert_eq!(orders[1].limit_price, Some(160.0));
2224
2225        // Stop loss is stop order
2226        assert_eq!(orders[2].order_type, "STP");
2227        assert_eq!(orders[2].aux_price, Some(140.0));
2228    }
2229
2230    #[test]
2231    fn test_build_ib_bracket_with_oca_actions_reversed_for_children() {
2232        // Buy bracket: entry=Buy, exits=Sell
2233        let buy_orders =
2234            build_ib_bracket_with_oca(100, Action::Buy, 10.0, 50.0, 55.0, 45.0, IbTimeInForce::Day)
2235                .unwrap();
2236        assert_eq!(buy_orders[0].action, Action::Buy);
2237        assert_eq!(buy_orders[1].action, Action::Sell);
2238        assert_eq!(buy_orders[2].action, Action::Sell);
2239
2240        // Sell bracket: entry=Sell, exits=Buy
2241        let sell_orders = build_ib_bracket_with_oca(
2242            200,
2243            Action::Sell,
2244            10.0,
2245            50.0,
2246            45.0,
2247            55.0,
2248            IbTimeInForce::Day,
2249        )
2250        .unwrap();
2251        assert_eq!(sell_orders[0].action, Action::Sell);
2252        assert_eq!(sell_orders[1].action, Action::Buy);
2253        assert_eq!(sell_orders[2].action, Action::Buy);
2254    }
2255
2256    #[test]
2257    fn test_build_ib_bracket_with_oca_applies_tif_to_all_legs() {
2258        let orders = build_ib_bracket_with_oca(
2259            1000,
2260            Action::Buy,
2261            10.0,
2262            150.0,
2263            160.0,
2264            140.0,
2265            IbTimeInForce::GoodTillCanceled,
2266        )
2267        .unwrap();
2268
2269        assert_eq!(orders[0].tif, IbTimeInForce::GoodTillCanceled);
2270        assert_eq!(orders[1].tif, IbTimeInForce::GoodTillCanceled);
2271        assert_eq!(orders[2].tif, IbTimeInForce::GoodTillCanceled);
2272    }
2273
2274    /// A client order id travels as the order reference whole, so one IB would reject or cut is
2275    /// refused: empty, longer than IB echoes back, or not ASCII (IB error 10363).
2276    #[test]
2277    fn order_ref_is_the_client_order_id_when_ib_echoes_it_whole() {
2278        let longest = "a".repeat(MAX_ORDER_REF_LEN);
2279        assert_eq!(
2280            order_ref(&ClientOrderId::new(longest.as_str())).unwrap(),
2281            longest
2282        );
2283        assert_eq!(
2284            order_ref(&ClientOrderId::new("cid-1_tp")).unwrap(),
2285            "cid-1_tp"
2286        );
2287
2288        let too_long = "a".repeat(MAX_ORDER_REF_LEN + 1);
2289        for refused in ["", too_long.as_str(), "ordre-é"] {
2290            let cid = ClientOrderId::new(refused);
2291            assert_eq!(
2292                order_ref(&cid),
2293                Err(OrderMappingError::InvalidClientOrderId(cid.clone()))
2294            );
2295        }
2296    }
2297
2298    /// Every order this client builds reads back as what it was built from, so an open order it no
2299    /// longer tracks is reported as placed.
2300    #[test]
2301    fn order_shape_from_ib_reads_back_what_build_ib_order_sends() {
2302        use chrono::TimeZone;
2303        use rust_decimal_macros::dec;
2304
2305        let expiry = chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap();
2306        let kinds = [
2307            (OrderKind::Market, None),
2308            (OrderKind::Limit, Some(dec!(150.25))),
2309            (
2310                OrderKind::Stop {
2311                    trigger_price: dec!(140.5),
2312                },
2313                None,
2314            ),
2315            (
2316                OrderKind::StopLimit {
2317                    trigger_price: dec!(140.5),
2318                },
2319                Some(dec!(140)),
2320            ),
2321            (
2322                OrderKind::TrailingStop {
2323                    offset: dec!(2.5),
2324                    offset_type: TrailingOffsetType::Percentage,
2325                    activation_price: None,
2326                },
2327                None,
2328            ),
2329            (
2330                OrderKind::TrailingStop {
2331                    offset: dec!(1.75),
2332                    offset_type: TrailingOffsetType::Absolute,
2333                    activation_price: None,
2334                },
2335                None,
2336            ),
2337            (
2338                OrderKind::TrailingStopLimit {
2339                    offset: dec!(2),
2340                    offset_type: TrailingOffsetType::Percentage,
2341                    stop_price: dec!(130.5),
2342                    limit_offset: dec!(0.5),
2343                },
2344                None,
2345            ),
2346            (
2347                OrderKind::TrailingStopLimit {
2348                    offset: dec!(1.5),
2349                    offset_type: TrailingOffsetType::Absolute,
2350                    stop_price: dec!(139.75),
2351                    limit_offset: dec!(0.25),
2352                },
2353                None,
2354            ),
2355        ];
2356        let tifs = [
2357            TimeInForce::GoodUntilCancelled { post_only: false },
2358            TimeInForce::GoodUntilEndOfDay,
2359            TimeInForce::ImmediateOrCancel,
2360            TimeInForce::FillOrKill,
2361            TimeInForce::AtOpen,
2362            TimeInForce::GoodTillDate { expiry },
2363        ];
2364
2365        for (kind, price) in kinds {
2366            for time_in_force in tifs {
2367                let order = build_ib_order(Side::Buy, 1.0, &kind, price, &time_in_force).unwrap();
2368                assert_eq!(
2369                    order_shape_from_ib(&order),
2370                    Ok(OrderShape {
2371                        kind,
2372                        price,
2373                        time_in_force
2374                    }),
2375                    "{kind} {time_in_force}"
2376                );
2377            }
2378        }
2379
2380        // At-close orders are typed MOC/LOC at IB.
2381        for (kind, price) in [
2382            (OrderKind::Market, None),
2383            (OrderKind::Limit, Some(dec!(99))),
2384        ] {
2385            let order =
2386                build_ib_order(Side::Sell, 1.0, &kind, price, &TimeInForce::AtClose).unwrap();
2387            assert_eq!(
2388                order_shape_from_ib(&order),
2389                Ok(OrderShape {
2390                    kind,
2391                    price,
2392                    time_in_force: TimeInForce::AtClose
2393                }),
2394                "{kind}"
2395            );
2396        }
2397    }
2398
2399    #[test]
2400    fn order_shape_from_ib_reads_ibs_own_good_till_date_form() {
2401        use chrono::TimeZone;
2402
2403        let order = Order {
2404            order_type: "MKT".to_owned(),
2405            tif: IbTimeInForce::GoodTillDate,
2406            good_till_date: "20261009 16:00:00 US/Eastern".to_owned(),
2407            ..Order::default()
2408        };
2409        assert_eq!(
2410            order_shape_from_ib(&order).unwrap().time_in_force,
2411            TimeInForce::GoodTillDate {
2412                expiry: chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap()
2413            }
2414        );
2415    }
2416
2417    #[test]
2418    fn order_shape_from_ib_refuses_what_this_client_never_sends() {
2419        let order = |order_type: &str, tif: IbTimeInForce| Order {
2420            order_type: order_type.to_owned(),
2421            tif,
2422            limit_price: Some(1.0),
2423            ..Order::default()
2424        };
2425        for (order, reason) in [
2426            (order("REL", IbTimeInForce::Day), "order type"),
2427            (
2428                order("LMT", IbTimeInForce::DayTillCanceled),
2429                "time in force",
2430            ),
2431            (
2432                Order {
2433                    limit_price: None,
2434                    ..order("LMT", IbTimeInForce::Day)
2435                },
2436                "without limit_price",
2437            ),
2438            (order("STP", IbTimeInForce::Day), "without aux_price"),
2439            (order("MKT", IbTimeInForce::GoodTillDate), "good-till-date"),
2440            (
2441                Order {
2442                    aux_price: Some(2.0),
2443                    limit_price_offset: Some(0.5),
2444                    ..order("TRAIL LIMIT", IbTimeInForce::Day)
2445                },
2446                "without trail_stop_price",
2447            ),
2448            (
2449                Order {
2450                    aux_price: Some(2.0),
2451                    trail_stop_price: Some(0.0),
2452                    limit_price_offset: Some(0.5),
2453                    ..order("TRAIL LIMIT", IbTimeInForce::Day)
2454                },
2455                "not positive",
2456            ),
2457            (
2458                Order {
2459                    trailing_percent: Some(5.0),
2460                    trail_stop_price: Some(-1.0),
2461                    limit_price_offset: Some(0.5),
2462                    ..order("TRAIL LIMIT", IbTimeInForce::Day)
2463                },
2464                "not positive",
2465            ),
2466        ] {
2467            let error = order_shape_from_ib(&order).unwrap_err();
2468            assert!(error.contains(reason), "{error:?} should name {reason:?}");
2469        }
2470    }
2471
2472    #[test]
2473    fn action_to_side_reads_a_short_sale_as_a_sell() {
2474        assert_eq!(action_to_side(&Action::Buy), Side::Buy);
2475        for action in [Action::Sell, Action::SellShort, Action::SellLong] {
2476            assert_eq!(action_to_side(&action), Side::Sell, "{action:?}");
2477        }
2478    }
2479}