Skip to main content

rustrade_execution/client/ibkr/
order.rs

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