Skip to main content

schwab_cli/
order_status.rs

1use std::time::Duration;
2
3use anyhow::{bail, Result};
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6
7/// Condition to wait for when polling order status.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
9#[serde(rename_all = "lowercase")]
10pub enum WaitCondition {
11    /// Stop after broker accepts the order (HTTP 201); no polling.
12    #[default]
13    Accepted,
14    /// Stop when status is `FILLED` (fails on reject/cancel/expire).
15    Filled,
16    /// Stop on any terminal status (`FILLED`, `CANCELED`, `REJECTED`, `EXPIRED`, …).
17    Terminal,
18}
19
20impl WaitCondition {
21    pub fn parse(raw: &str) -> Result<Self> {
22        match raw.trim().to_ascii_lowercase().as_str() {
23            "accepted" | "accept" | "submitted" => Ok(Self::Accepted),
24            "filled" | "fill" => Ok(Self::Filled),
25            "terminal" | "done" | "complete" => Ok(Self::Terminal),
26            other => bail!("Unknown wait condition `{other}` (use accepted, filled, or terminal)"),
27        }
28    }
29
30    pub fn as_str(self) -> &'static str {
31        match self {
32            Self::Accepted => "accepted",
33            Self::Filled => "filled",
34            Self::Terminal => "terminal",
35        }
36    }
37}
38
39#[derive(Debug, Clone, Serialize)]
40pub struct OrderWaitResult {
41    pub order_id: String,
42    pub condition: WaitCondition,
43    pub met: bool,
44    pub final_status: Option<String>,
45    pub polls: u32,
46    pub elapsed_seconds: u64,
47    pub order: Value,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub error: Option<String>,
50}
51
52/// Parse order id from Schwab `Location` header after place/replace.
53/// Example: `https://api.schwabapi.com/trader/v1/accounts/{hash}/orders/{orderId}`
54pub fn parse_order_id_from_location(location: &str) -> Option<String> {
55    let marker = "/orders/";
56    let idx = location.rfind(marker)?;
57    let id = location[idx + marker.len()..].trim();
58    let id = id.split('?').next()?.trim();
59    if id.is_empty() {
60        None
61    } else {
62        Some(id.to_string())
63    }
64}
65
66pub fn order_status(order: &Value) -> Option<String> {
67    order
68        .get("status")
69        .and_then(|v| v.as_str())
70        .map(|s| s.to_ascii_uppercase())
71}
72
73pub fn order_filled_quantity(order: &Value) -> Option<f64> {
74    order
75        .get("filledQuantity")
76        .and_then(parse_number)
77        .or_else(|| {
78            order
79                .get("orderActivityCollection")
80                .and_then(|v| v.as_array())
81                .and_then(|acts| acts.first())
82                .and_then(|a| a.get("quantity"))
83                .and_then(parse_number)
84        })
85}
86
87fn parse_number(v: &Value) -> Option<f64> {
88    v.as_f64()
89        .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
90        .or_else(|| v.as_i64().map(|n| n as f64))
91}
92
93pub fn is_terminal_status(status: &str) -> bool {
94    matches!(
95        status,
96        "FILLED" | "CANCELED" | "CANCELLED" | "REJECTED" | "EXPIRED" | "REPLACED"
97    )
98}
99
100pub fn is_failure_status(status: &str) -> bool {
101    matches!(
102        status,
103        "CANCELED" | "CANCELLED" | "REJECTED" | "EXPIRED"
104    )
105}
106
107pub fn condition_met(
108    status: &str,
109    condition: WaitCondition,
110    filled_qty: Option<f64>,
111    requested_qty: Option<f64>,
112    proceed_on_partial_fill: bool,
113) -> bool {
114    match condition {
115        WaitCondition::Accepted => true,
116        WaitCondition::Terminal => is_terminal_status(status),
117        WaitCondition::Filled => {
118            if status == "FILLED" {
119                return true;
120            }
121            if proceed_on_partial_fill {
122                if let (Some(filled), Some(requested)) = (filled_qty, requested_qty) {
123                    if filled >= requested {
124                        return true;
125                    }
126                }
127            }
128            false
129        }
130    }
131}
132
133pub fn condition_satisfied_or_failed(
134    status: &str,
135    condition: WaitCondition,
136) -> Option<Result<()>> {
137    match condition {
138        WaitCondition::Accepted => Some(Ok(())),
139        WaitCondition::Terminal if is_terminal_status(status) => {
140            if status == "FILLED" {
141                Some(Ok(()))
142            } else {
143                Some(Err(anyhow::anyhow!(
144                    "Order reached terminal status `{status}` before condition was satisfied"
145                )))
146            }
147        }
148        WaitCondition::Filled if is_failure_status(status) => Some(Err(anyhow::anyhow!(
149            "Order failed with status `{status}` while waiting for fill"
150        ))),
151        _ => None,
152    }
153}
154
155pub struct WaitOptions {
156    pub condition: WaitCondition,
157    pub timeout: Duration,
158    pub interval: Duration,
159    pub proceed_on_partial_fill: bool,
160    pub requested_quantity: Option<f64>,
161}
162
163impl Default for WaitOptions {
164    fn default() -> Self {
165        Self {
166            condition: WaitCondition::Filled,
167            timeout: Duration::from_secs(3600),
168            interval: Duration::from_secs(5),
169            proceed_on_partial_fill: false,
170            requested_quantity: None,
171        }
172    }
173}
174
175#[allow(unused_assignments)]
176pub async fn wait_for_order(
177    api: &schwab_api::TraderApi,
178    account_hash: &str,
179    order_id: &str,
180    opts: WaitOptions,
181) -> Result<OrderWaitResult> {
182    if opts.condition == WaitCondition::Accepted {
183        return Ok(OrderWaitResult {
184            order_id: order_id.to_string(),
185            condition: opts.condition,
186            met: true,
187            final_status: None,
188            polls: 0,
189            elapsed_seconds: 0,
190            order: Value::Null,
191            error: None,
192        });
193    }
194
195    let started = std::time::Instant::now();
196    let mut polls = 0u32;
197    let mut last_order = None::<Value>;
198    let mut last_status = None::<String>;
199
200    loop {
201        polls += 1;
202        let order = api.orders().get(account_hash, order_id).await?;
203        last_status = order_status(&order);
204        let filled_qty = order_filled_quantity(&order);
205        last_order = Some(order);
206
207        if let Some(status) = last_status.as_deref() {
208            if condition_met(
209                status,
210                opts.condition,
211                filled_qty,
212                opts.requested_quantity,
213                opts.proceed_on_partial_fill,
214            ) {
215                return Ok(OrderWaitResult {
216                    order_id: order_id.to_string(),
217                    condition: opts.condition,
218                    met: true,
219                    final_status: last_status,
220                    polls,
221                    elapsed_seconds: started.elapsed().as_secs(),
222                    order: last_order.clone().unwrap_or(Value::Null),
223                    error: None,
224                });
225            }
226
227            if let Some(result) = condition_satisfied_or_failed(status, opts.condition) {
228                result?;
229            }
230        }
231
232        if started.elapsed() >= opts.timeout {
233            return Ok(OrderWaitResult {
234                order_id: order_id.to_string(),
235                condition: opts.condition,
236                met: false,
237                final_status: last_status,
238                polls,
239                elapsed_seconds: started.elapsed().as_secs(),
240                order: last_order.clone().unwrap_or(Value::Null),
241                error: Some(format!(
242                    "Timed out after {}s waiting for {:?}",
243                    opts.timeout.as_secs(),
244                    opts.condition
245                )),
246            });
247        }
248
249        tokio::time::sleep(opts.interval).await;
250    }
251}
252
253pub fn wait_result_json(result: &OrderWaitResult) -> Value {
254    json!({
255        "order_id": result.order_id,
256        "condition": result.condition,
257        "met": result.met,
258        "final_status": result.final_status,
259        "polls": result.polls,
260        "elapsed_seconds": result.elapsed_seconds,
261        "error": result.error,
262        "order": result.order,
263    })
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn parses_order_id_from_location() {
272        let loc = "https://api.schwabapi.com/trader/v1/accounts/ABC/orders/1234567890";
273        assert_eq!(
274            parse_order_id_from_location(loc).as_deref(),
275            Some("1234567890")
276        );
277    }
278
279    #[test]
280    fn filled_condition() {
281        assert!(condition_met("FILLED", WaitCondition::Filled, None, Some(10.0), false));
282        assert!(!condition_met("WORKING", WaitCondition::Filled, None, Some(10.0), false));
283    }
284
285    #[test]
286    fn terminal_condition() {
287        assert!(condition_met("CANCELED", WaitCondition::Terminal, None, None, false));
288    }
289}