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