Skip to main content

rustyschwab_types/
trading.rs

1//! Trading types including orders, transactions, and execution reports.
2//!
3//! This module contains types for trading operations:
4//!
5//! - Order creation and management
6//! - Order status and responses
7//! - Transaction history
8//! - Execution reports
9//! - Query parameters for the order and transaction listings
10//!   ([`OrdersParams`](crate::trading::OrdersParams),
11//!   [`TransactionsParams`](crate::trading::TransactionsParams))
12//!
13//! # Building orders
14//!
15//! [`OrderRequest`](crate::trading::OrderRequest) is the strict outbound
16//! shape. Its constructors assemble it the way Schwab's order examples are
17//! written, so callers never hand-nest `childOrderStrategies`: single orders
18//! start from a leg
19//! ([`OrderRequestLeg::equity`](crate::trading::OrderRequestLeg::equity),
20//! [`OrderRequestLeg::option`](crate::trading::OrderRequestLeg::option)) and
21//! an order type ([`OrderRequest::market`](crate::trading::OrderRequest::market),
22//! [`OrderRequest::limit`](crate::trading::OrderRequest::limit), …), default
23//! to the `NORMAL` session and `DAY` duration, and take `with_*` setters;
24//! [`OrderRequest::one_cancels_other`](crate::trading::OrderRequest::one_cancels_other),
25//! [`OrderRequest::triggers`](crate::trading::OrderRequest::triggers) and
26//! [`OrderRequest::bracket`](crate::trading::OrderRequest::bracket) compose
27//! them. [`OptionSymbol`](crate::trading::OptionSymbol) formats and parses
28//! OSI option symbols.
29//! [`OrderRequest::validate`](crate::trading::OrderRequest::validate) checks
30//! structure before submission.
31//!
32//! ```
33//! use rustyschwab_types::Decimal;
34//! use rustyschwab_types::trading::{OrderDuration, OrderInstruction, OrderRequest, OrderRequestLeg};
35//!
36//! let entry = OrderRequest::limit(
37//!     OrderRequestLeg::equity(OrderInstruction::Buy, "AAPL", 10),
38//!     Decimal::new(15025, 2),
39//! )
40//! .with_duration(OrderDuration::Gtc)
41//! .with_tag("swing-1");
42//! // Buy at 150.25; once filled, sell at 160 or stop out at 145.
43//! let bracket = entry.bracket(Decimal::from(160), Decimal::from(145)).unwrap();
44//! bracket.validate().unwrap();
45//! ```
46
47use crate::common::{AccountsInstrument, AssetType, PutCall};
48use crate::validation::{ValidationError, ValidationError as VE, ValidationErrorKind as K};
49use chrono::{DateTime, Utc};
50use serde::{Deserialize, Serialize};
51use serde_with::skip_serializing_none;
52use std::collections::HashMap;
53
54mod builder;
55mod payoff;
56mod spec;
57pub use builder::OptionSymbol;
58pub use payoff::{DefinedRisk, defined_risk};
59pub use spec::{InstrumentSpec, LegSpec, OrderSettings, OrderSpec};
60
61/// An order as Schwab reports it. Tolerant: every enum decodes a value this
62/// release does not know as `Unknown` (see [`Wire`](crate::common::Wire)),
63/// and undocumented fields land in `extra`. Times are the broker's ISO-8601
64/// strings, kept verbatim.
65///
66/// `session`, `duration` and `order_type` are `None` when Schwab omits
67/// them, as it does on an `OCO` parent: nothing is inferred.
68#[skip_serializing_none]
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
70#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
71#[serde(rename_all = "camelCase")]
72#[non_exhaustive]
73pub struct Order {
74    #[serde(default, deserialize_with = "crate::common::optional_id")]
75    pub order_id: Option<String>,
76    pub session: Option<OrderSession>,
77    pub duration: Option<OrderDuration>,
78    pub order_type: Option<OrderType>,
79    pub complex_order_strategy_type: Option<ComplexOrderStrategyType>,
80    pub quantity: Option<f64>,
81    pub filled_quantity: Option<f64>,
82    pub remaining_quantity: Option<f64>,
83    pub requested_destination: Option<RequestedDestination>,
84    pub destination_link_name: Option<String>,
85    pub price: Option<f64>,
86    #[serde(default)]
87    pub order_leg_collection: Vec<OrderLeg>,
88    pub order_strategy_type: Option<OrderStrategyType>,
89    pub cancelable: Option<bool>,
90    pub editable: Option<bool>,
91    pub status: Option<OrderStatus>,
92    pub status_description: Option<String>,
93    pub entered_time: Option<String>,
94    pub close_time: Option<String>,
95    pub cancel_time: Option<String>,
96    pub release_time: Option<String>,
97    pub stop_price: Option<f64>,
98    pub stop_price_link_basis: Option<PriceLinkBasis>,
99    pub stop_price_link_type: Option<PriceLinkType>,
100    pub stop_price_offset: Option<f64>,
101    pub stop_type: Option<StopType>,
102    pub price_link_basis: Option<PriceLinkBasis>,
103    pub price_link_type: Option<PriceLinkType>,
104    pub activation_price: Option<f64>,
105    pub tax_lot_method: Option<TaxLotMethod>,
106    pub special_instruction: Option<SpecialInstruction>,
107    pub tag: Option<String>,
108    #[serde(default)]
109    pub child_order_strategies: Vec<Order>,
110    #[serde(default)]
111    pub replacing_order_collection: Vec<Order>,
112    #[serde(default)]
113    pub order_activity_collection: Vec<OrderActivity>,
114    #[serde(flatten)]
115    pub extra: HashMap<String, serde_json::Value>,
116    #[serde(default, deserialize_with = "crate::common::optional_id")]
117    pub account_number: Option<String>,
118}
119
120#[skip_serializing_none]
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
122#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
123// One leg Schwab sends without a field must not fail the whole order list.
124#[serde(rename_all = "camelCase", default)]
125#[non_exhaustive]
126pub struct OrderLeg {
127    pub order_leg_type: OrderLegType,
128    pub leg_id: Option<i64>,
129    pub instrument: AccountsInstrument,
130    pub instruction: OrderInstruction,
131    pub position_effect: Option<PositionEffect>,
132    pub quantity: f64,
133    pub quantity_type: Option<QuantityType>,
134    #[serde(flatten)]
135    pub extra: HashMap<String, serde_json::Value>,
136}
137
138/// One entry of `orderActivityCollection`: an execution (partial or full
139/// fill) or an order action. The real payload carries more than the spec
140/// documents (an `activityId`, `executionType: CANCELED`), so
141/// `execution_type` stays a string and the rest lands in `extra`.
142#[skip_serializing_none]
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
144#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
145#[serde(rename_all = "camelCase")]
146#[non_exhaustive]
147pub struct OrderActivity {
148    pub activity_type: Option<OrderActivityType>,
149    /// `FILL` is the only documented value; `CANCELED` has been observed.
150    pub execution_type: Option<String>,
151    pub quantity: Option<f64>,
152    pub order_remaining_quantity: Option<f64>,
153    #[serde(default)]
154    pub execution_legs: Vec<ExecutionLeg>,
155    #[serde(flatten)]
156    pub extra: HashMap<String, serde_json::Value>,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
160#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
161#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
162#[non_exhaustive]
163pub enum OrderActivityType {
164    Execution,
165    OrderAction,
166    #[serde(other)]
167    #[cfg_attr(feature = "schemars", schemars(skip))]
168    Unknown,
169}
170
171/// Per-leg detail of an [`OrderActivity`] execution.
172#[skip_serializing_none]
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
174#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
175#[serde(rename_all = "camelCase")]
176#[non_exhaustive]
177pub struct ExecutionLeg {
178    pub leg_id: Option<i64>,
179    pub price: Option<f64>,
180    pub quantity: Option<f64>,
181    pub mismarked_quantity: Option<f64>,
182    pub instrument_id: Option<i64>,
183    pub time: Option<String>,
184    #[serde(flatten)]
185    pub extra: HashMap<String, serde_json::Value>,
186}
187
188/// How an order leg's `quantity` is denominated.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
190#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
191#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
192#[non_exhaustive]
193pub enum QuantityType {
194    AllShares,
195    Dollars,
196    Shares,
197    #[serde(other)]
198    #[cfg_attr(feature = "schemars", schemars(skip))]
199    Unknown,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
203#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
204#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
205#[non_exhaustive]
206pub enum OrderSession {
207    #[default]
208    Normal,
209    Am,
210    Pm,
211    Seamless,
212    #[serde(other)]
213    #[cfg_attr(feature = "schemars", schemars(skip))]
214    Unknown,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
218#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
219#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
220#[non_exhaustive]
221pub enum OrderDuration {
222    #[default]
223    Day,
224    #[serde(rename = "GOOD_TILL_CANCEL")]
225    Gtc,
226    FillOrKill,
227    ImmediateOrCancel,
228    EndOfWeek,
229    EndOfMonth,
230    NextEndOfMonth,
231    #[serde(other)]
232    #[cfg_attr(feature = "schemars", schemars(skip))]
233    Unknown,
234}
235
236/// `CABINET`, `NON_MARKETABLE` and `EXERCISE` appear on orders Schwab
237/// reports back but cannot be submitted, so the request schema leaves them
238/// out.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
240#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
241#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
242#[non_exhaustive]
243pub enum OrderType {
244    Market,
245    Limit,
246    Stop,
247    StopLimit,
248    TrailingStop,
249    #[cfg_attr(feature = "schemars", schemars(skip))]
250    Cabinet,
251    #[cfg_attr(feature = "schemars", schemars(skip))]
252    NonMarketable,
253    MarketOnClose,
254    #[cfg_attr(feature = "schemars", schemars(skip))]
255    Exercise,
256    TrailingStopLimit,
257    NetDebit,
258    NetCredit,
259    NetZero,
260    LimitOnClose,
261    #[serde(other)]
262    #[cfg_attr(feature = "schemars", schemars(skip))]
263    Unknown,
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
267#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
268#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
269#[non_exhaustive]
270pub enum ComplexOrderStrategyType {
271    None,
272    Covered,
273    Vertical,
274    BackRatio,
275    Calendar,
276    Diagonal,
277    Straddle,
278    Strangle,
279    CollarSynthetic,
280    Butterfly,
281    Condor,
282    IronCondor,
283    VerticalRoll,
284    CollarWithStock,
285    DoubleDiagonal,
286    UnbalancedButterfly,
287    UnbalancedCondor,
288    UnbalancedIronCondor,
289    UnbalancedVerticalRoll,
290    MutualFundSwap,
291    Custom,
292    #[serde(other)]
293    #[cfg_attr(feature = "schemars", schemars(skip))]
294    Unknown,
295}
296
297/// Only [`Single`](Self::Single), [`Oco`](Self::Oco), and
298/// [`Trigger`](Self::Trigger) can be submitted; the rest appear on orders
299/// Schwab reports back, and are left out of the request schema.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
301#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
302#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
303#[non_exhaustive]
304pub enum OrderStrategyType {
305    #[default]
306    Single,
307    #[cfg_attr(feature = "schemars", schemars(skip))]
308    Cancel,
309    #[cfg_attr(feature = "schemars", schemars(skip))]
310    Recall,
311    #[cfg_attr(feature = "schemars", schemars(skip))]
312    Pair,
313    #[cfg_attr(feature = "schemars", schemars(skip))]
314    Flatten,
315    #[cfg_attr(feature = "schemars", schemars(skip))]
316    TwoDaySwap,
317    #[cfg_attr(feature = "schemars", schemars(skip))]
318    BlastAll,
319    Oco,
320    Trigger,
321    #[serde(other)]
322    #[cfg_attr(feature = "schemars", schemars(skip))]
323    Unknown,
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
327#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
328#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
329#[non_exhaustive]
330pub enum OrderStatus {
331    AwaitingParentOrder,
332    AwaitingCondition,
333    AwaitingStopCondition,
334    AwaitingManualReview,
335    Accepted,
336    AwaitingUrOut,
337    PendingActivation,
338    Queued,
339    Working,
340    Rejected,
341    PendingCancel,
342    Canceled,
343    PendingReplace,
344    Replaced,
345    Filled,
346    Expired,
347    New,
348    AwaitingReleaseTime,
349    PendingAcknowledgement,
350    PendingRecall,
351    #[serde(other)]
352    #[cfg_attr(feature = "schemars", schemars(skip))]
353    Unknown,
354}
355
356impl OrderStatus {
357    /// Schwab will not change the order again: it filled, was canceled,
358    /// rejected, expired or replaced. Every other status, including one
359    /// this crate does not know, may still fill.
360    pub const fn is_terminal(self) -> bool {
361        matches!(
362            self,
363            Self::Filled | Self::Canceled | Self::Rejected | Self::Expired | Self::Replaced
364        )
365    }
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
369#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
370#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
371#[non_exhaustive]
372pub enum OrderLegType {
373    #[default]
374    Equity,
375    Option,
376    Index,
377    MutualFund,
378    CashEquivalent,
379    FixedIncome,
380    Currency,
381    CollectiveInvestment,
382    #[serde(other)]
383    #[cfg_attr(feature = "schemars", schemars(skip))]
384    Unknown,
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
388#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
389#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
390#[non_exhaustive]
391pub enum OrderInstruction {
392    #[default]
393    Buy,
394    Sell,
395    BuyToCover,
396    SellShort,
397    BuyToOpen,
398    BuyToClose,
399    SellToOpen,
400    SellToClose,
401    Exchange,
402    SellShortExempt,
403    #[serde(other)]
404    #[cfg_attr(feature = "schemars", schemars(skip))]
405    Unknown,
406}
407
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
409#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
410#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
411#[non_exhaustive]
412pub enum PositionEffect {
413    Opening,
414    Closing,
415    #[default]
416    Automatic,
417    #[serde(other)]
418    #[cfg_attr(feature = "schemars", schemars(skip))]
419    Unknown,
420}
421
422/// What a linked (trailing) price follows: `priceLinkBasis` and
423/// `stopPriceLinkBasis`.
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
425#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
426#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
427#[non_exhaustive]
428pub enum PriceLinkBasis {
429    Manual,
430    Base,
431    Trigger,
432    Last,
433    Bid,
434    Ask,
435    AskBid,
436    Mark,
437    Average,
438    #[serde(other)]
439    #[cfg_attr(feature = "schemars", schemars(skip))]
440    Unknown,
441}
442
443/// How a linked price's offset is expressed: `priceLinkType` and
444/// `stopPriceLinkType`.
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
446#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
447#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
448#[non_exhaustive]
449pub enum PriceLinkType {
450    Value,
451    Percent,
452    Tick,
453    #[serde(other)]
454    #[cfg_attr(feature = "schemars", schemars(skip))]
455    Unknown,
456}
457
458/// Which price a stop order triggers on (`stopType`).
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
460#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
461#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
462#[non_exhaustive]
463pub enum StopType {
464    Standard,
465    Bid,
466    Ask,
467    Last,
468    Mark,
469    #[serde(other)]
470    #[cfg_attr(feature = "schemars", schemars(skip))]
471    Unknown,
472}
473
474#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
476#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
477#[non_exhaustive]
478pub enum TaxLotMethod {
479    Fifo,
480    Lifo,
481    HighCost,
482    LowCost,
483    AverageCost,
484    SpecificLot,
485    LossHarvester,
486    #[serde(other)]
487    #[cfg_attr(feature = "schemars", schemars(skip))]
488    Unknown,
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
492#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
493#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
494#[non_exhaustive]
495pub enum SpecialInstruction {
496    AllOrNone,
497    DoNotReduce,
498    AllOrNoneDoNotReduce,
499    #[serde(other)]
500    #[cfg_attr(feature = "schemars", schemars(skip))]
501    Unknown,
502}
503
504/// Routing venue for `requestedDestination`.
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
506#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
507#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
508#[non_exhaustive]
509pub enum RequestedDestination {
510    Inet,
511    EcnArca,
512    Cboe,
513    Amex,
514    Phlx,
515    Ise,
516    Box,
517    Nyse,
518    Nasdaq,
519    Bats,
520    C2,
521    Auto,
522    #[serde(other)]
523    #[cfg_attr(feature = "schemars", schemars(skip))]
524    Unknown,
525}
526
527/// The answer to `POST .../previewOrder`: Schwab's verdict on an order
528/// without placing it.
529///
530/// A preview Schwab would refuse still answers 200; read
531/// [`is_rejected`](Self::is_rejected) (and the `rejects`) rather than the
532/// HTTP status. Fields Schwab adds land in `extra`.
533#[skip_serializing_none]
534#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
535#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
536#[serde(rename_all = "camelCase", default)]
537#[non_exhaustive]
538pub struct OrderPreview {
539    #[serde(deserialize_with = "crate::common::optional_id")]
540    pub order_id: Option<String>,
541    pub order_strategy: Option<PreviewOrderStrategy>,
542    pub order_validation_result: OrderValidationResult,
543    /// Commission and fee legs, as Schwab reports them.
544    pub commission_and_fee: Option<serde_json::Value>,
545    #[serde(flatten)]
546    pub extra: HashMap<String, serde_json::Value>,
547}
548
549impl OrderPreview {
550    /// Whether Schwab would refuse the order: a `reject` finding, or the
551    /// previewed strategy's status `REJECTED`.
552    pub fn is_rejected(&self) -> bool {
553        !self.order_validation_result.rejects.is_empty()
554            || self
555                .order_strategy
556                .as_ref()
557                .is_some_and(|s| s.status == Some(OrderStatus::Rejected))
558    }
559    /// The reasons Schwab gave for refusing it.
560    pub fn rejection_reasons(&self) -> Vec<&str> {
561        self.order_validation_result
562            .rejects
563            .iter()
564            .filter_map(ValidationFinding::text)
565            .collect()
566    }
567}
568
569/// The order as Schwab previewed it: its status and projected balances;
570/// the rest of the strategy is in `extra`.
571#[skip_serializing_none]
572#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
573#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
574#[serde(rename_all = "camelCase", default)]
575#[non_exhaustive]
576pub struct PreviewOrderStrategy {
577    #[serde(deserialize_with = "crate::common::lenient")]
578    pub status: Option<OrderStatus>,
579    pub order_balance: Option<PreviewOrderBalance>,
580    pub order_value: Option<f64>,
581    #[serde(flatten)]
582    pub extra: HashMap<String, serde_json::Value>,
583}
584
585/// What the order would do to the account's balances.
586#[skip_serializing_none]
587#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
588#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
589#[serde(rename_all = "camelCase", default)]
590#[non_exhaustive]
591pub struct PreviewOrderBalance {
592    pub order_value: Option<f64>,
593    pub projected_available_fund: Option<f64>,
594    pub projected_buying_power: Option<f64>,
595    pub projected_commission: Option<f64>,
596    #[serde(flatten)]
597    pub extra: HashMap<String, serde_json::Value>,
598}
599
600/// Schwab's validation findings, by severity.
601#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
602#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
603#[serde(rename_all = "camelCase", default)]
604#[non_exhaustive]
605pub struct OrderValidationResult {
606    pub alerts: Vec<ValidationFinding>,
607    pub accepts: Vec<ValidationFinding>,
608    pub rejects: Vec<ValidationFinding>,
609    pub reviews: Vec<ValidationFinding>,
610    pub warns: Vec<ValidationFinding>,
611    #[serde(flatten)]
612    pub extra: HashMap<String, serde_json::Value>,
613}
614
615/// One validation finding on a preview.
616#[skip_serializing_none]
617#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
618#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
619#[serde(rename_all = "camelCase", default)]
620#[non_exhaustive]
621pub struct ValidationFinding {
622    pub validation_rule_name: Option<String>,
623    pub message: Option<String>,
624    pub activity_message: Option<String>,
625    pub original_severity: Option<String>,
626    pub override_name: Option<String>,
627    pub override_severity: Option<String>,
628    #[serde(flatten)]
629    pub extra: HashMap<String, serde_json::Value>,
630}
631
632impl ValidationFinding {
633    /// The finding's text: `activityMessage`, else `message`.
634    pub fn text(&self) -> Option<&str> {
635        self.activity_message.as_deref().or(self.message.as_deref())
636    }
637}
638
639/// Result of submitting an order via `POST .../orders` or `PUT .../orders/{id}`.
640///
641/// Schwab returns **201 Created with an empty body**; the only payload is a
642/// `Location` header pointing at the new order resource. The order ID is the
643/// last path segment of that URL. If the order filled immediately, Schwab may
644/// omit the header entirely — hence both fields are optional.
645#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
646#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
647#[serde(rename_all = "camelCase")]
648pub struct OrderSubmitResponse {
649    /// Order ID parsed from the `Location` header's last path segment.
650    pub order_id: Option<String>,
651    /// Full `Location` header value, when present.
652    pub location: Option<String>,
653}
654
655/// Query parameters for `GET .../orders` (one account) and `GET /orders`
656/// (every linked account).
657///
658/// Both bounds are required by Schwab, which formats them as
659/// [`schwab_timestamp`](crate::common::schwab_timestamp) on the wire and
660/// lists only orders entered in the last 60 days, however far back the
661/// window reaches. Callers that want the whole horizon should stay just
662/// inside it; the SDK's own reads use 59 days.
663#[skip_serializing_none]
664#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
665#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
666#[serde(rename_all = "camelCase")]
667pub struct OrdersParams {
668    /// No orders entered before this instant are returned.
669    #[serde(serialize_with = "crate::common::serialize_timestamp")]
670    pub from_entered_time: DateTime<Utc>,
671    /// No orders entered after this instant are returned.
672    #[serde(serialize_with = "crate::common::serialize_timestamp")]
673    pub to_entered_time: DateTime<Utc>,
674    /// Page size (≥ 1); Schwab's default is 3000.
675    pub max_results: Option<u32>,
676    /// Only orders in this status.
677    pub status: Option<OrderStatus>,
678}
679
680impl OrdersParams {
681    /// Every order entered in the window, in Schwab's default page size.
682    pub fn new(from_entered_time: DateTime<Utc>, to_entered_time: DateTime<Utc>) -> Self {
683        Self {
684            from_entered_time,
685            to_entered_time,
686            max_results: None,
687            status: None,
688        }
689    }
690
691    /// Check the request against Schwab's documented bounds.
692    pub fn validate(&self) -> Result<(), ValidationError> {
693        if self.from_entered_time > self.to_entered_time {
694            return Err(VE::new(
695                K::OutOfRange,
696                Some("fromEnteredTime"),
697                "fromEnteredTime must not be after toEnteredTime",
698            ));
699        }
700        if self.max_results == Some(0) {
701            return Err(VE::new(
702                K::OutOfRange,
703                Some("maxResults"),
704                "maxResults must be at least 1",
705            ));
706        }
707        crate::common::known("status", self.status.as_ref())
708    }
709}
710
711/// A transaction as Schwab reports it: header fields plus one
712/// [`TransferItem`] per instrument moved and per fee charged. There is no
713/// top-level instrument or fee map; a trade's security leg and each of its
714/// fees are separate items, and the fees are the ones whose
715/// [`fee_type`](TransferItem::fee_type) is set.
716#[skip_serializing_none]
717#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
718#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
719#[serde(rename_all = "camelCase")]
720#[non_exhaustive]
721pub struct Transaction {
722    pub activity_id: Option<i64>,
723    pub time: Option<String>,
724    pub user: Option<TransactionUser>,
725    pub description: Option<String>,
726    #[serde(default, deserialize_with = "crate::common::optional_id")]
727    pub account_number: Option<String>,
728    /// `Unknown` for a category this release does not know.
729    #[serde(rename = "type")]
730    pub type_: Option<TransactionType>,
731    pub status: Option<TransactionStatus>,
732    pub sub_account: Option<SubAccount>,
733    pub trade_date: Option<String>,
734    pub settlement_date: Option<String>,
735    pub position_id: Option<i64>,
736    #[serde(default, deserialize_with = "crate::common::optional_id")]
737    pub order_id: Option<String>,
738    pub net_amount: Option<f64>,
739    pub activity_type: Option<TransactionActivityType>,
740    #[serde(default)]
741    pub transfer_items: Vec<TransferItem>,
742    #[serde(flatten)]
743    pub extra: HashMap<String, serde_json::Value>,
744}
745
746/// Transaction category; the `type` of a [`Transaction`] and the value set
747/// of the `types` filter on `GET .../transactions`.
748#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
749#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
750#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
751#[non_exhaustive]
752pub enum TransactionType {
753    Trade,
754    ReceiveAndDeliver,
755    DividendOrInterest,
756    AchReceipt,
757    AchDisbursement,
758    CashReceipt,
759    CashDisbursement,
760    ElectronicFund,
761    WireOut,
762    WireIn,
763    Journal,
764    Memorandum,
765    MarginCall,
766    MoneyMarket,
767    SmaAdjustment,
768    #[serde(other)]
769    #[cfg_attr(feature = "schemars", schemars(skip))]
770    Unknown,
771}
772
773impl TransactionType {
774    /// Wire name used in the `types` query parameter.
775    pub const fn as_str(self) -> &'static str {
776        match self {
777            Self::Trade => "TRADE",
778            Self::ReceiveAndDeliver => "RECEIVE_AND_DELIVER",
779            Self::DividendOrInterest => "DIVIDEND_OR_INTEREST",
780            Self::AchReceipt => "ACH_RECEIPT",
781            Self::AchDisbursement => "ACH_DISBURSEMENT",
782            Self::CashReceipt => "CASH_RECEIPT",
783            Self::CashDisbursement => "CASH_DISBURSEMENT",
784            Self::ElectronicFund => "ELECTRONIC_FUND",
785            Self::WireOut => "WIRE_OUT",
786            Self::WireIn => "WIRE_IN",
787            Self::Journal => "JOURNAL",
788            Self::Memorandum => "MEMORANDUM",
789            Self::MarginCall => "MARGIN_CALL",
790            Self::MoneyMarket => "MONEY_MARKET",
791            Self::SmaAdjustment => "SMA_ADJUSTMENT",
792            Self::Unknown => "UNKNOWN",
793        }
794    }
795}
796
797impl std::fmt::Display for TransactionType {
798    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
799        f.write_str(self.as_str())
800    }
801}
802
803#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
804#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
805#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
806#[non_exhaustive]
807pub enum TransactionStatus {
808    Valid,
809    Invalid,
810    Pending,
811    #[serde(other)]
812    #[cfg_attr(feature = "schemars", schemars(skip))]
813    Unknown,
814}
815
816/// Ledger a transaction settled against.
817#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
818#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
819#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
820#[non_exhaustive]
821pub enum SubAccount {
822    Cash,
823    Margin,
824    Short,
825    Div,
826    Income,
827    #[serde(other)]
828    #[cfg_attr(feature = "schemars", schemars(skip))]
829    Unknown,
830}
831
832#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
833#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
834#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
835#[non_exhaustive]
836pub enum TransactionActivityType {
837    ActivityCorrection,
838    Execution,
839    OrderAction,
840    Transfer,
841    #[serde(other)]
842    #[cfg_attr(feature = "schemars", schemars(skip))]
843    Unknown,
844}
845
846/// One leg of a [`Transaction`]: a security or cash movement, or a fee.
847#[skip_serializing_none]
848#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
849#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
850#[serde(rename_all = "camelCase")]
851#[non_exhaustive]
852pub struct TransferItem {
853    pub instrument: Option<TransactionInstrument>,
854    /// Quantity moved (shares, contracts, or currency units).
855    pub amount: Option<f64>,
856    pub cost: Option<f64>,
857    pub price: Option<f64>,
858    /// Set on fee items only.
859    pub fee_type: Option<FeeType>,
860    /// `Unknown` when Schwab reports `UNKNOWN`.
861    pub position_effect: Option<PositionEffect>,
862    #[serde(flatten)]
863    pub extra: HashMap<String, serde_json::Value>,
864}
865
866#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
867#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
868#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
869#[non_exhaustive]
870pub enum FeeType {
871    Commission,
872    SecFee,
873    StrFee,
874    RFee,
875    CdscFee,
876    OptRegFee,
877    AdditionalFee,
878    MiscellaneousFee,
879    Ftt,
880    FuturesClearingFee,
881    FuturesDeskOfficeFee,
882    FuturesExchangeFee,
883    FuturesGlobexFee,
884    FuturesNfaFee,
885    FuturesPitBrokerageFee,
886    FuturesTransactionFee,
887    LowProceedsCommission,
888    BaseCharge,
889    GeneralCharge,
890    GstFee,
891    TafFee,
892    IndexOptionFee,
893    TefraTax,
894    StateTax,
895    #[serde(other)]
896    #[cfg_attr(feature = "schemars", schemars(skip))]
897    Unknown,
898}
899
900#[skip_serializing_none]
901#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
902#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
903#[serde(rename_all = "camelCase")]
904#[non_exhaustive]
905pub struct TransactionUser {
906    pub cd_domain_id: Option<String>,
907    pub login: Option<String>,
908    #[serde(rename = "type")]
909    pub type_: Option<String>,
910    pub user_id: Option<i64>,
911    pub system_user_name: Option<String>,
912    pub first_name: Option<String>,
913    pub last_name: Option<String>,
914    pub broker_rep_code: Option<String>,
915}
916
917/// The instrument on a [`TransferItem`] (`TransactionInstrument` in the
918/// Trader API spec). The base fields plus the option, fixed-income and
919/// future extensions are typed; the rest lands in `extra`.
920#[skip_serializing_none]
921#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
922#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
923#[serde(rename_all = "camelCase", default)]
924#[non_exhaustive]
925pub struct TransactionInstrument {
926    pub asset_type: AssetType,
927    pub symbol: String,
928    pub cusip: Option<String>,
929    pub description: Option<String>,
930    pub instrument_id: Option<i64>,
931    pub net_change: Option<f64>,
932    /// Subtype whose value set depends on `asset_type` (`COMMON_STOCK`,
933    /// `SWEEP_VEHICLE`, `VANILLA`, …).
934    #[serde(rename = "type")]
935    pub type_: Option<String>,
936    /// Observed on live payloads (`ACTIVE`), undocumented in the spec.
937    pub status: Option<String>,
938    /// Observed on live payloads, undocumented in the spec.
939    pub closing_price: Option<f64>,
940    /// Options and futures.
941    pub expiration_date: Option<String>,
942    /// Options only. `None` when Schwab reports `UNKNOWN`.
943    #[serde(default, deserialize_with = "crate::common::lenient")]
944    pub put_call: Option<PutCall>,
945    /// Options only.
946    pub strike_price: Option<f64>,
947    /// Options only.
948    pub option_premium_multiplier: Option<i64>,
949    /// Options only.
950    pub underlying_symbol: Option<String>,
951    /// Options only.
952    pub underlying_cusip: Option<String>,
953    /// Fixed income only.
954    pub maturity_date: Option<String>,
955    /// Fixed income only.
956    pub factor: Option<f64>,
957    /// Fixed income only.
958    pub variable_rate: Option<f64>,
959    /// Fixed income and futures.
960    pub multiplier: Option<f64>,
961    #[serde(flatten)]
962    pub extra: HashMap<String, serde_json::Value>,
963}
964
965/// Query parameters for `GET .../transactions`.
966///
967/// Both bounds and at least one type are required by Schwab, which formats
968/// the bounds as [`schwab_timestamp`](crate::common::schwab_timestamp) on
969/// the wire, joins the types with commas, and caps the window at one year.
970/// Transaction history reaches back that far; the order list
971/// ([`OrdersParams`]) reaches back only 60 days. The two limits are
972/// different, and neither is enforced here: Schwab answers the request.
973#[skip_serializing_none]
974#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
975#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
976#[serde(rename_all = "camelCase")]
977pub struct TransactionsParams {
978    /// No transactions before this instant are returned.
979    #[serde(serialize_with = "crate::common::serialize_timestamp")]
980    pub start_date: DateTime<Utc>,
981    /// No transactions after this instant are returned.
982    #[serde(serialize_with = "crate::common::serialize_timestamp")]
983    pub end_date: DateTime<Utc>,
984    /// Transaction categories to include (at least one). Serializes as the
985    /// comma-joined list the query string takes; deserializes from that or
986    /// from a JSON array.
987    #[serde(
988        serialize_with = "serialize_transaction_types",
989        deserialize_with = "deserialize_transaction_types"
990    )]
991    pub types: Vec<TransactionType>,
992    /// Only activity in this symbol.
993    pub symbol: Option<String>,
994}
995
996impl TransactionsParams {
997    /// Every transaction of the given types in the window.
998    pub fn new(
999        start_date: DateTime<Utc>,
1000        end_date: DateTime<Utc>,
1001        types: impl IntoIterator<Item = TransactionType>,
1002    ) -> Self {
1003        Self {
1004            start_date,
1005            end_date,
1006            types: types.into_iter().collect(),
1007            symbol: None,
1008        }
1009    }
1010
1011    /// Check the request against Schwab's documented bounds.
1012    pub fn validate(&self) -> Result<(), ValidationError> {
1013        if self.start_date > self.end_date {
1014            return Err(VE::new(
1015                K::OutOfRange,
1016                Some("startDate"),
1017                "startDate must not be after endDate",
1018            ));
1019        }
1020        if self.types.is_empty() {
1021            return Err(VE::new(
1022                K::Missing,
1023                Some("types"),
1024                "types must name at least one transaction type",
1025            ));
1026        }
1027        for t in &self.types {
1028            crate::common::known("types", Some(t))?;
1029        }
1030        if self.symbol.as_deref().is_some_and(|s| s.trim().is_empty()) {
1031            return Err(VE::new(
1032                K::Symbol,
1033                Some("symbol"),
1034                "symbol must not be blank",
1035            ));
1036        }
1037        Ok(())
1038    }
1039}
1040
1041/// Comma-joined wire names, the only list encoding the query string takes.
1042fn serialize_transaction_types<S: serde::Serializer>(
1043    types: &[TransactionType],
1044    serializer: S,
1045) -> Result<S::Ok, S::Error> {
1046    let csv = types
1047        .iter()
1048        .map(|t| t.as_str())
1049        .collect::<Vec<_>>()
1050        .join(",");
1051    serializer.serialize_str(&csv)
1052}
1053
1054/// Accept the comma-joined wire form back, so serialized params round-trip.
1055fn deserialize_transaction_types<'de, D: serde::Deserializer<'de>>(
1056    deserializer: D,
1057) -> Result<Vec<TransactionType>, D::Error> {
1058    use serde::de::IntoDeserializer;
1059
1060    #[derive(Deserialize)]
1061    #[serde(untagged)]
1062    enum Types {
1063        List(Vec<TransactionType>),
1064        Csv(String),
1065    }
1066
1067    match Types::deserialize(deserializer)? {
1068        Types::List(types) => Ok(types),
1069        Types::Csv(csv) => csv
1070            .split(',')
1071            .map(|name| TransactionType::deserialize(name.trim().into_deserializer()))
1072            .collect(),
1073    }
1074}
1075
1076/// Outgoing orders have a strict schema, separate from tolerant broker responses.
1077/// Unsupported fields fail deserialization rather than silently altering a trade,
1078/// and decimals parse exactly (see [`exact_decimal`](crate::common::exact_decimal)).
1079#[skip_serializing_none]
1080#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1081#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1082#[serde(rename_all = "camelCase", deny_unknown_fields)]
1083pub struct OrderRequest {
1084    pub session: Option<OrderSession>,
1085    pub duration: Option<OrderDuration>,
1086    pub order_type: Option<OrderType>,
1087    pub complex_order_strategy_type: Option<ComplexOrderStrategyType>,
1088    /// The order's unit count, when present: a single leg trades exactly it,
1089    /// and each leg of a multi-leg order a whole multiple of it (a 1x2
1090    /// ratio of 5 has legs of 5 and 10). Sent as a JSON number.
1091    #[serde(
1092        default,
1093        deserialize_with = "crate::common::exact_decimal::option::deserialize",
1094        serialize_with = "crate::common::exact_decimal::option::serialize_number"
1095    )]
1096    #[cfg_attr(
1097        feature = "schemars",
1098        schemars(with = "Option<crate::common::QuantityNumber>")
1099    )]
1100    pub quantity: Option<rust_decimal::Decimal>,
1101    #[serde(
1102        default,
1103        deserialize_with = "crate::common::exact_decimal::option::deserialize",
1104        serialize_with = "crate::common::exact_decimal::option::serialize_string"
1105    )]
1106    #[cfg_attr(
1107        feature = "schemars",
1108        schemars(with = "Option<crate::common::DecimalString>")
1109    )]
1110    pub price: Option<rust_decimal::Decimal>,
1111    #[serde(
1112        default,
1113        deserialize_with = "crate::common::exact_decimal::option::deserialize",
1114        serialize_with = "crate::common::exact_decimal::option::serialize_string"
1115    )]
1116    #[cfg_attr(
1117        feature = "schemars",
1118        schemars(with = "Option<crate::common::DecimalString>")
1119    )]
1120    pub stop_price: Option<rust_decimal::Decimal>,
1121    pub stop_price_link_basis: Option<PriceLinkBasis>,
1122    pub stop_price_link_type: Option<PriceLinkType>,
1123    #[serde(
1124        default,
1125        deserialize_with = "crate::common::exact_decimal::option::deserialize",
1126        serialize_with = "crate::common::exact_decimal::option::serialize_string"
1127    )]
1128    #[cfg_attr(
1129        feature = "schemars",
1130        schemars(with = "Option<crate::common::DecimalString>")
1131    )]
1132    pub stop_price_offset: Option<rust_decimal::Decimal>,
1133    pub stop_type: Option<StopType>,
1134    pub price_link_basis: Option<PriceLinkBasis>,
1135    pub price_link_type: Option<PriceLinkType>,
1136    pub tax_lot_method: Option<TaxLotMethod>,
1137    pub tag: Option<String>,
1138    pub special_instruction: Option<SpecialInstruction>,
1139    pub requested_destination: Option<RequestedDestination>,
1140    /// Hold the order until this time (Schwab reports it
1141    /// `AWAITING_RELEASE_TIME` until then).
1142    #[serde(
1143        default,
1144        serialize_with = "crate::common::serialize_optional_timestamp"
1145    )]
1146    pub release_time: Option<DateTime<Utc>>,
1147    /// Cancel the order at this time if it has not filled.
1148    #[serde(
1149        default,
1150        serialize_with = "crate::common::serialize_optional_timestamp"
1151    )]
1152    pub cancel_time: Option<DateTime<Utc>>,
1153    /// The price at which a conditional order activates.
1154    #[serde(
1155        default,
1156        deserialize_with = "crate::common::exact_decimal::option::deserialize",
1157        serialize_with = "crate::common::exact_decimal::option::serialize_string"
1158    )]
1159    #[cfg_attr(
1160        feature = "schemars",
1161        schemars(with = "Option<crate::common::DecimalString>")
1162    )]
1163    pub activation_price: Option<rust_decimal::Decimal>,
1164    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1165    pub order_leg_collection: Vec<OrderRequestLeg>,
1166    pub order_strategy_type: OrderStrategyType,
1167    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1168    pub child_order_strategies: Vec<OrderRequest>,
1169}
1170
1171#[skip_serializing_none]
1172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1173#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1174#[serde(rename_all = "camelCase", deny_unknown_fields)]
1175pub struct OrderRequestLeg {
1176    pub instruction: OrderInstruction,
1177    /// Shares or contracts; whole numbers for equities and options. For a
1178    /// mutual fund, denominated as `quantity_type` says. Sent as a JSON number.
1179    #[serde(
1180        deserialize_with = "crate::common::exact_decimal::deserialize",
1181        serialize_with = "crate::common::exact_decimal::serialize_number"
1182    )]
1183    #[cfg_attr(feature = "schemars", schemars(with = "crate::common::QuantityNumber"))]
1184    pub quantity: rust_decimal::Decimal,
1185    pub instrument: OrderRequestInstrument,
1186    pub position_effect: Option<PositionEffect>,
1187    /// Mutual funds only: whether `quantity` is shares, dollars or the whole
1188    /// position.
1189    #[serde(default)]
1190    pub quantity_type: Option<QuantityType>,
1191    /// Mutual funds only: what happens to dividends and capital gains.
1192    #[serde(default)]
1193    pub div_cap_gains: Option<DivCapGains>,
1194    /// Mutual funds only: the fund an `EXCHANGE` moves into.
1195    #[serde(default)]
1196    pub to_symbol: Option<String>,
1197}
1198
1199/// Dividend and capital-gain handling on a mutual-fund leg (`divCapGains`).
1200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1201#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1202#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
1203#[non_exhaustive]
1204pub enum DivCapGains {
1205    Reinvest,
1206    Payout,
1207    #[serde(other)]
1208    #[cfg_attr(feature = "schemars", schemars(skip))]
1209    Unknown,
1210}
1211
1212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1213#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1214#[serde(rename_all = "camelCase", deny_unknown_fields)]
1215pub struct OrderRequestInstrument {
1216    pub symbol: String,
1217    pub asset_type: AssetType,
1218}
1219
1220/// The request that would place `order` again, to edit for a replacement:
1221/// its strategy, legs, prices and timing, without what Schwab assigned
1222/// (ID, status, fills) and without its tag, which belongs to the original
1223/// intent. Fails when a reported number has no exact decimal spelling.
1224impl TryFrom<&Order> for OrderRequest {
1225    type Error = ValidationError;
1226
1227    fn try_from(order: &Order) -> Result<Self, ValidationError> {
1228        fn exact(field: &str, value: f64) -> Result<rust_decimal::Decimal, ValidationError> {
1229            crate::common::exact_decimal::parse(&value.to_string())
1230                .map_err(|e| VE::new(K::OutOfRange, Some(field), e))
1231        }
1232        let optional = |field: &str, value: Option<f64>| value.map(|v| exact(field, v)).transpose();
1233        let mut legs = Vec::with_capacity(order.order_leg_collection.len());
1234        for (i, leg) in order.order_leg_collection.iter().enumerate() {
1235            legs.push(OrderRequestLeg {
1236                instruction: leg.instruction,
1237                quantity: exact("quantity", leg.quantity)
1238                    .map_err(|e| e.within(&format!("orderLegCollection[{i}]")))?,
1239                instrument: OrderRequestInstrument {
1240                    symbol: leg.instrument.symbol.clone(),
1241                    asset_type: leg.instrument.asset_type,
1242                },
1243                position_effect: leg.position_effect,
1244                quantity_type: leg.quantity_type,
1245                div_cap_gains: None,
1246                to_symbol: None,
1247            });
1248        }
1249        let mut children = Vec::with_capacity(order.child_order_strategies.len());
1250        for (i, child) in order.child_order_strategies.iter().enumerate() {
1251            children.push(
1252                Self::try_from(child)
1253                    .map_err(|e| e.within(&format!("childOrderStrategies[{i}]")))?,
1254            );
1255        }
1256        let time = |value: &Option<String>| {
1257            value
1258                .as_deref()
1259                .and_then(|t| crate::common::parse_schwab_time(t).ok())
1260        };
1261        Ok(Self {
1262            session: order.session,
1263            duration: order.duration,
1264            order_type: order.order_type,
1265            complex_order_strategy_type: order.complex_order_strategy_type,
1266            quantity: optional("quantity", order.quantity)?,
1267            price: optional("price", order.price)?,
1268            stop_price: optional("stopPrice", order.stop_price)?,
1269            stop_price_link_basis: order.stop_price_link_basis,
1270            stop_price_link_type: order.stop_price_link_type,
1271            stop_price_offset: optional("stopPriceOffset", order.stop_price_offset)?,
1272            stop_type: order.stop_type,
1273            price_link_basis: order.price_link_basis,
1274            price_link_type: order.price_link_type,
1275            tax_lot_method: order.tax_lot_method,
1276            tag: None,
1277            special_instruction: order.special_instruction,
1278            requested_destination: order.requested_destination,
1279            release_time: time(&order.release_time),
1280            cancel_time: None,
1281            activation_price: optional("activationPrice", order.activation_price)?,
1282            order_leg_collection: legs,
1283            order_strategy_type: order.order_strategy_type.unwrap_or_default(),
1284            child_order_strategies: children,
1285        })
1286    }
1287}
1288
1289crate::common::wire_enum!(
1290    QuantityType,
1291    OrderSession,
1292    OrderDuration,
1293    OrderType,
1294    ComplexOrderStrategyType,
1295    OrderStrategyType,
1296    OrderStatus,
1297    OrderLegType,
1298    OrderInstruction,
1299    PositionEffect,
1300    PriceLinkBasis,
1301    PriceLinkType,
1302    StopType,
1303    TaxLotMethod,
1304    SpecialInstruction,
1305    RequestedDestination,
1306    OrderActivityType,
1307    TransactionType,
1308    TransactionStatus,
1309    SubAccount,
1310    TransactionActivityType,
1311    FeeType,
1312    DivCapGains,
1313);
1314
1315/// Strategies nest at most this many levels, the root counting as one.
1316const MAX_STRATEGY_DEPTH: usize = 8;
1317
1318/// What an executable order type takes. `true` means required, `false`
1319/// forbidden; the trailing link fields are required together.
1320struct TypeRules {
1321    price: bool,
1322    stop_price: bool,
1323    trailing: bool,
1324    /// `priceLinkBasis`/`priceLinkType` (a linked limit) are allowed.
1325    price_link: bool,
1326    /// `stopType` is meaningful.
1327    stop_type: bool,
1328    /// Net-priced: needs two or more legs.
1329    net: bool,
1330}
1331
1332impl OrderType {
1333    /// Submission rules for this type; `None` for types Schwab reports but
1334    /// does not accept on a new order.
1335    fn rules(self) -> Option<TypeRules> {
1336        let base = TypeRules {
1337            price: false,
1338            stop_price: false,
1339            trailing: false,
1340            price_link: false,
1341            stop_type: false,
1342            net: false,
1343        };
1344        Some(match self {
1345            Self::Market | Self::MarketOnClose => base,
1346            Self::Limit | Self::LimitOnClose => TypeRules {
1347                price: true,
1348                ..base
1349            },
1350            Self::Stop => TypeRules {
1351                stop_price: true,
1352                stop_type: true,
1353                ..base
1354            },
1355            Self::StopLimit => TypeRules {
1356                price: true,
1357                stop_price: true,
1358                stop_type: true,
1359                ..base
1360            },
1361            Self::TrailingStop => TypeRules {
1362                trailing: true,
1363                stop_type: true,
1364                ..base
1365            },
1366            Self::TrailingStopLimit => TypeRules {
1367                price: true,
1368                trailing: true,
1369                price_link: true,
1370                stop_type: true,
1371                ..base
1372            },
1373            Self::NetDebit | Self::NetCredit => TypeRules {
1374                price: true,
1375                net: true,
1376                ..base
1377            },
1378            Self::NetZero => TypeRules { net: true, ..base },
1379            Self::Cabinet | Self::NonMarketable | Self::Exercise | Self::Unknown => return None,
1380        })
1381    }
1382}
1383
1384impl OrderRequest {
1385    /// Validates structure without silently rounding prices: every field
1386    /// the order type takes and none it does not, instructions that fit the
1387    /// asset, whole shares and contracts, canonical symbols, extended hours
1388    /// only for limit orders. Broker eligibility, buying power and
1389    /// asset-specific increments still require a preview.
1390    pub fn validate(&self) -> Result<(), ValidationError> {
1391        self.validate_depth(1)
1392    }
1393
1394    fn validate_depth(&self, depth: usize) -> Result<(), ValidationError> {
1395        use crate::common::known;
1396        if depth > MAX_STRATEGY_DEPTH {
1397            return Err(VE::new(
1398                K::Structure,
1399                None,
1400                format!("Order strategies nest at most {MAX_STRATEGY_DEPTH} levels"),
1401            ));
1402        }
1403        known("session", self.session.as_ref())?;
1404        known("duration", self.duration.as_ref())?;
1405        known("orderType", self.order_type.as_ref())?;
1406        known(
1407            "complexOrderStrategyType",
1408            self.complex_order_strategy_type.as_ref(),
1409        )?;
1410        known("stopPriceLinkBasis", self.stop_price_link_basis.as_ref())?;
1411        known("stopPriceLinkType", self.stop_price_link_type.as_ref())?;
1412        known("stopType", self.stop_type.as_ref())?;
1413        known("priceLinkBasis", self.price_link_basis.as_ref())?;
1414        known("priceLinkType", self.price_link_type.as_ref())?;
1415        known("taxLotMethod", self.tax_lot_method.as_ref())?;
1416        known("specialInstruction", self.special_instruction.as_ref())?;
1417        known("requestedDestination", self.requested_destination.as_ref())?;
1418        known("orderStrategyType", Some(&self.order_strategy_type))?;
1419        for (name, value) in [
1420            ("quantity", self.quantity),
1421            ("price", self.price),
1422            ("stopPrice", self.stop_price),
1423            ("stopPriceOffset", self.stop_price_offset),
1424            ("activationPrice", self.activation_price),
1425        ] {
1426            if value.is_some_and(|v| v <= rust_decimal::Decimal::ZERO) {
1427                return Err(VE::new(
1428                    K::OutOfRange,
1429                    Some(name),
1430                    format!("{name} must be positive"),
1431                ));
1432            }
1433        }
1434        if let (Some(release), Some(cancel)) = (self.release_time, self.cancel_time)
1435            && cancel <= release
1436        {
1437            return Err(VE::new(
1438                K::Structure,
1439                Some("cancelTime"),
1440                "cancelTime must be after releaseTime",
1441            ));
1442        }
1443        match self.order_strategy_type {
1444            OrderStrategyType::Oco => self.validate_oco_parent()?,
1445            OrderStrategyType::Single | OrderStrategyType::Trigger => self.validate_executable()?,
1446            _ => {
1447                return Err(VE::new(
1448                    K::Unsupported,
1449                    Some("orderStrategyType"),
1450                    "Only SINGLE, OCO, and TRIGGER strategies can be submitted",
1451                ));
1452            }
1453        }
1454        for (i, child) in self.child_order_strategies.iter().enumerate() {
1455            child
1456                .validate_depth(depth + 1)
1457                .map_err(|e| e.within(&format!("childOrderStrategies[{i}]")))?;
1458        }
1459        Ok(())
1460    }
1461
1462    /// An OCO parent only groups its two children; anything executable on
1463    /// it would be ignored or misread.
1464    fn validate_oco_parent(&self) -> Result<(), ValidationError> {
1465        let executable = self.session.is_some()
1466            || self.duration.is_some()
1467            || self.order_type.is_some()
1468            || self.complex_order_strategy_type.is_some()
1469            || self.quantity.is_some()
1470            || self.price.is_some()
1471            || self.stop_price.is_some()
1472            || self.stop_price_link_basis.is_some()
1473            || self.stop_price_link_type.is_some()
1474            || self.stop_price_offset.is_some()
1475            || self.stop_type.is_some()
1476            || self.price_link_basis.is_some()
1477            || self.price_link_type.is_some()
1478            || self.tax_lot_method.is_some()
1479            || self.special_instruction.is_some()
1480            || self.requested_destination.is_some()
1481            || self.release_time.is_some()
1482            || self.cancel_time.is_some()
1483            || self.activation_price.is_some()
1484            || !self.order_leg_collection.is_empty();
1485        if self.child_order_strategies.len() != 2 || executable {
1486            return Err(VE::new(
1487                K::Structure,
1488                None,
1489                "OCO must contain exactly two children and no executable parent",
1490            ));
1491        }
1492        Ok(())
1493    }
1494
1495    fn validate_executable(&self) -> Result<(), ValidationError> {
1496        let missing = [
1497            ("orderType", self.order_type.is_none()),
1498            ("session", self.session.is_none()),
1499            ("duration", self.duration.is_none()),
1500            ("orderLegCollection", self.order_leg_collection.is_empty()),
1501        ]
1502        .into_iter()
1503        .find_map(|(field, absent)| absent.then_some(field));
1504        let (Some(kind), Some(session), Some(duration), None) =
1505            (self.order_type, self.session, self.duration, missing)
1506        else {
1507            return Err(VE::new(
1508                K::Missing,
1509                missing,
1510                "Executable orders require legs, session, duration, and orderType",
1511            ));
1512        };
1513        let name = serde_json::to_value(kind).unwrap_or_default();
1514        let name = name.as_str().unwrap_or("this order type");
1515        let rules = kind.rules().ok_or_else(|| {
1516            VE::new(
1517                K::Unsupported,
1518                Some("orderType"),
1519                format!("{name} orders cannot be submitted"),
1520            )
1521        })?;
1522        let field = |present: bool, required: bool, field: &str| -> Result<(), ValidationError> {
1523            match (present, required) {
1524                (false, true) => Err(VE::new(
1525                    K::Missing,
1526                    Some(field),
1527                    format!("{name} requires {field}"),
1528                )),
1529                (true, false) => Err(VE::new(
1530                    K::NotAllowed,
1531                    Some(field),
1532                    format!("{name} does not take {field}"),
1533                )),
1534                _ => Ok(()),
1535            }
1536        };
1537        field(self.price.is_some(), rules.price, "price")?;
1538        field(self.stop_price.is_some(), rules.stop_price, "stopPrice")?;
1539        for (present, f) in [
1540            (self.stop_price_offset.is_some(), "stopPriceOffset"),
1541            (self.stop_price_link_basis.is_some(), "stopPriceLinkBasis"),
1542            (self.stop_price_link_type.is_some(), "stopPriceLinkType"),
1543        ] {
1544            field(present, rules.trailing, f)?;
1545        }
1546        if self.price_link_basis.is_some() != self.price_link_type.is_some() {
1547            return Err(VE::new(
1548                K::Missing,
1549                Some(if self.price_link_basis.is_some() {
1550                    "priceLinkType"
1551                } else {
1552                    "priceLinkBasis"
1553                }),
1554                "priceLinkBasis and priceLinkType go together",
1555            ));
1556        }
1557        if self.price_link_basis.is_some() && !rules.price_link {
1558            field(true, false, "priceLinkBasis")?;
1559        }
1560        if self.stop_type.is_some() && !rules.stop_type {
1561            field(true, false, "stopType")?;
1562        }
1563        if rules.trailing {
1564            if self.stop_price_link_basis == Some(PriceLinkBasis::Manual) {
1565                return Err(VE::new(
1566                    K::OutOfRange,
1567                    Some("stopPriceLinkBasis"),
1568                    "A trailing stop cannot trail a MANUAL basis",
1569                ));
1570            }
1571            if self.stop_price_link_type == Some(PriceLinkType::Percent)
1572                && self
1573                    .stop_price_offset
1574                    .is_some_and(|o| o >= rust_decimal::Decimal::ONE_HUNDRED)
1575            {
1576                return Err(VE::new(
1577                    K::OutOfRange,
1578                    Some("stopPriceOffset"),
1579                    "A percentage trailing offset must be below 100",
1580                ));
1581            }
1582        }
1583        if rules.net && self.order_leg_collection.len() < 2 {
1584            return Err(VE::new(
1585                K::Structure,
1586                Some("orderLegCollection"),
1587                format!("{name} needs two or more legs"),
1588            ));
1589        }
1590        if session != OrderSession::Normal && kind != OrderType::Limit {
1591            return Err(VE::new(
1592                K::Structure,
1593                Some("session"),
1594                "Extended-hours sessions accept only LIMIT orders",
1595            ));
1596        }
1597        if matches!(kind, OrderType::MarketOnClose | OrderType::LimitOnClose)
1598            && (session != OrderSession::Normal || duration != OrderDuration::Day)
1599        {
1600            return Err(VE::new(
1601                K::Structure,
1602                Some(if session != OrderSession::Normal {
1603                    "session"
1604                } else {
1605                    "duration"
1606                }),
1607                format!("{name} requires the NORMAL session and DAY duration"),
1608            ));
1609        }
1610        let single = self.order_leg_collection.len() == 1;
1611        for (i, leg) in self.order_leg_collection.iter().enumerate() {
1612            leg.validate()
1613                .map_err(|e| e.within(&format!("orderLegCollection[{i}]")))?;
1614            // Schwab reports a complex order's unit count here: every leg
1615            // trades a whole multiple of it (a 1x2 ratio of 5 has legs of 5
1616            // and 10), and a single leg trades exactly it.
1617            let fits = |q: rust_decimal::Decimal| {
1618                if single {
1619                    q == leg.quantity
1620                } else {
1621                    (leg.quantity % q).is_zero()
1622                }
1623            };
1624            if self.quantity.is_some_and(|q| !fits(q)) {
1625                return Err(VE::new(
1626                    K::Structure,
1627                    Some("quantity"),
1628                    if single {
1629                        "quantity must equal the leg's quantity; size orders on the legs"
1630                    } else {
1631                        "quantity is the unit count; every leg's quantity must be a whole \
1632                         multiple of it"
1633                    },
1634                ));
1635            }
1636        }
1637        self.validate_leg_set(kind, session)?;
1638        match (
1639            self.order_strategy_type,
1640            self.child_order_strategies.is_empty(),
1641        ) {
1642            (OrderStrategyType::Single, false) => Err(VE::new(
1643                K::Structure,
1644                Some("childOrderStrategies"),
1645                "SINGLE cannot contain child strategies",
1646            )),
1647            (OrderStrategyType::Trigger, true) => Err(VE::new(
1648                K::Missing,
1649                Some("childOrderStrategies"),
1650                "TRIGGER requires child strategies",
1651            )),
1652            _ => Ok(()),
1653        }
1654    }
1655
1656    /// Rules over the legs together, which Schwab enforces and answers 400
1657    /// for: options trade only in the regular session; several legs are
1658    /// priced as one (`NET_DEBIT`, `NET_CREDIT`, `NET_ZERO` or `MARKET`); no
1659    /// leg repeats another; a named strategy has its number of legs, on one
1660    /// underlying; a trailing offset in ticks is a whole number of ticks.
1661    fn validate_leg_set(
1662        &self,
1663        kind: OrderType,
1664        session: OrderSession,
1665    ) -> Result<(), ValidationError> {
1666        let legs = &self.order_leg_collection;
1667        let options: Vec<OptionSymbol> = legs
1668            .iter()
1669            .filter(|leg| leg.instrument.asset_type == AssetType::Option)
1670            .filter_map(|leg| leg.instrument.symbol.parse().ok())
1671            .collect();
1672        if !options.is_empty() && session != OrderSession::Normal {
1673            return Err(VE::new(
1674                K::Structure,
1675                Some("session"),
1676                "Options trade only in the NORMAL session",
1677            ));
1678        }
1679        if legs.len() > 1
1680            && !matches!(
1681                kind,
1682                OrderType::NetDebit | OrderType::NetCredit | OrderType::NetZero | OrderType::Market
1683            )
1684        {
1685            return Err(VE::new(
1686                K::Structure,
1687                Some("orderType"),
1688                "Several legs are priced together: NET_DEBIT, NET_CREDIT, NET_ZERO or MARKET",
1689            ));
1690        }
1691        for (i, leg) in legs.iter().enumerate() {
1692            if legs[..i].iter().any(|other| {
1693                other.instrument.symbol == leg.instrument.symbol
1694                    && other.instruction == leg.instruction
1695            }) {
1696                return Err(VE::new(
1697                    K::Structure,
1698                    Some(&format!("orderLegCollection[{i}]")),
1699                    "A leg repeats another; trade the combined quantity on one leg",
1700                ));
1701            }
1702        }
1703        use ComplexOrderStrategyType as C;
1704        if let Some(strategy) = self.complex_order_strategy_type {
1705            let expected = match strategy {
1706                C::Vertical
1707                | C::BackRatio
1708                | C::Calendar
1709                | C::Diagonal
1710                | C::Straddle
1711                | C::Strangle
1712                | C::CollarSynthetic
1713                | C::Covered => Some(2),
1714                C::Butterfly | C::UnbalancedButterfly | C::CollarWithStock => Some(3),
1715                C::Condor
1716                | C::IronCondor
1717                | C::VerticalRoll
1718                | C::DoubleDiagonal
1719                | C::UnbalancedCondor
1720                | C::UnbalancedIronCondor
1721                | C::UnbalancedVerticalRoll => Some(4),
1722                _ => None,
1723            };
1724            if expected.is_some_and(|n| n != legs.len()) {
1725                return Err(VE::new(
1726                    K::Structure,
1727                    Some("orderLegCollection"),
1728                    format!("{strategy:?} takes {} legs", expected.unwrap_or_default()),
1729                ));
1730            }
1731            // A named strategy is on one underlying; a covered or collared
1732            // position's stock is that underlying.
1733            if expected.is_some() {
1734                let mut underlyings = options.iter().map(OptionSymbol::underlying).chain(
1735                    legs.iter()
1736                        .filter(|leg| leg.instrument.asset_type != AssetType::Option)
1737                        .map(|leg| leg.instrument.symbol.as_str()),
1738                );
1739                if let Some(first) = underlyings.next()
1740                    && underlyings.any(|u| u != first)
1741                {
1742                    return Err(VE::new(
1743                        K::Structure,
1744                        Some("orderLegCollection"),
1745                        format!("{strategy:?} legs must share one underlying"),
1746                    ));
1747                }
1748            }
1749        }
1750        if self.stop_price_link_type == Some(PriceLinkType::Tick)
1751            && self.stop_price_offset.is_some_and(|o| !o.fract().is_zero())
1752        {
1753            return Err(VE::new(
1754                K::NotWhole,
1755                Some("stopPriceOffset"),
1756                "A trailing offset in ticks is a whole number of ticks",
1757            ));
1758        }
1759        Ok(())
1760    }
1761
1762    /// Enforce a caller-supplied broker/asset tick size on every price and
1763    /// on a trailing offset expressed as a value; never round implicitly.
1764    pub fn validate_price_increment(
1765        &self,
1766        tick: rust_decimal::Decimal,
1767    ) -> Result<(), ValidationError> {
1768        self.validate()?;
1769        self.validate_ticks(tick)
1770    }
1771
1772    fn validate_ticks(&self, tick: rust_decimal::Decimal) -> Result<(), ValidationError> {
1773        if tick <= rust_decimal::Decimal::ZERO {
1774            return Err(VE::new(
1775                K::OutOfRange,
1776                None,
1777                "Tick size must be finite and positive",
1778            ));
1779        }
1780        let value_offset = self
1781            .stop_price_offset
1782            .filter(|_| self.stop_price_link_type == Some(PriceLinkType::Value));
1783        for (field, price) in [
1784            ("price", self.price),
1785            ("stopPrice", self.stop_price),
1786            ("stopPriceOffset", value_offset),
1787        ] {
1788            if price.is_some_and(|price| price % tick != rust_decimal::Decimal::ZERO) {
1789                return Err(VE::new(
1790                    K::Tick,
1791                    Some(field),
1792                    "Price does not match the supplied tick size",
1793                ));
1794            }
1795        }
1796        for (i, child) in self.child_order_strategies.iter().enumerate() {
1797            child
1798                .validate_ticks(tick)
1799                .map_err(|e| e.within(&format!("childOrderStrategies[{i}]")))?;
1800        }
1801        Ok(())
1802    }
1803}
1804
1805impl OrderRequestLeg {
1806    /// The leg-level rules [`OrderRequest::validate`] applies to each leg.
1807    ///
1808    /// Legs may be equities (and ETFs, which positions report as
1809    /// `COLLECTIVE_INVESTMENT`), options or mutual funds; other asset types
1810    /// are refused rather than sent unchecked.
1811    pub fn validate(&self) -> Result<(), ValidationError> {
1812        use crate::common::known;
1813        use OrderInstruction as I;
1814        known("instruction", Some(&self.instruction))?;
1815        known("positionEffect", self.position_effect.as_ref())?;
1816        known("instrument.assetType", Some(&self.instrument.asset_type))?;
1817        known("quantityType", self.quantity_type.as_ref())?;
1818        known("divCapGains", self.div_cap_gains.as_ref())?;
1819        let symbol = &self.instrument.symbol;
1820        if symbol.trim().is_empty()
1821            || symbol.trim() != symbol
1822            || symbol.bytes().any(|b| b.is_ascii_lowercase())
1823        {
1824            return Err(VE::new(
1825                K::Symbol,
1826                Some("instrument.symbol"),
1827                format!("Instrument symbol {symbol:?} must be non-empty, upper case and unpadded"),
1828            ));
1829        }
1830        if self.quantity <= rust_decimal::Decimal::ZERO {
1831            return Err(VE::new(
1832                K::OutOfRange,
1833                Some("quantity"),
1834                "Leg quantity must be positive",
1835            ));
1836        }
1837        let whole = self.quantity.fract().is_zero();
1838        let instruction = |allowed: bool, expected: &str| {
1839            if allowed {
1840                Ok(())
1841            } else {
1842                Err(VE::new(
1843                    K::NotAllowed,
1844                    Some("instruction"),
1845                    format!(
1846                        "{:?} does not fit this asset type; it takes {expected}",
1847                        self.instruction
1848                    ),
1849                ))
1850            }
1851        };
1852        let not_whole = |message: &str| VE::new(K::NotWhole, Some("quantity"), message);
1853        match self.instrument.asset_type {
1854            AssetType::Equity | AssetType::CollectiveInvestment => {
1855                instruction(
1856                    matches!(
1857                        self.instruction,
1858                        I::Buy | I::Sell | I::SellShort | I::BuyToCover | I::SellShortExempt
1859                    ),
1860                    "BUY, SELL, SELL_SHORT, SELL_SHORT_EXEMPT or BUY_TO_COVER",
1861                )?;
1862                if !whole {
1863                    return Err(not_whole("Equity orders are for whole shares"));
1864                }
1865            }
1866            AssetType::Option => {
1867                instruction(
1868                    matches!(
1869                        self.instruction,
1870                        I::BuyToOpen | I::BuyToClose | I::SellToOpen | I::SellToClose
1871                    ),
1872                    "BUY_TO_OPEN, BUY_TO_CLOSE, SELL_TO_OPEN or SELL_TO_CLOSE",
1873                )?;
1874                symbol
1875                    .parse::<OptionSymbol>()
1876                    .map_err(|e| VE::new(K::Symbol, Some("instrument.symbol"), e))?;
1877                if !whole {
1878                    return Err(not_whole("Option orders are for whole contracts"));
1879                }
1880            }
1881            AssetType::MutualFund => {
1882                instruction(
1883                    matches!(self.instruction, I::Buy | I::Sell | I::Exchange),
1884                    "BUY, SELL or EXCHANGE",
1885                )?;
1886            }
1887            _ => {
1888                return Err(VE::new(
1889                    K::Unsupported,
1890                    Some("instrument.assetType"),
1891                    "Order legs may be EQUITY, COLLECTIVE_INVESTMENT, OPTION or MUTUAL_FUND",
1892                ));
1893            }
1894        }
1895        if self.instrument.asset_type != AssetType::MutualFund {
1896            for (present, field) in [
1897                (self.quantity_type.is_some(), "quantityType"),
1898                (self.div_cap_gains.is_some(), "divCapGains"),
1899                (self.to_symbol.is_some(), "toSymbol"),
1900            ] {
1901                if present {
1902                    return Err(VE::new(
1903                        K::NotAllowed,
1904                        Some(field),
1905                        "quantityType, divCapGains and toSymbol apply to mutual funds only",
1906                    ));
1907                }
1908            }
1909        }
1910        if (self.instruction == I::Exchange) != self.to_symbol.is_some() {
1911            return Err(VE::new(
1912                K::Structure,
1913                Some("toSymbol"),
1914                "EXCHANGE takes a toSymbol, and only EXCHANGE does",
1915            ));
1916        }
1917        Ok(())
1918    }
1919}