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                },
591                None,
592                time_in_force,
593            )
594        }
595        "TRAIL LIMIT" => {
596            let (offset, offset_type) = trailing()?;
597            let stop_price = decimal(order.trail_stop_price, "trail_stop_price")?;
598            if stop_price <= Decimal::ZERO {
599                return Err(format!(
600                    "{order_type} order's trail_stop_price {stop_price} is not positive"
601                ));
602            }
603            (
604                OrderKind::TrailingStopLimit {
605                    offset,
606                    offset_type,
607                    stop_price,
608                    limit_offset: decimal(order.limit_price_offset, "limit_price_offset")?,
609                },
610                None,
611                time_in_force,
612            )
613        }
614        other => return Err(format!("order type {other:?}")),
615    };
616
617    Ok(OrderShape {
618        kind,
619        price,
620        time_in_force,
621    })
622}
623
624/// Parse IB's good-till-date field: the form [`format_gtd_datetime`] sends, in UTC, or IB's
625/// `yyyyMMdd HH:mm:ss` with an optional zone.
626fn parse_gtd_datetime(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
627    chrono::NaiveDateTime::parse_from_str(value, GTD_FORMAT)
628        .map(|naive| naive.and_utc())
629        .ok()
630        .or_else(|| super::execution::parse_ib_timestamp(value))
631}
632
633/// Error when mapping rustrade order types to IB.
634#[non_exhaustive]
635#[derive(Debug, Clone, PartialEq, Eq)]
636pub enum OrderMappingError {
637    PostOnlyNotSupported,
638    /// Price conversion to f64 failed (overflow or invalid decimal).
639    InvalidPrice(String),
640    /// Limit price required for this order type but was None.
641    MissingLimitPrice(OrderKind),
642    /// A limit price was given for a kind that derives its own, such as `TrailingStopLimit`,
643    /// whose limit follows from its stop and `limit_offset`.
644    UnexpectedLimitPrice(OrderKind),
645    /// The kind's stop price is zero or negative; IB rejects such an order.
646    NonPositiveStopPrice(OrderKind),
647    /// Trailing offset type not supported by IBKR.
648    UnsupportedOffsetType(TrailingOffsetType),
649    /// Order kind not supported by IBKR (e.g., TakeProfit).
650    UnsupportedOrderKind(OrderKind),
651    /// AtClose TIF only valid with Market or Limit orders (becomes MOC/LOC).
652    /// Stop orders cannot be combined with at-close execution.
653    UnsupportedOrderKindForAtClose(OrderKind),
654    /// AtClose TIF reached `time_in_force_to_ib` directly. AtClose changes the
655    /// order TYPE (MOC/LOC), not just the TIF; callers must route through
656    /// `build_ib_order` which handles the type promotion.
657    AtCloseRequiresOrderTypeChange,
658    /// `ibapi` rejected a bracket's prices: one is not finite, or the take profit or stop loss is
659    /// on the wrong side of the entry (for a buy, the take profit must be above the entry and the
660    /// stop loss below; a sell the reverse).
661    InvalidBracketPrices(String),
662    /// The client order id cannot be sent as the order's IB order reference, which carries it:
663    /// IB takes at most [`MAX_ORDER_REF_LEN`] ASCII characters there.
664    InvalidClientOrderId(ClientOrderId),
665}
666
667impl std::fmt::Display for OrderMappingError {
668    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
669        match self {
670            Self::PostOnlyNotSupported => write!(f, "post_only not supported by IB"),
671            Self::InvalidPrice(p) => write!(f, "invalid price for f64 conversion: {p}"),
672            Self::MissingLimitPrice(k) => {
673                write!(f, "limit price required for {k} but was None")
674            }
675            Self::UnexpectedLimitPrice(k) => write!(
676                f,
677                "{k} takes no limit price: it follows from the stop price and limit offset"
678            ),
679            Self::NonPositiveStopPrice(k) => {
680                write!(f, "stop price must be positive for {k}")
681            }
682            Self::UnsupportedOffsetType(t) => {
683                write!(f, "trailing offset type {t:?} not supported by IBKR")
684            }
685            Self::UnsupportedOrderKind(k) => {
686                write!(f, "order kind {k} not supported by IBKR")
687            }
688            Self::UnsupportedOrderKindForAtClose(k) => {
689                write!(
690                    f,
691                    "AtClose TIF only valid with Market or Limit orders, got {k}"
692                )
693            }
694            Self::AtCloseRequiresOrderTypeChange => write!(
695                f,
696                "AtClose TIF must be routed through build_ib_order, which promotes \
697                 the order to MOC/LOC; time_in_force_to_ib cannot map it directly"
698            ),
699            Self::InvalidBracketPrices(e) => write!(f, "invalid bracket prices: {e}"),
700            Self::InvalidClientOrderId(cid) => write!(
701                f,
702                "client order id {cid} is not an IB order reference: at most \
703                 {MAX_ORDER_REF_LEN} ASCII characters"
704            ),
705        }
706    }
707}
708
709/// The longest order reference IB echoes back whole, verified on paper. The client order id
710/// travels there, so it is limited to this.
711pub const MAX_ORDER_REF_LEN: usize = 128;
712
713/// The IB order reference that carries `cid`: the id itself.
714///
715/// IB echoes the reference on every open-order, completed-order and execution report, so an order
716/// can be named by the id it was placed with even by a client that did not place it.
717///
718/// # Errors
719/// [`OrderMappingError::InvalidClientOrderId`] for an id IB would reject (error 10363 for
720/// non-ASCII text) or not echo whole: empty, longer than [`MAX_ORDER_REF_LEN`], or not ASCII.
721pub fn order_ref(cid: &ClientOrderId) -> Result<String, OrderMappingError> {
722    let id = cid.0.as_str();
723    if id.is_empty() || id.len() > MAX_ORDER_REF_LEN || !id.is_ascii() {
724        return Err(OrderMappingError::InvalidClientOrderId(cid.clone()));
725    }
726    Ok(id.to_owned())
727}
728
729impl std::error::Error for OrderMappingError {}
730
731/// Convert rustrade TimeInForce to IB TimeInForce.
732///
733/// Returns [`OrderMappingError::AtCloseRequiresOrderTypeChange`] for
734/// [`TimeInForce::AtClose`]: AtClose changes the order TYPE (MOC/LOC), not
735/// just the TIF, and is handled by [`build_ib_order`] which promotes the
736/// order type. Callers needing AtClose support should route through
737/// [`build_ib_order`] rather than this helper.
738pub fn time_in_force_to_ib(tif: &TimeInForce) -> Result<IbTimeInForce, OrderMappingError> {
739    match tif {
740        TimeInForce::GoodUntilCancelled { post_only } => {
741            if *post_only {
742                Err(OrderMappingError::PostOnlyNotSupported)
743            } else {
744                Ok(IbTimeInForce::GoodTillCanceled)
745            }
746        }
747        TimeInForce::GoodUntilEndOfDay => Ok(IbTimeInForce::Day),
748        TimeInForce::FillOrKill => Ok(IbTimeInForce::FillOrKill),
749        TimeInForce::ImmediateOrCancel => Ok(IbTimeInForce::ImmediateOrCancel),
750        TimeInForce::GoodTillDate { .. } => Ok(IbTimeInForce::GoodTillDate),
751        TimeInForce::AtOpen => Ok(IbTimeInForce::OnOpen),
752        // AtClose changes the order TYPE to MOC/LOC, not just the TIF.
753        // `build_ib_order` intercepts AtClose; surfacing Err here lets other
754        // callers (e.g. the bracket-order path) reject gracefully.
755        TimeInForce::AtClose => Err(OrderMappingError::AtCloseRequiresOrderTypeChange),
756    }
757}
758
759/// Convert a Decimal to f64, returning an error if conversion fails.
760fn decimal_to_f64(value: rust_decimal::Decimal) -> Result<f64, OrderMappingError> {
761    value.try_into().or_else(|_| {
762        value
763            .to_string()
764            .parse()
765            .map_err(|_| OrderMappingError::InvalidPrice(value.to_string()))
766    })
767}
768
769/// Extract required limit price from Option, returning error if None.
770fn require_limit_price(
771    price: Option<rust_decimal::Decimal>,
772    kind: OrderKind,
773) -> Result<f64, OrderMappingError> {
774    match price {
775        Some(p) => decimal_to_f64(p),
776        None => Err(OrderMappingError::MissingLimitPrice(kind)),
777    }
778}
779
780/// Build an IB Order from rustrade order parameters.
781///
782/// # Order Type Mapping
783///
784/// - `Market` → IB "MKT"
785/// - `Limit` → IB "LMT" with `limit_price`
786/// - `Stop` → IB "STP" with `aux_price` as trigger
787/// - `StopLimit` → IB "STP LMT" with `aux_price` as trigger, `limit_price` from Order.price
788/// - `TrailingStop` (Percentage) → IB "TRAIL" with `trailing_percent`
789/// - `TrailingStop` (Absolute) → IB "TRAIL" with `aux_price`
790/// - `TrailingStopLimit` (Absolute) → IB "TRAIL LIMIT" with `aux_price`, `trail_stop_price`,
791///   `limit_price_offset`
792/// - `TrailingStopLimit` (Percentage) → IB "TRAIL LIMIT" with `trailing_percent`,
793///   `trail_stop_price`, `limit_price_offset`
794/// - `TakeProfit` → `Err(UnsupportedOrderKind)` (IBKR has no native TP; use bracket orders)
795/// - `TakeProfitLimit` → `Err(UnsupportedOrderKind)` (IBKR has no native TP; use bracket orders)
796/// - `BasisPoints` offset type → Error (not supported by IBKR)
797///
798/// # Price Requirements
799///
800/// - `Market`, `Stop`, `TrailingStop`: `price` should be `None` (ignored if provided)
801/// - `Limit`, `StopLimit`: `price` must be `Some(limit_price)`
802/// - `TrailingStopLimit`: `price` must be `None` (IB derives the limit from the stop and
803///   `limit_offset`), else `Err(UnexpectedLimitPrice)`; `stop_price` must be positive, else
804///   `Err(NonPositiveStopPrice)`. IB requires the initial stop and rejects a zero one.
805///
806/// # Time-in-Force Special Cases
807///
808/// - `AtClose` + `Market` → IB "MOC" (Market-on-Close)
809/// - `AtClose` + `Limit` → IB "LOC" (Limit-on-Close)
810/// - `AtClose` + Stop/Trailing → Error (not supported by IBKR)
811/// - `GoodTillDate` → Sets both TIF and `good_till_date` field
812pub fn build_ib_order(
813    side: rustrade_instrument::Side,
814    quantity: f64,
815    kind: &OrderKind,
816    price: Option<rust_decimal::Decimal>,
817    tif: &TimeInForce,
818) -> Result<Order, OrderMappingError> {
819    let action = side_to_action(side);
820
821    // Handle AtClose specially - it changes the order TYPE, not just TIF
822    if matches!(tif, TimeInForce::AtClose) {
823        return build_at_close_order(action, quantity, kind, price);
824    }
825
826    let tif_ib = time_in_force_to_ib(tif)?;
827
828    let mut order = match kind {
829        OrderKind::Market => order_builder::market_order(action, quantity),
830
831        OrderKind::Limit => {
832            let price_f64 = require_limit_price(price, *kind)?;
833            order_builder::limit_order(action, quantity, price_f64)
834        }
835
836        OrderKind::Stop { trigger_price } => {
837            let trigger_f64 = decimal_to_f64(*trigger_price)?;
838            order_builder::stop(action, quantity, trigger_f64)
839        }
840
841        OrderKind::StopLimit { trigger_price } => {
842            let limit_f64 = require_limit_price(price, *kind)?;
843            let trigger_f64 = decimal_to_f64(*trigger_price)?;
844            // Built by hand: `ibapi` 5 removed `order_builder::stop_limit`, and its fluent
845            // replacement adds nothing our checks above have not done. Same fields as before.
846            Order {
847                action,
848                order_type: "STP LMT".to_owned(),
849                total_quantity: quantity,
850                limit_price: Some(limit_f64),
851                aux_price: Some(trigger_f64),
852                ..Order::default()
853            }
854        }
855
856        OrderKind::TrailingStop {
857            offset,
858            offset_type,
859        } => {
860            let offset_f64 = decimal_to_f64(*offset)?;
861            match offset_type {
862                TrailingOffsetType::Percentage => {
863                    // Use the builder for percentage-based trailing stop.
864                    // trail_stop_price is None, letting IB derive it from market price.
865                    Order {
866                        action,
867                        order_type: "TRAIL".to_owned(),
868                        total_quantity: quantity,
869                        trailing_percent: Some(offset_f64),
870                        trail_stop_price: None,
871                        ..Order::default()
872                    }
873                }
874                TrailingOffsetType::Absolute => {
875                    // Manual construction for absolute trailing stop.
876                    // aux_price holds the trailing amount.
877                    Order {
878                        action,
879                        order_type: "TRAIL".to_owned(),
880                        total_quantity: quantity,
881                        aux_price: Some(offset_f64),
882                        trail_stop_price: None,
883                        ..Order::default()
884                    }
885                }
886                TrailingOffsetType::BasisPoints => {
887                    return Err(OrderMappingError::UnsupportedOffsetType(
888                        TrailingOffsetType::BasisPoints,
889                    ));
890                }
891            }
892        }
893
894        OrderKind::TrailingStopLimit {
895            offset,
896            offset_type,
897            stop_price,
898            limit_offset,
899        } => {
900            // IB sets the limit from the stop and `limit_price_offset`, so a limit price here
901            // would be ignored; refuse it rather than place something other than was asked.
902            if price.is_some() {
903                return Err(OrderMappingError::UnexpectedLimitPrice(*kind));
904            }
905            // IB refuses a TRAIL LIMIT without an initial stop (error 321), and rejects a zero
906            // one as an invalid price once the order reaches the exchange.
907            if *stop_price <= Decimal::ZERO {
908                return Err(OrderMappingError::NonPositiveStopPrice(*kind));
909            }
910            let offset_f64 = decimal_to_f64(*offset)?;
911            let stop_price_f64 = decimal_to_f64(*stop_price)?;
912            let limit_offset_f64 = decimal_to_f64(*limit_offset)?;
913            // The trail goes in `aux_price` as an amount, or in `trailing_percent`.
914            let (aux_price, trailing_percent) = match offset_type {
915                TrailingOffsetType::Absolute => (Some(offset_f64), None),
916                TrailingOffsetType::Percentage => (None, Some(offset_f64)),
917                TrailingOffsetType::BasisPoints => {
918                    return Err(OrderMappingError::UnsupportedOffsetType(
919                        TrailingOffsetType::BasisPoints,
920                    ));
921                }
922            };
923            Order {
924                action,
925                order_type: "TRAIL LIMIT".to_owned(),
926                total_quantity: quantity,
927                aux_price,
928                trailing_percent,
929                trail_stop_price: Some(stop_price_f64),
930                limit_price_offset: Some(limit_offset_f64),
931                ..Order::default()
932            }
933        }
934
935        // IBKR does not have native take-profit order types. Callers should use
936        // bracket orders or manage TP logic at the strategy level.
937        OrderKind::TakeProfit { .. } | OrderKind::TakeProfitLimit { .. } => {
938            return Err(OrderMappingError::UnsupportedOrderKind(*kind));
939        }
940    };
941
942    order.tif = tif_ib;
943
944    // Handle GoodTillDate: set the good_till_date string field
945    if let TimeInForce::GoodTillDate { expiry } = tif {
946        order.good_till_date = format_gtd_datetime(expiry);
947    }
948
949    Ok(order)
950}
951
952/// Format a `DateTime<Utc>` for IB's good_till_date field.
953///
954/// IB accepts format: "yyyyMMdd HH:mm:ss" with optional timezone suffix.
955/// We use UTC format "yyyyMMdd-HH:mm:ss" which IB interprets as UTC.
956fn format_gtd_datetime(dt: &chrono::DateTime<chrono::Utc>) -> String {
957    dt.format(GTD_FORMAT).to_string()
958}
959
960/// The good-till-date form this client sends, in UTC.
961const GTD_FORMAT: &str = "%Y%m%d-%H:%M:%S";
962
963/// Build an at-close order (MOC or LOC).
964///
965/// Only Market and Limit orders can be combined with at-close execution.
966/// Stop orders cannot execute at close due to IBKR's order model constraints.
967fn build_at_close_order(
968    action: Action,
969    quantity: f64,
970    kind: &OrderKind,
971    price: Option<rust_decimal::Decimal>,
972) -> Result<Order, OrderMappingError> {
973    match kind {
974        OrderKind::Market => Ok(order_builder::market_on_close(action, quantity)),
975        OrderKind::Limit => {
976            let price_f64 = require_limit_price(price, *kind)?;
977            Ok(order_builder::limit_on_close(action, quantity, price_f64))
978        }
979        // Stop orders cannot be combined with at-close execution
980        _ => Err(OrderMappingError::UnsupportedOrderKindForAtClose(*kind)),
981    }
982}
983
984/// Build a bracket order with proper OCA linking.
985///
986/// ibapi's `order_builder::bracket_order()` sets `parent_id` on child orders (TP/SL
987/// wait for entry fill) but does NOT set `oca_group` or `oca_type`. Without OCA
988/// linking, if take-profit fills, stop-loss stays open — dangerous position exposure.
989///
990/// This function wraps `bracket_order()` and adds OCA group linking so that when
991/// either child order fills, the other is automatically cancelled by IB.
992///
993/// # Arguments
994///
995/// * `parent_order_id` - The IB order ID for the parent (entry) order
996/// * `action` - Buy or Sell for the entry order (children use opposite action)
997/// * `quantity` - Number of shares/contracts
998/// * `limit_price` - Entry limit price
999/// * `take_profit_price` - Take profit limit price (above entry for Buy, below for Sell)
1000/// * `stop_loss_price` - Stop loss trigger price (below entry for Buy, above for Sell)
1001///
1002/// # Returns
1003///
1004/// [`OrderMappingError::InvalidBracketPrices`] if `ibapi` rejects the prices: one is not finite,
1005/// or the take profit or stop loss is on the wrong side of the entry. Otherwise a
1006/// Vec of 3 orders: `[parent, take_profit, stop_loss]` with:
1007/// - `parent_id` set on children (IB's bracket linkage)
1008/// - `oca_group` set on children (OCA linkage between TP and SL)
1009/// - `oca_type = CancelWithBlock` (safest: one at a time, cancel remaining)
1010/// - `transmit` flags: parent=false, TP=false, SL=true (atomic submission)
1011///
1012/// # Order ID Convention
1013///
1014/// ibapi's `bracket_order()` assigns consecutive IDs: parent=N, TP=N+1, SL=N+2.
1015/// Caller must ensure these IDs are reserved atomically via `allocate_order_id_range(3)`.
1016pub fn build_ib_bracket_with_oca(
1017    parent_order_id: i32,
1018    action: Action,
1019    quantity: f64,
1020    limit_price: f64,
1021    take_profit_price: f64,
1022    stop_loss_price: f64,
1023    tif: IbTimeInForce,
1024) -> Result<Vec<Order>, OrderMappingError> {
1025    let prices = BracketPrices {
1026        entry: limit_price,
1027        take_profit: take_profit_price,
1028        stop_loss: stop_loss_price,
1029    };
1030    let mut orders = order_builder::bracket_order(parent_order_id, action, quantity, prices)
1031        .map_err(|e| OrderMappingError::InvalidBracketPrices(e.to_string()))?;
1032    assert_eq!(
1033        orders.len(),
1034        3,
1035        "ibapi bracket_order must return exactly 3 orders (parent, TP, SL)"
1036    );
1037
1038    // ibapi's bracket_order() defaults all legs to TimeInForce::Day; override
1039    // with the caller-specified TIF so it actually reaches the wire.
1040    orders[0].tif = tif.clone();
1041    orders[1].tif = tif.clone();
1042    orders[2].tif = tif;
1043
1044    // Link TP and SL via OCA group so one cancels the other.
1045    // CancelWithBlock provides overfill protection by routing only one child at a time.
1046    let oca_group = format!("bracket_{}", parent_order_id);
1047    orders[1].oca_group = oca_group.clone();
1048    orders[1].oca_type = OcaType::CancelWithBlock;
1049    orders[2].oca_group = oca_group;
1050    orders[2].oca_type = OcaType::CancelWithBlock;
1051
1052    Ok(orders)
1053}
1054
1055#[cfg(test)]
1056#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
1057mod tests {
1058    use super::*;
1059    use rust_decimal::Decimal;
1060
1061    fn test_context() -> OrderContext {
1062        OrderContext {
1063            instrument: rustrade_instrument::instrument::name::InstrumentNameExchange::from("AAPL"),
1064            side: Side::Buy,
1065            price: Some(Decimal::from(150)),
1066            quantity: Decimal::from(100),
1067            kind: OrderKind::Limit,
1068            time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1069        }
1070    }
1071
1072    #[test]
1073    fn test_order_id_map_basic() {
1074        let map = OrderIdMap::new();
1075        let cid = ClientOrderId::new("order-123");
1076        let ctx = test_context();
1077
1078        map.register(cid.clone(), 42, ctx.clone()).unwrap();
1079
1080        assert_eq!(map.get_ib_id(&cid), Some(42));
1081        assert_eq!(map.get_client_id(42), Some(cid.clone()));
1082        assert_eq!(map.len(), 1);
1083
1084        let (retrieved_cid, retrieved_ctx) = map.get_client_id_and_context(42).unwrap();
1085        assert_eq!(retrieved_cid, cid);
1086        assert_eq!(retrieved_ctx.side, Side::Buy);
1087        assert_eq!(retrieved_ctx.price, Some(Decimal::from(150)));
1088    }
1089
1090    #[test]
1091    fn test_order_id_map_remove() {
1092        let map = OrderIdMap::new();
1093        let cid = ClientOrderId::new("order-456");
1094
1095        map.register(cid.clone(), 100, test_context()).unwrap();
1096        assert_eq!(map.len(), 1);
1097
1098        let removed = map.remove_by_ib_id(100);
1099        assert_eq!(removed, Some(cid.clone()));
1100        assert!(map.is_empty());
1101        assert!(map.get_ib_id(&cid).is_none());
1102        assert!(map.get_client_id(100).is_none());
1103        assert!(map.get_client_id_and_context(100).is_none());
1104    }
1105
1106    #[test]
1107    fn test_side_conversion() {
1108        assert!(matches!(
1109            side_to_action(rustrade_instrument::Side::Buy),
1110            Action::Buy
1111        ));
1112        assert!(matches!(
1113            side_to_action(rustrade_instrument::Side::Sell),
1114            Action::Sell
1115        ));
1116    }
1117
1118    #[test]
1119    fn test_time_in_force_conversion() {
1120        assert_eq!(
1121            time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: false }),
1122            Ok(IbTimeInForce::GoodTillCanceled)
1123        );
1124
1125        assert!(matches!(
1126            time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: true }),
1127            Err(OrderMappingError::PostOnlyNotSupported)
1128        ));
1129
1130        assert_eq!(
1131            time_in_force_to_ib(&TimeInForce::GoodUntilEndOfDay),
1132            Ok(IbTimeInForce::Day)
1133        );
1134        assert_eq!(
1135            time_in_force_to_ib(&TimeInForce::FillOrKill),
1136            Ok(IbTimeInForce::FillOrKill)
1137        );
1138        assert_eq!(
1139            time_in_force_to_ib(&TimeInForce::ImmediateOrCancel),
1140            Ok(IbTimeInForce::ImmediateOrCancel)
1141        );
1142    }
1143
1144    #[test]
1145    fn test_time_in_force_conversion_good_till_date() {
1146        use chrono::{TimeZone, Utc};
1147
1148        let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
1149        assert_eq!(
1150            time_in_force_to_ib(&TimeInForce::GoodTillDate { expiry }),
1151            Ok(IbTimeInForce::GoodTillDate)
1152        );
1153    }
1154
1155    #[test]
1156    fn test_time_in_force_conversion_at_open() {
1157        assert_eq!(
1158            time_in_force_to_ib(&TimeInForce::AtOpen),
1159            Ok(IbTimeInForce::OnOpen)
1160        );
1161    }
1162
1163    #[test]
1164    fn test_time_in_force_conversion_at_close_returns_err() {
1165        // AtClose changes the order TYPE (MOC/LOC), so callers must route
1166        // through build_ib_order. Other callers (e.g. bracket orders) get a
1167        // graceful Err that they can surface as an order rejection.
1168        assert_eq!(
1169            time_in_force_to_ib(&TimeInForce::AtClose),
1170            Err(OrderMappingError::AtCloseRequiresOrderTypeChange)
1171        );
1172    }
1173
1174    #[test]
1175    fn test_build_market_order() {
1176        let order = build_ib_order(
1177            rustrade_instrument::Side::Buy,
1178            100.0,
1179            &OrderKind::Market,
1180            None, // Market orders have no limit price
1181            &TimeInForce::GoodUntilEndOfDay,
1182        )
1183        .unwrap();
1184
1185        assert_eq!(order.action, Action::Buy);
1186        assert_eq!(order.total_quantity, 100.0);
1187        assert_eq!(order.order_type, "MKT");
1188    }
1189
1190    #[test]
1191    fn test_build_limit_order() {
1192        let order = build_ib_order(
1193            rustrade_instrument::Side::Sell,
1194            50.0,
1195            &OrderKind::Limit,
1196            Some(Decimal::try_from(150.5).unwrap()),
1197            &TimeInForce::GoodUntilCancelled { post_only: false },
1198        )
1199        .unwrap();
1200
1201        assert_eq!(order.action, Action::Sell);
1202        assert_eq!(order.total_quantity, 50.0);
1203        assert_eq!(order.order_type, "LMT");
1204    }
1205
1206    #[test]
1207    fn test_order_id_map_remove_and_get_context() {
1208        let map = OrderIdMap::new();
1209        let cid = ClientOrderId::new("order-789");
1210        let ctx = test_context();
1211
1212        map.register(cid.clone(), 50, ctx).unwrap();
1213        assert_eq!(map.len(), 1);
1214
1215        // Remove and get context in single operation
1216        let result = map.remove_and_get_context(50);
1217        assert!(result.is_some());
1218        let (retrieved_cid, retrieved_ctx) = result.unwrap();
1219        assert_eq!(retrieved_cid, cid);
1220        assert_eq!(retrieved_ctx.side, Side::Buy);
1221
1222        // Map should be empty now
1223        assert!(map.is_empty());
1224        assert!(map.get_client_id(50).is_none());
1225        assert!(map.get_ib_id(&cid).is_none());
1226
1227        // Second removal returns None
1228        assert!(map.remove_and_get_context(50).is_none());
1229    }
1230
1231    #[test]
1232    fn test_order_id_map_clear_stale() {
1233        use std::time::Duration;
1234
1235        let map = OrderIdMap::new();
1236
1237        // Register orders
1238        map.register(ClientOrderId::new("old-1"), 1, test_context())
1239            .unwrap();
1240        map.register(ClientOrderId::new("old-2"), 2, test_context())
1241            .unwrap();
1242
1243        // With zero max_age, all entries are stale
1244        let cleared = map.clear_stale(Duration::ZERO);
1245        assert_eq!(cleared, 2);
1246        assert!(map.is_empty());
1247
1248        // Register new orders
1249        map.register(ClientOrderId::new("new-1"), 10, test_context())
1250            .unwrap();
1251        map.register(ClientOrderId::new("new-2"), 20, test_context())
1252            .unwrap();
1253
1254        // With large max_age, nothing is stale
1255        let cleared = map.clear_stale(Duration::from_secs(3600));
1256        assert_eq!(cleared, 0);
1257        assert_eq!(map.len(), 2);
1258    }
1259
1260    /// A live order's id is refused to a second order, and the first keeps its mapping.
1261    #[test]
1262    fn a_live_orders_id_is_refused_to_a_second_order() {
1263        let map = OrderIdMap::new();
1264        let cid = ClientOrderId::new("taken");
1265        map.register(cid.clone(), 1, test_context()).unwrap();
1266
1267        assert_eq!(
1268            map.register(cid.clone(), 2, test_context()),
1269            Err(ClientOrderIdInUse(cid.clone()))
1270        );
1271        assert_eq!(
1272            map.get_ib_id(&cid),
1273            Some(1),
1274            "a cancel by id still reaches the first"
1275        );
1276        assert!(
1277            map.get_client_id(2).is_none(),
1278            "nothing of the second was registered"
1279        );
1280    }
1281
1282    /// A filled order frees its id at once, while its late executions still resolve to it.
1283    #[test]
1284    fn a_filled_order_frees_its_id_and_keeps_resolving_by_ib_id() {
1285        let map = OrderIdMap::new();
1286        let cid = ClientOrderId::new("reused");
1287        map.register(cid.clone(), 1, test_context()).unwrap();
1288
1289        assert_eq!(
1290            map.release_client_id(1).map(|(id, _)| id),
1291            Some(cid.clone())
1292        );
1293        assert!(map.is_empty(), "no order is live");
1294        assert_eq!(
1295            map.get_client_id(1),
1296            Some(cid.clone()),
1297            "a late execution resolves"
1298        );
1299
1300        // The id is free again.
1301        map.register(cid.clone(), 2, test_context()).unwrap();
1302        assert_eq!(map.get_ib_id(&cid), Some(2));
1303        assert_eq!(map.get_client_id(1), Some(cid.clone()), "and still does");
1304    }
1305
1306    /// An earlier order under a reused id ending, or being reaped, leaves the order now under the
1307    /// id mapped.
1308    #[test]
1309    fn an_earlier_order_ending_leaves_a_reused_ids_mapping() {
1310        let map = OrderIdMap::new();
1311        let cid = ClientOrderId::new("reused");
1312        map.register(cid.clone(), 1, test_context()).unwrap();
1313        let _ = map.release_client_id(1);
1314        map.register(cid.clone(), 2, test_context()).unwrap();
1315
1316        assert!(
1317            map.remove_and_get_context(1).is_some(),
1318            "the first order's entry goes"
1319        );
1320        assert_eq!(map.get_ib_id(&cid), Some(2), "the second keeps the id");
1321        assert!(map.remove_by_ib_id(1).is_none());
1322        assert_eq!(map.get_ib_id(&cid), Some(2));
1323    }
1324
1325    /// Reaping a filled order's entry leaves a reused id's mapping too.
1326    #[test]
1327    fn reaping_a_filled_order_leaves_a_reused_ids_mapping() {
1328        use std::time::Duration;
1329
1330        let map = OrderIdMap::new();
1331        let cid = ClientOrderId::new("reused");
1332        map.register(cid.clone(), 1, test_context()).unwrap();
1333        let _ = map.release_client_id(1);
1334        std::thread::sleep(Duration::from_millis(5));
1335        map.register(cid.clone(), 2, test_context()).unwrap();
1336
1337        assert_eq!(
1338            map.clear_stale(Duration::from_millis(5)),
1339            1,
1340            "only the filled one"
1341        );
1342        assert_eq!(map.get_ib_id(&cid), Some(2));
1343    }
1344
1345    /// Several orders register all or none: an id already live, or one repeated among them,
1346    /// registers none of them.
1347    #[test]
1348    fn register_all_registers_every_order_or_none() {
1349        let map = OrderIdMap::new();
1350        map.register(ClientOrderId::new("live"), 1, test_context())
1351            .unwrap();
1352
1353        let clash = map.register_all([
1354            (ClientOrderId::new("a"), 10, test_context()),
1355            (ClientOrderId::new("live"), 11, test_context()),
1356        ]);
1357        assert_eq!(clash, Err(ClientOrderIdInUse(ClientOrderId::new("live"))));
1358
1359        let repeated = map.register_all([
1360            (ClientOrderId::new("b"), 20, test_context()),
1361            (ClientOrderId::new("b"), 21, test_context()),
1362        ]);
1363        assert_eq!(repeated, Err(ClientOrderIdInUse(ClientOrderId::new("b"))));
1364
1365        assert_eq!(map.len(), 1, "neither registered anything");
1366        assert!(
1367            [10, 11, 20, 21]
1368                .iter()
1369                .all(|ib_id| map.get_client_id(*ib_id).is_none())
1370        );
1371
1372        map.register_all([
1373            (ClientOrderId::new("a"), 10, test_context()),
1374            (ClientOrderId::new("b"), 20, test_context()),
1375        ])
1376        .unwrap();
1377        assert_eq!(map.len(), 3);
1378    }
1379
1380    /// A listed order is adopted under its client order id: tracked, and marked adopted.
1381    #[test]
1382    fn an_adopted_order_is_tracked_and_marked_adopted() {
1383        let map = OrderIdMap::new();
1384        let cid = ClientOrderId::new("listed");
1385
1386        map.adopt(cid.clone(), 7, test_context()).unwrap();
1387
1388        assert_eq!(map.get_ib_id(&cid), Some(7));
1389        assert_eq!(map.get_client_id(7), Some(cid.clone()));
1390        assert!(map.registration(&cid).is_some_and(|r| r.adopted));
1391    }
1392
1393    /// An IB order id that already names a tracked order, live or filled and not yet reaped, is
1394    /// not adopted under another id, and the tracked order is left as it was.
1395    #[test]
1396    fn adopting_an_ib_order_id_already_tracked_is_refused_and_changes_nothing() {
1397        let map = OrderIdMap::new();
1398        let placed = ClientOrderId::new("placed");
1399        let adopted = ClientOrderId::new("adopted");
1400        let filled = ClientOrderId::new("filled");
1401        map.register(placed.clone(), 1, test_context()).unwrap();
1402        map.adopt(adopted.clone(), 2, test_context()).unwrap();
1403        map.register(filled.clone(), 3, test_context()).unwrap();
1404        let _ = map.release_client_id(3);
1405
1406        for ib_id in [1, 2, 3] {
1407            let other = ClientOrderId::new(smol_str::format_smolstr!("other-{ib_id}"));
1408            assert_eq!(
1409                map.adopt(other.clone(), ib_id, test_context()),
1410                Err(AdoptionRefused::IbOrderIdTracked)
1411            );
1412            assert!(map.get_ib_id(&other).is_none(), "{other} was not tracked");
1413        }
1414
1415        assert_eq!(map.get_client_id(1), Some(placed.clone()));
1416        assert_eq!(map.get_client_id(2), Some(adopted.clone()));
1417        assert_eq!(map.get_client_id(3), Some(filled.clone()));
1418        assert!(map.registration(&placed).is_some_and(|r| !r.adopted));
1419        assert!(map.registration(&adopted).is_some_and(|r| r.adopted));
1420        assert!(
1421            map.registration(&filled).is_none(),
1422            "the filled order's id stays free"
1423        );
1424        assert_eq!(map.len(), 2);
1425    }
1426
1427    /// A client order id a live order holds is not adopted for another IB order, and the live
1428    /// order is left as it was.
1429    #[test]
1430    fn adopting_a_client_order_id_a_live_order_holds_is_refused_and_changes_nothing() {
1431        let map = OrderIdMap::new();
1432        let placed = ClientOrderId::new("placed");
1433        let adopted = ClientOrderId::new("adopted");
1434        map.register(placed.clone(), 1, test_context()).unwrap();
1435        map.adopt(adopted.clone(), 2, test_context()).unwrap();
1436        let before = [
1437            map.registration(&placed).map(|r| r.adopted),
1438            map.registration(&adopted).map(|r| r.adopted),
1439        ];
1440
1441        for (cid, ib_id) in [(&placed, 10), (&adopted, 20)] {
1442            assert_eq!(
1443                map.adopt(cid.clone(), ib_id, test_context()),
1444                Err(AdoptionRefused::ClientOrderIdInUse)
1445            );
1446            assert!(!map.contains(ib_id), "IB order {ib_id} was not tracked");
1447        }
1448
1449        assert_eq!(map.get_ib_id(&placed), Some(1));
1450        assert_eq!(map.get_ib_id(&adopted), Some(2));
1451        assert_eq!(
1452            [
1453                map.registration(&placed).map(|r| r.adopted),
1454                map.registration(&adopted).map(|r| r.adopted),
1455            ],
1456            before
1457        );
1458        assert_eq!(map.len(), 2);
1459    }
1460
1461    /// A client order id whose earlier order ended, cancelled or filled, may name an adopted
1462    /// order. A filled order keeps its entry, so its late executions still resolve.
1463    #[test]
1464    fn an_order_is_adopted_under_an_id_whose_earlier_order_ended() {
1465        let map = OrderIdMap::new();
1466        let cancelled = ClientOrderId::new("cancelled");
1467        let filled = ClientOrderId::new("filled");
1468        map.register(cancelled.clone(), 1, test_context()).unwrap();
1469        map.register(filled.clone(), 2, test_context()).unwrap();
1470        let _ = map.remove_and_get_context(1);
1471        let _ = map.release_client_id(2);
1472
1473        map.adopt(cancelled.clone(), 11, test_context()).unwrap();
1474        map.adopt(filled.clone(), 12, test_context()).unwrap();
1475
1476        assert_eq!(map.get_ib_id(&cancelled), Some(11));
1477        assert_eq!(map.get_ib_id(&filled), Some(12));
1478        assert!(map.registration(&cancelled).is_some_and(|r| r.adopted));
1479        assert!(map.registration(&filled).is_some_and(|r| r.adopted));
1480        assert_eq!(
1481            map.get_client_id(2),
1482            Some(filled),
1483            "the filled order's executions resolve"
1484        );
1485    }
1486
1487    #[test]
1488    fn test_pending_cancels_insert_remove() {
1489        let cancels = PendingCancels::new();
1490        assert!(cancels.is_empty());
1491
1492        // Insert a cancel request
1493        cancels.insert(42);
1494        assert_eq!(cancels.len(), 1);
1495        assert!(!cancels.is_empty());
1496
1497        // Remove returns true for tracked ID
1498        assert!(cancels.remove(42));
1499        assert!(cancels.is_empty());
1500
1501        // Remove returns false for untracked ID
1502        assert!(!cancels.remove(42));
1503        assert!(!cancels.remove(999));
1504    }
1505
1506    #[test]
1507    fn test_pending_cancels_multiple() {
1508        let cancels = PendingCancels::new();
1509
1510        cancels.insert(1);
1511        cancels.insert(2);
1512        cancels.insert(3);
1513        assert_eq!(cancels.len(), 3);
1514
1515        // Remove middle one
1516        assert!(cancels.remove(2));
1517        assert_eq!(cancels.len(), 2);
1518
1519        // Other IDs still tracked
1520        assert!(cancels.remove(1));
1521        assert!(cancels.remove(3));
1522        assert!(cancels.is_empty());
1523    }
1524
1525    #[test]
1526    fn test_pending_cancels_clear_stale() {
1527        use std::time::Duration;
1528
1529        let cancels = PendingCancels::new();
1530
1531        cancels.insert(1);
1532        cancels.insert(2);
1533
1534        // With zero max_age, all entries are stale
1535        let cleared = cancels.clear_stale(Duration::ZERO);
1536        assert_eq!(cleared, 2);
1537        assert!(cancels.is_empty());
1538
1539        // Insert new ones
1540        cancels.insert(10);
1541        cancels.insert(20);
1542
1543        // With large max_age, nothing is stale
1544        let cleared = cancels.clear_stale(Duration::from_secs(3600));
1545        assert_eq!(cleared, 0);
1546        assert_eq!(cancels.len(), 2);
1547    }
1548
1549    #[test]
1550    fn test_pending_cancels_duplicate_insert() {
1551        let cancels = PendingCancels::new();
1552
1553        cancels.insert(42);
1554        cancels.insert(42);
1555
1556        // HashMap deduplicates by ID; second insert updates timestamp
1557        assert_eq!(cancels.len(), 1);
1558        assert!(cancels.remove(42));
1559        assert!(cancels.is_empty());
1560    }
1561
1562    #[test]
1563    fn test_build_stop_order() {
1564        let order = build_ib_order(
1565            rustrade_instrument::Side::Sell,
1566            100.0,
1567            &OrderKind::Stop {
1568                trigger_price: Decimal::from(45),
1569            },
1570            None, // Stop orders have no limit price
1571            &TimeInForce::GoodUntilCancelled { post_only: false },
1572        )
1573        .unwrap();
1574
1575        assert_eq!(order.action, Action::Sell);
1576        assert_eq!(order.total_quantity, 100.0);
1577        assert_eq!(order.order_type, "STP");
1578        assert_eq!(order.aux_price, Some(45.0));
1579        assert_eq!(order.limit_price, None);
1580    }
1581
1582    #[test]
1583    fn test_build_stop_limit_order() {
1584        let order = build_ib_order(
1585            rustrade_instrument::Side::Sell,
1586            100.0,
1587            &OrderKind::StopLimit {
1588                trigger_price: Decimal::from(44),
1589            },
1590            Some(Decimal::from(45)), // limit price
1591            &TimeInForce::GoodUntilCancelled { post_only: false },
1592        )
1593        .unwrap();
1594
1595        assert_eq!(order.action, Action::Sell);
1596        assert_eq!(order.total_quantity, 100.0);
1597        assert_eq!(order.order_type, "STP LMT");
1598        assert_eq!(order.aux_price, Some(44.0)); // trigger/stop price
1599        assert_eq!(order.limit_price, Some(45.0)); // limit price
1600    }
1601
1602    #[test]
1603    fn test_build_trailing_stop_percentage() {
1604        let order = build_ib_order(
1605            rustrade_instrument::Side::Sell,
1606            100.0,
1607            &OrderKind::TrailingStop {
1608                offset: Decimal::from(5),
1609                offset_type: TrailingOffsetType::Percentage,
1610            },
1611            None, // Trailing stop orders have no limit price
1612            &TimeInForce::GoodUntilCancelled { post_only: false },
1613        )
1614        .unwrap();
1615
1616        assert_eq!(order.action, Action::Sell);
1617        assert_eq!(order.total_quantity, 100.0);
1618        assert_eq!(order.order_type, "TRAIL");
1619        assert_eq!(order.trailing_percent, Some(5.0));
1620        assert_eq!(order.aux_price, None); // percentage uses trailing_percent, not aux_price
1621        assert_eq!(order.trail_stop_price, None); // let IB derive from market
1622    }
1623
1624    #[test]
1625    fn test_build_trailing_stop_absolute() {
1626        let order = build_ib_order(
1627            rustrade_instrument::Side::Sell,
1628            100.0,
1629            &OrderKind::TrailingStop {
1630                offset: Decimal::from(2),
1631                offset_type: TrailingOffsetType::Absolute,
1632            },
1633            None, // Trailing stop orders have no limit price
1634            &TimeInForce::GoodUntilCancelled { post_only: false },
1635        )
1636        .unwrap();
1637
1638        assert_eq!(order.action, Action::Sell);
1639        assert_eq!(order.total_quantity, 100.0);
1640        assert_eq!(order.order_type, "TRAIL");
1641        assert_eq!(order.aux_price, Some(2.0)); // absolute uses aux_price
1642        assert_eq!(order.trailing_percent, None);
1643        assert_eq!(order.trail_stop_price, None);
1644    }
1645
1646    #[test]
1647    fn test_build_trailing_stop_limit_absolute() {
1648        let order = build_ib_order(
1649            rustrade_instrument::Side::Sell,
1650            100.0,
1651            &OrderKind::TrailingStopLimit {
1652                offset: Decimal::from(2),
1653                offset_type: TrailingOffsetType::Absolute,
1654                stop_price: Decimal::from(95),
1655                limit_offset: Decimal::try_from(0.5).unwrap(),
1656            },
1657            None, // Trailing stop limit uses limit_offset, not a fixed price
1658            &TimeInForce::GoodUntilCancelled { post_only: false },
1659        )
1660        .unwrap();
1661
1662        assert_eq!(order.action, Action::Sell);
1663        assert_eq!(order.total_quantity, 100.0);
1664        assert_eq!(order.order_type, "TRAIL LIMIT");
1665        assert_eq!(order.aux_price, Some(2.0)); // trailing amount
1666        assert_eq!(order.limit_price_offset, Some(0.5)); // limit offset from stop
1667        assert_eq!(order.trailing_percent, None);
1668        assert_eq!(order.trail_stop_price, Some(95.0)); // initial stop
1669        assert_eq!(order.limit_price, None); // IB derives it from the stop
1670    }
1671
1672    #[test]
1673    fn test_build_trailing_stop_limit_percentage() {
1674        let order = build_ib_order(
1675            rustrade_instrument::Side::Sell,
1676            100.0,
1677            &OrderKind::TrailingStopLimit {
1678                offset: Decimal::from(5),
1679                offset_type: TrailingOffsetType::Percentage,
1680                stop_price: Decimal::from(95),
1681                limit_offset: Decimal::try_from(0.5).unwrap(),
1682            },
1683            None, // Trailing stop limit uses limit_offset, not a fixed price
1684            &TimeInForce::GoodUntilCancelled { post_only: false },
1685        )
1686        .unwrap();
1687
1688        assert_eq!(order.action, Action::Sell);
1689        assert_eq!(order.total_quantity, 100.0);
1690        assert_eq!(order.order_type, "TRAIL LIMIT");
1691        assert_eq!(order.trailing_percent, Some(5.0));
1692        assert_eq!(order.limit_price_offset, Some(0.5));
1693        assert_eq!(order.aux_price, None); // percentage doesn't use aux_price
1694        assert_eq!(order.trail_stop_price, Some(95.0)); // initial stop
1695        assert_eq!(order.limit_price, None); // IB derives it from the stop
1696    }
1697
1698    /// IB refuses a TRAIL LIMIT without a stop, and rejects a zero one; neither is sent.
1699    #[test]
1700    fn test_build_trailing_stop_limit_rejects_non_positive_stop_price() {
1701        for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1702            for stop_price in [Decimal::ZERO, Decimal::from(-1)] {
1703                let kind = OrderKind::TrailingStopLimit {
1704                    offset: Decimal::from(2),
1705                    offset_type,
1706                    stop_price,
1707                    limit_offset: Decimal::from(1),
1708                };
1709                assert_eq!(
1710                    build_ib_order(
1711                        rustrade_instrument::Side::Sell,
1712                        1.0,
1713                        &kind,
1714                        None,
1715                        &TimeInForce::GoodUntilCancelled { post_only: false },
1716                    ),
1717                    Err(OrderMappingError::NonPositiveStopPrice(kind)),
1718                    "{kind}"
1719                );
1720            }
1721        }
1722    }
1723
1724    /// The limit follows from the stop and limit offset, so a limit price is refused, not
1725    /// silently dropped.
1726    #[test]
1727    fn test_build_trailing_stop_limit_rejects_limit_price() {
1728        for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1729            let kind = OrderKind::TrailingStopLimit {
1730                offset: Decimal::from(2),
1731                offset_type,
1732                stop_price: Decimal::from(95),
1733                limit_offset: Decimal::from(1),
1734            };
1735            assert_eq!(
1736                build_ib_order(
1737                    rustrade_instrument::Side::Sell,
1738                    1.0,
1739                    &kind,
1740                    Some(Decimal::from(94)),
1741                    &TimeInForce::GoodUntilCancelled { post_only: false },
1742                ),
1743                Err(OrderMappingError::UnexpectedLimitPrice(kind)),
1744                "{kind}"
1745            );
1746        }
1747    }
1748
1749    #[test]
1750    fn test_build_trailing_stop_basis_points_unsupported() {
1751        let result = build_ib_order(
1752            rustrade_instrument::Side::Sell,
1753            100.0,
1754            &OrderKind::TrailingStop {
1755                offset: Decimal::from(50), // 50 basis points
1756                offset_type: TrailingOffsetType::BasisPoints,
1757            },
1758            None, // Trailing stop orders have no limit price
1759            &TimeInForce::GoodUntilCancelled { post_only: false },
1760        );
1761
1762        assert!(matches!(
1763            result,
1764            Err(OrderMappingError::UnsupportedOffsetType(
1765                TrailingOffsetType::BasisPoints
1766            ))
1767        ));
1768    }
1769
1770    #[test]
1771    fn test_build_trailing_stop_limit_basis_points_unsupported() {
1772        let result = build_ib_order(
1773            rustrade_instrument::Side::Sell,
1774            100.0,
1775            &OrderKind::TrailingStopLimit {
1776                offset: Decimal::from(50),
1777                offset_type: TrailingOffsetType::BasisPoints,
1778                stop_price: Decimal::from(95),
1779                limit_offset: Decimal::try_from(0.5).unwrap(),
1780            },
1781            None, // Trailing stop limit uses limit_offset, not a fixed price
1782            &TimeInForce::GoodUntilCancelled { post_only: false },
1783        );
1784
1785        assert!(matches!(
1786            result,
1787            Err(OrderMappingError::UnsupportedOffsetType(
1788                TrailingOffsetType::BasisPoints
1789            ))
1790        ));
1791    }
1792
1793    #[test]
1794    fn test_build_take_profit_unsupported() {
1795        let result = build_ib_order(
1796            rustrade_instrument::Side::Sell,
1797            100.0,
1798            &OrderKind::TakeProfit {
1799                trigger_price: Decimal::from(150),
1800            },
1801            None,
1802            &TimeInForce::GoodUntilCancelled { post_only: false },
1803        );
1804
1805        assert!(matches!(
1806            result,
1807            Err(OrderMappingError::UnsupportedOrderKind(
1808                OrderKind::TakeProfit { .. }
1809            ))
1810        ));
1811    }
1812
1813    #[test]
1814    fn test_build_take_profit_limit_unsupported() {
1815        let result = build_ib_order(
1816            rustrade_instrument::Side::Sell,
1817            100.0,
1818            &OrderKind::TakeProfitLimit {
1819                trigger_price: Decimal::from(150),
1820            },
1821            None,
1822            &TimeInForce::GoodUntilCancelled { post_only: false },
1823        );
1824
1825        assert!(matches!(
1826            result,
1827            Err(OrderMappingError::UnsupportedOrderKind(
1828                OrderKind::TakeProfitLimit { .. }
1829            ))
1830        ));
1831    }
1832
1833    // =========================================================================
1834    // Extended Time-in-Force Tests
1835    // =========================================================================
1836
1837    #[test]
1838    fn test_build_market_order_at_open() {
1839        let order = build_ib_order(
1840            rustrade_instrument::Side::Buy,
1841            100.0,
1842            &OrderKind::Market,
1843            None, // Market orders have no limit price
1844            &TimeInForce::AtOpen,
1845        )
1846        .unwrap();
1847
1848        assert_eq!(order.action, Action::Buy);
1849        assert_eq!(order.total_quantity, 100.0);
1850        assert_eq!(order.order_type, "MKT");
1851        assert_eq!(order.tif, IbTimeInForce::OnOpen);
1852    }
1853
1854    #[test]
1855    fn test_build_limit_order_at_open() {
1856        let order = build_ib_order(
1857            rustrade_instrument::Side::Sell,
1858            50.0,
1859            &OrderKind::Limit,
1860            Some(Decimal::from(150)),
1861            &TimeInForce::AtOpen,
1862        )
1863        .unwrap();
1864
1865        assert_eq!(order.action, Action::Sell);
1866        assert_eq!(order.total_quantity, 50.0);
1867        assert_eq!(order.order_type, "LMT");
1868        assert_eq!(order.limit_price, Some(150.0));
1869        assert_eq!(order.tif, IbTimeInForce::OnOpen);
1870    }
1871
1872    #[test]
1873    fn test_build_stop_order_at_open() {
1874        // Stop orders can use AtOpen TIF
1875        let order = build_ib_order(
1876            rustrade_instrument::Side::Sell,
1877            100.0,
1878            &OrderKind::Stop {
1879                trigger_price: Decimal::from(45),
1880            },
1881            None, // Stop orders have no limit price
1882            &TimeInForce::AtOpen,
1883        )
1884        .unwrap();
1885
1886        assert_eq!(order.order_type, "STP");
1887        assert_eq!(order.aux_price, Some(45.0));
1888        assert_eq!(order.tif, IbTimeInForce::OnOpen);
1889    }
1890
1891    #[test]
1892    fn test_build_market_on_close() {
1893        let order = build_ib_order(
1894            rustrade_instrument::Side::Buy,
1895            100.0,
1896            &OrderKind::Market,
1897            None, // Market orders have no limit price
1898            &TimeInForce::AtClose,
1899        )
1900        .unwrap();
1901
1902        assert_eq!(order.action, Action::Buy);
1903        assert_eq!(order.total_quantity, 100.0);
1904        assert_eq!(order.order_type, "MOC");
1905    }
1906
1907    #[test]
1908    fn test_build_limit_on_close() {
1909        let order = build_ib_order(
1910            rustrade_instrument::Side::Sell,
1911            50.0,
1912            &OrderKind::Limit,
1913            Some(Decimal::from(150)),
1914            &TimeInForce::AtClose,
1915        )
1916        .unwrap();
1917
1918        assert_eq!(order.action, Action::Sell);
1919        assert_eq!(order.total_quantity, 50.0);
1920        assert_eq!(order.order_type, "LOC");
1921        assert_eq!(order.limit_price, Some(150.0));
1922    }
1923
1924    #[test]
1925    fn test_build_stop_at_close_unsupported() {
1926        let result = build_ib_order(
1927            rustrade_instrument::Side::Sell,
1928            100.0,
1929            &OrderKind::Stop {
1930                trigger_price: Decimal::from(45),
1931            },
1932            None, // Stop orders have no limit price
1933            &TimeInForce::AtClose,
1934        );
1935
1936        assert!(matches!(
1937            result,
1938            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
1939                OrderKind::Stop { .. }
1940            ))
1941        ));
1942    }
1943
1944    #[test]
1945    fn test_build_stop_limit_at_close_unsupported() {
1946        let result = build_ib_order(
1947            rustrade_instrument::Side::Sell,
1948            100.0,
1949            &OrderKind::StopLimit {
1950                trigger_price: Decimal::from(44),
1951            },
1952            Some(Decimal::from(45)),
1953            &TimeInForce::AtClose,
1954        );
1955
1956        assert!(matches!(
1957            result,
1958            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
1959                OrderKind::StopLimit { .. }
1960            ))
1961        ));
1962    }
1963
1964    #[test]
1965    fn test_build_trailing_stop_at_close_unsupported() {
1966        let result = build_ib_order(
1967            rustrade_instrument::Side::Sell,
1968            100.0,
1969            &OrderKind::TrailingStop {
1970                offset: Decimal::from(5),
1971                offset_type: TrailingOffsetType::Percentage,
1972            },
1973            None, // Trailing stop orders have no limit price
1974            &TimeInForce::AtClose,
1975        );
1976
1977        assert!(matches!(
1978            result,
1979            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
1980                OrderKind::TrailingStop { .. }
1981            ))
1982        ));
1983    }
1984
1985    #[test]
1986    fn test_build_trailing_stop_limit_at_close_unsupported() {
1987        let result = build_ib_order(
1988            rustrade_instrument::Side::Sell,
1989            100.0,
1990            &OrderKind::TrailingStopLimit {
1991                offset: Decimal::from(5),
1992                offset_type: TrailingOffsetType::Absolute,
1993                stop_price: Decimal::from(95),
1994                limit_offset: Decimal::from(1),
1995            },
1996            None, // Trailing stop limit uses limit_offset, not a fixed price
1997            &TimeInForce::AtClose,
1998        );
1999
2000        assert!(matches!(
2001            result,
2002            Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2003                OrderKind::TrailingStopLimit { .. }
2004            ))
2005        ));
2006    }
2007
2008    #[test]
2009    fn test_build_good_till_date_order() {
2010        use chrono::{TimeZone, Utc};
2011
2012        let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
2013        let order = build_ib_order(
2014            rustrade_instrument::Side::Buy,
2015            100.0,
2016            &OrderKind::Limit,
2017            Some(Decimal::from(150)),
2018            &TimeInForce::GoodTillDate { expiry },
2019        )
2020        .unwrap();
2021
2022        assert_eq!(order.action, Action::Buy);
2023        assert_eq!(order.total_quantity, 100.0);
2024        assert_eq!(order.order_type, "LMT");
2025        assert_eq!(order.tif, IbTimeInForce::GoodTillDate);
2026        assert_eq!(order.good_till_date, "20250630-23:59:59");
2027    }
2028
2029    #[test]
2030    fn test_format_gtd_datetime() {
2031        use chrono::{TimeZone, Utc};
2032
2033        let dt = Utc.with_ymd_and_hms(2024, 12, 25, 14, 30, 0).unwrap();
2034        assert_eq!(format_gtd_datetime(&dt), "20241225-14:30:00");
2035
2036        let dt2 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
2037        assert_eq!(format_gtd_datetime(&dt2), "20250101-00:00:00");
2038    }
2039
2040    // =========================================================================
2041    // Bracket Order Tests
2042    // =========================================================================
2043
2044    #[test]
2045    fn test_build_ib_bracket_with_oca_sets_oca_fields() {
2046        let orders = build_ib_bracket_with_oca(
2047            1000,
2048            Action::Buy,
2049            10.0,
2050            150.0,
2051            160.0,
2052            140.0,
2053            IbTimeInForce::Day,
2054        )
2055        .unwrap();
2056
2057        assert_eq!(orders.len(), 3);
2058
2059        // OCA group must be non-empty on both child orders
2060        assert!(!orders[1].oca_group.is_empty());
2061        assert_eq!(orders[1].oca_group, orders[2].oca_group);
2062
2063        // Both children must use CancelWithBlock
2064        assert_eq!(orders[1].oca_type, OcaType::CancelWithBlock);
2065        assert_eq!(orders[2].oca_type, OcaType::CancelWithBlock);
2066
2067        // Parent must NOT be in OCA group (only children are linked)
2068        assert!(orders[0].oca_group.is_empty());
2069        assert_eq!(orders[0].oca_type, OcaType::None);
2070    }
2071
2072    #[test]
2073    fn test_build_ib_bracket_with_oca_parent_id_linkage() {
2074        let orders = build_ib_bracket_with_oca(
2075            1000,
2076            Action::Buy,
2077            10.0,
2078            150.0,
2079            160.0,
2080            140.0,
2081            IbTimeInForce::Day,
2082        )
2083        .unwrap();
2084
2085        // Children must reference parent via parent_id
2086        assert_eq!(orders[1].parent_id, 1000); // TP waits for parent fill
2087        assert_eq!(orders[2].parent_id, 1000); // SL waits for parent fill
2088
2089        // Parent has no parent
2090        assert_eq!(orders[0].parent_id, 0);
2091    }
2092
2093    #[test]
2094    fn test_build_ib_bracket_with_oca_transmit_flags() {
2095        let orders = build_ib_bracket_with_oca(
2096            1000,
2097            Action::Buy,
2098            10.0,
2099            150.0,
2100            160.0,
2101            140.0,
2102            IbTimeInForce::Day,
2103        )
2104        .unwrap();
2105
2106        // Only the last order triggers transmission of all three
2107        assert!(!orders[0].transmit); // Parent: don't transmit yet
2108        assert!(!orders[1].transmit); // TP: don't transmit yet
2109        assert!(orders[2].transmit); // SL: transmit all
2110    }
2111
2112    #[test]
2113    fn test_build_ib_bracket_with_oca_order_ids_are_consecutive() {
2114        let orders = build_ib_bracket_with_oca(
2115            500,
2116            Action::Sell,
2117            5.0,
2118            100.0,
2119            90.0,
2120            110.0,
2121            IbTimeInForce::Day,
2122        )
2123        .unwrap();
2124
2125        assert_eq!(orders[0].order_id, 500); // Parent
2126        assert_eq!(orders[1].order_id, 501); // Take profit
2127        assert_eq!(orders[2].order_id, 502); // Stop loss
2128    }
2129
2130    #[test]
2131    fn test_build_ib_bracket_with_oca_rejects_prices_on_the_wrong_side_of_the_entry() {
2132        // A buy's take profit below its entry.
2133        let result =
2134            build_ib_bracket_with_oca(7, Action::Buy, 1.0, 10.0, 9.0, 8.0, IbTimeInForce::Day);
2135        assert!(matches!(
2136            result,
2137            Err(OrderMappingError::InvalidBracketPrices(_))
2138        ));
2139
2140        // A sell's stop loss below its entry.
2141        let result =
2142            build_ib_bracket_with_oca(7, Action::Sell, 1.0, 10.0, 8.0, 9.0, IbTimeInForce::Day);
2143        assert!(matches!(
2144            result,
2145            Err(OrderMappingError::InvalidBracketPrices(_))
2146        ));
2147    }
2148
2149    #[test]
2150    fn test_build_ib_bracket_with_oca_group_name_contains_parent_id() {
2151        let orders =
2152            build_ib_bracket_with_oca(42, Action::Buy, 1.0, 10.0, 12.0, 8.0, IbTimeInForce::Day)
2153                .unwrap();
2154
2155        assert!(orders[1].oca_group.contains("42"));
2156        assert!(orders[2].oca_group.contains("42"));
2157    }
2158
2159    #[test]
2160    fn test_build_ib_bracket_with_oca_order_types() {
2161        let orders = build_ib_bracket_with_oca(
2162            1000,
2163            Action::Buy,
2164            100.0,
2165            150.0,
2166            160.0,
2167            140.0,
2168            IbTimeInForce::Day,
2169        )
2170        .unwrap();
2171
2172        // Parent is limit order
2173        assert_eq!(orders[0].order_type, "LMT");
2174        assert_eq!(orders[0].limit_price, Some(150.0));
2175
2176        // Take profit is limit order
2177        assert_eq!(orders[1].order_type, "LMT");
2178        assert_eq!(orders[1].limit_price, Some(160.0));
2179
2180        // Stop loss is stop order
2181        assert_eq!(orders[2].order_type, "STP");
2182        assert_eq!(orders[2].aux_price, Some(140.0));
2183    }
2184
2185    #[test]
2186    fn test_build_ib_bracket_with_oca_actions_reversed_for_children() {
2187        // Buy bracket: entry=Buy, exits=Sell
2188        let buy_orders =
2189            build_ib_bracket_with_oca(100, Action::Buy, 10.0, 50.0, 55.0, 45.0, IbTimeInForce::Day)
2190                .unwrap();
2191        assert_eq!(buy_orders[0].action, Action::Buy);
2192        assert_eq!(buy_orders[1].action, Action::Sell);
2193        assert_eq!(buy_orders[2].action, Action::Sell);
2194
2195        // Sell bracket: entry=Sell, exits=Buy
2196        let sell_orders = build_ib_bracket_with_oca(
2197            200,
2198            Action::Sell,
2199            10.0,
2200            50.0,
2201            45.0,
2202            55.0,
2203            IbTimeInForce::Day,
2204        )
2205        .unwrap();
2206        assert_eq!(sell_orders[0].action, Action::Sell);
2207        assert_eq!(sell_orders[1].action, Action::Buy);
2208        assert_eq!(sell_orders[2].action, Action::Buy);
2209    }
2210
2211    #[test]
2212    fn test_build_ib_bracket_with_oca_applies_tif_to_all_legs() {
2213        let orders = build_ib_bracket_with_oca(
2214            1000,
2215            Action::Buy,
2216            10.0,
2217            150.0,
2218            160.0,
2219            140.0,
2220            IbTimeInForce::GoodTillCanceled,
2221        )
2222        .unwrap();
2223
2224        assert_eq!(orders[0].tif, IbTimeInForce::GoodTillCanceled);
2225        assert_eq!(orders[1].tif, IbTimeInForce::GoodTillCanceled);
2226        assert_eq!(orders[2].tif, IbTimeInForce::GoodTillCanceled);
2227    }
2228
2229    /// A client order id travels as the order reference whole, so one IB would reject or cut is
2230    /// refused: empty, longer than IB echoes back, or not ASCII (IB error 10363).
2231    #[test]
2232    fn order_ref_is_the_client_order_id_when_ib_echoes_it_whole() {
2233        let longest = "a".repeat(MAX_ORDER_REF_LEN);
2234        assert_eq!(
2235            order_ref(&ClientOrderId::new(longest.as_str())).unwrap(),
2236            longest
2237        );
2238        assert_eq!(
2239            order_ref(&ClientOrderId::new("cid-1_tp")).unwrap(),
2240            "cid-1_tp"
2241        );
2242
2243        let too_long = "a".repeat(MAX_ORDER_REF_LEN + 1);
2244        for refused in ["", too_long.as_str(), "ordre-é"] {
2245            let cid = ClientOrderId::new(refused);
2246            assert_eq!(
2247                order_ref(&cid),
2248                Err(OrderMappingError::InvalidClientOrderId(cid.clone()))
2249            );
2250        }
2251    }
2252
2253    /// Every order this client builds reads back as what it was built from, so an open order it no
2254    /// longer tracks is reported as placed.
2255    #[test]
2256    fn order_shape_from_ib_reads_back_what_build_ib_order_sends() {
2257        use chrono::TimeZone;
2258        use rust_decimal_macros::dec;
2259
2260        let expiry = chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap();
2261        let kinds = [
2262            (OrderKind::Market, None),
2263            (OrderKind::Limit, Some(dec!(150.25))),
2264            (
2265                OrderKind::Stop {
2266                    trigger_price: dec!(140.5),
2267                },
2268                None,
2269            ),
2270            (
2271                OrderKind::StopLimit {
2272                    trigger_price: dec!(140.5),
2273                },
2274                Some(dec!(140)),
2275            ),
2276            (
2277                OrderKind::TrailingStop {
2278                    offset: dec!(2.5),
2279                    offset_type: TrailingOffsetType::Percentage,
2280                },
2281                None,
2282            ),
2283            (
2284                OrderKind::TrailingStop {
2285                    offset: dec!(1.75),
2286                    offset_type: TrailingOffsetType::Absolute,
2287                },
2288                None,
2289            ),
2290            (
2291                OrderKind::TrailingStopLimit {
2292                    offset: dec!(2),
2293                    offset_type: TrailingOffsetType::Percentage,
2294                    stop_price: dec!(130.5),
2295                    limit_offset: dec!(0.5),
2296                },
2297                None,
2298            ),
2299            (
2300                OrderKind::TrailingStopLimit {
2301                    offset: dec!(1.5),
2302                    offset_type: TrailingOffsetType::Absolute,
2303                    stop_price: dec!(139.75),
2304                    limit_offset: dec!(0.25),
2305                },
2306                None,
2307            ),
2308        ];
2309        let tifs = [
2310            TimeInForce::GoodUntilCancelled { post_only: false },
2311            TimeInForce::GoodUntilEndOfDay,
2312            TimeInForce::ImmediateOrCancel,
2313            TimeInForce::FillOrKill,
2314            TimeInForce::AtOpen,
2315            TimeInForce::GoodTillDate { expiry },
2316        ];
2317
2318        for (kind, price) in kinds {
2319            for time_in_force in tifs {
2320                let order = build_ib_order(Side::Buy, 1.0, &kind, price, &time_in_force).unwrap();
2321                assert_eq!(
2322                    order_shape_from_ib(&order),
2323                    Ok(OrderShape {
2324                        kind,
2325                        price,
2326                        time_in_force
2327                    }),
2328                    "{kind} {time_in_force}"
2329                );
2330            }
2331        }
2332
2333        // At-close orders are typed MOC/LOC at IB.
2334        for (kind, price) in [
2335            (OrderKind::Market, None),
2336            (OrderKind::Limit, Some(dec!(99))),
2337        ] {
2338            let order =
2339                build_ib_order(Side::Sell, 1.0, &kind, price, &TimeInForce::AtClose).unwrap();
2340            assert_eq!(
2341                order_shape_from_ib(&order),
2342                Ok(OrderShape {
2343                    kind,
2344                    price,
2345                    time_in_force: TimeInForce::AtClose
2346                }),
2347                "{kind}"
2348            );
2349        }
2350    }
2351
2352    #[test]
2353    fn order_shape_from_ib_reads_ibs_own_good_till_date_form() {
2354        use chrono::TimeZone;
2355
2356        let order = Order {
2357            order_type: "MKT".to_owned(),
2358            tif: IbTimeInForce::GoodTillDate,
2359            good_till_date: "20261009 16:00:00 US/Eastern".to_owned(),
2360            ..Order::default()
2361        };
2362        assert_eq!(
2363            order_shape_from_ib(&order).unwrap().time_in_force,
2364            TimeInForce::GoodTillDate {
2365                expiry: chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap()
2366            }
2367        );
2368    }
2369
2370    #[test]
2371    fn order_shape_from_ib_refuses_what_this_client_never_sends() {
2372        let order = |order_type: &str, tif: IbTimeInForce| Order {
2373            order_type: order_type.to_owned(),
2374            tif,
2375            limit_price: Some(1.0),
2376            ..Order::default()
2377        };
2378        for (order, reason) in [
2379            (order("REL", IbTimeInForce::Day), "order type"),
2380            (
2381                order("LMT", IbTimeInForce::DayTillCanceled),
2382                "time in force",
2383            ),
2384            (
2385                Order {
2386                    limit_price: None,
2387                    ..order("LMT", IbTimeInForce::Day)
2388                },
2389                "without limit_price",
2390            ),
2391            (order("STP", IbTimeInForce::Day), "without aux_price"),
2392            (order("MKT", IbTimeInForce::GoodTillDate), "good-till-date"),
2393            (
2394                Order {
2395                    aux_price: Some(2.0),
2396                    limit_price_offset: Some(0.5),
2397                    ..order("TRAIL LIMIT", IbTimeInForce::Day)
2398                },
2399                "without trail_stop_price",
2400            ),
2401            (
2402                Order {
2403                    aux_price: Some(2.0),
2404                    trail_stop_price: Some(0.0),
2405                    limit_price_offset: Some(0.5),
2406                    ..order("TRAIL LIMIT", IbTimeInForce::Day)
2407                },
2408                "not positive",
2409            ),
2410            (
2411                Order {
2412                    trailing_percent: Some(5.0),
2413                    trail_stop_price: Some(-1.0),
2414                    limit_price_offset: Some(0.5),
2415                    ..order("TRAIL LIMIT", IbTimeInForce::Day)
2416                },
2417                "not positive",
2418            ),
2419        ] {
2420            let error = order_shape_from_ib(&order).unwrap_err();
2421            assert!(error.contains(reason), "{error:?} should name {reason:?}");
2422        }
2423    }
2424
2425    #[test]
2426    fn action_to_side_reads_a_short_sale_as_a_sell() {
2427        assert_eq!(action_to_side(&Action::Buy), Side::Buy);
2428        for action in [Action::Sell, Action::SellShort, Action::SellLong] {
2429            assert_eq!(action_to_side(&action), Side::Sell, "{action:?}");
2430        }
2431    }
2432}