Skip to main content

schwab_sdk/orders/
request.rs

1//! Request shapes and the typestate builder for constructing them.
2//!
3//! [`OrderRequest`] is the body of `POST /accounts/{n}/orders` (place) and
4//! `PUT /accounts/{n}/orders/{id}` (replace). Construct via
5//! [`OrderRequest::single`] for the typed-state builder, or via the
6//! composite-strategy factories [`OrderRequest::oco`] and
7//! [`OrderRequest::trigger`]. The builder is the only path to a valid
8//! request body.
9//!
10//! # Examples
11//!
12//! The typestate builder enforces construction order at compile time: an
13//! order type before a leg, and at least one leg before `.build()`.
14//!
15//! ```
16//! use rust_decimal_macros::dec;
17//! use schwab_sdk::orders::{Duration, OrderRequest, Session};
18//!
19//! // Limit buy, good-till-cancel, extended session.
20//! let order = OrderRequest::single()
21//!     .limit(dec!(140.00))
22//!     .equity_buy("AAPL", dec!(5))
23//!     .duration(Duration::GoodTillCancel)
24//!     .session(Session::Seamless)
25//!     .build();
26//! # let _ = order;
27//! ```
28//!
29//! A two-leg vertical spread chains legs on one order with a net-debit price:
30//!
31//! ```
32//! use rust_decimal_macros::dec;
33//! use schwab_sdk::orders::OrderRequest;
34//!
35//! let spread = OrderRequest::single()
36//!     .net_debit(dec!(0.10))
37//!     .option_buy_to_open("XYZ   240315P00045000", dec!(2))
38//!     .option_sell_to_open("XYZ   240315P00043000", dec!(2))
39//!     .build();
40//! # let _ = spread;
41//! ```
42
43use std::marker::PhantomData;
44
45use rust_decimal::Decimal;
46use serde::Serialize;
47
48use crate::accounts::AssetType;
49use crate::error::Error;
50use crate::orders::enums::{
51    ComplexOrderStrategyType, Duration, Instruction, OrderStrategyType, OrderType, PositionEffect,
52    PriceLinkBasis, PriceLinkType, QuantityType, Session, SpecialInstruction, StopPriceLinkBasis,
53    StopPriceLinkType, StopType, TaxLotMethod,
54};
55use crate::orders::response::{Order, OrderLegCollection};
56
57/// Conversion trait for **quantity** arguments on the order builders and
58/// shortcut factories. Lets integer literals flow into `qty` parameters
59/// without an explicit `.into()` or a `dec!(...)` wrapper.
60///
61/// Monetary arguments (prices, stops, net debits / credits, activation
62/// prices) deliberately do **not** implement this trait and remain
63/// `Decimal`-only.
64///
65/// `f32` / `f64` are intentionally not implemented: float quantities
66/// are not safe in a money path.
67pub trait IntoQuantity: sealed::Sealed {
68    /// Convert into the `Decimal` quantity stored on the request body.
69    fn into_quantity(self) -> Decimal;
70}
71
72impl IntoQuantity for Decimal {
73    fn into_quantity(self) -> Decimal {
74        self
75    }
76}
77
78macro_rules! impl_into_quantity_int {
79    ($($t:ty),* $(,)?) => {
80        $(
81            impl sealed::Sealed for $t {}
82            impl IntoQuantity for $t {
83                fn into_quantity(self) -> Decimal {
84                    Decimal::from(self)
85                }
86            }
87        )*
88    };
89}
90
91impl_into_quantity_int!(u8, u16, u32, u64, i8, i16, i32, i64);
92
93/// Local serde helper for `Option<Decimal>` on **request bodies** that
94/// preserves the textual form of the decimal value. Read-side helpers can
95/// keep using the upstream `float_option` because its deserialize path
96/// preserves the string representation already.
97mod decimal_opt {
98    use rust_decimal::Decimal;
99    use serde::{Serialize, Serializer};
100
101    pub fn serialize<S: Serializer>(value: &Option<Decimal>, s: S) -> Result<S::Ok, S::Error> {
102        match value {
103            Some(d) => {
104                let n: serde_json::Number =
105                    d.to_string().parse().map_err(serde::ser::Error::custom)?;
106                n.serialize(s)
107            }
108            None => s.serialize_none(),
109        }
110    }
111}
112
113/// Body of `POST /accounts/{accountNumber}/orders` (place) and
114/// `PUT /accounts/{accountNumber}/orders/{orderId}` (replace). Construct
115/// via [`OrderRequest::single`] (typestate builder) or via the
116/// composite-strategy factories [`OrderRequest::oco`] and
117/// [`OrderRequest::trigger`].
118///
119/// Response-only fields (`status`, `filledQuantity`, `enteredTime`,
120/// `tag`, `requestedDestination`, etc.) are not present here; they live
121/// on [`Order`](crate::orders::Order) instead.
122#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash)]
123#[non_exhaustive]
124pub struct OrderRequest {
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub(crate) session: Option<Session>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub(crate) duration: Option<Duration>,
129    #[serde(rename = "orderType", skip_serializing_if = "Option::is_none")]
130    pub(crate) order_type: Option<OrderType>,
131    #[serde(
132        rename = "complexOrderStrategyType",
133        skip_serializing_if = "Option::is_none"
134    )]
135    pub(crate) complex_order_strategy_type: Option<ComplexOrderStrategyType>,
136    #[serde(skip_serializing_if = "Option::is_none", with = "decimal_opt")]
137    pub(crate) quantity: Option<Decimal>,
138    #[serde(
139        rename = "destinationLinkName",
140        skip_serializing_if = "Option::is_none"
141    )]
142    pub(crate) destination_link_name: Option<String>,
143    #[serde(
144        rename = "stopPrice",
145        skip_serializing_if = "Option::is_none",
146        with = "decimal_opt"
147    )]
148    pub(crate) stop_price: Option<Decimal>,
149    #[serde(rename = "stopPriceLinkBasis", skip_serializing_if = "Option::is_none")]
150    pub(crate) stop_price_link_basis: Option<StopPriceLinkBasis>,
151    #[serde(rename = "stopPriceLinkType", skip_serializing_if = "Option::is_none")]
152    pub(crate) stop_price_link_type: Option<StopPriceLinkType>,
153    #[serde(
154        rename = "stopPriceOffset",
155        skip_serializing_if = "Option::is_none",
156        with = "decimal_opt"
157    )]
158    pub(crate) stop_price_offset: Option<Decimal>,
159    #[serde(rename = "stopType", skip_serializing_if = "Option::is_none")]
160    pub(crate) stop_type: Option<StopType>,
161    #[serde(rename = "priceLinkBasis", skip_serializing_if = "Option::is_none")]
162    pub(crate) price_link_basis: Option<PriceLinkBasis>,
163    #[serde(rename = "priceLinkType", skip_serializing_if = "Option::is_none")]
164    pub(crate) price_link_type: Option<PriceLinkType>,
165    #[serde(skip_serializing_if = "Option::is_none", with = "decimal_opt")]
166    pub(crate) price: Option<Decimal>,
167    #[serde(rename = "taxLotMethod", skip_serializing_if = "Option::is_none")]
168    pub(crate) tax_lot_method: Option<TaxLotMethod>,
169    #[serde(rename = "orderLegCollection", skip_serializing_if = "Vec::is_empty")]
170    pub(crate) order_leg_collection: Vec<OrderLegRequest>,
171    #[serde(
172        rename = "activationPrice",
173        skip_serializing_if = "Option::is_none",
174        with = "decimal_opt"
175    )]
176    pub(crate) activation_price: Option<Decimal>,
177    #[serde(rename = "specialInstruction", skip_serializing_if = "Option::is_none")]
178    pub(crate) special_instruction: Option<SpecialInstruction>,
179    #[serde(rename = "orderStrategyType", skip_serializing_if = "Option::is_none")]
180    pub(crate) order_strategy_type: Option<OrderStrategyType>,
181    #[serde(rename = "childOrderStrategies", skip_serializing_if = "Vec::is_empty")]
182    pub(crate) child_order_strategies: Vec<OrderRequest>,
183}
184
185impl OrderRequest {
186    pub(crate) fn empty() -> Self {
187        Self {
188            session: None,
189            duration: None,
190            order_type: None,
191            complex_order_strategy_type: None,
192            quantity: None,
193            destination_link_name: None,
194            stop_price: None,
195            stop_price_link_basis: None,
196            stop_price_link_type: None,
197            stop_price_offset: None,
198            stop_type: None,
199            price_link_basis: None,
200            price_link_type: None,
201            price: None,
202            tax_lot_method: None,
203            order_leg_collection: Vec::new(),
204            activation_price: None,
205            special_instruction: None,
206            order_strategy_type: None,
207            child_order_strategies: Vec::new(),
208        }
209    }
210}
211
212impl OrderRequest {
213    /// Session in which the order is eligible to trade.
214    pub fn session(&self) -> Option<&Session> {
215        self.session.as_ref()
216    }
217
218    /// Time in force (`DAY`, `GOOD_TILL_CANCEL`, etc.).
219    pub fn duration(&self) -> Option<&Duration> {
220        self.duration.as_ref()
221    }
222
223    /// Order type (`MARKET`, `LIMIT`, `STOP`, `NET_DEBIT`, etc.).
224    pub fn order_type(&self) -> Option<&OrderType> {
225        self.order_type.as_ref()
226    }
227
228    /// Multi-leg option strategy shape, if any.
229    pub fn complex_order_strategy_type(&self) -> Option<&ComplexOrderStrategyType> {
230        self.complex_order_strategy_type.as_ref()
231    }
232
233    /// Top-level quantity, when Schwab carries it separately from the
234    /// per-leg quantity.
235    pub fn quantity(&self) -> Option<Decimal> {
236        self.quantity
237    }
238
239    /// Limit / net-debit / net-credit price.
240    pub fn price(&self) -> Option<Decimal> {
241        self.price
242    }
243
244    /// Stop-trigger price for stop and stop-limit orders.
245    pub fn stop_price(&self) -> Option<Decimal> {
246        self.stop_price
247    }
248
249    /// Special instruction (e.g. `AllOrNone`).
250    pub fn special_instruction(&self) -> Option<&SpecialInstruction> {
251        self.special_instruction.as_ref()
252    }
253
254    /// Envelope strategy (`SINGLE`, `OCO`, `TRIGGER`, ...).
255    pub fn order_strategy_type(&self) -> Option<&OrderStrategyType> {
256        self.order_strategy_type.as_ref()
257    }
258
259    /// Order legs.
260    pub fn legs(&self) -> &[OrderLegRequest] {
261        &self.order_leg_collection
262    }
263
264    /// Child strategies of a composite envelope (`OCO` or `TRIGGER`).
265    /// Empty for `SINGLE`.
266    pub fn child_strategies(&self) -> &[OrderRequest] {
267        &self.child_order_strategies
268    }
269}
270
271/// One leg of an [`OrderRequest`]. Legs are constructed by the builder's
272/// `equity_*` / `option_*` methods.
273#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq, Hash)]
274#[non_exhaustive]
275pub struct OrderLegRequest {
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub(crate) instruction: Option<Instruction>,
278    #[serde(skip_serializing_if = "Option::is_none", with = "decimal_opt")]
279    pub(crate) quantity: Option<Decimal>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub(crate) instrument: Option<OrderInstrumentRequest>,
282    #[serde(rename = "positionEffect", skip_serializing_if = "Option::is_none")]
283    pub(crate) position_effect: Option<PositionEffect>,
284    #[serde(rename = "quantityType", skip_serializing_if = "Option::is_none")]
285    pub(crate) quantity_type: Option<QuantityType>,
286}
287
288/// Minimal request-side instrument: only `symbol` and `assetType` are
289/// settable. Uses the typed [`AssetType`] from [`crate::accounts`]. Instruments
290/// are produced by the builder.
291#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq, Hash)]
292#[non_exhaustive]
293pub struct OrderInstrumentRequest {
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub(crate) symbol: Option<String>,
296    #[serde(rename = "assetType", skip_serializing_if = "Option::is_none")]
297    pub(crate) asset_type: Option<AssetType>,
298}
299
300impl OrderLegRequest {
301    /// Side of the order (`BUY`, `SELL`, `BUY_TO_OPEN`, ...).
302    pub fn instruction(&self) -> Option<&Instruction> {
303        self.instruction.as_ref()
304    }
305
306    /// Leg quantity in shares or contracts.
307    pub fn quantity(&self) -> Option<Decimal> {
308        self.quantity
309    }
310
311    /// Instrument the leg trades.
312    pub fn instrument(&self) -> Option<&OrderInstrumentRequest> {
313        self.instrument.as_ref()
314    }
315
316    /// Position effect (`OPENING`, `CLOSING`).
317    pub fn position_effect(&self) -> Option<&PositionEffect> {
318        self.position_effect.as_ref()
319    }
320
321    /// Quantity-type discriminant when the leg expresses quantity in
322    /// non-share units.
323    pub fn quantity_type(&self) -> Option<&QuantityType> {
324        self.quantity_type.as_ref()
325    }
326}
327
328impl OrderInstrumentRequest {
329    /// Schwab symbol.
330    pub fn symbol(&self) -> Option<&str> {
331        self.symbol.as_deref()
332    }
333
334    /// Asset class (`EQUITY`, `OPTION`, ...).
335    pub fn asset_type(&self) -> Option<&AssetType> {
336        self.asset_type.as_ref()
337    }
338}
339
340// --- Typestate builder for SINGLE-strategy orders ---
341
342/// Builder state: order type (market / limit / etc.) has not been set yet.
343#[derive(Debug)]
344pub struct NeedsType;
345/// Builder state: order type is set; at least one leg must still be added.
346#[derive(Debug)]
347pub struct NeedsLeg;
348/// Builder state: at least one leg has been added. Optional fields may be
349/// set, additional legs may be appended (for multi-leg single orders such
350/// as vertical spreads), and `.build()` is callable.
351#[derive(Debug)]
352pub struct Ready;
353
354/// Trait used to lift leg-adding methods across the two states that
355/// accept legs (`NeedsLeg` -> `Ready`, and `Ready` -> `Ready` for
356/// multi-leg orders). The associated type lets one set of method
357/// definitions serve both transitions.
358pub trait AcceptsLeg: sealed::Sealed {
359    /// State to transition into after a leg is added.
360    type AfterLeg;
361}
362
363mod sealed {
364    pub trait Sealed {}
365    impl Sealed for super::NeedsLeg {}
366    impl Sealed for super::Ready {}
367    impl Sealed for rust_decimal::Decimal {}
368}
369
370impl AcceptsLeg for NeedsLeg {
371    type AfterLeg = Ready;
372}
373
374impl AcceptsLeg for Ready {
375    type AfterLeg = Ready;
376}
377
378/// Typestate builder for a `SINGLE` strategy order. Construct via
379/// [`OrderRequest::single`].
380#[derive(Debug)]
381#[must_use = "call .build() to finalize the OrderRequest"]
382pub struct SingleOrderBuilder<State> {
383    inner: OrderRequest,
384    _state: PhantomData<State>,
385}
386
387impl OrderRequest {
388    /// Begin building a `SINGLE` strategy order. Defaults `session=NORMAL`
389    /// and `duration=DAY`; override with [`SingleOrderBuilder::session`]
390    /// and [`SingleOrderBuilder::duration`] on the `Ready` state.
391    pub fn single() -> SingleOrderBuilder<NeedsType> {
392        let inner = OrderRequest {
393            session: Some(Session::Normal),
394            duration: Some(Duration::Day),
395            order_strategy_type: Some(OrderStrategyType::Single),
396            ..OrderRequest::empty()
397        };
398        SingleOrderBuilder {
399            inner,
400            _state: PhantomData,
401        }
402    }
403
404    // --- Convenience shortcuts for common equity SINGLE orders ---
405    //
406    // These return a [`SingleOrderBuilder`] in the [`Ready`] state, so
407    // callers may chain optional setters (`.duration()`, `.session()`,
408    // `.special_instruction()`) and finish with `.build()`. For the
409    // simplest case the call site reads `.buy_market(sym, qty).build()`.
410    //
411    // OCO / TRIGGER factories accept either a built `OrderRequest` or a
412    // `SingleOrderBuilder<Ready>` via `impl Into<OrderRequest>`, so the
413    // builder can be passed straight through without an explicit `.build()`.
414
415    /// Equity buy-at-market, default day order.
416    pub fn buy_market(
417        symbol: impl Into<String>,
418        qty: impl IntoQuantity,
419    ) -> SingleOrderBuilder<Ready> {
420        Self::single().market().equity_buy(symbol, qty)
421    }
422
423    /// Equity buy-at-limit, default day order.
424    pub fn buy_limit(
425        symbol: impl Into<String>,
426        qty: impl IntoQuantity,
427        price: Decimal,
428    ) -> SingleOrderBuilder<Ready> {
429        Self::single().limit(price).equity_buy(symbol, qty)
430    }
431
432    /// Equity long-sale at market, default day order.
433    pub fn sell_market(
434        symbol: impl Into<String>,
435        qty: impl IntoQuantity,
436    ) -> SingleOrderBuilder<Ready> {
437        Self::single().market().equity_sell(symbol, qty)
438    }
439
440    /// Equity long-sale at limit, default day order.
441    pub fn sell_limit(
442        symbol: impl Into<String>,
443        qty: impl IntoQuantity,
444        price: Decimal,
445    ) -> SingleOrderBuilder<Ready> {
446        Self::single().limit(price).equity_sell(symbol, qty)
447    }
448
449    /// Equity stop-market sell, default day order. Useful for stop-loss
450    /// exits.
451    pub fn sell_stop(
452        symbol: impl Into<String>,
453        qty: impl IntoQuantity,
454        stop_price: Decimal,
455    ) -> SingleOrderBuilder<Ready> {
456        Self::single().stop(stop_price).equity_sell(symbol, qty)
457    }
458
459    /// Equity stop-limit sell, default day order. Triggered when the
460    /// market crosses `stop_price`, then becomes a limit order at
461    /// `limit_price`.
462    pub fn sell_stop_limit(
463        symbol: impl Into<String>,
464        qty: impl IntoQuantity,
465        stop_price: Decimal,
466        limit_price: Decimal,
467    ) -> SingleOrderBuilder<Ready> {
468        Self::single()
469            .stop_limit(stop_price, limit_price)
470            .equity_sell(symbol, qty)
471    }
472
473    // --- Convenience shortcuts for common single-leg option orders ---
474    //
475    // `symbol` should be the Schwab option symbol (e.g.
476    // `"AAPL  240315C00200000"`). Return a [`SingleOrderBuilder<Ready>`]
477    // for chaining. For multi-leg option strategies (vertical, condor,
478    // etc.), use [`Self::single`] with `.net_debit` / `.net_credit` and
479    // chain multiple legs.
480
481    /// Option buy-to-open at market, default day order. Opens a long
482    /// option position.
483    pub fn buy_to_open_market(
484        symbol: impl Into<String>,
485        qty: impl IntoQuantity,
486    ) -> SingleOrderBuilder<Ready> {
487        Self::single().market().option_buy_to_open(symbol, qty)
488    }
489
490    /// Option buy-to-open at limit, default day order.
491    pub fn buy_to_open_limit(
492        symbol: impl Into<String>,
493        qty: impl IntoQuantity,
494        price: Decimal,
495    ) -> SingleOrderBuilder<Ready> {
496        Self::single().limit(price).option_buy_to_open(symbol, qty)
497    }
498
499    /// Option sell-to-open at market, default day order. Writes (shorts)
500    /// an option.
501    pub fn sell_to_open_market(
502        symbol: impl Into<String>,
503        qty: impl IntoQuantity,
504    ) -> SingleOrderBuilder<Ready> {
505        Self::single().market().option_sell_to_open(symbol, qty)
506    }
507
508    /// Option sell-to-open at limit, default day order.
509    pub fn sell_to_open_limit(
510        symbol: impl Into<String>,
511        qty: impl IntoQuantity,
512        price: Decimal,
513    ) -> SingleOrderBuilder<Ready> {
514        Self::single().limit(price).option_sell_to_open(symbol, qty)
515    }
516
517    /// Option buy-to-close at market, default day order. Closes a
518    /// previously written (short) option.
519    pub fn buy_to_close_market(
520        symbol: impl Into<String>,
521        qty: impl IntoQuantity,
522    ) -> SingleOrderBuilder<Ready> {
523        Self::single().market().option_buy_to_close(symbol, qty)
524    }
525
526    /// Option buy-to-close at limit, default day order.
527    pub fn buy_to_close_limit(
528        symbol: impl Into<String>,
529        qty: impl IntoQuantity,
530        price: Decimal,
531    ) -> SingleOrderBuilder<Ready> {
532        Self::single().limit(price).option_buy_to_close(symbol, qty)
533    }
534
535    /// Option sell-to-close at market, default day order. Closes a long
536    /// option position.
537    pub fn sell_to_close_market(
538        symbol: impl Into<String>,
539        qty: impl IntoQuantity,
540    ) -> SingleOrderBuilder<Ready> {
541        Self::single().market().option_sell_to_close(symbol, qty)
542    }
543
544    /// Option sell-to-close at limit, default day order.
545    pub fn sell_to_close_limit(
546        symbol: impl Into<String>,
547        qty: impl IntoQuantity,
548        price: Decimal,
549    ) -> SingleOrderBuilder<Ready> {
550        Self::single()
551            .limit(price)
552            .option_sell_to_close(symbol, qty)
553    }
554
555    // --- Composite strategies ---
556
557    /// One-cancels-other: two child orders, the first to fill cancels the
558    /// other. Top-level carries only `orderStrategyType=OCO` and the two
559    /// children; each child is a complete order in its own right
560    /// (typically a `SINGLE`).
561    ///
562    /// Accepts either a finished `OrderRequest` or any
563    /// [`SingleOrderBuilder<Ready>`]; the shortcuts and the explicit
564    /// builder both satisfy `impl Into<OrderRequest>`.
565    ///
566    /// The `duration` on each child controls how long that side stays
567    /// live - for a take-profit + stop-loss pair you typically want both
568    /// children set to [`Duration::GoodTillCancel`] via the builder.
569    ///
570    /// # Examples
571    ///
572    /// A bracket exit: a take-profit limit paired with a stop-loss, first to
573    /// fill cancels the other. Both children are good-till-cancel so neither
574    /// expires at the close.
575    ///
576    /// ```
577    /// use rust_decimal_macros::dec;
578    /// use schwab_sdk::orders::{Duration, OrderRequest};
579    ///
580    /// let take_profit = OrderRequest::single()
581    ///     .limit(dec!(15.27))
582    ///     .equity_sell("XYZ", dec!(5))
583    ///     .duration(Duration::GoodTillCancel)
584    ///     .build();
585    /// let stop_loss = OrderRequest::single()
586    ///     .stop(dec!(11.27))
587    ///     .equity_sell("XYZ", dec!(5))
588    ///     .duration(Duration::GoodTillCancel)
589    ///     .build();
590    ///
591    /// let bracket = OrderRequest::oco(take_profit, stop_loss);
592    /// # let _ = bracket;
593    /// ```
594    pub fn oco(child_a: impl Into<OrderRequest>, child_b: impl Into<OrderRequest>) -> OrderRequest {
595        OrderRequest {
596            order_strategy_type: Some(OrderStrategyType::Oco),
597            child_order_strategies: vec![child_a.into(), child_b.into()],
598            ..OrderRequest::empty()
599        }
600    }
601
602    /// First-trigger-sequence: `parent` is the order Schwab places
603    /// immediately; once it fills, `child` is released. The parent's
604    /// `orderStrategyType` is overwritten with `TRIGGER`.
605    ///
606    /// Both arguments accept any `impl Into<OrderRequest>` - the
607    /// shortcuts return a [`SingleOrderBuilder<Ready>`] which is
608    /// converted transparently.
609    ///
610    /// 1st-Trigger-OCO is the composition
611    /// `OrderRequest::trigger(parent, OrderRequest::oco(profit, stop))`.
612    ///
613    /// # Examples
614    ///
615    /// Open a position, then attach a profit target and a stop once the
616    /// entry fills (1st-trigger-OCO):
617    ///
618    /// ```
619    /// use rust_decimal_macros::dec;
620    /// use schwab_sdk::orders::{Duration, OrderRequest};
621    ///
622    /// let entry = OrderRequest::buy_limit("XYZ", dec!(5), dec!(14.97));
623    /// let take_profit = OrderRequest::single()
624    ///     .limit(dec!(15.27))
625    ///     .equity_sell("XYZ", dec!(5))
626    ///     .duration(Duration::GoodTillCancel)
627    ///     .build();
628    /// let stop_loss = OrderRequest::single()
629    ///     .stop(dec!(11.27))
630    ///     .equity_sell("XYZ", dec!(5))
631    ///     .duration(Duration::GoodTillCancel)
632    ///     .build();
633    ///
634    /// let order = OrderRequest::trigger(entry, OrderRequest::oco(take_profit, stop_loss));
635    /// # let _ = order;
636    /// ```
637    pub fn trigger(
638        parent: impl Into<OrderRequest>,
639        child: impl Into<OrderRequest>,
640    ) -> OrderRequest {
641        let mut parent: OrderRequest = parent.into();
642        parent.order_strategy_type = Some(OrderStrategyType::Trigger);
643        parent.child_order_strategies.push(child.into());
644        parent
645    }
646}
647
648impl From<SingleOrderBuilder<Ready>> for OrderRequest {
649    fn from(builder: SingleOrderBuilder<Ready>) -> Self {
650        builder.build()
651    }
652}
653
654impl SingleOrderBuilder<NeedsType> {
655    /// Market order.
656    pub fn market(mut self) -> SingleOrderBuilder<NeedsLeg> {
657        self.inner.order_type = Some(OrderType::Market);
658        self.transition()
659    }
660
661    /// Limit order at `price`.
662    pub fn limit(mut self, price: Decimal) -> SingleOrderBuilder<NeedsLeg> {
663        self.inner.order_type = Some(OrderType::Limit);
664        self.inner.price = Some(price);
665        self.transition()
666    }
667
668    /// Stop (stop-market) order at `stop_price`.
669    pub fn stop(mut self, stop_price: Decimal) -> SingleOrderBuilder<NeedsLeg> {
670        self.inner.order_type = Some(OrderType::Stop);
671        self.inner.stop_price = Some(stop_price);
672        self.transition()
673    }
674
675    /// Stop-limit order: triggered when the market crosses `stop_price`,
676    /// then becomes a limit order at `limit_price`.
677    pub fn stop_limit(
678        mut self,
679        stop_price: Decimal,
680        limit_price: Decimal,
681    ) -> SingleOrderBuilder<NeedsLeg> {
682        self.inner.order_type = Some(OrderType::StopLimit);
683        self.inner.stop_price = Some(stop_price);
684        self.inner.price = Some(limit_price);
685        self.transition()
686    }
687
688    /// Net-debit order (multi-leg options, debit spread). The `price` is
689    /// the net premium paid.
690    pub fn net_debit(mut self, price: Decimal) -> SingleOrderBuilder<NeedsLeg> {
691        self.inner.order_type = Some(OrderType::NetDebit);
692        self.inner.price = Some(price);
693        self.transition()
694    }
695
696    /// Net-credit order (multi-leg options, credit spread). The `price`
697    /// is the net premium received.
698    pub fn net_credit(mut self, price: Decimal) -> SingleOrderBuilder<NeedsLeg> {
699        self.inner.order_type = Some(OrderType::NetCredit);
700        self.inner.price = Some(price);
701        self.transition()
702    }
703
704    fn transition(self) -> SingleOrderBuilder<NeedsLeg> {
705        SingleOrderBuilder {
706            inner: self.inner,
707            _state: PhantomData,
708        }
709    }
710}
711
712impl<S: AcceptsLeg> SingleOrderBuilder<S> {
713    fn push_leg(mut self, leg: OrderLegRequest) -> SingleOrderBuilder<S::AfterLeg> {
714        self.inner.order_leg_collection.push(leg);
715        SingleOrderBuilder {
716            inner: self.inner,
717            _state: PhantomData,
718        }
719    }
720
721    /// Buy `qty` shares of `symbol` (equity).
722    pub fn equity_buy(
723        self,
724        symbol: impl Into<String>,
725        qty: impl IntoQuantity,
726    ) -> SingleOrderBuilder<S::AfterLeg> {
727        self.push_leg(equity_leg(Instruction::Buy, symbol, qty))
728    }
729
730    /// Sell `qty` shares of `symbol` (equity, long sale).
731    pub fn equity_sell(
732        self,
733        symbol: impl Into<String>,
734        qty: impl IntoQuantity,
735    ) -> SingleOrderBuilder<S::AfterLeg> {
736        self.push_leg(equity_leg(Instruction::Sell, symbol, qty))
737    }
738
739    /// Short-sell `qty` shares of `symbol` (equity).
740    pub fn equity_sell_short(
741        self,
742        symbol: impl Into<String>,
743        qty: impl IntoQuantity,
744    ) -> SingleOrderBuilder<S::AfterLeg> {
745        self.push_leg(equity_leg(Instruction::SellShort, symbol, qty))
746    }
747
748    /// Buy to cover (close a short) `qty` shares of `symbol`.
749    pub fn equity_buy_to_cover(
750        self,
751        symbol: impl Into<String>,
752        qty: impl IntoQuantity,
753    ) -> SingleOrderBuilder<S::AfterLeg> {
754        self.push_leg(equity_leg(Instruction::BuyToCover, symbol, qty))
755    }
756
757    /// Buy to open `qty` contracts of `symbol` (option).
758    pub fn option_buy_to_open(
759        self,
760        symbol: impl Into<String>,
761        qty: impl IntoQuantity,
762    ) -> SingleOrderBuilder<S::AfterLeg> {
763        self.push_leg(option_leg(Instruction::BuyToOpen, symbol, qty))
764    }
765
766    /// Sell to open `qty` contracts of `symbol` (option).
767    pub fn option_sell_to_open(
768        self,
769        symbol: impl Into<String>,
770        qty: impl IntoQuantity,
771    ) -> SingleOrderBuilder<S::AfterLeg> {
772        self.push_leg(option_leg(Instruction::SellToOpen, symbol, qty))
773    }
774
775    /// Buy to close `qty` contracts of `symbol` (option).
776    pub fn option_buy_to_close(
777        self,
778        symbol: impl Into<String>,
779        qty: impl IntoQuantity,
780    ) -> SingleOrderBuilder<S::AfterLeg> {
781        self.push_leg(option_leg(Instruction::BuyToClose, symbol, qty))
782    }
783
784    /// Sell to close `qty` contracts of `symbol` (option).
785    pub fn option_sell_to_close(
786        self,
787        symbol: impl Into<String>,
788        qty: impl IntoQuantity,
789    ) -> SingleOrderBuilder<S::AfterLeg> {
790        self.push_leg(option_leg(Instruction::SellToClose, symbol, qty))
791    }
792}
793
794impl SingleOrderBuilder<Ready> {
795    /// Override the default `DAY` duration.
796    pub fn duration(mut self, duration: Duration) -> Self {
797        self.inner.duration = Some(duration);
798        self
799    }
800
801    /// Override the default `NORMAL` session.
802    pub fn session(mut self, session: Session) -> Self {
803        self.inner.session = Some(session);
804        self
805    }
806
807    /// Attach a special instruction (e.g. `ALL_OR_NONE`).
808    pub fn special_instruction(mut self, instr: SpecialInstruction) -> Self {
809        self.inner.special_instruction = Some(instr);
810        self
811    }
812
813    /// Set the complex-order-strategy type (defaults to absent, which
814    /// Schwab interprets as `NONE`). Useful for option spreads.
815    pub fn complex_order_strategy_type(mut self, t: ComplexOrderStrategyType) -> Self {
816        self.inner.complex_order_strategy_type = Some(t);
817        self
818    }
819
820    /// Finish the builder and return the assembled [`OrderRequest`].
821    pub fn build(self) -> OrderRequest {
822        self.inner
823    }
824}
825
826fn equity_leg(
827    instruction: Instruction,
828    symbol: impl Into<String>,
829    qty: impl IntoQuantity,
830) -> OrderLegRequest {
831    OrderLegRequest {
832        instruction: Some(instruction),
833        quantity: Some(qty.into_quantity()),
834        instrument: Some(OrderInstrumentRequest {
835            symbol: Some(symbol.into()),
836            asset_type: Some(AssetType::Equity),
837        }),
838        ..Default::default()
839    }
840}
841
842fn option_leg(
843    instruction: Instruction,
844    symbol: impl Into<String>,
845    qty: impl IntoQuantity,
846) -> OrderLegRequest {
847    OrderLegRequest {
848        instruction: Some(instruction),
849        quantity: Some(qty.into_quantity()),
850        instrument: Some(OrderInstrumentRequest {
851            symbol: Some(symbol.into()),
852            asset_type: Some(AssetType::Option),
853        }),
854        ..Default::default()
855    }
856}
857
858// --- Response -> Request conversion ---
859//
860// Round-trip a fetched [`Order`] back into an [`OrderRequest`] body suitable
861// for `PUT /accounts/{n}/orders/{id}` (replace). Broker-assigned fields
862// (`orderId`, `status`, `enteredTime`, `cancelable`, fills, lineage, etc.)
863// have no place in a request and are dropped. Fields that *could* be
864// represented but cannot be decoded unambiguously (e.g. a leg with no
865// instrument, or an instrument with no symbol) surface as
866// [`Error::OrderResponseNotRepresentable`] rather than being silently
867// defaulted.
868
869impl TryFrom<Order> for OrderRequest {
870    type Error = Error;
871
872    /// Convert a fetched [`Order`] into an [`OrderRequest`] body. Useful for
873    /// constructing the body of a replace request from a previously-fetched
874    /// order: take the live order, mutate the field(s) you want to change,
875    /// and send it back.
876    ///
877    /// Broker-assigned fields (`orderId`, `status`, `enteredTime`,
878    /// `cancelable`, `editable`, fills, lineage, etc.) are not part of a
879    /// request body and are dropped. Child strategies (`OCO` / `TRIGGER`)
880    /// are converted recursively. Fields that cannot be represented in a
881    /// request (a leg missing its instrument, an instrument missing its
882    /// `symbol`) surface as [`Error::OrderResponseNotRepresentable`].
883    fn try_from(order: Order) -> Result<Self, Self::Error> {
884        let Order {
885            session,
886            duration,
887            order_type,
888            complex_order_strategy_type,
889            quantity,
890            destination_link_name,
891            stop_price,
892            stop_price_link_basis,
893            stop_price_link_type,
894            stop_price_offset,
895            stop_type,
896            price_link_basis,
897            price_link_type,
898            price,
899            tax_lot_method,
900            order_leg_collection,
901            activation_price,
902            special_instruction,
903            order_strategy_type,
904            child_order_strategies,
905            // Dropped: broker-assigned, response-only, or activity data with
906            // no request counterpart.
907            ..
908        } = order;
909
910        let order_leg_collection = order_leg_collection
911            .into_iter()
912            .map(OrderLegRequest::try_from)
913            .collect::<Result<Vec<_>, _>>()?;
914
915        let child_order_strategies = child_order_strategies
916            .into_iter()
917            .map(OrderRequest::try_from)
918            .collect::<Result<Vec<_>, _>>()?;
919
920        Ok(OrderRequest {
921            session,
922            duration,
923            order_type,
924            complex_order_strategy_type,
925            quantity,
926            destination_link_name,
927            stop_price,
928            stop_price_link_basis,
929            stop_price_link_type,
930            stop_price_offset,
931            stop_type,
932            price_link_basis,
933            price_link_type,
934            price,
935            tax_lot_method,
936            order_leg_collection,
937            activation_price,
938            special_instruction,
939            order_strategy_type,
940            child_order_strategies,
941        })
942    }
943}
944
945impl TryFrom<OrderLegCollection> for OrderLegRequest {
946    type Error = Error;
947
948    fn try_from(leg: OrderLegCollection) -> Result<Self, Self::Error> {
949        let OrderLegCollection {
950            instruction,
951            quantity,
952            instrument,
953            position_effect,
954            quantity_type,
955            // Dropped: response-only or carried elsewhere (the leg's asset
956            // class lives on the instrument's `asset_type` instead).
957            ..
958        } = leg;
959
960        let instrument = instrument
961            .map(|inst| {
962                let symbol = inst
963                    .symbol
964                    .ok_or_else(|| Error::OrderResponseNotRepresentable {
965                        reason: "order leg instrument is missing `symbol`".to_string(),
966                    })?;
967                if let AssetType::Unknown(raw) = &inst.asset_type {
968                    return Err(Error::OrderResponseNotRepresentable {
969                        reason: format!("order leg instrument has unknown assetType `{raw}`"),
970                    });
971                }
972                Ok(OrderInstrumentRequest {
973                    symbol: Some(symbol),
974                    asset_type: Some(inst.asset_type),
975                })
976            })
977            .ok_or_else(|| Error::OrderResponseNotRepresentable {
978                reason: "order leg is missing its instrument".to_string(),
979            })
980            .flatten()?;
981
982        Ok(OrderLegRequest {
983            instruction,
984            quantity,
985            instrument: Some(instrument),
986            position_effect,
987            quantity_type,
988        })
989    }
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995    use rust_decimal_macros::dec;
996
997    fn pretty(value: &serde_json::Value) -> String {
998        serde_json::to_string_pretty(value).unwrap()
999    }
1000
1001    #[test]
1002    fn builder_buy_market_equity_matches_schwab_example() {
1003        let req = OrderRequest::single()
1004            .market()
1005            .equity_buy("XYZ", dec!(15))
1006            .build();
1007        let actual: serde_json::Value = serde_json::to_value(&req).unwrap();
1008        let expected: serde_json::Value = serde_json::from_str(
1009            r#"{
1010                "session": "NORMAL",
1011                "duration": "DAY",
1012                "orderType": "MARKET",
1013                "orderStrategyType": "SINGLE",
1014                "orderLegCollection": [{
1015                    "instruction": "BUY",
1016                    "quantity": 15,
1017                    "instrument": {
1018                        "symbol": "XYZ",
1019                        "assetType": "EQUITY"
1020                    }
1021                }]
1022            }"#,
1023        )
1024        .unwrap();
1025        assert_eq!(actual, expected, "got: {}", pretty(&actual));
1026    }
1027
1028    #[test]
1029    fn builder_buy_limit_option_matches_schwab_example() {
1030        let req = OrderRequest::single()
1031            .limit(dec!(6.45))
1032            .option_buy_to_open("XYZ   240315C00500000", dec!(10))
1033            .complex_order_strategy_type(ComplexOrderStrategyType::None)
1034            .build();
1035        let actual: serde_json::Value = serde_json::to_value(&req).unwrap();
1036        let expected: serde_json::Value = serde_json::from_str(
1037            r#"{
1038                "complexOrderStrategyType": "NONE",
1039                "orderType": "LIMIT",
1040                "session": "NORMAL",
1041                "price": 6.45,
1042                "duration": "DAY",
1043                "orderStrategyType": "SINGLE",
1044                "orderLegCollection": [{
1045                    "instruction": "BUY_TO_OPEN",
1046                    "quantity": 10,
1047                    "instrument": {
1048                        "symbol": "XYZ   240315C00500000",
1049                        "assetType": "OPTION"
1050                    }
1051                }]
1052            }"#,
1053        )
1054        .unwrap();
1055        assert_eq!(actual, expected, "got: {}", pretty(&actual));
1056    }
1057
1058    #[test]
1059    fn builder_vertical_spread_uses_net_debit_with_two_legs() {
1060        let req = OrderRequest::single()
1061            .net_debit(dec!(0.10))
1062            .option_buy_to_open("XYZ   240315P00045000", dec!(2))
1063            .option_sell_to_open("XYZ   240315P00043000", dec!(2))
1064            .build();
1065        let actual: serde_json::Value = serde_json::to_value(&req).unwrap();
1066        let expected: serde_json::Value = serde_json::from_str(
1067            r#"{
1068                "orderType": "NET_DEBIT",
1069                "session": "NORMAL",
1070                "price": 0.10,
1071                "duration": "DAY",
1072                "orderStrategyType": "SINGLE",
1073                "orderLegCollection": [
1074                    {
1075                        "instruction": "BUY_TO_OPEN",
1076                        "quantity": 2,
1077                        "instrument": {
1078                            "symbol": "XYZ   240315P00045000",
1079                            "assetType": "OPTION"
1080                        }
1081                    },
1082                    {
1083                        "instruction": "SELL_TO_OPEN",
1084                        "quantity": 2,
1085                        "instrument": {
1086                            "symbol": "XYZ   240315P00043000",
1087                            "assetType": "OPTION"
1088                        }
1089                    }
1090                ]
1091            }"#,
1092        )
1093        .unwrap();
1094        assert_eq!(actual, expected, "got: {}", pretty(&actual));
1095    }
1096
1097    #[test]
1098    fn builder_optional_setters_override_defaults() {
1099        let req = OrderRequest::single()
1100            .limit(dec!(140.00))
1101            .equity_buy("AAPL", dec!(5))
1102            .duration(Duration::GoodTillCancel)
1103            .session(Session::Seamless)
1104            .special_instruction(SpecialInstruction::AllOrNone)
1105            .build();
1106        assert_eq!(req.duration, Some(Duration::GoodTillCancel));
1107        assert_eq!(req.session, Some(Session::Seamless));
1108        assert_eq!(req.special_instruction, Some(SpecialInstruction::AllOrNone));
1109    }
1110
1111    #[test]
1112    fn builder_serialization_omits_response_only_fields() {
1113        let req = OrderRequest::single()
1114            .market()
1115            .equity_buy("AAPL", dec!(1))
1116            .build();
1117        let json = serde_json::to_string(&req).unwrap();
1118        for forbidden in [
1119            "status",
1120            "orderId",
1121            "accountNumber",
1122            "tag",
1123            "requestedDestination",
1124            "filledQuantity",
1125            "remainingQuantity",
1126            "enteredTime",
1127            "closeTime",
1128            "cancelable",
1129            "editable",
1130            "orderActivityCollection",
1131        ] {
1132            assert!(
1133                !json.contains(forbidden),
1134                "request body should not contain {forbidden}, got: {json}"
1135            );
1136        }
1137    }
1138
1139    // --- Shortcut equivalence ---
1140
1141    #[test]
1142    fn shortcut_buy_market_equals_explicit_builder() {
1143        let a = OrderRequest::buy_market("AAPL", dec!(10)).build();
1144        let b = OrderRequest::single()
1145            .market()
1146            .equity_buy("AAPL", dec!(10))
1147            .build();
1148        assert_eq!(
1149            serde_json::to_value(&a).unwrap(),
1150            serde_json::to_value(&b).unwrap()
1151        );
1152    }
1153
1154    #[test]
1155    fn shortcut_buy_limit_equals_explicit_builder() {
1156        let a = OrderRequest::buy_limit("AAPL", dec!(10), dec!(150.00)).build();
1157        let b = OrderRequest::single()
1158            .limit(dec!(150.00))
1159            .equity_buy("AAPL", dec!(10))
1160            .build();
1161        assert_eq!(
1162            serde_json::to_value(&a).unwrap(),
1163            serde_json::to_value(&b).unwrap()
1164        );
1165    }
1166
1167    #[test]
1168    fn shortcut_sell_stop_equals_explicit_builder() {
1169        let a = OrderRequest::sell_stop("AAPL", dec!(10), dec!(140.00)).build();
1170        let b = OrderRequest::single()
1171            .stop(dec!(140.00))
1172            .equity_sell("AAPL", dec!(10))
1173            .build();
1174        assert_eq!(
1175            serde_json::to_value(&a).unwrap(),
1176            serde_json::to_value(&b).unwrap()
1177        );
1178    }
1179
1180    #[test]
1181    fn shortcut_sell_stop_limit_equals_explicit_builder() {
1182        let a = OrderRequest::sell_stop_limit("AAPL", dec!(10), dec!(140.00), dec!(139.50)).build();
1183        let b = OrderRequest::single()
1184            .stop_limit(dec!(140.00), dec!(139.50))
1185            .equity_sell("AAPL", dec!(10))
1186            .build();
1187        assert_eq!(
1188            serde_json::to_value(&a).unwrap(),
1189            serde_json::to_value(&b).unwrap()
1190        );
1191    }
1192
1193    #[test]
1194    fn option_shortcut_buy_to_open_market_equals_explicit_builder() {
1195        let symbol = "AAPL  240315C00200000";
1196        let a = OrderRequest::buy_to_open_market(symbol, dec!(2)).build();
1197        let b = OrderRequest::single()
1198            .market()
1199            .option_buy_to_open(symbol, dec!(2))
1200            .build();
1201        assert_eq!(
1202            serde_json::to_value(&a).unwrap(),
1203            serde_json::to_value(&b).unwrap()
1204        );
1205    }
1206
1207    #[test]
1208    fn option_shortcuts_cover_all_four_instructions() {
1209        // Each option shortcut should pin the right Instruction and the
1210        // OPTION assetType in the resulting leg.
1211        let cases: [(OrderRequest, &str); 4] = [
1212            (
1213                OrderRequest::buy_to_open_limit("XYZ  240315C00500000", dec!(1), dec!(6.45))
1214                    .build(),
1215                "BUY_TO_OPEN",
1216            ),
1217            (
1218                OrderRequest::sell_to_open_limit("XYZ  240315C00500000", dec!(1), dec!(6.45))
1219                    .build(),
1220                "SELL_TO_OPEN",
1221            ),
1222            (
1223                OrderRequest::buy_to_close_limit("XYZ  240315C00500000", dec!(1), dec!(6.45))
1224                    .build(),
1225                "BUY_TO_CLOSE",
1226            ),
1227            (
1228                OrderRequest::sell_to_close_limit("XYZ  240315C00500000", dec!(1), dec!(6.45))
1229                    .build(),
1230                "SELL_TO_CLOSE",
1231            ),
1232        ];
1233        for (req, expected_instruction) in cases {
1234            let v = serde_json::to_value(&req).unwrap();
1235            let leg = &v["orderLegCollection"][0];
1236            assert_eq!(leg["instruction"], expected_instruction);
1237            assert_eq!(leg["instrument"]["assetType"], "OPTION");
1238            assert_eq!(v["orderStrategyType"], "SINGLE");
1239        }
1240    }
1241
1242    #[test]
1243    fn shortcut_supports_chaining_optional_setters() {
1244        let req = OrderRequest::buy_limit("AAPL", dec!(10), dec!(150.00))
1245            .duration(Duration::GoodTillCancel)
1246            .session(Session::Seamless)
1247            .special_instruction(SpecialInstruction::AllOrNone)
1248            .build();
1249        assert_eq!(req.duration, Some(Duration::GoodTillCancel));
1250        assert_eq!(req.session, Some(Session::Seamless));
1251        assert_eq!(req.special_instruction, Some(SpecialInstruction::AllOrNone));
1252        // Underlying order shape is preserved.
1253        assert_eq!(req.order_type, Some(OrderType::Limit));
1254        assert_eq!(req.price, Some(dec!(150.00)));
1255    }
1256
1257    #[test]
1258    fn oco_accepts_shortcut_builders_via_into() {
1259        // OCO takes `impl Into<OrderRequest>`, so the shortcut return
1260        // type (a `SingleOrderBuilder<Ready>`) flows in without
1261        // requiring the caller to `.build()` first.
1262        let oco = OrderRequest::oco(
1263            OrderRequest::sell_limit("XYZ", dec!(1), dec!(50)),
1264            OrderRequest::sell_stop("XYZ", dec!(1), dec!(40)),
1265        );
1266        let v = serde_json::to_value(&oco).unwrap();
1267        assert_eq!(v["orderStrategyType"], "OCO");
1268        assert_eq!(v["childOrderStrategies"].as_array().unwrap().len(), 2);
1269    }
1270
1271    // --- OCO and TRIGGER strategies ---
1272
1273    #[test]
1274    fn oco_pair_matches_schwab_example() {
1275        // "Sell 2 XYZ at LIMIT 45.97 or Sell 2 XYZ at STOP_LIMIT 37.03/37.00,
1276        // whichever fills first cancels the other. Both DAY."
1277        let limit_leg = OrderRequest::single()
1278            .limit(dec!(45.97))
1279            .equity_sell("XYZ", dec!(2))
1280            .build();
1281        let stop_limit_leg = OrderRequest::single()
1282            .stop_limit(dec!(37.03), dec!(37.00))
1283            .equity_sell("XYZ", dec!(2))
1284            .build();
1285        let req = OrderRequest::oco(limit_leg, stop_limit_leg);
1286        let actual: serde_json::Value = serde_json::to_value(&req).unwrap();
1287        let expected: serde_json::Value = serde_json::from_str(
1288            r#"{
1289                "orderStrategyType": "OCO",
1290                "childOrderStrategies": [
1291                    {
1292                        "orderType": "LIMIT",
1293                        "session": "NORMAL",
1294                        "price": 45.97,
1295                        "duration": "DAY",
1296                        "orderStrategyType": "SINGLE",
1297                        "orderLegCollection": [{
1298                            "instruction": "SELL",
1299                            "quantity": 2,
1300                            "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
1301                        }]
1302                    },
1303                    {
1304                        "orderType": "STOP_LIMIT",
1305                        "session": "NORMAL",
1306                        "price": 37.00,
1307                        "stopPrice": 37.03,
1308                        "duration": "DAY",
1309                        "orderStrategyType": "SINGLE",
1310                        "orderLegCollection": [{
1311                            "instruction": "SELL",
1312                            "quantity": 2,
1313                            "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
1314                        }]
1315                    }
1316                ]
1317            }"#,
1318        )
1319        .unwrap();
1320        assert_eq!(actual, expected, "got: {}", pretty(&actual));
1321    }
1322
1323    #[test]
1324    fn trigger_buy_then_sell_matches_schwab_example() {
1325        // "Buy 10 XYZ LIMIT 34.97. If filled, send a SELL 10 XYZ LIMIT
1326        // 42.03. Both DAY."
1327        let entry = OrderRequest::buy_limit("XYZ", dec!(10), dec!(34.97));
1328        let exit = OrderRequest::sell_limit("XYZ", dec!(10), dec!(42.03));
1329        let req = OrderRequest::trigger(entry, exit);
1330        let actual: serde_json::Value = serde_json::to_value(&req).unwrap();
1331        let expected: serde_json::Value = serde_json::from_str(
1332            r#"{
1333                "orderType": "LIMIT",
1334                "session": "NORMAL",
1335                "price": 34.97,
1336                "duration": "DAY",
1337                "orderStrategyType": "TRIGGER",
1338                "orderLegCollection": [{
1339                    "instruction": "BUY",
1340                    "quantity": 10,
1341                    "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
1342                }],
1343                "childOrderStrategies": [{
1344                    "orderType": "LIMIT",
1345                    "session": "NORMAL",
1346                    "price": 42.03,
1347                    "duration": "DAY",
1348                    "orderStrategyType": "SINGLE",
1349                    "orderLegCollection": [{
1350                        "instruction": "SELL",
1351                        "quantity": 10,
1352                        "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
1353                    }]
1354                }]
1355            }"#,
1356        )
1357        .unwrap();
1358        assert_eq!(actual, expected, "got: {}", pretty(&actual));
1359    }
1360
1361    #[test]
1362    fn one_triggers_oco_matches_schwab_example() {
1363        // "Buy 5 XYZ LIMIT 14.97 DAY. Once filled, send an OCO of
1364        // (SELL 5 XYZ LIMIT 15.27 GTC) and (SELL 5 XYZ STOP 11.27 GTC)."
1365        let entry = OrderRequest::buy_limit("XYZ", dec!(5), dec!(14.97));
1366        let take_profit = OrderRequest::single()
1367            .limit(dec!(15.27))
1368            .equity_sell("XYZ", dec!(5))
1369            .duration(Duration::GoodTillCancel)
1370            .build();
1371        let stop_loss = OrderRequest::single()
1372            .stop(dec!(11.27))
1373            .equity_sell("XYZ", dec!(5))
1374            .duration(Duration::GoodTillCancel)
1375            .build();
1376        let oco = OrderRequest::oco(take_profit, stop_loss);
1377        let req = OrderRequest::trigger(entry, oco);
1378        let actual: serde_json::Value = serde_json::to_value(&req).unwrap();
1379        let expected: serde_json::Value = serde_json::from_str(
1380            r#"{
1381                "orderStrategyType": "TRIGGER",
1382                "session": "NORMAL",
1383                "duration": "DAY",
1384                "orderType": "LIMIT",
1385                "price": 14.97,
1386                "orderLegCollection": [{
1387                    "instruction": "BUY",
1388                    "quantity": 5,
1389                    "instrument": { "assetType": "EQUITY", "symbol": "XYZ" }
1390                }],
1391                "childOrderStrategies": [{
1392                    "orderStrategyType": "OCO",
1393                    "childOrderStrategies": [
1394                        {
1395                            "orderStrategyType": "SINGLE",
1396                            "session": "NORMAL",
1397                            "duration": "GOOD_TILL_CANCEL",
1398                            "orderType": "LIMIT",
1399                            "price": 15.27,
1400                            "orderLegCollection": [{
1401                                "instruction": "SELL",
1402                                "quantity": 5,
1403                                "instrument": { "assetType": "EQUITY", "symbol": "XYZ" }
1404                            }]
1405                        },
1406                        {
1407                            "orderStrategyType": "SINGLE",
1408                            "session": "NORMAL",
1409                            "duration": "GOOD_TILL_CANCEL",
1410                            "orderType": "STOP",
1411                            "stopPrice": 11.27,
1412                            "orderLegCollection": [{
1413                                "instruction": "SELL",
1414                                "quantity": 5,
1415                                "instrument": { "assetType": "EQUITY", "symbol": "XYZ" }
1416                            }]
1417                        }
1418                    ]
1419                }]
1420            }"#,
1421        )
1422        .unwrap();
1423        assert_eq!(actual, expected, "got: {}", pretty(&actual));
1424    }
1425
1426    // --- IntoQuantity: integer literals on quantity arguments ---
1427
1428    #[test]
1429    fn into_quantity_decimal_is_identity() {
1430        assert_eq!(dec!(10).into_quantity(), dec!(10));
1431        assert_eq!(dec!(0.5).into_quantity(), dec!(0.5));
1432    }
1433
1434    #[test]
1435    fn into_quantity_accepts_unsigned_and_signed_ints() {
1436        assert_eq!(IntoQuantity::into_quantity(10u8), dec!(10));
1437        assert_eq!(IntoQuantity::into_quantity(10u16), dec!(10));
1438        assert_eq!(IntoQuantity::into_quantity(10u32), dec!(10));
1439        assert_eq!(IntoQuantity::into_quantity(10u64), dec!(10));
1440        assert_eq!(IntoQuantity::into_quantity(10i8), dec!(10));
1441        assert_eq!(IntoQuantity::into_quantity(10i16), dec!(10));
1442        assert_eq!(IntoQuantity::into_quantity(10i32), dec!(10));
1443        assert_eq!(IntoQuantity::into_quantity(10i64), dec!(10));
1444    }
1445
1446    #[test]
1447    fn factory_shortcuts_accept_integer_literal_for_qty() {
1448        // Integer literals should infer cleanly into `impl IntoQuantity`.
1449        // The matching `Decimal` form must yield the same serialized body.
1450        let a = OrderRequest::buy_market("AAPL", 10).build();
1451        let b = OrderRequest::buy_market("AAPL", dec!(10)).build();
1452        assert_eq!(
1453            serde_json::to_value(&a).unwrap(),
1454            serde_json::to_value(&b).unwrap()
1455        );
1456    }
1457
1458    #[test]
1459    fn oco_top_level_has_no_session_or_duration() {
1460        // OCO is purely a composition wrapper. Schwab's documented OCO
1461        // example shows no top-level session/duration/orderType, only
1462        // `orderStrategyType` and `childOrderStrategies`.
1463        let a = OrderRequest::sell_limit("XYZ", dec!(1), dec!(50));
1464        let b = OrderRequest::sell_stop("XYZ", dec!(1), dec!(40));
1465        let req = OrderRequest::oco(a, b);
1466        let v = serde_json::to_value(&req).unwrap();
1467        let obj = v.as_object().unwrap();
1468        assert_eq!(obj.len(), 2);
1469        assert!(obj.contains_key("orderStrategyType"));
1470        assert!(obj.contains_key("childOrderStrategies"));
1471    }
1472
1473    // --- Response -> Request round trip ---
1474
1475    fn try_round_trip(req: &OrderRequest) -> OrderRequest {
1476        // Serialize a request, deserialize as the response shape, convert
1477        // back.
1478        let wire = serde_json::to_string(req).expect("serialize OrderRequest");
1479        let order: crate::orders::Order =
1480            serde_json::from_str(&wire).expect("deserialize as Order");
1481        OrderRequest::try_from(order).expect("Order -> OrderRequest")
1482    }
1483
1484    #[test]
1485    fn try_from_round_trips_equity_limit_buy() {
1486        let req = OrderRequest::single()
1487            .limit(dec!(140.00))
1488            .equity_buy("AAPL", dec!(5))
1489            .duration(Duration::GoodTillCancel)
1490            .session(Session::Seamless)
1491            .special_instruction(SpecialInstruction::AllOrNone)
1492            .build();
1493        let after = try_round_trip(&req);
1494        assert_eq!(req, after);
1495    }
1496
1497    #[test]
1498    fn try_from_round_trips_vertical_spread() {
1499        let req = OrderRequest::single()
1500            .net_debit(dec!(0.10))
1501            .option_buy_to_open("XYZ   240315P00045000", dec!(2))
1502            .option_sell_to_open("XYZ   240315P00043000", dec!(2))
1503            .build();
1504        let after = try_round_trip(&req);
1505        assert_eq!(req, after);
1506    }
1507
1508    #[test]
1509    fn try_from_round_trips_oco_pair() {
1510        let limit_leg = OrderRequest::single()
1511            .limit(dec!(45.97))
1512            .equity_sell("XYZ", dec!(2))
1513            .build();
1514        let stop_limit_leg = OrderRequest::single()
1515            .stop_limit(dec!(37.03), dec!(37.00))
1516            .equity_sell("XYZ", dec!(2))
1517            .build();
1518        let req = OrderRequest::oco(limit_leg, stop_limit_leg);
1519        let after = try_round_trip(&req);
1520        assert_eq!(req, after);
1521    }
1522
1523    #[test]
1524    fn try_from_round_trips_one_triggers_oco() {
1525        let entry = OrderRequest::buy_limit("XYZ", dec!(5), dec!(14.97));
1526        let take_profit = OrderRequest::single()
1527            .limit(dec!(15.27))
1528            .equity_sell("XYZ", dec!(5))
1529            .duration(Duration::GoodTillCancel)
1530            .build();
1531        let stop_loss = OrderRequest::single()
1532            .stop(dec!(11.27))
1533            .equity_sell("XYZ", dec!(5))
1534            .duration(Duration::GoodTillCancel)
1535            .build();
1536        let oco = OrderRequest::oco(take_profit, stop_loss);
1537        let req = OrderRequest::trigger(entry, oco);
1538        let after = try_round_trip(&req);
1539        assert_eq!(req, after);
1540    }
1541
1542    #[test]
1543    fn try_from_drops_broker_assigned_fields_on_a_live_order() {
1544        // A live Order from a read endpoint carries broker-assigned fields
1545        // (orderId, status, enteredTime, fills, instrument metadata, ...).
1546        // The replace body must not echo any of them back; the conversion
1547        // drops them. Top-level fields that the response carries and that
1548        // the request schema also accepts (e.g. `quantity`) are preserved.
1549        let live: crate::orders::Order = serde_json::from_str(
1550            r#"{
1551                "orderId": 100000001,
1552                "accountNumber": 12345678,
1553                "status": "WORKING",
1554                "orderType": "LIMIT",
1555                "session": "NORMAL",
1556                "duration": "DAY",
1557                "orderStrategyType": "SINGLE",
1558                "quantity": 10.0,
1559                "filledQuantity": 0.0,
1560                "remainingQuantity": 10.0,
1561                "price": 140.00,
1562                "enteredTime": "2024-03-15T15:30:00.000Z",
1563                "cancelable": true,
1564                "editable": true,
1565                "orderLegCollection": [{
1566                    "orderLegType": "EQUITY",
1567                    "legId": 1,
1568                    "instruction": "BUY",
1569                    "quantity": 10.0,
1570                    "instrument": {
1571                        "assetType": "EQUITY",
1572                        "symbol": "AAPL",
1573                        "cusip": "037833100",
1574                        "instrumentId": 12345
1575                    }
1576                }]
1577            }"#,
1578        )
1579        .unwrap();
1580        let replace_body = OrderRequest::try_from(live).expect("convert live order");
1581
1582        // Order shape carried through: type, prices, leg.
1583        assert_eq!(replace_body.order_type, Some(OrderType::Limit));
1584        assert_eq!(replace_body.price, Some(dec!(140.00)));
1585        assert_eq!(replace_body.session, Some(Session::Normal));
1586        assert_eq!(replace_body.duration, Some(Duration::Day));
1587        assert_eq!(
1588            replace_body.order_strategy_type,
1589            Some(OrderStrategyType::Single)
1590        );
1591        assert_eq!(replace_body.order_leg_collection.len(), 1);
1592        let leg = &replace_body.order_leg_collection[0];
1593        assert_eq!(leg.instruction, Some(Instruction::Buy));
1594        assert_eq!(leg.quantity, Some(dec!(10)));
1595        let inst = leg.instrument.as_ref().unwrap();
1596        assert_eq!(inst.symbol.as_deref(), Some("AAPL"));
1597        assert_eq!(inst.asset_type, Some(AssetType::Equity));
1598
1599        // None of the broker-assigned fields appear in the replace body.
1600        let json = serde_json::to_string(&replace_body).unwrap();
1601        for forbidden in [
1602            "orderId",
1603            "accountNumber",
1604            "status",
1605            "enteredTime",
1606            "filledQuantity",
1607            "remainingQuantity",
1608            "cancelable",
1609            "editable",
1610            "cusip",
1611            "instrumentId",
1612            "legId",
1613        ] {
1614            assert!(
1615                !json.contains(forbidden),
1616                "replace body should not contain {forbidden}, got: {json}"
1617            );
1618        }
1619    }
1620
1621    #[test]
1622    fn try_from_errors_when_leg_has_no_instrument() {
1623        let order: crate::orders::Order = serde_json::from_str(
1624            r#"{
1625                "orderId": 1,
1626                "orderStrategyType": "SINGLE",
1627                "orderType": "MARKET",
1628                "orderLegCollection": [{
1629                    "instruction": "BUY",
1630                    "quantity": 1
1631                }]
1632            }"#,
1633        )
1634        .unwrap();
1635        match OrderRequest::try_from(order) {
1636            Err(Error::OrderResponseNotRepresentable { reason }) => {
1637                assert!(reason.contains("instrument"), "unexpected reason: {reason}");
1638            }
1639            other => panic!("expected OrderResponseNotRepresentable, got {other:?}"),
1640        }
1641    }
1642
1643    #[test]
1644    fn try_from_errors_when_instrument_has_no_symbol() {
1645        let order: crate::orders::Order = serde_json::from_str(
1646            r#"{
1647                "orderId": 1,
1648                "orderStrategyType": "SINGLE",
1649                "orderType": "MARKET",
1650                "orderLegCollection": [{
1651                    "instruction": "BUY",
1652                    "quantity": 1,
1653                    "instrument": { "assetType": "EQUITY" }
1654                }]
1655            }"#,
1656        )
1657        .unwrap();
1658        match OrderRequest::try_from(order) {
1659            Err(Error::OrderResponseNotRepresentable { reason }) => {
1660                assert!(reason.contains("symbol"), "unexpected reason: {reason}");
1661            }
1662            other => panic!("expected OrderResponseNotRepresentable, got {other:?}"),
1663        }
1664    }
1665
1666    #[test]
1667    fn try_from_errors_when_asset_type_is_unknown() {
1668        // Schwab may asset types over time; `string_enum!` decodes any
1669        // unrecognized value to `AssetType::Unknown(_)`. The request side
1670        // cannot safely send those back to a different endpoint, so the
1671        // conversion refuses rather than guessing.
1672        let order: crate::orders::Order = serde_json::from_str(
1673            r#"{
1674                "orderId": 1,
1675                "orderStrategyType": "SINGLE",
1676                "orderType": "MARKET",
1677                "orderLegCollection": [{
1678                    "instruction": "BUY",
1679                    "quantity": 1,
1680                    "instrument": { "assetType": "NEW_ASSET_CLASS", "symbol": "X" }
1681                }]
1682            }"#,
1683        )
1684        .unwrap();
1685        match OrderRequest::try_from(order) {
1686            Err(Error::OrderResponseNotRepresentable { reason }) => {
1687                assert!(
1688                    reason.contains("NEW_ASSET_CLASS"),
1689                    "unexpected reason: {reason}"
1690                );
1691            }
1692            other => panic!("expected OrderResponseNotRepresentable, got {other:?}"),
1693        }
1694    }
1695
1696    #[test]
1697    fn try_from_error_is_not_retryable() {
1698        let err = Error::OrderResponseNotRepresentable {
1699            reason: "leg missing instrument".to_string(),
1700        };
1701        assert!(!err.is_retryable());
1702        assert_eq!(err.retry_after(), None);
1703    }
1704
1705    #[test]
1706    fn accessors_read_single_limit_order() {
1707        let req = OrderRequest::buy_limit("AAPL", dec!(10), dec!(150.25)).build();
1708
1709        assert_eq!(req.session(), Some(&Session::Normal));
1710        assert_eq!(req.duration(), Some(&Duration::Day));
1711        assert_eq!(req.order_type(), Some(&OrderType::Limit));
1712        assert_eq!(req.price(), Some(dec!(150.25)));
1713        assert_eq!(req.stop_price(), None);
1714        assert_eq!(req.order_strategy_type(), Some(&OrderStrategyType::Single));
1715        assert!(req.child_strategies().is_empty());
1716
1717        let legs = req.legs();
1718        assert_eq!(legs.len(), 1);
1719        let leg = &legs[0];
1720        assert_eq!(leg.instruction(), Some(&Instruction::Buy));
1721        assert_eq!(leg.quantity(), Some(dec!(10)));
1722        let instrument = leg.instrument().expect("leg has instrument");
1723        assert_eq!(instrument.symbol(), Some("AAPL"));
1724        assert_eq!(instrument.asset_type(), Some(&AssetType::Equity));
1725    }
1726
1727    #[test]
1728    fn accessors_walk_oco_composite() {
1729        let take_profit = OrderRequest::sell_limit("XYZ", dec!(1), dec!(50));
1730        let stop_loss = OrderRequest::sell_stop("XYZ", dec!(1), dec!(40));
1731        let req = OrderRequest::oco(take_profit, stop_loss);
1732
1733        assert_eq!(req.order_strategy_type(), Some(&OrderStrategyType::Oco));
1734        assert!(req.legs().is_empty());
1735
1736        let children = req.child_strategies();
1737        assert_eq!(children.len(), 2);
1738        assert_eq!(children[0].order_type(), Some(&OrderType::Limit));
1739        assert_eq!(children[0].price(), Some(dec!(50)));
1740        assert_eq!(children[1].order_type(), Some(&OrderType::Stop));
1741        assert_eq!(children[1].stop_price(), Some(dec!(40)));
1742    }
1743}