Skip to main content

rustyschwab_types/trading/
spec.rs

1//! A compact JSON description of an order that expands to an
2//! [`OrderRequest`] through the constructors, so JSON callers (the MCP
3//! `build_order` tool, config files) get the same composition helpers as
4//! Rust code without hand-nesting `childOrderStrategies`.
5
6use super::{
7    ComplexOrderStrategyType, OptionSymbol, OrderDuration, OrderInstruction, OrderRequest,
8    OrderRequestInstrument, OrderRequestLeg, OrderSession, PositionEffect, PriceLinkBasis,
9    PriceLinkType, RequestedDestination, SpecialInstruction, StopType, TaxLotMethod,
10};
11use crate::common::PutCall;
12use chrono::NaiveDate;
13use rust_decimal::Decimal;
14use serde::{Deserialize, Serialize};
15use serde_with::skip_serializing_none;
16
17/// One order, keyed by `kind`. Single orders take a `leg` (or `legs` for
18/// the net-priced multi-leg kinds) and optional `settings`; `oco`,
19/// `trigger` and `bracket` nest other specs.
20///
21/// ```
22/// use rustyschwab_types::trading::{OrderSpec, OrderStrategyType};
23///
24/// let spec: OrderSpec = serde_json::from_value(serde_json::json!({
25///     "kind": "bracket",
26///     "entry": {
27///         "kind": "limit",
28///         "leg": {"instruction": "BUY", "quantity": "10",
29///                 "instrument": {"asset_type": "EQUITY", "symbol": "AAPL"}},
30///         "price": "150.25",
31///         "settings": {"tag": "swing-1"}
32///     },
33///     "take_profit": "160",
34///     "stop_loss": "145"
35/// }))
36/// .unwrap();
37/// let order = spec.build().unwrap();
38/// assert_eq!(order.order_strategy_type, OrderStrategyType::Trigger);
39/// assert_eq!(order.child_order_strategies[0].child_order_strategies.len(), 2);
40/// ```
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
43// The rustdoc example above is for Rust readers; the schema gets prose.
44#[cfg_attr(
45    feature = "schemars",
46    schemars(
47        description = "One order, keyed by `kind`. Single orders take a `leg` (or `legs` \
48                            for the net-priced multi-leg kinds) and optional `settings`; `oco`, \
49                            `trigger` and `bracket` nest other specs. Quantities and prices are \
50                            decimal strings."
51    )
52)]
53#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
54#[non_exhaustive]
55pub enum OrderSpec {
56    /// Fill at the market.
57    Market {
58        leg: LegSpec,
59        #[serde(default)]
60        settings: OrderSettings,
61    },
62    /// Fill at `price` or better.
63    Limit {
64        leg: LegSpec,
65        #[serde(deserialize_with = "crate::common::exact_decimal::deserialize")]
66        #[cfg_attr(feature = "schemars", schemars(with = "crate::common::DecimalString"))]
67        price: Decimal,
68        #[serde(default)]
69        settings: OrderSettings,
70    },
71    /// Become a market order once `stop_price` trades.
72    Stop {
73        leg: LegSpec,
74        #[serde(deserialize_with = "crate::common::exact_decimal::deserialize")]
75        #[cfg_attr(feature = "schemars", schemars(with = "crate::common::DecimalString"))]
76        stop_price: Decimal,
77        #[serde(default)]
78        settings: OrderSettings,
79    },
80    /// Become a limit order at `price` once `stop_price` trades.
81    StopLimit {
82        leg: LegSpec,
83        #[serde(deserialize_with = "crate::common::exact_decimal::deserialize")]
84        #[cfg_attr(feature = "schemars", schemars(with = "crate::common::DecimalString"))]
85        stop_price: Decimal,
86        #[serde(deserialize_with = "crate::common::exact_decimal::deserialize")]
87        #[cfg_attr(feature = "schemars", schemars(with = "crate::common::DecimalString"))]
88        price: Decimal,
89        #[serde(default)]
90        settings: OrderSettings,
91    },
92    /// A stop that trails `basis` by `offset`, expressed as `link`.
93    TrailingStop {
94        leg: LegSpec,
95        basis: PriceLinkBasis,
96        link: PriceLinkType,
97        #[serde(deserialize_with = "crate::common::exact_decimal::deserialize")]
98        #[cfg_attr(feature = "schemars", schemars(with = "crate::common::DecimalString"))]
99        offset: Decimal,
100        #[serde(default)]
101        settings: OrderSettings,
102    },
103    /// Multi-leg order paying at most `price` net.
104    NetDebit {
105        legs: Vec<LegSpec>,
106        #[serde(deserialize_with = "crate::common::exact_decimal::deserialize")]
107        #[cfg_attr(feature = "schemars", schemars(with = "crate::common::DecimalString"))]
108        price: Decimal,
109        #[serde(default)]
110        settings: OrderSettings,
111    },
112    /// Multi-leg order collecting at least `price` net.
113    NetCredit {
114        legs: Vec<LegSpec>,
115        #[serde(deserialize_with = "crate::common::exact_decimal::deserialize")]
116        #[cfg_attr(feature = "schemars", schemars(with = "crate::common::DecimalString"))]
117        price: Decimal,
118        #[serde(default)]
119        settings: OrderSettings,
120    },
121    /// Multi-leg order that must fill for zero net cost.
122    NetZero {
123        legs: Vec<LegSpec>,
124        #[serde(default)]
125        settings: OrderSettings,
126    },
127    /// Two orders; a fill on either cancels the other.
128    Oco {
129        first: Box<OrderSpec>,
130        second: Box<OrderSpec>,
131    },
132    /// `children` are submitted once `entry` fills.
133    Trigger {
134        entry: Box<OrderSpec>,
135        children: Vec<OrderSpec>,
136    },
137    /// `entry` triggers a good-till-cancel take-profit limit and stop-loss
138    /// pair that close the entry leg; see [`OrderRequest::bracket`].
139    Bracket {
140        entry: Box<OrderSpec>,
141        #[serde(deserialize_with = "crate::common::exact_decimal::deserialize")]
142        #[cfg_attr(feature = "schemars", schemars(with = "crate::common::DecimalString"))]
143        take_profit: Decimal,
144        #[serde(deserialize_with = "crate::common::exact_decimal::deserialize")]
145        #[cfg_attr(feature = "schemars", schemars(with = "crate::common::DecimalString"))]
146        stop_loss: Decimal,
147    },
148}
149
150/// One leg of an order.
151#[skip_serializing_none]
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
154#[serde(rename_all = "snake_case", deny_unknown_fields)]
155pub struct LegSpec {
156    pub instruction: OrderInstruction,
157    /// Shares, or contracts for options.
158    #[serde(deserialize_with = "crate::common::exact_decimal::deserialize")]
159    #[cfg_attr(feature = "schemars", schemars(with = "crate::common::DecimalString"))]
160    pub quantity: Decimal,
161    pub instrument: InstrumentSpec,
162    #[serde(default)]
163    pub position_effect: Option<PositionEffect>,
164}
165
166/// What a leg trades, keyed by `asset_type`.
167#[skip_serializing_none]
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
170#[serde(
171    tag = "asset_type",
172    rename_all = "SCREAMING_SNAKE_CASE",
173    deny_unknown_fields
174)]
175#[non_exhaustive]
176pub enum InstrumentSpec {
177    Equity {
178        symbol: String,
179    },
180    /// Either an OSI `symbol` (`AAPL  240517P00190000`) or the contract by
181    /// parts: `underlying`, `expiration` (`YYYY-MM-DD`), `right` (`PUT` /
182    /// `CALL`) and `strike`.
183    Option {
184        #[serde(default)]
185        symbol: Option<String>,
186        #[serde(default)]
187        underlying: Option<String>,
188        #[serde(default)]
189        expiration: Option<NaiveDate>,
190        #[serde(default)]
191        right: Option<PutCall>,
192        #[serde(
193            default,
194            deserialize_with = "crate::common::exact_decimal::option::deserialize"
195        )]
196        #[cfg_attr(
197            feature = "schemars",
198            schemars(with = "Option<crate::common::DecimalString>")
199        )]
200        strike: Option<Decimal>,
201    },
202}
203
204/// Fields every single order accepts. `session` defaults to `NORMAL` and
205/// `duration` to `DAY`.
206#[skip_serializing_none]
207#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
208#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
209#[serde(rename_all = "snake_case", deny_unknown_fields, default)]
210pub struct OrderSettings {
211    pub session: Option<OrderSession>,
212    pub duration: Option<OrderDuration>,
213    /// Caller's own identifier for the order.
214    pub tag: Option<String>,
215    pub complex_strategy: Option<ComplexOrderStrategyType>,
216    pub special_instruction: Option<SpecialInstruction>,
217    pub destination: Option<RequestedDestination>,
218    pub tax_lot_method: Option<TaxLotMethod>,
219    pub stop_type: Option<StopType>,
220}
221
222impl OrderSettings {
223    fn apply(self, mut order: OrderRequest) -> OrderRequest {
224        if let Some(session) = self.session {
225            order = order.with_session(session);
226        }
227        if let Some(duration) = self.duration {
228            order = order.with_duration(duration);
229        }
230        if let Some(tag) = self.tag {
231            order = order.with_tag(tag);
232        }
233        if let Some(strategy) = self.complex_strategy {
234            order = order.with_complex_strategy(strategy);
235        }
236        if let Some(instruction) = self.special_instruction {
237            order = order.with_special_instruction(instruction);
238        }
239        if let Some(destination) = self.destination {
240            order = order.with_destination(destination);
241        }
242        if let Some(method) = self.tax_lot_method {
243            order = order.with_tax_lot_method(method);
244        }
245        if let Some(stop_type) = self.stop_type {
246            order = order.with_stop_type(stop_type);
247        }
248        order
249    }
250}
251
252impl InstrumentSpec {
253    fn build(self) -> Result<OrderRequestInstrument, String> {
254        match self {
255            Self::Equity { symbol } => Ok(OrderRequestInstrument::equity(symbol)),
256            Self::Option {
257                symbol: Some(symbol),
258                underlying: None,
259                expiration: None,
260                right: None,
261                strike: None,
262            } => {
263                let parsed: OptionSymbol = symbol.parse()?;
264                Ok(OrderRequestInstrument::option(parsed))
265            }
266            Self::Option {
267                symbol: None,
268                underlying: Some(underlying),
269                expiration: Some(expiration),
270                right: Some(right),
271                strike: Some(strike),
272            } => Ok(OrderRequestInstrument::option(OptionSymbol::new(
273                underlying, expiration, right, strike,
274            )?)),
275            Self::Option { .. } => Err("An option instrument takes either an OSI symbol \
276                 or all of underlying, expiration, right and strike"
277                .into()),
278        }
279    }
280}
281
282impl LegSpec {
283    fn build(self) -> Result<OrderRequestLeg, String> {
284        let leg = OrderRequestLeg::new(self.instruction, self.instrument.build()?, self.quantity);
285        Ok(match self.position_effect {
286            Some(effect) => leg.with_position_effect(effect),
287            None => leg,
288        })
289    }
290}
291
292fn build_legs(legs: Vec<LegSpec>) -> Result<Vec<OrderRequestLeg>, String> {
293    legs.into_iter().map(LegSpec::build).collect()
294}
295
296impl OrderSpec {
297    /// Expand into a validated [`OrderRequest`].
298    pub fn build(self) -> Result<OrderRequest, String> {
299        let order = self.expand()?;
300        order.validate().map_err(|e| e.to_string())?;
301        Ok(order)
302    }
303
304    fn expand(self) -> Result<OrderRequest, String> {
305        Ok(match self {
306            Self::Market { leg, settings } => settings.apply(OrderRequest::market(leg.build()?)),
307            Self::Limit {
308                leg,
309                price,
310                settings,
311            } => settings.apply(OrderRequest::limit(leg.build()?, price)),
312            Self::Stop {
313                leg,
314                stop_price,
315                settings,
316            } => settings.apply(OrderRequest::stop(leg.build()?, stop_price)),
317            Self::StopLimit {
318                leg,
319                stop_price,
320                price,
321                settings,
322            } => settings.apply(OrderRequest::stop_limit(leg.build()?, stop_price, price)),
323            Self::TrailingStop {
324                leg,
325                basis,
326                link,
327                offset,
328                settings,
329            } => settings.apply(OrderRequest::trailing_stop(
330                leg.build()?,
331                basis,
332                link,
333                offset,
334            )),
335            Self::NetDebit {
336                legs,
337                price,
338                settings,
339            } => settings.apply(OrderRequest::net_debit(build_legs(legs)?, price)),
340            Self::NetCredit {
341                legs,
342                price,
343                settings,
344            } => settings.apply(OrderRequest::net_credit(build_legs(legs)?, price)),
345            Self::NetZero { legs, settings } => {
346                settings.apply(OrderRequest::net_zero(build_legs(legs)?))
347            }
348            Self::Oco { first, second } => {
349                OrderRequest::one_cancels_other(first.expand()?, second.expand()?)
350            }
351            Self::Trigger { entry, children } => {
352                if children.is_empty() {
353                    return Err("A trigger needs at least one child order".into());
354                }
355                children
356                    .into_iter()
357                    .try_fold(entry.expand()?, |order, child| {
358                        Ok::<_, String>(order.triggers(child.expand()?))
359                    })?
360            }
361            Self::Bracket {
362                entry,
363                take_profit,
364                stop_loss,
365            } => entry.expand()?.bracket(take_profit, stop_loss)?,
366        })
367    }
368}
369
370impl TryFrom<OrderSpec> for OrderRequest {
371    type Error = String;
372
373    fn try_from(spec: OrderSpec) -> Result<Self, Self::Error> {
374        spec.build()
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use serde_json::json;
382
383    fn build(spec: serde_json::Value) -> Result<serde_json::Value, String> {
384        let spec: OrderSpec = serde_json::from_value(spec).map_err(|e| e.to_string())?;
385        Ok(serde_json::to_value(spec.build()?).unwrap())
386    }
387
388    #[test]
389    fn specs_expand_to_the_documented_request_shapes() {
390        let leg = json!({"instruction": "BUY", "quantity": "10",
391            "instrument": {"asset_type": "EQUITY", "symbol": "AMD"}});
392        assert_eq!(
393            build(json!({"kind": "market", "leg": leg})).unwrap(),
394            json!({
395                "orderType": "MARKET", "session": "NORMAL", "duration": "DAY",
396                "orderStrategyType": "SINGLE",
397                "orderLegCollection": [{"instruction": "BUY", "quantity": 10,
398                    "instrument": {"symbol": "AMD", "assetType": "EQUITY"}}]
399            })
400        );
401        let bracket = build(json!({
402            "kind": "bracket",
403            "entry": {"kind": "limit", "leg": leg, "price": "150.25",
404                      "settings": {"duration": "GOOD_TILL_CANCEL", "tag": "swing-1"}},
405            "take_profit": "160",
406            "stop_loss": "145"
407        }))
408        .unwrap();
409        assert_eq!(bracket["orderStrategyType"], "TRIGGER");
410        assert_eq!(bracket["duration"], "GOOD_TILL_CANCEL");
411        assert_eq!(bracket["tag"], "swing-1");
412        let exits = &bracket["childOrderStrategies"][0];
413        assert_eq!(exits["orderStrategyType"], "OCO");
414        assert_eq!(exits["childOrderStrategies"][0]["price"], "160");
415        assert_eq!(exits["childOrderStrategies"][1]["stopPrice"], "145");
416        assert_eq!(
417            exits["childOrderStrategies"][1]["orderLegCollection"][0]["instruction"],
418            "SELL"
419        );
420        let sell = json!({"instruction": "SELL", "quantity": "2",
421            "instrument": {"asset_type": "EQUITY", "symbol": "XYZ"}});
422        let trigger = build(json!({
423            "kind": "trigger",
424            "entry": {"kind": "limit", "leg": leg, "price": "14.97"},
425            "children": [{
426                "kind": "oco",
427                "first": {"kind": "limit", "leg": sell, "price": "15.27"},
428                "second": {"kind": "stop_limit", "leg": sell, "stop_price": "11.30",
429                           "price": "11.27"}
430            }]
431        }))
432        .unwrap();
433        assert_eq!(trigger["orderStrategyType"], "TRIGGER");
434        assert_eq!(
435            trigger["childOrderStrategies"][0]["childOrderStrategies"][1]["orderType"],
436            "STOP_LIMIT"
437        );
438        let trailing = build(json!({
439            "kind": "trailing_stop", "leg": sell, "basis": "BID", "link": "VALUE",
440            "offset": "10", "settings": {"stop_type": "MARK", "destination": "AUTO"}
441        }))
442        .unwrap();
443        assert_eq!(trailing["stopPriceLinkBasis"], "BID");
444        assert_eq!(trailing["stopPriceOffset"], "10");
445        assert_eq!(trailing["stopType"], "MARK");
446        assert_eq!(trailing["requestedDestination"], "AUTO");
447    }
448
449    #[test]
450    fn option_instruments_accept_osi_symbols_or_contract_parts() {
451        let by_parts = json!({"asset_type": "OPTION", "underlying": "aapl",
452            "expiration": "2024-05-17", "right": "PUT", "strike": "190"});
453        let by_symbol = json!({"asset_type": "OPTION", "symbol": "AAPL  240517P00190000"});
454        for instrument in [by_parts, by_symbol] {
455            let order = build(json!({
456                "kind": "net_credit",
457                "legs": [
458                    {"instruction": "SELL_TO_OPEN", "quantity": "1", "instrument": instrument,
459                     "position_effect": "OPENING"},
460                    {"instruction": "BUY_TO_OPEN", "quantity": "1",
461                     "instrument": {"asset_type": "OPTION", "symbol": "AAPL  240517P00185000"}}
462                ],
463                "price": "1.05",
464                "settings": {"complex_strategy": "VERTICAL"}
465            }))
466            .unwrap();
467            assert_eq!(order["orderType"], "NET_CREDIT");
468            assert_eq!(order["complexOrderStrategyType"], "VERTICAL");
469            let leg = &order["orderLegCollection"][0];
470            assert_eq!(leg["instrument"]["symbol"], "AAPL  240517P00190000");
471            assert_eq!(leg["instrument"]["assetType"], "OPTION");
472            assert_eq!(leg["positionEffect"], "OPENING");
473        }
474    }
475
476    #[test]
477    fn malformed_specs_are_rejected() {
478        let leg = json!({"instruction": "BUY", "quantity": "1",
479            "instrument": {"asset_type": "EQUITY", "symbol": "AMD"}});
480        // Unknown keys and unknown kinds fail deserialization.
481        assert!(
482            build(json!({"kind": "limit", "leg": leg, "price": "1", "duration": "DAY"})).is_err()
483        );
484        assert!(
485            build(json!({"kind": "limit", "leg": leg, "price": "1",
486            "settings": {"durration": "DAY"}}))
487            .is_err()
488        );
489        assert!(build(json!({"kind": "iceberg", "leg": leg})).is_err());
490        assert!(build(json!({"kind": "limit", "leg": leg})).is_err());
491        // Options need exactly one description.
492        for instrument in [
493            json!({"asset_type": "OPTION"}),
494            json!({"asset_type": "OPTION", "symbol": "AAPL  240517P00190000", "strike": "190"}),
495            json!({"asset_type": "OPTION", "underlying": "AAPL", "strike": "190"}),
496            json!({"asset_type": "OPTION", "symbol": "AAPL"}),
497            json!({"asset_type": "FUTURE", "symbol": "/ES"}),
498        ] {
499            assert!(
500                build(
501                    json!({"kind": "market", "leg": {"instruction": "BUY_TO_OPEN",
502                    "quantity": "1", "instrument": instrument}})
503                )
504                .is_err(),
505                "{instrument}"
506            );
507        }
508        // Structural rules from the constructors and validate() apply.
509        assert!(
510            build(
511                json!({"kind": "trigger", "entry": {"kind": "market", "leg": leg},
512            "children": []})
513            )
514            .is_err()
515        );
516        assert!(
517            build(
518                json!({"kind": "bracket", "entry": {"kind": "market", "leg": leg},
519            "take_profit": "1", "stop_loss": "2"})
520            )
521            .is_err()
522        );
523        assert!(build(json!({"kind": "limit", "leg": leg, "price": "0"})).is_err());
524    }
525
526    #[test]
527    fn specs_round_trip_through_serde() {
528        let spec = OrderSpec::Bracket {
529            entry: Box::new(OrderSpec::Limit {
530                leg: LegSpec {
531                    instruction: OrderInstruction::Buy,
532                    quantity: Decimal::from(10),
533                    instrument: InstrumentSpec::Equity {
534                        symbol: "AAPL".into(),
535                    },
536                    position_effect: None,
537                },
538                price: Decimal::new(15025, 2),
539                settings: OrderSettings {
540                    duration: Some(OrderDuration::Gtc),
541                    ..OrderSettings::default()
542                },
543            }),
544            take_profit: Decimal::from(160),
545            stop_loss: Decimal::from(145),
546        };
547        let json = serde_json::to_value(&spec).unwrap();
548        assert_eq!(json["kind"], "bracket");
549        assert_eq!(json["entry"]["kind"], "limit");
550        assert_eq!(json["entry"]["settings"]["duration"], "GOOD_TILL_CANCEL");
551        assert_eq!(serde_json::from_value::<OrderSpec>(json).unwrap(), spec);
552    }
553}