mod enums;
mod preview;
mod request;
mod response;
pub use enums::*;
pub use preview::{
AdvancedOrderType, AmountIndicator, ApiRuleAction, Commission, CommissionAndFee, CommissionLeg,
CommissionValue, FeeLeg, FeeValue, Fees, OrderBalance, OrderLeg, OrderStrategy,
OrderValidationDetail, OrderValidationResult, PreviewOrder, SettlementInstruction,
};
pub use request::{OrderRequest, SingleOrderBuilder};
pub use response::{ExecutionLeg, Order, OrderActivity, OrderLegCollection};
use chrono::{DateTime, SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use crate::client::SchwabClient;
use crate::error::{Error, Result};
use crate::secrets::AccountHash;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct OrderId(i64);
impl OrderId {
pub fn new(id: i64) -> Self {
Self(id)
}
pub fn get(self) -> i64 {
self.0
}
}
impl std::fmt::Display for OrderId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<i64> for OrderId {
fn from(id: i64) -> Self {
Self(id)
}
}
impl From<OrderId> for i64 {
fn from(id: OrderId) -> Self {
id.0
}
}
#[derive(Debug)]
pub struct Orders<'a, 'b> {
client: &'a SchwabClient,
account_hash: &'b AccountHash,
}
impl<'a, 'b> Orders<'a, 'b> {
pub(crate) fn new(client: &'a SchwabClient, account_hash: &'b AccountHash) -> Self {
Self {
client,
account_hash,
}
}
pub async fn get(&self, order_id: OrderId) -> Result<Order> {
let hash = self.account_hash.expose_secret();
let path = format!("/accounts/{hash}/orders/{order_id}");
self.client.trader_http().get_json(&path).await
}
pub async fn place(&self, order: impl Into<OrderRequest>) -> Result<OrderId> {
let order = order.into();
let hash = self.account_hash.expose_secret();
let response = self
.client
.trader_http()
.post(&format!("/accounts/{hash}/orders"))
.json(&order)
.send()
.await?;
parse_order_id_from_location(&response)
}
pub async fn replace(
&self,
order_id: OrderId,
order: impl Into<OrderRequest>,
) -> Result<OrderId> {
let order = order.into();
let hash = self.account_hash.expose_secret();
let response = self
.client
.trader_http()
.put(&format!("/accounts/{hash}/orders/{order_id}"))
.json(&order)
.send()
.await?;
parse_order_id_from_location(&response)
}
pub async fn cancel(&self, order_id: OrderId) -> Result<()> {
let hash = self.account_hash.expose_secret();
self.client
.trader_http()
.delete(&format!("/accounts/{hash}/orders/{order_id}"))
.send()
.await?;
Ok(())
}
pub async fn preview(&self, order: impl Into<OrderRequest>) -> Result<PreviewOrder> {
let order = order.into();
let hash = self.account_hash.expose_secret();
self.client
.trader_http()
.post(&format!("/accounts/{hash}/previewOrder"))
.json(&order)
.send_json()
.await
}
pub fn list(
&self,
from_entered_time: DateTime<Utc>,
to_entered_time: DateTime<Utc>,
) -> ListOrdersBuilder<'a, 'b> {
ListOrdersBuilder {
client: self.client,
account_hash: self.account_hash,
from_entered_time,
to_entered_time,
max_results: None,
status: None,
}
}
}
#[derive(Debug)]
pub struct AllOrders<'a> {
client: &'a SchwabClient,
}
impl<'a> AllOrders<'a> {
pub(crate) fn new(client: &'a SchwabClient) -> Self {
Self { client }
}
pub fn list(
&self,
from_entered_time: DateTime<Utc>,
to_entered_time: DateTime<Utc>,
) -> ListAllOrdersBuilder<'a> {
ListAllOrdersBuilder {
client: self.client,
from_entered_time,
to_entered_time,
max_results: None,
status: None,
}
}
}
#[derive(Debug)]
#[must_use = "call .send() to execute the request"]
pub struct ListOrdersBuilder<'a, 'b> {
client: &'a SchwabClient,
account_hash: &'b AccountHash,
from_entered_time: DateTime<Utc>,
to_entered_time: DateTime<Utc>,
max_results: Option<i64>,
status: Option<ApiOrderStatus>,
}
impl<'a, 'b> ListOrdersBuilder<'a, 'b> {
pub fn max_results(mut self, n: i64) -> Self {
self.max_results = Some(n);
self
}
pub fn status(mut self, status: ApiOrderStatus) -> Self {
self.status = Some(status);
self
}
pub async fn send(self) -> Result<Vec<Order>> {
let hash = self.account_hash.expose_secret();
let from = self
.from_entered_time
.to_rfc3339_opts(SecondsFormat::Millis, true);
let to = self
.to_entered_time
.to_rfc3339_opts(SecondsFormat::Millis, true);
let mut request = self
.client
.trader_http()
.get(&format!("/accounts/{hash}/orders"))
.query(&[
("fromEnteredTime", from.as_str()),
("toEnteredTime", to.as_str()),
]);
if let Some(n) = self.max_results {
let n_str = n.to_string();
request = request.query(&[("maxResults", n_str.as_str())]);
}
if let Some(s) = self.status {
let s_str = s.to_string();
request = request.query(&[("status", s_str.as_str())]);
}
request.send_json().await
}
}
#[derive(Debug)]
#[must_use = "call .send() to execute the request"]
pub struct ListAllOrdersBuilder<'a> {
client: &'a SchwabClient,
from_entered_time: DateTime<Utc>,
to_entered_time: DateTime<Utc>,
max_results: Option<i64>,
status: Option<ApiOrderStatus>,
}
impl<'a> ListAllOrdersBuilder<'a> {
pub fn max_results(mut self, n: i64) -> Self {
self.max_results = Some(n);
self
}
pub fn status(mut self, status: ApiOrderStatus) -> Self {
self.status = Some(status);
self
}
pub async fn send(self) -> Result<Vec<Order>> {
let from = self
.from_entered_time
.to_rfc3339_opts(SecondsFormat::Millis, true);
let to = self
.to_entered_time
.to_rfc3339_opts(SecondsFormat::Millis, true);
let mut request = self.client.trader_http().get("/orders").query(&[
("fromEnteredTime", from.as_str()),
("toEnteredTime", to.as_str()),
]);
if let Some(n) = self.max_results {
let n_str = n.to_string();
request = request.query(&[("maxResults", n_str.as_str())]);
}
if let Some(s) = self.status {
let s_str = s.to_string();
request = request.query(&[("status", s_str.as_str())]);
}
request.send_json().await
}
}
fn parse_order_id_from_location(response: &reqwest::Response) -> Result<OrderId> {
let value = response
.headers()
.get(reqwest::header::LOCATION)
.ok_or_else(|| Error::OrderIdUnrecoverable("missing Location header".to_string()))?
.to_str()
.map_err(|e| Error::OrderIdUnrecoverable(format!("Location header not ASCII: {e}")))?;
parse_order_id_from_location_str(value)
}
fn parse_order_id_from_location_str(location: &str) -> Result<OrderId> {
let trimmed = location.trim_end_matches('/');
let id_segment = trimmed
.rsplit('/')
.next()
.ok_or_else(|| Error::OrderIdUnrecoverable(location.to_string()))?;
let id_segment = id_segment.split(['?', '#']).next().unwrap_or(id_segment);
id_segment
.parse::<i64>()
.map(OrderId::new)
.map_err(|_| Error::OrderIdUnrecoverable(location.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_order_id_from_absolute_url() {
let id = parse_order_id_from_location_str(
"https://api.schwabapi.com/trader/v1/accounts/ABCDEF/orders/100000001",
)
.unwrap();
assert_eq!(id, OrderId::new(100000001));
}
#[test]
fn parse_order_id_from_relative_path() {
let id = parse_order_id_from_location_str("/trader/v1/accounts/ABCDEF/orders/42").unwrap();
assert_eq!(id, OrderId::new(42));
}
#[test]
fn parse_order_id_strips_trailing_slash() {
let id = parse_order_id_from_location_str("/accounts/ABCDEF/orders/99/").unwrap();
assert_eq!(id, OrderId::new(99));
}
#[test]
fn parse_order_id_strips_query_string() {
let id = parse_order_id_from_location_str("/accounts/ABCDEF/orders/77?v=1").unwrap();
assert_eq!(id, OrderId::new(77));
}
#[test]
fn parse_order_id_rejects_non_numeric() {
let err = parse_order_id_from_location_str("/accounts/ABCDEF/orders/oops").unwrap_err();
match err {
Error::OrderIdUnrecoverable(s) => assert!(s.contains("oops")),
other => panic!("expected OrderIdUnrecoverable, got {other:?}"),
}
}
}