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!(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}