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!(status, "CANCELED" | "CANCELLED" | "REJECTED" | "EXPIRED")
102}
103
104pub fn condition_met(
105    status: &str,
106    condition: WaitCondition,
107    filled_qty: Option<f64>,
108    requested_qty: Option<f64>,
109    proceed_on_partial_fill: bool,
110) -> bool {
111    match condition {
112        WaitCondition::Accepted => true,
113        WaitCondition::Terminal => is_terminal_status(status),
114        WaitCondition::Filled => {
115            if status == "FILLED" {
116                return true;
117            }
118            if proceed_on_partial_fill {
119                if let (Some(filled), Some(requested)) = (filled_qty, requested_qty) {
120                    if filled >= requested {
121                        return true;
122                    }
123                }
124            }
125            false
126        }
127    }
128}
129
130pub fn condition_satisfied_or_failed(status: &str, condition: WaitCondition) -> Option<Result<()>> {
131    match condition {
132        WaitCondition::Accepted => Some(Ok(())),
133        WaitCondition::Terminal if is_terminal_status(status) => {
134            if status == "FILLED" {
135                Some(Ok(()))
136            } else {
137                Some(Err(anyhow::anyhow!(
138                    "Order reached terminal status `{status}` before condition was satisfied"
139                )))
140            }
141        }
142        WaitCondition::Filled if is_failure_status(status) => Some(Err(anyhow::anyhow!(
143            "Order failed with status `{status}` while waiting for fill"
144        ))),
145        _ => None,
146    }
147}
148
149pub struct WaitOptions {
150    pub condition: WaitCondition,
151    pub timeout: Duration,
152    pub interval: Duration,
153    pub proceed_on_partial_fill: bool,
154    pub requested_quantity: Option<f64>,
155}
156
157impl Default for WaitOptions {
158    fn default() -> Self {
159        Self {
160            condition: WaitCondition::Filled,
161            timeout: Duration::from_secs(3600),
162            interval: Duration::from_secs(5),
163            proceed_on_partial_fill: false,
164            requested_quantity: None,
165        }
166    }
167}
168
169#[allow(unused_assignments)]
170pub async fn wait_for_order(
171    api: &schwab_api::TraderApi,
172    account_hash: &str,
173    order_id: &str,
174    opts: WaitOptions,
175) -> Result<OrderWaitResult> {
176    if opts.condition == WaitCondition::Accepted {
177        return Ok(OrderWaitResult {
178            order_id: order_id.to_string(),
179            condition: opts.condition,
180            met: true,
181            final_status: None,
182            polls: 0,
183            elapsed_seconds: 0,
184            order: Value::Null,
185            error: None,
186        });
187    }
188
189    let started = std::time::Instant::now();
190    let mut polls = 0u32;
191    let mut last_order = None::<Value>;
192    let mut last_status = None::<String>;
193
194    loop {
195        polls += 1;
196        let order = api.orders().get(account_hash, order_id).await?;
197        last_status = order_status(&order);
198        let filled_qty = order_filled_quantity(&order);
199        last_order = Some(order);
200
201        if let Some(status) = last_status.as_deref() {
202            if condition_met(
203                status,
204                opts.condition,
205                filled_qty,
206                opts.requested_quantity,
207                opts.proceed_on_partial_fill,
208            ) {
209                return Ok(OrderWaitResult {
210                    order_id: order_id.to_string(),
211                    condition: opts.condition,
212                    met: true,
213                    final_status: last_status,
214                    polls,
215                    elapsed_seconds: started.elapsed().as_secs(),
216                    order: last_order.clone().unwrap_or(Value::Null),
217                    error: None,
218                });
219            }
220
221            if let Some(result) = condition_satisfied_or_failed(status, opts.condition) {
222                result?;
223            }
224        }
225
226        if started.elapsed() >= opts.timeout {
227            return Ok(OrderWaitResult {
228                order_id: order_id.to_string(),
229                condition: opts.condition,
230                met: false,
231                final_status: last_status,
232                polls,
233                elapsed_seconds: started.elapsed().as_secs(),
234                order: last_order.clone().unwrap_or(Value::Null),
235                error: Some(format!(
236                    "Timed out after {}s waiting for {:?}",
237                    opts.timeout.as_secs(),
238                    opts.condition
239                )),
240            });
241        }
242
243        tokio::time::sleep(opts.interval).await;
244    }
245}
246
247pub fn wait_result_json(result: &OrderWaitResult) -> Value {
248    json!({
249        "order_id": result.order_id,
250        "condition": result.condition,
251        "met": result.met,
252        "final_status": result.final_status,
253        "polls": result.polls,
254        "elapsed_seconds": result.elapsed_seconds,
255        "error": result.error,
256        "order": result.order,
257    })
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn parses_order_id_from_location() {
266        let loc = "https://api.schwabapi.com/trader/v1/accounts/ABC/orders/1234567890";
267        assert_eq!(
268            parse_order_id_from_location(loc).as_deref(),
269            Some("1234567890")
270        );
271    }
272
273    #[test]
274    fn filled_condition() {
275        assert!(condition_met(
276            "FILLED",
277            WaitCondition::Filled,
278            None,
279            Some(10.0),
280            false
281        ));
282        assert!(!condition_met(
283            "WORKING",
284            WaitCondition::Filled,
285            None,
286            Some(10.0),
287            false
288        ));
289    }
290
291    #[test]
292    fn terminal_condition() {
293        assert!(condition_met(
294            "CANCELED",
295            WaitCondition::Terminal,
296            None,
297            None,
298            false
299        ));
300    }
301}