Skip to main content

schwab_sdk/
orders.rs

1//! `/orders` and `/accounts/{accountNumber}/orders*`
2//!
3//! Endpoint coverage:
4//!
5//! - `GET /accounts/{accountNumber}/orders` - per-account list with required
6//!   `fromEnteredTime` and `toEnteredTime`, optional `maxResults` and
7//!   `status`. Schwab caps the date range at 1 year.
8//! - `GET /accounts/{accountNumber}/orders/{orderId}` - single fetch.
9//! - `GET /orders` - same shape, across every linked account. Date range
10//!   is capped at 60 days.
11//! - `POST /accounts/{accountNumber}/orders` - place an order.
12//! - `PUT /accounts/{accountNumber}/orders/{orderId}` - replace an order.
13//!   Schwab cancels the existing order and creates a new one; the new
14//!   `orderId` is returned via the `Location` header.
15//! - `DELETE /accounts/{accountNumber}/orders/{orderId}` - cancel an order.
16//! - `POST /accounts/{accountNumber}/previewOrder` - preview an order
17//!   without placing it. Returns Schwab's projected commissions, fees,
18//!   buying-power impact, and validation alerts / rejects.
19//!
20//! `{accountNumber}` is the encrypted [`AccountHash`], not the plain
21//! account number. `orderId` is the Schwab-assigned `int64` returned in
22//! the `Location` header of a successful place / replace.
23//!
24//! Per-account methods are on [`Orders`], list methods are on [`AllOrders`].
25//! See the respective structs for available methods.
26//!
27//! # Example
28//!
29//! Place a limit buy, then fetch it back to read its status:
30//!
31//! ```no_run
32//! use rust_decimal_macros::dec;
33//! use schwab_sdk::{AuthToken, SchwabClient};
34//! use schwab_sdk::orders::OrderRequest;
35//!
36//! # async fn run() -> schwab_sdk::Result<()> {
37//! let client = SchwabClient::new(AuthToken::new("token"));
38//! let accounts = client.accounts().numbers().await?;
39//! let account_hash = &accounts.first().expect("a linked account").hash_value;
40//!
41//! let order_id = client
42//!     .orders(account_hash)
43//!     .place(OrderRequest::buy_limit("AAPL", dec!(10), dec!(140)))
44//!     .await?;
45//! let order = client.orders(account_hash).get(order_id).await?;
46//! println!("order {order_id}: {:?}", order.status);
47//! # Ok(())
48//! # }
49//! ```
50//!
51//! ## Idempotency
52//!
53//! Schwab's Trader API exposes **no client-controllable idempotency key**.
54//! [`Orders::place`] takes only the order body; if a network or 5xx
55//! failure interrupts the response, the order may still have been
56//! accepted. Callers that need retry-safe submission must dedupe at
57//! their own layer, typically by listing orders after a transient
58//! failure and matching by entered-time window, symbol, side, and
59//! quantity.
60
61mod enums;
62mod preview;
63mod request;
64mod response;
65
66pub use enums::*;
67pub use preview::{
68    AdvancedOrderType, AmountIndicator, ApiRuleAction, Commission, CommissionAndFee, CommissionLeg,
69    CommissionValue, FeeLeg, FeeValue, Fees, OrderBalance, OrderLeg, OrderStrategy,
70    OrderValidationDetail, OrderValidationResult, PreviewOrder, SettlementInstruction,
71};
72pub use request::{
73    IntoQuantity, OrderInstrumentRequest, OrderLegRequest, OrderRequest, SingleOrderBuilder,
74};
75pub use response::{ExecutionLeg, Order, OrderActivity, OrderLegCollection};
76
77use chrono::{DateTime, SecondsFormat, Utc};
78use serde::{Deserialize, Serialize};
79
80use crate::client::SchwabClient;
81use crate::error::{Error, Result};
82use crate::secrets::AccountHash;
83
84// --- Order id ---
85
86/// Schwab-assigned order identifier (`int64`).
87///
88/// Returned by [`Orders::place`] and [`Orders::replace`] and accepted by
89/// [`Orders::get`], [`Orders::replace`], and [`Orders::cancel`]. The
90/// newtype keeps an order id from being transposed with a leg id, a
91/// quantity, or a `maxResults` count at a call site. Serializes and
92/// deserializes transparently as the bare `int64` Schwab puts on the wire.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
94#[serde(transparent)]
95pub struct OrderId(i64);
96
97impl OrderId {
98    /// Wrap a raw Schwab order id.
99    pub fn new(id: i64) -> Self {
100        Self(id)
101    }
102
103    /// The underlying `int64` order id.
104    pub fn get(self) -> i64 {
105        self.0
106    }
107}
108
109impl std::fmt::Display for OrderId {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        write!(f, "{}", self.0)
112    }
113}
114
115impl From<i64> for OrderId {
116    fn from(id: i64) -> Self {
117        Self(id)
118    }
119}
120
121impl From<OrderId> for i64 {
122    fn from(id: OrderId) -> Self {
123        id.0
124    }
125}
126
127// --- Namespaces ---
128
129/// Accessor for `/accounts/{accountNumber}/orders*`. Construct via
130/// [`SchwabClient::orders`](crate::SchwabClient::orders).
131///
132/// `account_hash` throughout is the encrypted [`AccountHash`] from
133/// [`SchwabClient::accounts`](crate::SchwabClient::accounts) ->
134/// [`numbers`](crate::accounts::Accounts::numbers), never the plain account
135/// number.
136///
137/// # Examples
138///
139/// ## Place a market order
140///
141/// [`Orders::place`] accepts any `impl Into<OrderRequest>`, so a shortcut
142/// builder flows in without an explicit `.build()`. On success Schwab returns
143/// the new order id parsed from the `Location` header; fetch the order back
144/// with [`Orders::get`] to see its fill status.
145///
146/// ```no_run
147/// use rust_decimal_macros::dec;
148/// use schwab_sdk::{AuthToken, SchwabClient};
149/// use schwab_sdk::orders::OrderRequest;
150///
151/// # async fn run() -> schwab_sdk::Result<()> {
152/// let client = SchwabClient::new(AuthToken::new("token"));
153///
154/// let accounts = client.accounts().numbers().await?;
155/// let account_hash = &accounts.first().expect("a linked account").hash_value;
156///
157/// let order_id = client
158///     .orders(account_hash)
159///     .place(OrderRequest::buy_market("AAPL", dec!(10)))
160///     .await?;
161///
162/// let order = client.orders(account_hash).get(order_id).await?;
163/// println!("order {order_id} status: {:?}", order.status);
164/// # Ok(())
165/// # }
166/// ```
167///
168/// ## Replace an order
169///
170/// A replace cancels the original and returns a **new** id; the old id is
171/// dead afterward. The example below lists the working orders from the last
172/// week, then reprices one and cancels the original.
173///
174/// ```no_run
175/// use chrono::{Duration as ChronoDuration, Utc};
176/// use rust_decimal_macros::dec;
177/// use schwab_sdk::{AuthToken, SchwabClient};
178/// use schwab_sdk::orders::{ApiOrderStatus, OrderRequest};
179///
180/// # async fn run() -> schwab_sdk::Result<()> {
181/// let client = SchwabClient::new(AuthToken::new("token"));
182/// let accounts = client.accounts().numbers().await?;
183/// let account_hash = &accounts.first().expect("a linked account").hash_value;
184/// let orders = client.orders(account_hash);
185///
186/// let working = orders
187///     .list(Utc::now() - ChronoDuration::days(7), Utc::now())
188///     .status(ApiOrderStatus::Working)
189///     .send()
190///     .await?;
191///
192/// // Each `Order` carries its id and the symbol on its first leg.
193/// for order in &working {
194///     println!("{:?}: {:?}", order.order_id, order.status);
195/// }
196///
197/// if let Some(open_id) = working.first().and_then(|o| o.order_id) {
198///     let new_id = orders
199///         .replace(open_id, OrderRequest::buy_limit("AAPL", dec!(10), dec!(141.00)))
200///         .await?;
201///     orders.cancel(new_id).await?;
202/// }
203/// # Ok(())
204/// # }
205/// ```
206#[derive(Debug)]
207pub struct Orders<'a, 'b> {
208    client: &'a SchwabClient,
209    account_hash: &'b AccountHash,
210}
211
212impl<'a, 'b> Orders<'a, 'b> {
213    pub(crate) fn new(client: &'a SchwabClient, account_hash: &'b AccountHash) -> Self {
214        Self {
215            client,
216            account_hash,
217        }
218    }
219
220    /// `GET /accounts/{accountNumber}/orders/{orderId}` - fetch a single
221    /// order. `order_id` is the Schwab-assigned [`OrderId`] (from the
222    /// `Location` header on a place / replace, or from a list call).
223    pub async fn get(&self, order_id: OrderId) -> Result<Order> {
224        let hash = self.account_hash.expose_secret();
225        let path = format!("/accounts/{hash}/orders/{order_id}");
226        self.client.trader_http().get_json(&path).await
227    }
228
229    /// `POST /accounts/{accountNumber}/orders` - place an order.
230    ///
231    /// On success Schwab returns 201 with an empty body and a `Location`
232    /// header pointing at the new order's resource. This method parses the
233    /// trailing `{orderId}` segment from that header and returns it.
234    /// Callers may then use [`Self::get`] to fetch the placed order's
235    /// status and execution detail.
236    ///
237    /// Accepts any `impl Into<OrderRequest>` - the shortcuts (e.g.
238    /// [`OrderRequest::buy_market`]) and the typestate builder both
239    /// satisfy this, so callers can pass them in without an explicit
240    /// `.build()`. A pre-built `OrderRequest` works too.
241    ///
242    /// Schwab has no client-controllable idempotency key, so a transient
243    /// failure here may have placed the order anyway. Query orders placed
244    /// since the time the original order was submitted and match by symbol,
245    /// side, and quantity to determine whether the order was placed.
246    ///
247    /// # Examples
248    ///
249    /// ```no_run
250    /// use chrono::{Duration, Utc};
251    /// use rust_decimal_macros::dec;
252    /// use schwab_sdk::{AuthToken, SchwabClient};
253    /// use schwab_sdk::orders::OrderRequest;
254    ///
255    /// # async fn run() -> schwab_sdk::Result<()> {
256    /// let client = SchwabClient::new(AuthToken::new("token"));
257    /// let accounts = client.accounts().numbers().await?;
258    /// let account_hash = &accounts.first().expect("a linked account").hash_value;
259    /// let orders = client.orders(account_hash);
260    ///
261    /// // Record the window *before* submitting, so a failed place is
262    /// // reconcilable.
263    /// let submitted_at = Utc::now();
264    ///
265    /// let order_id = match orders.place(OrderRequest::buy_limit("AAPL", dec!(10), dec!(140))).await {
266    ///     Ok(id) => id,
267    ///     Err(err) if err.is_retryable() => {
268    ///         // The order may have been accepted before the failure. List
269    ///         // orders entered since `submitted_at` and match by symbol,
270    ///         // side, and quantity rather than resubmitting blindly.
271    ///         let candidates = orders
272    ///             .list(submitted_at - Duration::seconds(5), Utc::now())
273    ///             .send()
274    ///             .await?;
275    ///         println!("place failed; {} order(s) to reconcile", candidates.len());
276    ///         return Ok(());
277    ///     }
278    ///     Err(err) => return Err(err),
279    /// };
280    /// println!("placed {order_id}");
281    /// # Ok(())
282    /// # }
283    /// ```
284    pub async fn place(&self, order: impl Into<OrderRequest>) -> Result<OrderId> {
285        let order = order.into();
286        let hash = self.account_hash.expose_secret();
287        let response = self
288            .client
289            .trader_http()
290            .post(&format!("/accounts/{hash}/orders"))
291            .json(&order)
292            .send()
293            .await?;
294        parse_order_id_from_location(&response)
295    }
296
297    /// `PUT /accounts/{accountNumber}/orders/{orderId}` - replace an order.
298    ///
299    /// Schwab cancels `order_id` and creates a brand-new order from the
300    /// supplied order body; the returned [`OrderId`] is the **new** order's
301    /// id, parsed from the response `Location` header. The original
302    /// `order_id` is no longer valid after a successful replace.
303    pub async fn replace(
304        &self,
305        order_id: OrderId,
306        order: impl Into<OrderRequest>,
307    ) -> Result<OrderId> {
308        let order = order.into();
309        let hash = self.account_hash.expose_secret();
310        let response = self
311            .client
312            .trader_http()
313            .put(&format!("/accounts/{hash}/orders/{order_id}"))
314            .json(&order)
315            .send()
316            .await?;
317        parse_order_id_from_location(&response)
318    }
319
320    /// `DELETE /accounts/{accountNumber}/orders/{orderId}` - cancel an
321    /// order. Schwab returns 200 with an empty body on success; this
322    /// method discards the response and returns `Ok(())`. Inspecting the
323    /// order's terminal state after cancel is the caller's responsibility
324    /// (typically by calling [`Self::get`]).
325    pub async fn cancel(&self, order_id: OrderId) -> Result<()> {
326        let hash = self.account_hash.expose_secret();
327        self.client
328            .trader_http()
329            .delete(&format!("/accounts/{hash}/orders/{order_id}"))
330            .send()
331            .await?;
332        Ok(())
333    }
334
335    /// `POST /accounts/{accountNumber}/previewOrder` - preview an order
336    /// without submitting it. Returns Schwab's projected commissions,
337    /// fees, buying-power impact, and validation result (which may
338    /// include `rejects` even though the response status is 200; callers
339    /// should inspect [`PreviewOrder::order_validation_result`] before
340    /// treating the preview as approval).
341    ///
342    /// # Examples
343    ///
344    /// ```no_run
345    /// use rust_decimal_macros::dec;
346    /// use schwab_sdk::{AuthToken, SchwabClient};
347    /// use schwab_sdk::orders::OrderRequest;
348    ///
349    /// # async fn run() -> schwab_sdk::Result<()> {
350    /// let client = SchwabClient::new(AuthToken::new("token"));
351    /// let accounts = client.accounts().numbers().await?;
352    /// let account_hash = &accounts.first().expect("a linked account").hash_value;
353    ///
354    /// let preview = client
355    ///     .orders(account_hash)
356    ///     .preview(OrderRequest::buy_limit("AAPL", dec!(10), dec!(140.00)))
357    ///     .await?;
358    ///
359    /// // A 200 can still carry rejects; check before treating it as approval.
360    /// if let Some(result) = &preview.order_validation_result {
361    ///     if !result.rejects.is_empty() {
362    ///         println!("rejected: {:?}", result.rejects);
363    ///         return Ok(());
364    ///     }
365    /// }
366    /// # Ok(())
367    /// # }
368    /// ```
369    pub async fn preview(&self, order: impl Into<OrderRequest>) -> Result<PreviewOrder> {
370        let order = order.into();
371        let hash = self.account_hash.expose_secret();
372        self.client
373            .trader_http()
374            .post(&format!("/accounts/{hash}/previewOrder"))
375            .json(&order)
376            .send_json()
377            .await
378    }
379
380    /// Begin a `GET /accounts/{accountNumber}/orders` request.
381    ///
382    /// `from_entered_time` and `to_entered_time` bound the result window.
383    /// Schwab caps the window at one year; this builder does not enforce
384    /// that. Optional filters chain before [`ListOrdersBuilder::send`].
385    pub fn list(
386        &self,
387        from_entered_time: DateTime<Utc>,
388        to_entered_time: DateTime<Utc>,
389    ) -> ListOrdersBuilder<'a, 'b> {
390        ListOrdersBuilder {
391            client: self.client,
392            account_hash: self.account_hash,
393            from_entered_time,
394            to_entered_time,
395            max_results: None,
396            status: None,
397        }
398    }
399}
400
401/// Accessor for `/orders` (across every linked account). Construct via
402/// [`SchwabClient::orders_all`](crate::SchwabClient::orders_all).
403#[derive(Debug)]
404pub struct AllOrders<'a> {
405    client: &'a SchwabClient,
406}
407
408impl<'a> AllOrders<'a> {
409    pub(crate) fn new(client: &'a SchwabClient) -> Self {
410        Self { client }
411    }
412
413    /// Begin a `GET /orders` request.
414    ///
415    /// `from_entered_time` and `to_entered_time` bound the result window.
416    /// Schwab caps the window at 60 days for the cross-account endpoint;
417    /// this builder does not enforce that.
418    pub fn list(
419        &self,
420        from_entered_time: DateTime<Utc>,
421        to_entered_time: DateTime<Utc>,
422    ) -> ListAllOrdersBuilder<'a> {
423        ListAllOrdersBuilder {
424            client: self.client,
425            from_entered_time,
426            to_entered_time,
427            max_results: None,
428            status: None,
429        }
430    }
431}
432
433// --- List builders ---
434
435/// In-flight request for `GET /accounts/{accountNumber}/orders`. Built via
436/// [`Orders::list`].
437#[derive(Debug)]
438#[must_use = "call .send() to execute the request"]
439pub struct ListOrdersBuilder<'a, 'b> {
440    client: &'a SchwabClient,
441    account_hash: &'b AccountHash,
442    from_entered_time: DateTime<Utc>,
443    to_entered_time: DateTime<Utc>,
444    max_results: Option<i64>,
445    status: Option<ApiOrderStatus>,
446}
447
448impl<'a, 'b> ListOrdersBuilder<'a, 'b> {
449    /// Cap the response size. Schwab's default is 3000.
450    pub fn max_results(mut self, n: i64) -> Self {
451        self.max_results = Some(n);
452        self
453    }
454
455    /// Restrict the response to orders in a specific status.
456    pub fn status(mut self, status: ApiOrderStatus) -> Self {
457        self.status = Some(status);
458        self
459    }
460
461    /// Execute the request.
462    pub async fn send(self) -> Result<Vec<Order>> {
463        let hash = self.account_hash.expose_secret();
464        let from = self
465            .from_entered_time
466            .to_rfc3339_opts(SecondsFormat::Millis, true);
467        let to = self
468            .to_entered_time
469            .to_rfc3339_opts(SecondsFormat::Millis, true);
470        let mut request = self
471            .client
472            .trader_http()
473            .get(&format!("/accounts/{hash}/orders"))
474            .query(&[
475                ("fromEnteredTime", from.as_str()),
476                ("toEnteredTime", to.as_str()),
477            ]);
478        if let Some(n) = self.max_results {
479            let n_str = n.to_string();
480            request = request.query(&[("maxResults", n_str.as_str())]);
481        }
482        if let Some(s) = self.status {
483            let s_str = s.to_string();
484            request = request.query(&[("status", s_str.as_str())]);
485        }
486        request.send_json().await
487    }
488}
489
490/// In-flight request for `GET /orders` across every linked account. Built
491/// via [`AllOrders::list`].
492#[derive(Debug)]
493#[must_use = "call .send() to execute the request"]
494pub struct ListAllOrdersBuilder<'a> {
495    client: &'a SchwabClient,
496    from_entered_time: DateTime<Utc>,
497    to_entered_time: DateTime<Utc>,
498    max_results: Option<i64>,
499    status: Option<ApiOrderStatus>,
500}
501
502impl<'a> ListAllOrdersBuilder<'a> {
503    /// Cap the response size. Schwab's default is 3000.
504    pub fn max_results(mut self, n: i64) -> Self {
505        self.max_results = Some(n);
506        self
507    }
508
509    /// Restrict the response to orders in a specific status.
510    pub fn status(mut self, status: ApiOrderStatus) -> Self {
511        self.status = Some(status);
512        self
513    }
514
515    /// Execute the request.
516    pub async fn send(self) -> Result<Vec<Order>> {
517        let from = self
518            .from_entered_time
519            .to_rfc3339_opts(SecondsFormat::Millis, true);
520        let to = self
521            .to_entered_time
522            .to_rfc3339_opts(SecondsFormat::Millis, true);
523        let mut request = self.client.trader_http().get("/orders").query(&[
524            ("fromEnteredTime", from.as_str()),
525            ("toEnteredTime", to.as_str()),
526        ]);
527        if let Some(n) = self.max_results {
528            let n_str = n.to_string();
529            request = request.query(&[("maxResults", n_str.as_str())]);
530        }
531        if let Some(s) = self.status {
532            let s_str = s.to_string();
533            request = request.query(&[("status", s_str.as_str())]);
534        }
535        request.send_json().await
536    }
537}
538
539// --- Location header parsing ---
540
541/// Parse Schwab's `Location` header after a successful place / replace and
542/// extract the trailing `{orderId}` segment. Accepts both absolute URLs
543/// (`https://api.schwabapi.com/.../orders/123`) and bare paths.
544fn parse_order_id_from_location(response: &reqwest::Response) -> Result<OrderId> {
545    let value = response
546        .headers()
547        .get(reqwest::header::LOCATION)
548        .ok_or_else(|| Error::OrderIdUnrecoverable("missing Location header".to_string()))?
549        .to_str()
550        .map_err(|e| Error::OrderIdUnrecoverable(format!("Location header not ASCII: {e}")))?;
551    parse_order_id_from_location_str(value)
552}
553
554fn parse_order_id_from_location_str(location: &str) -> Result<OrderId> {
555    let trimmed = location.trim_end_matches('/');
556    let id_segment = trimmed
557        .rsplit('/')
558        .next()
559        .ok_or_else(|| Error::OrderIdUnrecoverable(location.to_string()))?;
560    let id_segment = id_segment.split(['?', '#']).next().unwrap_or(id_segment);
561    id_segment
562        .parse::<i64>()
563        .map(OrderId::new)
564        .map_err(|_| Error::OrderIdUnrecoverable(location.to_string()))
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn parse_order_id_from_absolute_url() {
573        let id = parse_order_id_from_location_str(
574            "https://api.schwabapi.com/trader/v1/accounts/ABCDEF/orders/100000001",
575        )
576        .unwrap();
577        assert_eq!(id, OrderId::new(100000001));
578    }
579
580    #[test]
581    fn parse_order_id_from_relative_path() {
582        let id = parse_order_id_from_location_str("/trader/v1/accounts/ABCDEF/orders/42").unwrap();
583        assert_eq!(id, OrderId::new(42));
584    }
585
586    #[test]
587    fn parse_order_id_strips_trailing_slash() {
588        let id = parse_order_id_from_location_str("/accounts/ABCDEF/orders/99/").unwrap();
589        assert_eq!(id, OrderId::new(99));
590    }
591
592    #[test]
593    fn parse_order_id_strips_query_string() {
594        let id = parse_order_id_from_location_str("/accounts/ABCDEF/orders/77?v=1").unwrap();
595        assert_eq!(id, OrderId::new(77));
596    }
597
598    #[test]
599    fn parse_order_id_rejects_non_numeric() {
600        let err = parse_order_id_from_location_str("/accounts/ABCDEF/orders/oops").unwrap_err();
601        match err {
602            Error::OrderIdUnrecoverable(s) => assert!(s.contains("oops")),
603            other => panic!("expected OrderIdUnrecoverable, got {other:?}"),
604        }
605    }
606}