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}