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        // An unresolved kind is only read back from a venue, never placed.
943        OrderKind::TakeProfit { .. }
944        | OrderKind::TakeProfitLimit { .. }
945        | OrderKind::TrailingStop {
946            activation_price: Some(_),
947            ..
948        }
949        | OrderKind::TrailingStopFixedLimit { .. }
950        | OrderKind::ConditionalUnresolved { .. } => {
951            return Err(OrderMappingError::UnsupportedOrderKind(*kind));
952        }
953    };
954
955    order.tif = tif_ib;
956
957    // Handle GoodTillDate: set the good_till_date string field
958    if let TimeInForce::GoodTillDate { expiry } = tif {
959        order.good_till_date = format_gtd_datetime(expiry);
960    }
961
962    Ok(order)
963}
964
965/// Format a `DateTime<Utc>` for IB's good_till_date field.
966///
967/// IB accepts format: "yyyyMMdd HH:mm:ss" with optional timezone suffix.
968/// We use UTC format "yyyyMMdd-HH:mm:ss" which IB interprets as UTC.
969fn format_gtd_datetime(dt: &chrono::DateTime<chrono::Utc>) -> String {
970    dt.format(GTD_FORMAT).to_string()
971}
972
973/// The good-till-date form this client sends, in UTC.
974const GTD_FORMAT: &str = "%Y%m%d-%H:%M:%S";
975
976/// Build an at-close order (MOC or LOC).
977///
978/// Only Market and Limit orders can be combined with at-close execution.
979/// Stop orders cannot execute at close due to IBKR's order model constraints.
980fn build_at_close_order(
981    action: Action,
982    quantity: f64,
983    kind: &OrderKind,
984    price: Option<rust_decimal::Decimal>,
985) -> Result<Order, OrderMappingError> {
986    match kind {
987        OrderKind::Market => Ok(order_builder::market_on_close(action, quantity)),
988        OrderKind::Limit => {
989            let price_f64 = require_limit_price(price, *kind)?;
990            Ok(order_builder::limit_on_close(action, quantity, price_f64))
991        }
992        // Stop orders cannot be combined with at-close execution
993        _ => Err(OrderMappingError::UnsupportedOrderKindForAtClose(*kind)),
994    }
995}
996
997/// Build a bracket order with proper OCA linking.
998///
999/// ibapi's `order_builder::bracket_order()` sets `parent_id` on child orders (TP/SL
1000/// wait for entry fill) but does NOT set `oca_group` or `oca_type`. Without OCA
1001/// linking, if take-profit fills, stop-loss stays open — dangerous position exposure.
1002///
1003/// This function wraps `bracket_order()` and adds OCA group linking so that when
1004/// either child order fills, the other is automatically cancelled by IB.
1005///
1006/// # Arguments
1007///
1008/// * `parent_order_id` - The IB order ID for the parent (entry) order
1009/// * `action` - Buy or Sell for the entry order (children use opposite action)
1010/// * `quantity` - Number of shares/contracts
1011/// * `limit_price` - Entry limit price
1012/// * `take_profit_price` - Take profit limit price (above entry for Buy, below for Sell)
1013/// * `stop_loss_price` - Stop loss trigger price (below entry for Buy, above for Sell)
1014///
1015/// # Returns
1016///
1017/// [`OrderMappingError::InvalidBracketPrices`] if `ibapi` rejects the prices: one is not finite,
1018/// or the take profit or stop loss is on the wrong side of the entry. Otherwise a
1019/// Vec of 3 orders: `[parent, take_profit, stop_loss]` with:
1020/// - `parent_id` set on children (IB's bracket linkage)
1021/// - `oca_group` set on children (OCA linkage between TP and SL)
1022/// - `oca_type = CancelWithBlock` (safest: one at a time, cancel remaining)
1023/// - `transmit` flags: parent=false, TP=false, SL=true (atomic submission)
1024///
1025/// # Order ID Convention
1026///
1027/// ibapi's `bracket_order()` assigns consecutive IDs: parent=N, TP=N+1, SL=N+2.
1028/// Caller must ensure these IDs are reserved atomically via `allocate_order_id_range(3)`.
1029pub fn build_ib_bracket_with_oca(
1030    parent_order_id: i32,
1031    action: Action,
1032    quantity: f64,
1033    limit_price: f64,
1034    take_profit_price: f64,
1035    stop_loss_price: f64,
1036    tif: IbTimeInForce,
1037) -> Result<Vec<Order>, OrderMappingError> {
1038    let prices = BracketPrices {
1039        entry: limit_price,
1040        take_profit: take_profit_price,
1041        stop_loss: stop_loss_price,
1042    };
1043    let mut orders = order_builder::bracket_order(parent_order_id, action, quantity, prices)
1044        .map_err(|e| OrderMappingError::InvalidBracketPrices(e.to_string()))?;
1045    assert_eq!(
1046        orders.len(),
1047        3,
1048        "ibapi bracket_order must return exactly 3 orders (parent, TP, SL)"
1049    );
1050
1051    // ibapi's bracket_order() defaults all legs to TimeInForce::Day; override
1052    // with the caller-specified TIF so it actually reaches the wire.
1053    orders[0].tif = tif.clone();
1054    orders[1].tif = tif.clone();
1055    orders[2].tif = tif;
1056
1057    // Link TP and SL via OCA group so one cancels the other.
1058    // CancelWithBlock provides overfill protection by routing only one child at a time.
1059    let oca_group = format!("bracket_{}", parent_order_id);
1060    orders[1].oca_group = oca_group.clone();
1061    orders[1].oca_type = OcaType::CancelWithBlock;
1062    orders[2].oca_group = oca_group;
1063    orders[2].oca_type = OcaType::CancelWithBlock;
1064
1065    Ok(orders)
1066}
1067
1068#[cfg(test)]
1069#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
1070mod tests {
1071    use super::*;
1072    use rust_decimal::Decimal;
1073
1074    fn test_context() -> OrderContext {
1075        OrderContext {
1076            instrument: rustrade_instrument::instrument::name::InstrumentNameExchange::from("AAPL"),
1077            side: Side::Buy,
1078            price: Some(Decimal::from(150)),
1079            quantity: Decimal::from(100),
1080            kind: OrderKind::Limit,
1081            time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1082        }
1083    }
1084
1085    #[test]
1086    fn test_order_id_map_basic() {
1087        let map = OrderIdMap::new();
1088        let cid = ClientOrderId::new("order-123");
1089        let ctx = test_context();
1090
1091        map.register(cid.clone(), 42, ctx.clone()).unwrap();
1092
1093        assert_eq!(map.get_ib_id(&cid), Some(42));
1094        assert_eq!(map.get_client_id(42), Some(cid.clone()));
1095        assert_eq!(map.len(), 1);
1096
1097        let (retrieved_cid, retrieved_ctx) = map.get_client_id_and_context(42).unwrap();
1098        assert_eq!(retrieved_cid, cid);
1099        assert_eq!(retrieved_ctx.side, Side::Buy);
1100        assert_eq!(retrieved_ctx.price, Some(Decimal::from(150)));
1101    }
1102
1103    #[test]
1104    fn test_order_id_map_remove() {
1105        let map = OrderIdMap::new();
1106        let cid = ClientOrderId::new("order-456");
1107
1108        map.register(cid.clone(), 100, test_context()).unwrap();
1109        assert_eq!(map.len(), 1);
1110
1111        let removed = map.remove_by_ib_id(100);
1112        assert_eq!(removed, Some(cid.clone()));
1113        assert!(map.is_empty());
1114        assert!(map.get_ib_id(&cid).is_none());
1115        assert!(map.get_client_id(100).is_none());
1116        assert!(map.get_client_id_and_context(100).is_none());
1117    }
1118
1119    #[test]
1120    fn test_side_conversion() {
1121        assert!(matches!(
1122            side_to_action(rustrade_instrument::Side::Buy),
1123            Action::Buy
1124        ));
1125        assert!(matches!(
1126            side_to_action(rustrade_instrument::Side::Sell),
1127            Action::Sell
1128        ));
1129    }
1130
1131    #[test]
1132    fn test_time_in_force_conversion() {
1133        assert_eq!(
1134            time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: false }),
1135            Ok(IbTimeInForce::GoodTillCanceled)
1136        );
1137
1138        assert!(matches!(
1139            time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: true }),
1140            Err(OrderMappingError::PostOnlyNotSupported)
1141        ));
1142
1143        assert_eq!(
1144            time_in_force_to_ib(&TimeInForce::GoodUntilEndOfDay),
1145            Ok(IbTimeInForce::Day)
1146        );
1147        assert_eq!(
1148            time_in_force_to_ib(&TimeInForce::FillOrKill),
1149            Ok(IbTimeInForce::FillOrKill)
1150        );
1151        assert_eq!(
1152            time_in_force_to_ib(&TimeInForce::ImmediateOrCancel),
1153            Ok(IbTimeInForce::ImmediateOrCancel)
1154        );
1155    }
1156
1157    #[test]
1158    fn test_time_in_force_conversion_good_till_date() {
1159        use chrono::{TimeZone, Utc};
1160
1161        let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
1162        assert_eq!(
1163            time_in_force_to_ib(&TimeInForce::GoodTillDate { expiry }),
1164            Ok(IbTimeInForce::GoodTillDate)
1165        );
1166    }
1167
1168    #[test]
1169    fn test_time_in_force_conversion_at_open() {
1170        assert_eq!(
1171            time_in_force_to_ib(&TimeInForce::AtOpen),
1172            Ok(IbTimeInForce::OnOpen)
1173        );
1174    }
1175
1176    #[test]
1177    fn test_time_in_force_conversion_at_close_returns_err() {
1178        // AtClose changes the order TYPE (MOC/LOC), so callers must route
1179        // through build_ib_order. Other callers (e.g. bracket orders) get a
1180        // graceful Err that they can surface as an order rejection.
1181        assert_eq!(
1182            time_in_force_to_ib(&TimeInForce::AtClose),
1183            Err(OrderMappingError::AtCloseRequiresOrderTypeChange)
1184        );
1185    }
1186
1187    #[test]
1188    fn test_build_market_order() {
1189        let order = build_ib_order(
1190            rustrade_instrument::Side::Buy,
1191            100.0,
1192            &OrderKind::Market,
1193            None, // Market orders have no limit price
1194            &TimeInForce::GoodUntilEndOfDay,
1195        )
1196        .unwrap();
1197
1198        assert_eq!(order.action, Action::Buy);
1199        assert_eq!(order.total_quantity, 100.0);
1200        assert_eq!(order.order_type, "MKT");
1201    }
1202
1203    #[test]
1204    fn test_build_limit_order() {
1205        let order = build_ib_order(
1206            rustrade_instrument::Side::Sell,
1207            50.0,
1208            &OrderKind::Limit,
1209            Some(Decimal::try_from(150.5).unwrap()),
1210            &TimeInForce::GoodUntilCancelled { post_only: false },
1211        )
1212        .unwrap();
1213
1214        assert_eq!(order.action, Action::Sell);
1215        assert_eq!(order.total_quantity, 50.0);
1216        assert_eq!(order.order_type, "LMT");
1217    }
1218
1219    #[test]
1220    fn test_order_id_map_remove_and_get_context() {
1221        let map = OrderIdMap::new();
1222        let cid = ClientOrderId::new("order-789");
1223        let ctx = test_context();
1224
1225        map.register(cid.clone(), 50, ctx).unwrap();
1226        assert_eq!(map.len(), 1);
1227
1228        // Remove and get context in single operation
1229        let result = map.remove_and_get_context(50);
1230        assert!(result.is_some());
1231        let (retrieved_cid, retrieved_ctx) = result.unwrap();
1232        assert_eq!(retrieved_cid, cid);
1233        assert_eq!(retrieved_ctx.side, Side::Buy);
1234
1235        // Map should be empty now
1236        assert!(map.is_empty());
1237        assert!(map.get_client_id(50).is_none());
1238        assert!(map.get_ib_id(&cid).is_none());
1239
1240        // Second removal returns None
1241        assert!(map.remove_and_get_context(50).is_none());
1242    }
1243
1244    #[test]
1245    fn test_order_id_map_clear_stale() {
1246        use std::time::Duration;
1247
1248        let map = OrderIdMap::new();
1249
1250        // Register orders
1251        map.register(ClientOrderId::new("old-1"), 1, test_context())
1252            .unwrap();
1253        map.register(ClientOrderId::new("old-2"), 2, test_context())
1254            .unwrap();
1255
1256        // With zero max_age, all entries are stale
1257        let cleared = map.clear_stale(Duration::ZERO);
1258        assert_eq!(cleared, 2);
1259        assert!(map.is_empty());
1260
1261        // Register new orders
1262        map.register(ClientOrderId::new("new-1"), 10, test_context())
1263            .unwrap();
1264        map.register(ClientOrderId::new("new-2"), 20, test_context())
1265            .unwrap();
1266
1267        // With large max_age, nothing is stale
1268        let cleared = map.clear_stale(Duration::from_secs(3600));
1269        assert_eq!(cleared, 0);
1270        assert_eq!(map.len(), 2);
1271    }
1272
1273    /// A live order's id is refused to a second order, and the first keeps its mapping.
1274    #[test]
1275    fn a_live_orders_id_is_refused_to_a_second_order() {
1276        let map = OrderIdMap::new();
1277        let cid = ClientOrderId::new("taken");
1278        map.register(cid.clone(), 1, test_context()).unwrap();
1279
1280        assert_eq!(
1281            map.register(cid.clone(), 2, test_context()),
1282            Err(ClientOrderIdInUse(cid.clone()))
1283        );
1284        assert_eq!(
1285            map.get_ib_id(&cid),
1286            Some(1),
1287            "a cancel by id still reaches the first"
1288        );
1289        assert!(
1290            map.get_client_id(2).is_none(),
1291            "nothing of the second was registered"
1292        );
1293    }
1294
1295    /// A filled order frees its id at once, while its late executions still resolve to it.
1296    #[test]
1297    fn a_filled_order_frees_its_id_and_keeps_resolving_by_ib_id() {
1298        let map = OrderIdMap::new();
1299        let cid = ClientOrderId::new("reused");
1300        map.register(cid.clone(), 1, test_context()).unwrap();
1301
1302        assert_eq!(
1303            map.release_client_id(1).map(|(id, _)| id),
1304            Some(cid.clone())
1305        );
1306        assert!(map.is_empty(), "no order is live");
1307        assert_eq!(
1308            map.get_client_id(1),
1309            Some(cid.clone()),
1310            "a late execution resolves"
1311        );
1312
1313        // The id is free again.
1314        map.register(cid.clone(), 2, test_context()).unwrap();
1315        assert_eq!(map.get_ib_id(&cid), Some(2));
1316        assert_eq!(map.get_client_id(1), Some(cid.clone()), "and still does");
1317    }
1318
1319    /// An earlier order under a reused id ending, or being reaped, leaves the order now under the
1320    /// id mapped.
1321    #[test]
1322    fn an_earlier_order_ending_leaves_a_reused_ids_mapping() {
1323        let map = OrderIdMap::new();
1324        let cid = ClientOrderId::new("reused");
1325        map.register(cid.clone(), 1, test_context()).unwrap();
1326        let _ = map.release_client_id(1);
1327        map.register(cid.clone(), 2, test_context()).unwrap();
1328
1329        assert!(
1330            map.remove_and_get_context(1).is_some(),
1331            "the first order's entry goes"
1332        );
1333        assert_eq!(map.get_ib_id(&cid), Some(2), "the second keeps the id");
1334        assert!(map.remove_by_ib_id(1).is_none());
1335        assert_eq!(map.get_ib_id(&cid), Some(2));
1336    }
1337
1338    /// Reaping a filled order's entry leaves a reused id's mapping too.
1339    #[test]
1340    fn reaping_a_filled_order_leaves_a_reused_ids_mapping() {
1341        use std::time::Duration;
1342
1343        let map = OrderIdMap::new();
1344        let cid = ClientOrderId::new("reused");
1345        map.register(cid.clone(), 1, test_context()).unwrap();
1346        let _ = map.release_client_id(1);
1347        std::thread::sleep(Duration::from_millis(5));
1348        map.register(cid.clone(), 2, test_context()).unwrap();
1349
1350        assert_eq!(
1351            map.clear_stale(Duration::from_millis(5)),
1352            1,
1353            "only the filled one"
1354        );
1355        assert_eq!(map.get_ib_id(&cid), Some(2));
1356    }
1357
1358    /// Several orders register all or none: an id already live, or one repeated among them,
1359    /// registers none of them.
1360    #[test]
1361    fn register_all_registers_every_order_or_none() {
1362        let map = OrderIdMap::new();
1363        map.register(ClientOrderId::new("live"), 1, test_context())
1364            .unwrap();
1365
1366        let clash = map.register_all([
1367            (ClientOrderId::new("a"), 10, test_context()),
1368            (ClientOrderId::new("live"), 11, test_context()),
1369        ]);
1370        assert_eq!(clash, Err(ClientOrderIdInUse(ClientOrderId::new("live"))));
1371
1372        let repeated = map.register_all([
1373            (ClientOrderId::new("b"), 20, test_context()),
1374            (ClientOrderId::new("b"), 21, test_context()),
1375        ]);
1376        assert_eq!(repeated, Err(ClientOrderIdInUse(ClientOrderId::new("b"))));
1377
1378        assert_eq!(map.len(), 1, "neither registered anything");
1379        assert!(
1380            [10, 11, 20, 21]
1381                .iter()
1382                .all(|ib_id| map.get_client_id(*ib_id).is_none())
1383        );
1384
1385        map.register_all([
1386            (ClientOrderId::new("a"), 10, test_context()),
1387            (ClientOrderId::new("b"), 20, test_context()),
1388        ])
1389        .unwrap();
1390        assert_eq!(map.len(), 3);
1391    }
1392
1393    /// A listed order is adopted under its client order id: tracked, and marked adopted.
1394    #[test]
1395    fn an_adopted_order_is_tracked_and_marked_adopted() {
1396        let map = OrderIdMap::new();
1397        let cid = ClientOrderId::new("listed");
1398
1399        map.adopt(cid.clone(), 7, test_context()).unwrap();
1400
1401        assert_eq!(map.get_ib_id(&cid), Some(7));
1402        assert_eq!(map.get_client_id(7), Some(cid.clone()));
1403        assert!(map.registration(&cid).is_some_and(|r| r.adopted));
1404    }
1405
1406    /// An IB order id that already names a tracked order, live or filled and not yet reaped, is
1407    /// not adopted under another id, and the tracked order is left as it was.
1408    #[test]
1409    fn adopting_an_ib_order_id_already_tracked_is_refused_and_changes_nothing() {
1410        let map = OrderIdMap::new();
1411        let placed = ClientOrderId::new("placed");
1412        let adopted = ClientOrderId::new("adopted");
1413        let filled = ClientOrderId::new("filled");
1414        map.register(placed.clone(), 1, test_context()).unwrap();
1415        map.adopt(adopted.clone(), 2, test_context()).unwrap();
1416        map.register(filled.clone(), 3, test_context()).unwrap();
1417        let _ = map.release_client_id(3);
1418
1419        for ib_id in [1, 2, 3] {
1420            let other = ClientOrderId::new(smol_str::format_smolstr!("other-{ib_id}"));
1421            assert_eq!(
1422                map.adopt(other.clone(), ib_id, test_context()),
1423                Err(AdoptionRefused::IbOrderIdTracked)
1424            );
1425            assert!(map.get_ib_id(&other).is_none(), "{other} was not tracked");
1426        }
1427
1428        assert_eq!(map.get_client_id(1), Some(placed.clone()));
1429        assert_eq!(map.get_client_id(2), Some(adopted.clone()));
1430        assert_eq!(map.get_client_id(3), Some(filled.clone()));
1431        assert!(map.registration(&placed).is_some_and(|r| !r.adopted));
1432        assert!(map.registration(&adopted).is_some_and(|r| r.adopted));
1433        assert!(
1434            map.registration(&filled).is_none(),
1435            "the filled order's id stays free"
1436        );
1437        assert_eq!(map.len(), 2);
1438    }
1439
1440    /// A client order id a live order holds is not adopted for another IB order, and the live
1441    /// order is left as it was.
1442    #[test]
1443    fn adopting_a_client_order_id_a_live_order_holds_is_refused_and_changes_nothing() {
1444        let map = OrderIdMap::new();
1445        let placed = ClientOrderId::new("placed");
1446        let adopted = ClientOrderId::new("adopted");
1447        map.register(placed.clone(), 1, test_context()).unwrap();
1448        map.adopt(adopted.clone(), 2, test_context()).unwrap();
1449        let before = [
1450            map.registration(&placed).map(|r| r.adopted),
1451            map.registration(&adopted).map(|r| r.adopted),
1452        ];
1453
1454        for (cid, ib_id) in [(&placed, 10), (&adopted, 20)] {
1455            assert_eq!(
1456                map.adopt(cid.clone(), ib_id, test_context()),
1457                Err(AdoptionRefused::ClientOrderIdInUse)
1458            );
1459            assert!(!map.contains(ib_id), "IB order {ib_id} was not tracked");
1460        }
1461
1462        assert_eq!(map.get_ib_id(&placed), Some(1));
1463        assert_eq!(map.get_ib_id(&adopted), Some(2));
1464        assert_eq!(
1465            [
1466                map.registration(&placed).map(|r| r.adopted),
1467                map.registration(&adopted).map(|r| r.adopted),
1468            ],
1469            before
1470        );
1471        assert_eq!(map.len(), 2);
1472    }
1473
1474    /// A client order id whose earlier order ended, cancelled or filled, may name an adopted
1475    /// order. A filled order keeps its entry, so its late executions still resolve.
1476    #[test]
1477    fn an_order_is_adopted_under_an_id_whose_earlier_order_ended() {
1478        let map = OrderIdMap::new();
1479        let cancelled = ClientOrderId::new("cancelled");
1480        let filled = ClientOrderId::new("filled");
1481        map.register(cancelled.clone(), 1, test_context()).unwrap();
1482        map.register(filled.clone(), 2, test_context()).unwrap();
1483        let _ = map.remove_and_get_context(1);
1484        let _ = map.release_client_id(2);
1485
1486        map.adopt(cancelled.clone(), 11, test_context()).unwrap();
1487        map.adopt(filled.clone(), 12, test_context()).unwrap();
1488
1489        assert_eq!(map.get_ib_id(&cancelled), Some(11));
1490        assert_eq!(map.get_ib_id(&filled), Some(12));
1491        assert!(map.registration(&cancelled).is_some_and(|r| r.adopted));
1492        assert!(map.registration(&filled).is_some_and(|r| r.adopted));
1493        assert_eq!(
1494            map.get_client_id(2),
1495            Some(filled),
1496            "the filled order's executions resolve"
1497        );
1498    }
1499
1500    #[test]
1501    fn test_pending_cancels_insert_remove() {
1502        let cancels = PendingCancels::new();
1503        assert!(cancels.is_empty());
1504
1505        // Insert a cancel request
1506        cancels.insert(42);
1507        assert_eq!(cancels.len(), 1);
1508        assert!(!cancels.is_empty());
1509
1510        // Remove returns true for tracked ID
1511        assert!(cancels.remove(42));
1512        assert!(cancels.is_empty());
1513
1514        // Remove returns false for untracked ID
1515        assert!(!cancels.remove(42));
1516        assert!(!cancels.remove(999));
1517    }
1518
1519    #[test]
1520    fn test_pending_cancels_multiple() {
1521        let cancels = PendingCancels::new();
1522
1523        cancels.insert(1);
1524        cancels.insert(2);
1525        cancels.insert(3);
1526        assert_eq!(cancels.len(), 3);
1527
1528        // Remove middle one
1529        assert!(cancels.remove(2));
1530        assert_eq!(cancels.len(), 2);
1531
1532        // Other IDs still tracked
1533        assert!(cancels.remove(1));
1534        assert!(cancels.remove(3));
1535        assert!(cancels.is_empty());
1536    }
1537
1538    #[test]
1539    fn test_pending_cancels_clear_stale() {
1540        use std::time::Duration;
1541
1542        let cancels = PendingCancels::new();
1543
1544        cancels.insert(1);
1545        cancels.insert(2);
1546
1547        // With zero max_age, all entries are stale
1548        let cleared = cancels.clear_stale(Duration::ZERO);
1549        assert_eq!(cleared, 2);
1550        assert!(cancels.is_empty());
1551
1552        // Insert new ones
1553        cancels.insert(10);
1554        cancels.insert(20);
1555
1556        // With large max_age, nothing is stale
1557        let cleared = cancels.clear_stale(Duration::from_secs(3600));
1558        assert_eq!(cleared, 0);
1559        assert_eq!(cancels.len(), 2);
1560    }
1561
1562    #[test]
1563    fn test_pending_cancels_duplicate_insert() {
1564        let cancels = PendingCancels::new();
1565
1566        cancels.insert(42);
1567        cancels.insert(42);
1568
1569        // HashMap deduplicates by ID; second insert updates timestamp
1570        assert_eq!(cancels.len(), 1);
1571        assert!(cancels.remove(42));
1572        assert!(cancels.is_empty());
1573    }
1574
1575    #[test]
1576    fn test_build_stop_order() {
1577        let order = build_ib_order(
1578            rustrade_instrument::Side::Sell,
1579            100.0,
1580            &OrderKind::Stop {
1581                trigger_price: Decimal::from(45),
1582            },
1583            None, // Stop orders have no limit price
1584            &TimeInForce::GoodUntilCancelled { post_only: false },
1585        )
1586        .unwrap();
1587
1588        assert_eq!(order.action, Action::Sell);
1589        assert_eq!(order.total_quantity, 100.0);
1590        assert_eq!(order.order_type, "STP");
1591        assert_eq!(order.aux_price, Some(45.0));
1592        assert_eq!(order.limit_price, None);
1593    }
1594
1595    #[test]
1596    fn test_build_stop_limit_order() {
1597        let order = build_ib_order(
1598            rustrade_instrument::Side::Sell,
1599            100.0,
1600            &OrderKind::StopLimit {
1601                trigger_price: Decimal::from(44),
1602            },
1603            Some(Decimal::from(45)), // limit price
1604            &TimeInForce::GoodUntilCancelled { post_only: false },
1605        )
1606        .unwrap();
1607
1608        assert_eq!(order.action, Action::Sell);
1609        assert_eq!(order.total_quantity, 100.0);
1610        assert_eq!(order.order_type, "STP LMT");
1611        assert_eq!(order.aux_price, Some(44.0)); // trigger/stop price
1612        assert_eq!(order.limit_price, Some(45.0)); // limit price
1613    }
1614
1615    #[test]
1616    fn test_build_trailing_stop_percentage() {
1617        let order = build_ib_order(
1618            rustrade_instrument::Side::Sell,
1619            100.0,
1620            &OrderKind::TrailingStop {
1621                offset: Decimal::from(5),
1622                offset_type: TrailingOffsetType::Percentage,
1623                activation_price: None,
1624            },
1625            None, // Trailing stop orders have no limit price
1626            &TimeInForce::GoodUntilCancelled { post_only: false },
1627        )
1628        .unwrap();
1629
1630        assert_eq!(order.action, Action::Sell);
1631        assert_eq!(order.total_quantity, 100.0);
1632        assert_eq!(order.order_type, "TRAIL");
1633        assert_eq!(order.trailing_percent, Some(5.0));
1634        assert_eq!(order.aux_price, None); // percentage uses trailing_percent, not aux_price
1635        assert_eq!(order.trail_stop_price, None); // let IB derive from market
1636    }
1637
1638    #[test]
1639    fn test_build_trailing_stop_absolute() {
1640        let order = build_ib_order(
1641            rustrade_instrument::Side::Sell,
1642            100.0,
1643            &OrderKind::TrailingStop {
1644                offset: Decimal::from(2),
1645                offset_type: TrailingOffsetType::Absolute,
1646                activation_price: None,
1647            },
1648            None, // Trailing stop orders have no limit price
1649            &TimeInForce::GoodUntilCancelled { post_only: false },
1650        )
1651        .unwrap();
1652
1653        assert_eq!(order.action, Action::Sell);
1654        assert_eq!(order.total_quantity, 100.0);
1655        assert_eq!(order.order_type, "TRAIL");
1656        assert_eq!(order.aux_price, Some(2.0)); // absolute uses aux_price
1657        assert_eq!(order.trailing_percent, None);
1658        assert_eq!(order.trail_stop_price, None);
1659    }
1660
1661    #[test]
1662    fn test_build_trailing_stop_limit_absolute() {
1663        let order = build_ib_order(
1664            rustrade_instrument::Side::Sell,
1665            100.0,
1666            &OrderKind::TrailingStopLimit {
1667                offset: Decimal::from(2),
1668                offset_type: TrailingOffsetType::Absolute,
1669                stop_price: Decimal::from(95),
1670                limit_offset: Decimal::try_from(0.5).unwrap(),
1671            },
1672            None, // Trailing stop limit uses limit_offset, not a fixed price
1673            &TimeInForce::GoodUntilCancelled { post_only: false },
1674        )
1675        .unwrap();
1676
1677        assert_eq!(order.action, Action::Sell);
1678        assert_eq!(order.total_quantity, 100.0);
1679        assert_eq!(order.order_type, "TRAIL LIMIT");
1680        assert_eq!(order.aux_price, Some(2.0)); // trailing amount
1681        assert_eq!(order.limit_price_offset, Some(0.5)); // limit offset from stop
1682        assert_eq!(order.trailing_percent, None);
1683        assert_eq!(order.trail_stop_price, Some(95.0)); // initial stop
1684        assert_eq!(order.limit_price, None); // IB derives it from the stop
1685    }
1686
1687    #[test]
1688    fn test_build_trailing_stop_limit_percentage() {
1689        let order = build_ib_order(
1690            rustrade_instrument::Side::Sell,
1691            100.0,
1692            &OrderKind::TrailingStopLimit {
1693                offset: Decimal::from(5),
1694                offset_type: TrailingOffsetType::Percentage,
1695                stop_price: Decimal::from(95),
1696                limit_offset: Decimal::try_from(0.5).unwrap(),
1697            },
1698            None, // Trailing stop limit uses limit_offset, not a fixed price
1699            &TimeInForce::GoodUntilCancelled { post_only: false },
1700        )
1701        .unwrap();
1702
1703        assert_eq!(order.action, Action::Sell);
1704        assert_eq!(order.total_quantity, 100.0);
1705        assert_eq!(order.order_type, "TRAIL LIMIT");
1706        assert_eq!(order.trailing_percent, Some(5.0));
1707        assert_eq!(order.limit_price_offset, Some(0.5));
1708        assert_eq!(order.aux_price, None); // percentage doesn't use aux_price
1709        assert_eq!(order.trail_stop_price, Some(95.0)); // initial stop
1710        assert_eq!(order.limit_price, None); // IB derives it from the stop
1711    }
1712
1713    /// IB refuses a TRAIL LIMIT without a stop, and rejects a zero one; neither is sent.
1714    #[test]
1715    fn test_build_trailing_stop_limit_rejects_non_positive_stop_price() {
1716        for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1717            for stop_price in [Decimal::ZERO, Decimal::from(-1)] {
1718                let kind = OrderKind::TrailingStopLimit {
1719                    offset: Decimal::from(2),
1720                    offset_type,
1721                    stop_price,
1722                    limit_offset: Decimal::from(1),
1723                };
1724                assert_eq!(
1725                    build_ib_order(
1726                        rustrade_instrument::Side::Sell,
1727                        1.0,
1728                        &kind,
1729                        None,
1730                        &TimeInForce::GoodUntilCancelled { post_only: false },
1731                    ),
1732                    Err(OrderMappingError::NonPositiveStopPrice(kind)),
1733                    "{kind}"
1734                );
1735            }
1736        }
1737    }
1738
1739    /// The limit follows from the stop and limit offset, so a limit price is refused, not
1740    /// silently dropped.
1741    #[test]
1742    fn test_build_trailing_stop_limit_rejects_limit_price() {
1743        for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1744            let kind = OrderKind::TrailingStopLimit {
1745                offset: Decimal::from(2),
1746                offset_type,
1747                stop_price: Decimal::from(95),
1748                limit_offset: Decimal::from(1),
1749            };
1750            assert_eq!(
1751                build_ib_order(
1752                    rustrade_instrument::Side::Sell,
1753                    1.0,
1754                    &kind,
1755                    Some(Decimal::from(94)),
1756                    &TimeInForce::GoodUntilCancelled { post_only: false },
1757                ),
1758                Err(OrderMappingError::UnexpectedLimitPrice(kind)),
1759                "{kind}"
1760            );
1761        }
1762    }
1763
1764    #[test]
1765    fn test_build_trailing_stop_basis_points_unsupported() {
1766        let result = build_ib_order(
1767            rustrade_instrument::Side::Sell,
1768            100.0,
1769            &OrderKind::TrailingStop {
1770                offset: Decimal::from(50), // 50 basis points
1771                offset_type: TrailingOffsetType::BasisPoints,
1772                activation_price: None,
1773            },
1774            None, // Trailing stop orders have no limit price
1775            &TimeInForce::GoodUntilCancelled { post_only: false },
1776        );
1777
1778        assert!(matches!(
1779            result,
1780            Err(OrderMappingError::UnsupportedOffsetType(
1781                TrailingOffsetType::BasisPoints
1782            ))
1783        ));
1784    }
1785
1786    /// An unresolved kind is only read back from a venue, never placed.
1787    #[test]
1788    fn test_build_unresolved_kind_unsupported() {
1789        let kind = OrderKind::ConditionalUnresolved {
1790            family: crate::order::ConditionalFamily::StopLimit,
1791            trigger_price: Some(Decimal::from(95)),
1792        };
1793        let result = build_ib_order(
1794            rustrade_instrument::Side::Sell,
1795            100.0,
1796            &kind,
1797            Some(Decimal::from(94)),
1798            &TimeInForce::GoodUntilCancelled { post_only: false },
1799        );
1800        assert!(matches!(result, Err(OrderMappingError::UnsupportedOrderKind(k)) if k == kind));
1801    }
1802
1803    #[test]
1804    fn test_build_trailing_stop_limit_basis_points_unsupported() {
1805        let result = build_ib_order(
1806            rustrade_instrument::Side::Sell,
1807            100.0,
1808            &OrderKind::TrailingStopLimit {
1809                offset: Decimal::from(50),
1810                offset_type: TrailingOffsetType::BasisPoints,
1811                stop_price: Decimal::from(95),
1812                limit_offset: Decimal::try_from(0.5).unwrap(),
1813            },
1814            None, // Trailing stop limit uses limit_offset, not a fixed price
1815            &TimeInForce::GoodUntilCancelled { post_only: false },
1816        );
1817
1818        assert!(matches!(
1819            result,
1820            Err(OrderMappingError::UnsupportedOffsetType(
1821                TrailingOffsetType::BasisPoints
1822            ))
1823        ));
1824    }
1825
1826    /// IBKR has no activation price on a trailing stop, and no fixed-limit trailing stop.
1827    #[test]
1828    fn test_build_activated_and_fixed_limit_trailing_stops_unsupported() {
1829        let tif = TimeInForce::GoodUntilCancelled { post_only: false };
1830        for kind in [
1831            OrderKind::TrailingStop {
1832                offset: Decimal::from(2),
1833                offset_type: TrailingOffsetType::Percentage,
1834                activation_price: Some(Decimal::from(150)),
1835            },
1836            OrderKind::TrailingStopFixedLimit {
1837                offset: Decimal::from(2),
1838                offset_type: TrailingOffsetType::Percentage,
1839                activation_price: None,
1840            },
1841        ] {
1842            let result = build_ib_order(
1843                rustrade_instrument::Side::Sell,
1844                100.0,
1845                &kind,
1846                Some(Decimal::from(140)),
1847                &tif,
1848            );
1849            assert!(
1850                matches!(result, Err(OrderMappingError::UnsupportedOrderKind(_))),
1851                "{kind}: {result:?}"
1852            );
1853        }
1854    }
1855
1856    #[test]
1857    fn test_build_take_profit_unsupported() {
1858        let result = build_ib_order(
1859            rustrade_instrument::Side::Sell,
1860            100.0,
1861            &OrderKind::TakeProfit {
1862                trigger_price: Decimal::from(150),
1863            },
1864            None,
1865            &TimeInForce::GoodUntilCancelled { post_only: false },
1866        );
1867
1868        assert!(matches!(
1869            result,
1870            Err(OrderMappingError::UnsupportedOrderKind(
1871                OrderKind::TakeProfit { .. }
1872            ))
1873        ));
1874    }
1875
1876    #[test]
1877    fn test_build_take_profit_limit_unsupported() {
1878        let result = build_ib_order(
1879            rustrade_instrument::Side::Sell,
1880            100.0,
1881            &OrderKind::TakeProfitLimit {
1882                trigger_price: Decimal::from(150),
1883            },
1884            None,
1885            &TimeInForce::GoodUntilCancelled { post_only: false },
1886        );
1887
1888        assert!(matches!(
1889            result,
1890            Err(OrderMappingError::UnsupportedOrderKind(
1891                OrderKind::TakeProfitLimit { .. }
1892            ))
1893        ));
1894    }
1895
1896    // =========================================================================
1897    // Extended Time-in-Force Tests
1898    // =========================================================================
1899
1900    #[test]
1901    fn test_build_market_order_at_open() {
1902        let order = build_ib_order(
1903            rustrade_instrument::Side::Buy,
1904            100.0,
1905            &OrderKind::Market,
1906            None, // Market orders have no limit price
1907            &TimeInForce::AtOpen,
1908        )
1909        .unwrap();
1910
1911        assert_eq!(order.action, Action::Buy);
1912        assert_eq!(order.total_quantity, 100.0);
1913        assert_eq!(order.order_type, "MKT");
1914        assert_eq!(order.tif, IbTimeInForce::OnOpen);
1915    }
1916
1917    #[test]
1918    fn test_build_limit_order_at_open() {
1919        let order = build_ib_order(
1920            rustrade_instrument::Side::Sell,
1921            50.0,
1922            &OrderKind::Limit,
1923            Some(Decimal::from(150)),
1924            &TimeInForce::AtOpen,
1925        )
1926        .unwrap();
1927
1928        assert_eq!(order.action, Action::Sell);
1929        assert_eq!(order.total_quantity, 50.0);
1930        assert_eq!(order.order_type, "LMT");
1931        assert_eq!(order.limit_price, Some(150.0));
1932        assert_eq!(order.tif, IbTimeInForce::OnOpen);
1933    }
1934
1935    #[test]
1936    fn test_build_stop_order_at_open() {
1937        // Stop orders can use AtOpen TIF
1938        let order = build_ib_order(
1939            rustrade_instrument::Side::Sell,
1940            100.0,
1941            &OrderKind::Stop {
1942                trigger_price: Decimal::from(45),
1943            },
1944            None, // Stop orders have no limit price
1945            &TimeInForce::AtOpen,
1946        )
1947        .unwrap();
1948
1949        assert_eq!(order.order_type, "STP");
1950        assert_eq!(order.aux_price, Some(45.0));
1951        assert_eq!(order.tif, IbTimeInForce::OnOpen);
1952    }
1953
1954    #[test]
1955    fn test_build_market_on_close() {
1956        let order = build_ib_order(
1957            rustrade_instrument::Side::Buy,
1958            100.0,
1959            &OrderKind::Market,
1960            None, // Market orders have no limit price
1961            &TimeInForce::AtClose,
1962        )
1963        .unwrap();
1964
1965        assert_eq!(order.action, Action::Buy);
1966        assert_eq!(order.total_quantity, 100.0);
1967        assert_eq!(order.order_type, "MOC");
1968    }
1969
1970    #[test]
1971    fn test_build_limit_on_close() {
1972        let order = build_ib_order(
1973            rustrade_instrument::Side::Sell,
1974            50.0,
1975            &OrderKind::Limit,
1976            Some(Decimal::from(150)),
1977            &TimeInForce::AtClose,
1978        )
1979        .unwrap();
1980
1981        assert_eq!(order.action, Action::Sell);
1982        assert_eq!(order.total_quantity, 50.0);
1983        assert_eq!(order.order_type, "LOC");
1984        assert_eq!(order.limit_price, Some(150.0));
1985    }
1986
1987    #[test]
1988    fn test_build_stop_at_close_unsupported() {
1989        let result = build_ib_order(
1990            rustrade_instrument::Side::Sell,
1991            100.0,
1992            &OrderKind::Stop {
1993                trigger_price: Decimal::from(45),
1994            },
1995            None, // Stop orders have no limit price
1996            &TimeInForce::AtClose,
1997        );
1998
1999        assert!(matches!(
2000            result,
2001            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2002                OrderKind::Stop { .. }
2003            ))
2004        ));
2005    }
2006
2007    #[test]
2008    fn test_build_stop_limit_at_close_unsupported() {
2009        let result = build_ib_order(
2010            rustrade_instrument::Side::Sell,
2011            100.0,
2012            &OrderKind::StopLimit {
2013                trigger_price: Decimal::from(44),
2014            },
2015            Some(Decimal::from(45)),
2016            &TimeInForce::AtClose,
2017        );
2018
2019        assert!(matches!(
2020            result,
2021            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2022                OrderKind::StopLimit { .. }
2023            ))
2024        ));
2025    }
2026
2027    #[test]
2028    fn test_build_trailing_stop_at_close_unsupported() {
2029        let result = build_ib_order(
2030            rustrade_instrument::Side::Sell,
2031            100.0,
2032            &OrderKind::TrailingStop {
2033                offset: Decimal::from(5),
2034                offset_type: TrailingOffsetType::Percentage,
2035                activation_price: None,
2036            },
2037            None, // Trailing stop orders have no limit price
2038            &TimeInForce::AtClose,
2039        );
2040
2041        assert!(matches!(
2042            result,
2043            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2044                OrderKind::TrailingStop { .. }
2045            ))
2046        ));
2047    }
2048
2049    #[test]
2050    fn test_build_trailing_stop_limit_at_close_unsupported() {
2051        let result = build_ib_order(
2052            rustrade_instrument::Side::Sell,
2053            100.0,
2054            &OrderKind::TrailingStopLimit {
2055                offset: Decimal::from(5),
2056                offset_type: TrailingOffsetType::Absolute,
2057                stop_price: Decimal::from(95),
2058                limit_offset: Decimal::from(1),
2059            },
2060            None, // Trailing stop limit uses limit_offset, not a fixed price
2061            &TimeInForce::AtClose,
2062        );
2063
2064        assert!(matches!(
2065            result,
2066            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2067                OrderKind::TrailingStopLimit { .. }
2068            ))
2069        ));
2070    }
2071
2072    #[test]
2073    fn test_build_good_till_date_order() {
2074        use chrono::{TimeZone, Utc};
2075
2076        let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
2077        let order = build_ib_order(
2078            rustrade_instrument::Side::Buy,
2079            100.0,
2080            &OrderKind::Limit,
2081            Some(Decimal::from(150)),
2082            &TimeInForce::GoodTillDate { expiry },
2083        )
2084        .unwrap();
2085
2086        assert_eq!(order.action, Action::Buy);
2087        assert_eq!(order.total_quantity, 100.0);
2088        assert_eq!(order.order_type, "LMT");
2089        assert_eq!(order.tif, IbTimeInForce::GoodTillDate);
2090        assert_eq!(order.good_till_date, "20250630-23:59:59");
2091    }
2092
2093    #[test]
2094    fn test_format_gtd_datetime() {
2095        use chrono::{TimeZone, Utc};
2096
2097        let dt = Utc.with_ymd_and_hms(2024, 12, 25, 14, 30, 0).unwrap();
2098        assert_eq!(format_gtd_datetime(&dt), "20241225-14:30:00");
2099
2100        let dt2 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
2101        assert_eq!(format_gtd_datetime(&dt2), "20250101-00:00:00");
2102    }
2103
2104    // =========================================================================
2105    // Bracket Order Tests
2106    // =========================================================================
2107
2108    #[test]
2109    fn test_build_ib_bracket_with_oca_sets_oca_fields() {
2110        let orders = build_ib_bracket_with_oca(
2111            1000,
2112            Action::Buy,
2113            10.0,
2114            150.0,
2115            160.0,
2116            140.0,
2117            IbTimeInForce::Day,
2118        )
2119        .unwrap();
2120
2121        assert_eq!(orders.len(), 3);
2122
2123        // OCA group must be non-empty on both child orders
2124        assert!(!orders[1].oca_group.is_empty());
2125        assert_eq!(orders[1].oca_group, orders[2].oca_group);
2126
2127        // Both children must use CancelWithBlock
2128        assert_eq!(orders[1].oca_type, OcaType::CancelWithBlock);
2129        assert_eq!(orders[2].oca_type, OcaType::CancelWithBlock);
2130
2131        // Parent must NOT be in OCA group (only children are linked)
2132        assert!(orders[0].oca_group.is_empty());
2133        assert_eq!(orders[0].oca_type, OcaType::None);
2134    }
2135
2136    #[test]
2137    fn test_build_ib_bracket_with_oca_parent_id_linkage() {
2138        let orders = build_ib_bracket_with_oca(
2139            1000,
2140            Action::Buy,
2141            10.0,
2142            150.0,
2143            160.0,
2144            140.0,
2145            IbTimeInForce::Day,
2146        )
2147        .unwrap();
2148
2149        // Children must reference parent via parent_id
2150        assert_eq!(orders[1].parent_id, 1000); // TP waits for parent fill
2151        assert_eq!(orders[2].parent_id, 1000); // SL waits for parent fill
2152
2153        // Parent has no parent
2154        assert_eq!(orders[0].parent_id, 0);
2155    }
2156
2157    #[test]
2158    fn test_build_ib_bracket_with_oca_transmit_flags() {
2159        let orders = build_ib_bracket_with_oca(
2160            1000,
2161            Action::Buy,
2162            10.0,
2163            150.0,
2164            160.0,
2165            140.0,
2166            IbTimeInForce::Day,
2167        )
2168        .unwrap();
2169
2170        // Only the last order triggers transmission of all three
2171        assert!(!orders[0].transmit); // Parent: don't transmit yet
2172        assert!(!orders[1].transmit); // TP: don't transmit yet
2173        assert!(orders[2].transmit); // SL: transmit all
2174    }
2175
2176    #[test]
2177    fn test_build_ib_bracket_with_oca_order_ids_are_consecutive() {
2178        let orders = build_ib_bracket_with_oca(
2179            500,
2180            Action::Sell,
2181            5.0,
2182            100.0,
2183            90.0,
2184            110.0,
2185            IbTimeInForce::Day,
2186        )
2187        .unwrap();
2188
2189        assert_eq!(orders[0].order_id, 500); // Parent
2190        assert_eq!(orders[1].order_id, 501); // Take profit
2191        assert_eq!(orders[2].order_id, 502); // Stop loss
2192    }
2193
2194    #[test]
2195    fn test_build_ib_bracket_with_oca_rejects_prices_on_the_wrong_side_of_the_entry() {
2196        // A buy's take profit below its entry.
2197        let result =
2198            build_ib_bracket_with_oca(7, Action::Buy, 1.0, 10.0, 9.0, 8.0, IbTimeInForce::Day);
2199        assert!(matches!(
2200            result,
2201            Err(OrderMappingError::InvalidBracketPrices(_))
2202        ));
2203
2204        // A sell's stop loss below its entry.
2205        let result =
2206            build_ib_bracket_with_oca(7, Action::Sell, 1.0, 10.0, 8.0, 9.0, IbTimeInForce::Day);
2207        assert!(matches!(
2208            result,
2209            Err(OrderMappingError::InvalidBracketPrices(_))
2210        ));
2211    }
2212
2213    #[test]
2214    fn test_build_ib_bracket_with_oca_group_name_contains_parent_id() {
2215        let orders =
2216            build_ib_bracket_with_oca(42, Action::Buy, 1.0, 10.0, 12.0, 8.0, IbTimeInForce::Day)
2217                .unwrap();
2218
2219        assert!(orders[1].oca_group.contains("42"));
2220        assert!(orders[2].oca_group.contains("42"));
2221    }
2222
2223    #[test]
2224    fn test_build_ib_bracket_with_oca_order_types() {
2225        let orders = build_ib_bracket_with_oca(
2226            1000,
2227            Action::Buy,
2228            100.0,
2229            150.0,
2230            160.0,
2231            140.0,
2232            IbTimeInForce::Day,
2233        )
2234        .unwrap();
2235
2236        // Parent is limit order
2237        assert_eq!(orders[0].order_type, "LMT");
2238        assert_eq!(orders[0].limit_price, Some(150.0));
2239
2240        // Take profit is limit order
2241        assert_eq!(orders[1].order_type, "LMT");
2242        assert_eq!(orders[1].limit_price, Some(160.0));
2243
2244        // Stop loss is stop order
2245        assert_eq!(orders[2].order_type, "STP");
2246        assert_eq!(orders[2].aux_price, Some(140.0));
2247    }
2248
2249    #[test]
2250    fn test_build_ib_bracket_with_oca_actions_reversed_for_children() {
2251        // Buy bracket: entry=Buy, exits=Sell
2252        let buy_orders =
2253            build_ib_bracket_with_oca(100, Action::Buy, 10.0, 50.0, 55.0, 45.0, IbTimeInForce::Day)
2254                .unwrap();
2255        assert_eq!(buy_orders[0].action, Action::Buy);
2256        assert_eq!(buy_orders[1].action, Action::Sell);
2257        assert_eq!(buy_orders[2].action, Action::Sell);
2258
2259        // Sell bracket: entry=Sell, exits=Buy
2260        let sell_orders = build_ib_bracket_with_oca(
2261            200,
2262            Action::Sell,
2263            10.0,
2264            50.0,
2265            45.0,
2266            55.0,
2267            IbTimeInForce::Day,
2268        )
2269        .unwrap();
2270        assert_eq!(sell_orders[0].action, Action::Sell);
2271        assert_eq!(sell_orders[1].action, Action::Buy);
2272        assert_eq!(sell_orders[2].action, Action::Buy);
2273    }
2274
2275    #[test]
2276    fn test_build_ib_bracket_with_oca_applies_tif_to_all_legs() {
2277        let orders = build_ib_bracket_with_oca(
2278            1000,
2279            Action::Buy,
2280            10.0,
2281            150.0,
2282            160.0,
2283            140.0,
2284            IbTimeInForce::GoodTillCanceled,
2285        )
2286        .unwrap();
2287
2288        assert_eq!(orders[0].tif, IbTimeInForce::GoodTillCanceled);
2289        assert_eq!(orders[1].tif, IbTimeInForce::GoodTillCanceled);
2290        assert_eq!(orders[2].tif, IbTimeInForce::GoodTillCanceled);
2291    }
2292
2293    /// A client order id travels as the order reference whole, so one IB would reject or cut is
2294    /// refused: empty, longer than IB echoes back, or not ASCII (IB error 10363).
2295    #[test]
2296    fn order_ref_is_the_client_order_id_when_ib_echoes_it_whole() {
2297        let longest = "a".repeat(MAX_ORDER_REF_LEN);
2298        assert_eq!(
2299            order_ref(&ClientOrderId::new(longest.as_str())).unwrap(),
2300            longest
2301        );
2302        assert_eq!(
2303            order_ref(&ClientOrderId::new("cid-1_tp")).unwrap(),
2304            "cid-1_tp"
2305        );
2306
2307        let too_long = "a".repeat(MAX_ORDER_REF_LEN + 1);
2308        for refused in ["", too_long.as_str(), "ordre-é"] {
2309            let cid = ClientOrderId::new(refused);
2310            assert_eq!(
2311                order_ref(&cid),
2312                Err(OrderMappingError::InvalidClientOrderId(cid.clone()))
2313            );
2314        }
2315    }
2316
2317    /// Every order this client builds reads back as what it was built from, so an open order it no
2318    /// longer tracks is reported as placed.
2319    #[test]
2320    fn order_shape_from_ib_reads_back_what_build_ib_order_sends() {
2321        use chrono::TimeZone;
2322        use rust_decimal_macros::dec;
2323
2324        let expiry = chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap();
2325        let kinds = [
2326            (OrderKind::Market, None),
2327            (OrderKind::Limit, Some(dec!(150.25))),
2328            (
2329                OrderKind::Stop {
2330                    trigger_price: dec!(140.5),
2331                },
2332                None,
2333            ),
2334            (
2335                OrderKind::StopLimit {
2336                    trigger_price: dec!(140.5),
2337                },
2338                Some(dec!(140)),
2339            ),
2340            (
2341                OrderKind::TrailingStop {
2342                    offset: dec!(2.5),
2343                    offset_type: TrailingOffsetType::Percentage,
2344                    activation_price: None,
2345                },
2346                None,
2347            ),
2348            (
2349                OrderKind::TrailingStop {
2350                    offset: dec!(1.75),
2351                    offset_type: TrailingOffsetType::Absolute,
2352                    activation_price: None,
2353                },
2354                None,
2355            ),
2356            (
2357                OrderKind::TrailingStopLimit {
2358                    offset: dec!(2),
2359                    offset_type: TrailingOffsetType::Percentage,
2360                    stop_price: dec!(130.5),
2361                    limit_offset: dec!(0.5),
2362                },
2363                None,
2364            ),
2365            (
2366                OrderKind::TrailingStopLimit {
2367                    offset: dec!(1.5),
2368                    offset_type: TrailingOffsetType::Absolute,
2369                    stop_price: dec!(139.75),
2370                    limit_offset: dec!(0.25),
2371                },
2372                None,
2373            ),
2374        ];
2375        let tifs = [
2376            TimeInForce::GoodUntilCancelled { post_only: false },
2377            TimeInForce::GoodUntilEndOfDay,
2378            TimeInForce::ImmediateOrCancel,
2379            TimeInForce::FillOrKill,
2380            TimeInForce::AtOpen,
2381            TimeInForce::GoodTillDate { expiry },
2382        ];
2383
2384        for (kind, price) in kinds {
2385            for time_in_force in tifs {
2386                let order = build_ib_order(Side::Buy, 1.0, &kind, price, &time_in_force).unwrap();
2387                assert_eq!(
2388                    order_shape_from_ib(&order),
2389                    Ok(OrderShape {
2390                        kind,
2391                        price,
2392                        time_in_force
2393                    }),
2394                    "{kind} {time_in_force}"
2395                );
2396            }
2397        }
2398
2399        // At-close orders are typed MOC/LOC at IB.
2400        for (kind, price) in [
2401            (OrderKind::Market, None),
2402            (OrderKind::Limit, Some(dec!(99))),
2403        ] {
2404            let order =
2405                build_ib_order(Side::Sell, 1.0, &kind, price, &TimeInForce::AtClose).unwrap();
2406            assert_eq!(
2407                order_shape_from_ib(&order),
2408                Ok(OrderShape {
2409                    kind,
2410                    price,
2411                    time_in_force: TimeInForce::AtClose
2412                }),
2413                "{kind}"
2414            );
2415        }
2416    }
2417
2418    #[test]
2419    fn order_shape_from_ib_reads_ibs_own_good_till_date_form() {
2420        use chrono::TimeZone;
2421
2422        let order = Order {
2423            order_type: "MKT".to_owned(),
2424            tif: IbTimeInForce::GoodTillDate,
2425            good_till_date: "20261009 16:00:00 US/Eastern".to_owned(),
2426            ..Order::default()
2427        };
2428        assert_eq!(
2429            order_shape_from_ib(&order).unwrap().time_in_force,
2430            TimeInForce::GoodTillDate {
2431                expiry: chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap()
2432            }
2433        );
2434    }
2435
2436    #[test]
2437    fn order_shape_from_ib_refuses_what_this_client_never_sends() {
2438        let order = |order_type: &str, tif: IbTimeInForce| Order {
2439            order_type: order_type.to_owned(),
2440            tif,
2441            limit_price: Some(1.0),
2442            ..Order::default()
2443        };
2444        for (order, reason) in [
2445            (order("REL", IbTimeInForce::Day), "order type"),
2446            (
2447                order("LMT", IbTimeInForce::DayTillCanceled),
2448                "time in force",
2449            ),
2450            (
2451                Order {
2452                    limit_price: None,
2453                    ..order("LMT", IbTimeInForce::Day)
2454                },
2455                "without limit_price",
2456            ),
2457            (order("STP", IbTimeInForce::Day), "without aux_price"),
2458            (order("MKT", IbTimeInForce::GoodTillDate), "good-till-date"),
2459            (
2460                Order {
2461                    aux_price: Some(2.0),
2462                    limit_price_offset: Some(0.5),
2463                    ..order("TRAIL LIMIT", IbTimeInForce::Day)
2464                },
2465                "without trail_stop_price",
2466            ),
2467            (
2468                Order {
2469                    aux_price: Some(2.0),
2470                    trail_stop_price: Some(0.0),
2471                    limit_price_offset: Some(0.5),
2472                    ..order("TRAIL LIMIT", IbTimeInForce::Day)
2473                },
2474                "not positive",
2475            ),
2476            (
2477                Order {
2478                    trailing_percent: Some(5.0),
2479                    trail_stop_price: Some(-1.0),
2480                    limit_price_offset: Some(0.5),
2481                    ..order("TRAIL LIMIT", IbTimeInForce::Day)
2482                },
2483                "not positive",
2484            ),
2485        ] {
2486            let error = order_shape_from_ib(&order).unwrap_err();
2487            assert!(error.contains(reason), "{error:?} should name {reason:?}");
2488        }
2489    }
2490
2491    #[test]
2492    fn action_to_side_reads_a_short_sale_as_a_sell() {
2493        assert_eq!(action_to_side(&Action::Buy), Side::Buy);
2494        for action in [Action::Sell, Action::SellShort, Action::SellLong] {
2495            assert_eq!(action_to_side(&action), Side::Sell, "{action:?}");
2496        }
2497    }
2498}