1use std::time::Duration;
2
3use anyhow::{bail, Result};
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
9#[serde(rename_all = "lowercase")]
10pub enum WaitCondition {
11 #[default]
13 Accepted,
14 Filled,
16 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
52pub 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}