Skip to main content

rustyschwab_types/trading/
builder.rs

1//! Constructors that assemble [`OrderRequest`] trees the way Schwab's order
2//! examples are written; the module-level docs on [`super`] show the flow.
3
4use super::{
5    ComplexOrderStrategyType, OrderDuration, OrderInstruction, OrderRequest,
6    OrderRequestInstrument, OrderRequestLeg, OrderSession, OrderStrategyType, OrderType,
7    PositionEffect, PriceLinkBasis, PriceLinkType, RequestedDestination, SpecialInstruction,
8    StopType, TaxLotMethod,
9};
10use crate::common::{AssetType, PutCall};
11use chrono::{Datelike, NaiveDate};
12use rust_decimal::Decimal;
13use std::{fmt, str::FromStr};
14
15/// An option contract in the 21-character OSI form Schwab uses for order
16/// legs and quotes: the underlying padded to six characters, the expiration
17/// as `YYMMDD`, `C` or `P`, and the strike times 1000 as eight digits
18/// (`AAPL  240517P00190000`).
19///
20/// ```
21/// use chrono::NaiveDate;
22/// use rustyschwab_types::Decimal;
23/// use rustyschwab_types::trading::OptionSymbol;
24///
25/// let expiry = NaiveDate::from_ymd_opt(2024, 5, 17).unwrap();
26/// let put = OptionSymbol::put("AAPL", expiry, Decimal::from(190)).unwrap();
27/// assert_eq!(put.to_string(), "AAPL  240517P00190000");
28/// assert_eq!("AAPL  240517P00190000".parse::<OptionSymbol>().unwrap(), put);
29/// ```
30#[derive(Debug, Clone, PartialEq, Eq, Hash)]
31pub struct OptionSymbol {
32    underlying: String,
33    expiration: NaiveDate,
34    put_call: PutCall,
35    strike: Decimal,
36}
37
38impl OptionSymbol {
39    /// Build a symbol, rejecting anything the OSI format cannot express: an
40    /// underlying longer than six ASCII letters or digits, an expiration
41    /// outside 2000–2099 (the symbol keeps two year digits, read as 20YY),
42    /// a strike that is not positive, above 99999.999, or finer than a
43    /// tenth of a cent.
44    pub fn new(
45        underlying: impl AsRef<str>,
46        expiration: NaiveDate,
47        put_call: PutCall,
48        strike: Decimal,
49    ) -> Result<Self, String> {
50        let underlying = underlying.as_ref().trim().to_ascii_uppercase();
51        if underlying.is_empty()
52            || underlying.len() > 6
53            || !underlying.bytes().all(|b| b.is_ascii_alphanumeric())
54        {
55            return Err(format!(
56                "Option underlying must be one to six letters or digits, got {underlying:?}"
57            ));
58        }
59        if !(2000..=2099).contains(&expiration.year()) {
60            return Err(format!(
61                "Option expiration must be in 2000-2099, got {expiration}"
62            ));
63        }
64        let strike = strike.normalize();
65        if strike <= Decimal::ZERO || strike.scale() > 3 || strike >= Decimal::from(100_000) {
66            return Err(format!(
67                "Option strike must be positive, below 100000, \
68                 and a multiple of 0.001, got {strike}"
69            ));
70        }
71        Ok(Self {
72            underlying,
73            expiration,
74            put_call,
75            strike,
76        })
77    }
78
79    /// A call contract; see [`OptionSymbol::new`] for the validation rules.
80    pub fn call(
81        underlying: impl AsRef<str>,
82        expiration: NaiveDate,
83        strike: Decimal,
84    ) -> Result<Self, String> {
85        Self::new(underlying, expiration, PutCall::Call, strike)
86    }
87
88    /// A put contract; see [`OptionSymbol::new`] for the validation rules.
89    pub fn put(
90        underlying: impl AsRef<str>,
91        expiration: NaiveDate,
92        strike: Decimal,
93    ) -> Result<Self, String> {
94        Self::new(underlying, expiration, PutCall::Put, strike)
95    }
96
97    pub fn underlying(&self) -> &str {
98        &self.underlying
99    }
100
101    pub fn expiration(&self) -> NaiveDate {
102        self.expiration
103    }
104
105    pub fn put_call(&self) -> PutCall {
106        self.put_call
107    }
108
109    pub fn strike(&self) -> Decimal {
110        self.strike
111    }
112}
113
114impl fmt::Display for OptionSymbol {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        let right = match self.put_call {
117            PutCall::Call => 'C',
118            PutCall::Put => 'P',
119        };
120        // `new` bounds the strike, so the mantissa fits eight digits.
121        let thousandths = (self.strike * Decimal::from(1000)).normalize().mantissa();
122        write!(
123            f,
124            "{:<6}{}{right}{thousandths:08}",
125            self.underlying,
126            self.expiration.format("%y%m%d")
127        )
128    }
129}
130
131impl FromStr for OptionSymbol {
132    type Err = String;
133
134    /// Parses exactly the canonical form [`Display`](fmt::Display) writes:
135    /// an upper-case root padded on the right to six characters, so a symbol
136    /// round-trips unchanged. The two-digit year is 20YY: listed options do
137    /// not expire in the last century, and `%y` would read 70–99 as 19YY.
138    fn from_str(s: &str) -> Result<Self, Self::Err> {
139        let invalid = || format!("Expected a 21-character OSI option symbol, got {s:?}");
140        if s.len() != 21 || !s.is_ascii() {
141            return Err(invalid());
142        }
143        let (underlying, rest) = s.split_at(6);
144        let (date, rest) = rest.split_at(6);
145        let (right, strike) = rest.split_at(1);
146        let digits = |t: &str| t.bytes().all(|b| b.is_ascii_digit());
147        if !digits(date) {
148            return Err(invalid());
149        }
150        let field = |range: std::ops::Range<usize>| date[range].parse::<u32>().ok();
151        let expiration = field(0..2)
152            .zip(field(2..4))
153            .zip(field(4..6))
154            .and_then(|((y, m), d)| NaiveDate::from_ymd_opt(2000 + y as i32, m, d))
155            .ok_or_else(invalid)?;
156        let put_call = match right {
157            "C" => PutCall::Call,
158            "P" => PutCall::Put,
159            _ => return Err(invalid()),
160        };
161        let thousandths: i64 = strike
162            .parse()
163            .ok()
164            .filter(|_| digits(strike))
165            .ok_or_else(invalid)?;
166        let symbol = Self::new(
167            underlying,
168            expiration,
169            put_call,
170            Decimal::new(thousandths, 3),
171        )?;
172        if symbol.to_string() != s {
173            return Err(format!(
174                "{s:?} is not in canonical OSI form; expected {:?}",
175                symbol.to_string()
176            ));
177        }
178        Ok(symbol)
179    }
180}
181
182impl From<OptionSymbol> for String {
183    fn from(symbol: OptionSymbol) -> Self {
184        symbol.to_string()
185    }
186}
187
188impl From<&OptionSymbol> for String {
189    fn from(symbol: &OptionSymbol) -> Self {
190        symbol.to_string()
191    }
192}
193
194impl OrderInstruction {
195    /// The instruction that flattens a position this one opens: `BUY` →
196    /// `SELL`, `SELL_SHORT` and `SELL_SHORT_EXEMPT` → `BUY_TO_COVER`,
197    /// `BUY_TO_OPEN` → `SELL_TO_CLOSE`, `SELL_TO_OPEN` → `BUY_TO_CLOSE`.
198    /// Closing instructions and `EXCHANGE` have no counterpart.
199    pub fn closing(self) -> Option<Self> {
200        match self {
201            Self::Buy => Some(Self::Sell),
202            Self::SellShort | Self::SellShortExempt => Some(Self::BuyToCover),
203            Self::BuyToOpen => Some(Self::SellToClose),
204            Self::SellToOpen => Some(Self::BuyToClose),
205            Self::Sell
206            | Self::BuyToCover
207            | Self::BuyToClose
208            | Self::SellToClose
209            | Self::Exchange
210            | Self::Unknown => None,
211        }
212    }
213
214    /// Whether filling this instruction leaves the account long the
215    /// instrument (`BUY`, `BUY_TO_OPEN`) rather than short; `None` for
216    /// instructions that close or exchange.
217    pub fn opens_long(self) -> Option<bool> {
218        match self {
219            Self::Buy | Self::BuyToOpen => Some(true),
220            Self::SellShort | Self::SellShortExempt | Self::SellToOpen => Some(false),
221            _ => None,
222        }
223    }
224
225    /// Whether this instruction buys (`true`) or sells (`false`), opening
226    /// or closing alike; `None` for `EXCHANGE` and unknown instructions.
227    pub fn buys(self) -> Option<bool> {
228        match self {
229            Self::Buy | Self::BuyToCover | Self::BuyToOpen | Self::BuyToClose => Some(true),
230            Self::Sell
231            | Self::SellShort
232            | Self::SellShortExempt
233            | Self::SellToOpen
234            | Self::SellToClose => Some(false),
235            Self::Exchange | Self::Unknown => None,
236        }
237    }
238}
239
240impl OrderRequestInstrument {
241    pub fn equity(symbol: impl Into<String>) -> Self {
242        Self {
243            symbol: symbol.into(),
244            asset_type: AssetType::Equity,
245        }
246    }
247
248    /// An option leg; accepts an [`OptionSymbol`] or an already formatted
249    /// OSI string.
250    pub fn option(symbol: impl Into<String>) -> Self {
251        Self {
252            symbol: symbol.into(),
253            asset_type: AssetType::Option,
254        }
255    }
256}
257
258impl OrderRequestLeg {
259    pub fn new(
260        instruction: OrderInstruction,
261        instrument: OrderRequestInstrument,
262        quantity: impl Into<Decimal>,
263    ) -> Self {
264        Self {
265            instruction,
266            quantity: quantity.into(),
267            instrument,
268            position_effect: None,
269            quantity_type: None,
270            div_cap_gains: None,
271            to_symbol: None,
272        }
273    }
274
275    pub fn equity(
276        instruction: OrderInstruction,
277        symbol: impl Into<String>,
278        quantity: impl Into<Decimal>,
279    ) -> Self {
280        Self::new(
281            instruction,
282            OrderRequestInstrument::equity(symbol),
283            quantity,
284        )
285    }
286
287    /// `quantity` is in contracts.
288    pub fn option(
289        instruction: OrderInstruction,
290        symbol: impl Into<String>,
291        quantity: impl Into<Decimal>,
292    ) -> Self {
293        Self::new(
294            instruction,
295            OrderRequestInstrument::option(symbol),
296            quantity,
297        )
298    }
299
300    pub fn with_position_effect(mut self, effect: PositionEffect) -> Self {
301        self.position_effect = Some(effect);
302        self
303    }
304}
305
306impl OrderRequest {
307    fn single(order_type: OrderType, legs: Vec<OrderRequestLeg>) -> Self {
308        Self {
309            session: Some(OrderSession::Normal),
310            duration: Some(OrderDuration::Day),
311            order_type: Some(order_type),
312            order_strategy_type: OrderStrategyType::Single,
313            order_leg_collection: legs,
314            ..Self::default()
315        }
316    }
317
318    pub fn market(leg: OrderRequestLeg) -> Self {
319        Self::single(OrderType::Market, vec![leg])
320    }
321
322    pub fn limit(leg: OrderRequestLeg, price: Decimal) -> Self {
323        Self {
324            price: Some(price),
325            ..Self::single(OrderType::Limit, vec![leg])
326        }
327    }
328
329    /// A stop order that becomes a market order at `stop_price`.
330    pub fn stop(leg: OrderRequestLeg, stop_price: Decimal) -> Self {
331        Self {
332            stop_price: Some(stop_price),
333            ..Self::single(OrderType::Stop, vec![leg])
334        }
335    }
336
337    /// A stop order that becomes a limit order at `price` once `stop_price`
338    /// trades.
339    pub fn stop_limit(leg: OrderRequestLeg, stop_price: Decimal, price: Decimal) -> Self {
340        Self {
341            price: Some(price),
342            stop_price: Some(stop_price),
343            ..Self::single(OrderType::StopLimit, vec![leg])
344        }
345    }
346
347    /// A stop that trails `basis` by `offset`, expressed as `link` (a price
348    /// difference, a percentage, or ticks).
349    ///
350    /// There is no `trailing_stop_limit` constructor on purpose: Schwab's
351    /// schema has no `priceOffset` and no published `TRAILING_STOP_LIMIT`
352    /// example, so how the limit should be linked is unspecified. To send one,
353    /// set `order_type` to [`OrderType::TrailingStopLimit`] and a fixed `price`
354    /// on the result (the reading `validate()` enforces) and confirm the shape
355    /// with a preview first.
356    pub fn trailing_stop(
357        leg: OrderRequestLeg,
358        basis: PriceLinkBasis,
359        link: PriceLinkType,
360        offset: Decimal,
361    ) -> Self {
362        Self {
363            stop_price_link_basis: Some(basis),
364            stop_price_link_type: Some(link),
365            stop_price_offset: Some(offset),
366            ..Self::single(OrderType::TrailingStop, vec![leg])
367        }
368    }
369
370    /// A multi-leg order that pays at most `price` net.
371    pub fn net_debit(legs: Vec<OrderRequestLeg>, price: Decimal) -> Self {
372        Self {
373            price: Some(price),
374            ..Self::single(OrderType::NetDebit, legs)
375        }
376    }
377
378    /// A multi-leg order that collects at least `price` net.
379    pub fn net_credit(legs: Vec<OrderRequestLeg>, price: Decimal) -> Self {
380        Self {
381            price: Some(price),
382            ..Self::single(OrderType::NetCredit, legs)
383        }
384    }
385
386    /// A multi-leg order that must fill for zero net cost.
387    pub fn net_zero(legs: Vec<OrderRequestLeg>) -> Self {
388        Self::single(OrderType::NetZero, legs)
389    }
390
391    pub fn with_session(mut self, session: OrderSession) -> Self {
392        self.session = Some(session);
393        self
394    }
395
396    pub fn with_duration(mut self, duration: OrderDuration) -> Self {
397        self.duration = Some(duration);
398        self
399    }
400
401    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
402        self.tag = Some(tag.into());
403        self
404    }
405
406    pub fn with_complex_strategy(mut self, strategy: ComplexOrderStrategyType) -> Self {
407        self.complex_order_strategy_type = Some(strategy);
408        self
409    }
410
411    pub fn with_special_instruction(mut self, instruction: SpecialInstruction) -> Self {
412        self.special_instruction = Some(instruction);
413        self
414    }
415
416    pub fn with_destination(mut self, destination: RequestedDestination) -> Self {
417        self.requested_destination = Some(destination);
418        self
419    }
420
421    pub fn with_tax_lot_method(mut self, method: TaxLotMethod) -> Self {
422        self.tax_lot_method = Some(method);
423        self
424    }
425
426    pub fn with_stop_type(mut self, stop_type: StopType) -> Self {
427        self.stop_type = Some(stop_type);
428        self
429    }
430
431    /// Two orders of which a fill on either cancels the other.
432    pub fn one_cancels_other(first: OrderRequest, second: OrderRequest) -> Self {
433        Self {
434            order_strategy_type: OrderStrategyType::Oco,
435            child_order_strategies: vec![first, second],
436            ..Self::default()
437        }
438    }
439
440    /// Make this order a `TRIGGER` parent: `child` is submitted only after
441    /// this order fills. Call again to trigger several children.
442    pub fn triggers(mut self, child: OrderRequest) -> Self {
443        self.order_strategy_type = OrderStrategyType::Trigger;
444        self.child_order_strategies.push(child);
445        self
446    }
447
448    /// Attach a take-profit limit and a stop-loss to a single-leg opening
449    /// order: the exits are a good-till-cancel OCO pair that Schwab submits
450    /// once the entry fills, using the closing instruction and full quantity
451    /// of the entry leg.
452    ///
453    /// Fails if the entry is not a single-leg `SINGLE` order, if its
454    /// instruction does not open a position, or if the exits are on the
455    /// wrong sides for the position's direction: of each other always, and
456    /// of the entry's price: its limit (`LIMIT`, `STOP_LIMIT`), or for a
457    /// `STOP` entry its trigger. A long entry at 150 needs its stop below
458    /// 150 and its target above; otherwise an exit is marketable the moment
459    /// the entry fills. A market entry has no price to compare, so only the
460    /// ordering of the exits is checked.
461    pub fn bracket(self, take_profit: Decimal, stop_loss: Decimal) -> Result<Self, String> {
462        if self.order_strategy_type != OrderStrategyType::Single
463            || !self.child_order_strategies.is_empty()
464        {
465            return Err("A bracket entry must be a SINGLE order without children".into());
466        }
467        let [leg] = self.order_leg_collection.as_slice() else {
468            return Err("A bracket entry must have exactly one leg".into());
469        };
470        let (Some(closing), Some(long)) = (leg.instruction.closing(), leg.instruction.opens_long())
471        else {
472            return Err(format!(
473                "{:?} does not open a position, so it cannot be bracketed",
474                leg.instruction
475            ));
476        };
477        if (long && take_profit <= stop_loss) || (!long && take_profit >= stop_loss) {
478            let side = if long { "long" } else { "short" };
479            return Err(format!(
480                "Bracket exits are inverted for a {side} entry: \
481                 take profit {take_profit}, stop loss {stop_loss}"
482            ));
483        }
484        if let Some(entry) = self.price.or(self.stop_price) {
485            let (below, above) = if long {
486                (stop_loss, take_profit)
487            } else {
488                (take_profit, stop_loss)
489            };
490            if !(below < entry && entry < above) {
491                let side = if long { "long" } else { "short" };
492                return Err(format!(
493                    "Bracket exits must straddle a {side} entry at {entry}: \
494                     take profit {take_profit}, stop loss {stop_loss}"
495                ));
496            }
497        }
498        let exit = || OrderRequestLeg {
499            position_effect: leg.position_effect.map(|_| PositionEffect::Closing),
500            ..OrderRequestLeg::new(closing, leg.instrument.clone(), leg.quantity)
501        };
502        let exits = Self::one_cancels_other(
503            Self::limit(exit(), take_profit).with_duration(OrderDuration::Gtc),
504            Self::stop(exit(), stop_loss).with_duration(OrderDuration::Gtc),
505        );
506        Ok(self.triggers(exits))
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use serde_json::json;
514
515    fn d(s: &str) -> Decimal {
516        s.parse().unwrap()
517    }
518
519    fn expiry() -> NaiveDate {
520        NaiveDate::from_ymd_opt(2024, 5, 17).unwrap()
521    }
522
523    #[test]
524    fn option_symbols_round_trip_the_osi_format() {
525        let put = OptionSymbol::put("aapl", expiry(), d("190")).unwrap();
526        assert_eq!(put.to_string(), "AAPL  240517P00190000");
527        assert_eq!(put.underlying(), "AAPL");
528        let parsed: OptionSymbol = "AAPL  240517P00190000".parse().unwrap();
529        assert_eq!(parsed, put);
530        // Fractional strikes and six-character roots.
531        let call = OptionSymbol::call("SPXW", expiry(), d("4512.5")).unwrap();
532        assert_eq!(call.to_string(), "SPXW  240517C04512500");
533        assert_eq!(call.to_string().parse::<OptionSymbol>().unwrap(), call);
534        let long = OptionSymbol::call("ABCDEF", expiry(), d("0.5")).unwrap();
535        assert_eq!(long.to_string(), "ABCDEF240517C00000500");
536        assert_eq!(long.to_string().parse::<OptionSymbol>().unwrap(), long);
537        assert_eq!(
538            "AAPL  240517P00190000"
539                .parse::<OptionSymbol>()
540                .unwrap()
541                .strike(),
542            d("190")
543        );
544    }
545
546    #[test]
547    fn option_symbols_reject_what_osi_cannot_express() {
548        assert!(OptionSymbol::call("ABCDEFG", expiry(), d("1")).is_err());
549        assert!(OptionSymbol::call("", expiry(), d("1")).is_err());
550        assert!(OptionSymbol::call("BRK.B", expiry(), d("1")).is_err());
551        assert!(OptionSymbol::call("AAPL", expiry(), d("0")).is_err());
552        assert!(OptionSymbol::call("AAPL", expiry(), d("-1")).is_err());
553        assert!(OptionSymbol::call("AAPL", expiry(), d("100000")).is_err());
554        assert!(OptionSymbol::call("AAPL", expiry(), d("1.0005")).is_err());
555        // Two year digits: 2125 and 1999 would print as 2025 and 2099.
556        for year in [1999, 2100, 2125] {
557            let date = NaiveDate::from_ymd_opt(year, 6, 20).unwrap();
558            assert!(OptionSymbol::call("SPY", date, d("550")).is_err(), "{year}");
559        }
560        for bad in [
561            "AAPL 240517P00190000",
562            "AAPL  240517X00190000",
563            "AAPL  241317P00190000",
564            "AAPL  240517P0019000a",
565            "AAPL  240517P-0190000",
566        ] {
567            assert!(bad.parse::<OptionSymbol>().is_err(), "{bad}");
568        }
569    }
570
571    #[test]
572    fn single_orders_match_the_documented_shapes() {
573        let market =
574            OrderRequest::market(OrderRequestLeg::equity(OrderInstruction::Buy, "AMD", 10));
575        market.validate().unwrap();
576        assert_eq!(
577            serde_json::to_value(&market).unwrap(),
578            json!({
579                "orderType": "MARKET", "session": "NORMAL", "duration": "DAY",
580                "orderStrategyType": "SINGLE",
581                "orderLegCollection": [{"instruction": "BUY", "quantity": 10,
582                    "instrument": {"symbol": "AMD", "assetType": "EQUITY"}}]
583            })
584        );
585        let put = OptionSymbol::put("AAPL", expiry(), d("190")).unwrap();
586        let sell = OrderRequest::limit(
587            OrderRequestLeg::option(OrderInstruction::SellToOpen, &put, 3),
588            d("1.0"),
589        )
590        .with_duration(OrderDuration::Gtc)
591        .with_complex_strategy(ComplexOrderStrategyType::None);
592        sell.validate().unwrap();
593        assert_eq!(
594            serde_json::to_value(&sell).unwrap(),
595            json!({
596                "orderType": "LIMIT", "session": "NORMAL", "price": "1.0",
597                "duration": "GOOD_TILL_CANCEL", "orderStrategyType": "SINGLE",
598                "complexOrderStrategyType": "NONE",
599                "orderLegCollection": [{"instruction": "SELL_TO_OPEN", "quantity": 3,
600                    "instrument": {"symbol": "AAPL  240517P00190000", "assetType": "OPTION"}}]
601            })
602        );
603        let trailing = OrderRequest::trailing_stop(
604            OrderRequestLeg::equity(OrderInstruction::Sell, "XYZ", 10),
605            PriceLinkBasis::Bid,
606            PriceLinkType::Value,
607            d("10"),
608        );
609        trailing.validate().unwrap();
610        assert_eq!(
611            serde_json::to_value(&trailing).unwrap(),
612            json!({
613                "orderType": "TRAILING_STOP", "session": "NORMAL", "stopPriceLinkBasis": "BID",
614                "stopPriceLinkType": "VALUE", "stopPriceOffset": "10", "duration": "DAY",
615                "orderStrategyType": "SINGLE",
616                "orderLegCollection": [{"instruction": "SELL", "quantity": 10,
617                    "instrument": {"symbol": "XYZ", "assetType": "EQUITY"}}]
618            })
619        );
620        let spread = OrderRequest::net_debit(
621            vec![
622                OrderRequestLeg::option(OrderInstruction::BuyToOpen, "XYZ   240315P00045000", 2),
623                OrderRequestLeg::option(OrderInstruction::SellToOpen, "XYZ   240315P00043000", 2),
624            ],
625            d("0.10"),
626        );
627        spread.validate().unwrap();
628        let value = serde_json::to_value(&spread).unwrap();
629        assert_eq!(value["orderType"], "NET_DEBIT");
630        assert_eq!(value["price"], "0.10");
631        assert_eq!(value["orderLegCollection"].as_array().unwrap().len(), 2);
632    }
633
634    #[test]
635    fn setters_cover_every_typed_request_field() {
636        let order = OrderRequest::stop(
637            OrderRequestLeg::equity(OrderInstruction::Sell, "XYZ", 1)
638                .with_position_effect(PositionEffect::Closing),
639            d("9"),
640        )
641        .with_duration(OrderDuration::Gtc)
642        .with_tag("t")
643        .with_special_instruction(SpecialInstruction::AllOrNone)
644        .with_destination(RequestedDestination::Auto)
645        .with_tax_lot_method(TaxLotMethod::Lifo)
646        .with_stop_type(StopType::Mark);
647        order.validate().unwrap();
648        let value = serde_json::to_value(&order).unwrap();
649        assert_eq!(value["session"], "NORMAL");
650        assert_eq!(value["duration"], "GOOD_TILL_CANCEL");
651        // Extended hours take limit orders only.
652        assert!(
653            order
654                .clone()
655                .with_session(OrderSession::Seamless)
656                .validate()
657                .is_err()
658        );
659        let seamless = OrderRequest::limit(
660            OrderRequestLeg::equity(OrderInstruction::Sell, "XYZ", 1),
661            d("9"),
662        )
663        .with_session(OrderSession::Seamless);
664        seamless.validate().unwrap();
665        assert_eq!(
666            serde_json::to_value(&seamless).unwrap()["session"],
667            "SEAMLESS"
668        );
669        assert_eq!(value["tag"], "t");
670        assert_eq!(value["specialInstruction"], "ALL_OR_NONE");
671        assert_eq!(value["requestedDestination"], "AUTO");
672        assert_eq!(value["taxLotMethod"], "LIFO");
673        assert_eq!(value["stopType"], "MARK");
674        assert_eq!(value["orderLegCollection"][0]["positionEffect"], "CLOSING");
675    }
676
677    #[test]
678    fn composites_nest_like_the_documented_examples() {
679        let sell = |symbol: &str, quantity: u32| {
680            OrderRequestLeg::equity(OrderInstruction::Sell, symbol, quantity)
681        };
682        let oco = OrderRequest::one_cancels_other(
683            OrderRequest::limit(sell("XYZ", 2), d("45.97")),
684            OrderRequest::stop_limit(sell("ABC", 2), d("37.03"), d("37.00")),
685        );
686        oco.validate().unwrap();
687        let value = serde_json::to_value(&oco).unwrap();
688        assert_eq!(
689            value,
690            json!({
691                "orderStrategyType": "OCO",
692                "childOrderStrategies": [
693                    {"orderType": "LIMIT", "session": "NORMAL", "price": "45.97", "duration": "DAY",
694                     "orderStrategyType": "SINGLE",
695                     "orderLegCollection": [{"instruction": "SELL", "quantity": 2,
696                         "instrument": {"symbol": "XYZ", "assetType": "EQUITY"}}]},
697                    {"orderType": "STOP_LIMIT", "session": "NORMAL", "price": "37.00",
698                     "stopPrice": "37.03", "duration": "DAY", "orderStrategyType": "SINGLE",
699                     "orderLegCollection": [{"instruction": "SELL", "quantity": 2,
700                         "instrument": {"symbol": "ABC", "assetType": "EQUITY"}}]}
701                ]
702            })
703        );
704        let trigger = OrderRequest::limit(
705            OrderRequestLeg::equity(OrderInstruction::Buy, "XYZ", 5),
706            d("14.97"),
707        )
708        .triggers(OrderRequest::one_cancels_other(
709            OrderRequest::limit(sell("ABC", 5), d("15.27")).with_duration(OrderDuration::Gtc),
710            OrderRequest::stop(sell("IJK", 5), d("11.27")).with_duration(OrderDuration::Gtc),
711        ));
712        trigger.validate().unwrap();
713        let value = serde_json::to_value(&trigger).unwrap();
714        assert_eq!(value["orderStrategyType"], "TRIGGER");
715        assert_eq!(value["childOrderStrategies"][0]["orderStrategyType"], "OCO");
716        assert_eq!(
717            value["childOrderStrategies"][0]["childOrderStrategies"][1]["stopPrice"],
718            "11.27"
719        );
720        assert_eq!(
721            value["childOrderStrategies"][0]["childOrderStrategies"][1]["duration"],
722            "GOOD_TILL_CANCEL"
723        );
724    }
725
726    #[test]
727    fn brackets_derive_exits_from_the_entry() {
728        let entry = OrderRequest::limit(
729            OrderRequestLeg::equity(OrderInstruction::Buy, "AAPL", 10)
730                .with_position_effect(PositionEffect::Opening),
731            d("150.25"),
732        );
733        let bracket = entry.clone().bracket(d("160"), d("145")).unwrap();
734        bracket.validate().unwrap();
735        let value = serde_json::to_value(&bracket).unwrap();
736        assert_eq!(value["orderStrategyType"], "TRIGGER");
737        assert_eq!(value["price"], "150.25");
738        let exits = &value["childOrderStrategies"][0];
739        assert_eq!(exits["orderStrategyType"], "OCO");
740        let (profit, loss) = (
741            &exits["childOrderStrategies"][0],
742            &exits["childOrderStrategies"][1],
743        );
744        assert_eq!(profit["orderType"], "LIMIT");
745        assert_eq!(profit["price"], "160");
746        assert_eq!(profit["duration"], "GOOD_TILL_CANCEL");
747        assert_eq!(loss["orderType"], "STOP");
748        assert_eq!(loss["stopPrice"], "145");
749        for exit in [profit, loss] {
750            let leg = &exit["orderLegCollection"][0];
751            assert_eq!(leg["instruction"], "SELL");
752            assert_eq!(leg["quantity"], 10);
753            assert_eq!(leg["instrument"]["symbol"], "AAPL");
754            assert_eq!(leg["positionEffect"], "CLOSING");
755        }
756        // Shorts flip the instruction and the price ordering.
757        let short = OrderRequest::market(OrderRequestLeg::equity(
758            OrderInstruction::SellShort,
759            "AAPL",
760            10,
761        ));
762        let covered = short.clone().bracket(d("140"), d("155")).unwrap();
763        let exits = &serde_json::to_value(&covered).unwrap()["childOrderStrategies"][0];
764        assert_eq!(
765            exits["childOrderStrategies"][0]["orderLegCollection"][0]["instruction"],
766            "BUY_TO_COVER"
767        );
768        assert!(short.bracket(d("155"), d("140")).is_err());
769        assert!(entry.clone().bracket(d("145"), d("160")).is_err());
770        assert!(entry.clone().bracket(d("150"), d("150")).is_err());
771        // Only single-leg opening SINGLE orders can be bracketed.
772        assert!(
773            OrderRequest::market(OrderRequestLeg::equity(OrderInstruction::Sell, "AAPL", 10))
774                .bracket(d("160"), d("145"))
775                .is_err()
776        );
777        assert!(
778            OrderRequest::net_debit(
779                vec![
780                    OrderRequestLeg::option(
781                        OrderInstruction::BuyToOpen,
782                        "XYZ   240315P00045000",
783                        1
784                    ),
785                    OrderRequestLeg::option(
786                        OrderInstruction::SellToOpen,
787                        "XYZ   240315P00043000",
788                        1
789                    ),
790                ],
791                d("0.10"),
792            )
793            .bracket(d("1"), d("0.05"))
794            .is_err()
795        );
796        assert!(
797            entry
798                .triggers(OrderRequest::market(OrderRequestLeg::equity(
799                    OrderInstruction::Sell,
800                    "AAPL",
801                    10
802                )))
803                .bracket(d("160"), d("145"))
804                .is_err()
805        );
806    }
807
808    #[test]
809    fn closing_instructions_pair_with_opening_ones() {
810        use OrderInstruction::*;
811        assert_eq!(Buy.closing(), Some(Sell));
812        assert_eq!(SellShort.closing(), Some(BuyToCover));
813        assert_eq!(BuyToOpen.closing(), Some(SellToClose));
814        assert_eq!(SellToOpen.closing(), Some(BuyToClose));
815        assert_eq!(SellShortExempt.closing(), Some(BuyToCover));
816        assert_eq!(SellShortExempt.opens_long(), Some(false));
817        for closing in [Sell, BuyToCover, BuyToClose, SellToClose, Exchange] {
818            assert_eq!(closing.closing(), None);
819        }
820    }
821}