1use anyhow::{bail, Result};
2use schwab_api::models::order::{
3 ComplexOrderStrategyType, OrderDuration, OrderInstruction, OrderSession, OrderStrategyType,
4 OrderTypeRequest,
5};
6use serde_json::{json, Value};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TradeSide {
10 Buy,
11 Sell,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum TradeOrderType {
16 Market,
17 Limit,
18}
19
20#[derive(Debug, Clone)]
21pub struct OrderLegSpec {
22 pub instruction: OrderInstruction,
23 pub symbol: String,
24 pub asset_type: &'static str,
25 pub quantity: f64,
26}
27
28#[derive(Debug, Clone)]
29pub struct OrderRequestSpec {
30 pub session: OrderSession,
31 pub duration: OrderDuration,
32 pub order_type: OrderTypeRequest,
33 pub order_strategy_type: OrderStrategyType,
34 pub complex_strategy: ComplexOrderStrategyType,
35 pub legs: Vec<OrderLegSpec>,
36 pub price: Option<f64>,
37 pub stop_price: Option<f64>,
38 pub cancel_time: Option<String>,
39}
40
41pub fn build_order_request(spec: OrderRequestSpec) -> Result<Value> {
42 if spec.legs.is_empty() {
43 bail!("orderLegCollection must contain at least one leg");
44 }
45
46 for leg in &spec.legs {
47 if leg.quantity <= 0.0 {
48 bail!("leg quantity must be positive");
49 }
50 }
51
52 if matches!(
53 spec.order_type,
54 OrderTypeRequest::Limit
55 | OrderTypeRequest::StopLimit
56 | OrderTypeRequest::NetDebit
57 | OrderTypeRequest::NetCredit
58 | OrderTypeRequest::LimitOnClose
59 ) && spec.price.is_none()
60 {
61 bail!("price is required for {:?}", spec.order_type);
62 }
63
64 let legs: Vec<Value> = spec
65 .legs
66 .iter()
67 .map(|leg| {
68 json!({
69 "instruction": leg.instruction,
70 "quantity": leg.quantity,
71 "instrument": {
72 "symbol": leg.symbol.trim().to_uppercase(),
73 "assetType": leg.asset_type
74 }
75 })
76 })
77 .collect();
78
79 let mut order = json!({
80 "orderType": spec.order_type,
81 "session": spec.session,
82 "duration": spec.duration,
83 "orderStrategyType": spec.order_strategy_type,
84 "complexOrderStrategyType": spec.complex_strategy,
85 "orderLegCollection": legs,
86 });
87
88 if let Some(price) = spec.price {
89 order["price"] = json!(format_price(price));
90 }
91 if let Some(stop) = spec.stop_price {
92 order["stopPrice"] = json!(format_price(stop));
93 }
94 if let Some(cancel_time) = spec.cancel_time {
95 order["cancelTime"] = json!(cancel_time);
96 }
97
98 Ok(order)
99}
100
101pub fn build_equity_order(
102 side: TradeSide,
103 symbol: &str,
104 quantity: f64,
105 order_type: TradeOrderType,
106 limit_price: Option<f64>,
107 duration: OrderDuration,
108 session: OrderSession,
109) -> Result<Value> {
110 let instruction = match side {
111 TradeSide::Buy => OrderInstruction::Buy,
112 TradeSide::Sell => OrderInstruction::Sell,
113 };
114
115 let order_type_api = match order_type {
116 TradeOrderType::Market => OrderTypeRequest::Market,
117 TradeOrderType::Limit => OrderTypeRequest::Limit,
118 };
119
120 build_order_request(OrderRequestSpec {
121 session,
122 duration,
123 order_type: order_type_api,
124 order_strategy_type: OrderStrategyType::Single,
125 complex_strategy: ComplexOrderStrategyType::None,
126 legs: vec![OrderLegSpec {
127 instruction,
128 symbol: symbol.to_string(),
129 asset_type: "EQUITY",
130 quantity,
131 }],
132 price: limit_price,
133 stop_price: None,
134 cancel_time: None,
135 })
136}
137
138#[allow(clippy::too_many_arguments)]
140pub fn build_single_option_order(
141 instruction: OrderInstruction,
142 option_symbol: &str,
143 quantity: f64,
144 order_type: OrderTypeRequest,
145 price: Option<f64>,
146 duration: OrderDuration,
147 session: OrderSession,
148 cancel_time: Option<String>,
149) -> Result<Value> {
150 build_order_request(OrderRequestSpec {
151 session,
152 duration,
153 order_type,
154 order_strategy_type: OrderStrategyType::Single,
155 complex_strategy: ComplexOrderStrategyType::None,
156 legs: vec![OrderLegSpec {
157 instruction,
158 symbol: option_symbol.to_string(),
159 asset_type: "OPTION",
160 quantity,
161 }],
162 price,
163 stop_price: None,
164 cancel_time,
165 })
166}
167
168pub fn build_complex_option_order(
170 complex_strategy: ComplexOrderStrategyType,
171 order_type: OrderTypeRequest,
172 legs: Vec<OrderLegSpec>,
173 price: Option<f64>,
174 duration: OrderDuration,
175 session: OrderSession,
176 cancel_time: Option<String>,
177) -> Result<Value> {
178 if legs.len() < 2 {
179 bail!("complex option orders require at least two legs");
180 }
181 if complex_strategy == ComplexOrderStrategyType::None {
182 bail!("complexOrderStrategyType must be set for multi-leg option orders");
183 }
184
185 build_order_request(OrderRequestSpec {
186 session,
187 duration,
188 order_type,
189 order_strategy_type: OrderStrategyType::Single,
190 complex_strategy,
191 legs,
192 price,
193 stop_price: None,
194 cancel_time,
195 })
196}
197
198fn format_price(price: f64) -> String {
199 if (price.fract()).abs() < f64::EPSILON {
200 format!("{price:.0}")
201 } else {
202 format!("{price:.2}")
203 }
204}
205
206pub fn parse_trade_order_type(raw: &str) -> Result<TradeOrderType> {
207 match raw.trim().to_ascii_lowercase().as_str() {
208 "market" | "mkt" => Ok(TradeOrderType::Market),
209 "limit" | "lmt" => Ok(TradeOrderType::Limit),
210 other => bail!("Unknown order type `{other}` (use market or limit)"),
211 }
212}
213
214#[allow(dead_code)]
215pub fn parse_order_type_request(raw: &str) -> Result<OrderTypeRequest> {
216 match raw.trim().to_ascii_uppercase().as_str() {
217 "MARKET" => Ok(OrderTypeRequest::Market),
218 "LIMIT" => Ok(OrderTypeRequest::Limit),
219 "STOP" => Ok(OrderTypeRequest::Stop),
220 "STOP_LIMIT" => Ok(OrderTypeRequest::StopLimit),
221 "TRAILING_STOP" => Ok(OrderTypeRequest::TrailingStop),
222 "NET_DEBIT" => Ok(OrderTypeRequest::NetDebit),
223 "NET_CREDIT" => Ok(OrderTypeRequest::NetCredit),
224 "NET_ZERO" => Ok(OrderTypeRequest::NetZero),
225 "LIMIT_ON_CLOSE" => Ok(OrderTypeRequest::LimitOnClose),
226 "MARKET_ON_CLOSE" => Ok(OrderTypeRequest::MarketOnClose),
227 "EXERCISE" => Ok(OrderTypeRequest::Exercise),
228 other => bail!("Unknown order type `{other}`"),
229 }
230}
231
232#[allow(dead_code)]
233pub fn parse_order_instruction(raw: &str) -> Result<OrderInstruction> {
234 match raw.trim().to_ascii_uppercase().as_str() {
235 "BUY" => Ok(OrderInstruction::Buy),
236 "SELL" => Ok(OrderInstruction::Sell),
237 "BUY_TO_OPEN" => Ok(OrderInstruction::BuyToOpen),
238 "SELL_TO_CLOSE" => Ok(OrderInstruction::SellToClose),
239 "SELL_TO_OPEN" => Ok(OrderInstruction::SellToOpen),
240 "BUY_TO_CLOSE" => Ok(OrderInstruction::BuyToClose),
241 "SELL_SHORT" => Ok(OrderInstruction::SellShort),
242 "BUY_TO_COVER" => Ok(OrderInstruction::BuyToCover),
243 other => bail!("Unknown instruction `{other}`"),
244 }
245}
246
247#[allow(dead_code)]
248pub fn parse_complex_order_strategy_type(raw: &str) -> Result<ComplexOrderStrategyType> {
249 match raw.trim().to_ascii_uppercase().as_str() {
250 "NONE" => Ok(ComplexOrderStrategyType::None),
251 "COVERED" => Ok(ComplexOrderStrategyType::Covered),
252 "VERTICAL" => Ok(ComplexOrderStrategyType::Vertical),
253 "BACK_RATIO" => Ok(ComplexOrderStrategyType::BackRatio),
254 "CALENDAR" => Ok(ComplexOrderStrategyType::Calendar),
255 "DIAGONAL" => Ok(ComplexOrderStrategyType::Diagonal),
256 "STRADDLE" => Ok(ComplexOrderStrategyType::Straddle),
257 "STRANGLE" => Ok(ComplexOrderStrategyType::Strangle),
258 "COLLAR_SYNTHETIC" => Ok(ComplexOrderStrategyType::CollarSynthetic),
259 "BUTTERFLY" => Ok(ComplexOrderStrategyType::Butterfly),
260 "CONDOR" => Ok(ComplexOrderStrategyType::Condor),
261 "IRON_CONDOR" => Ok(ComplexOrderStrategyType::IronCondor),
262 "VERTICAL_ROLL" => Ok(ComplexOrderStrategyType::VerticalRoll),
263 "COLLAR_WITH_STOCK" => Ok(ComplexOrderStrategyType::CollarWithStock),
264 "DOUBLE_DIAGONAL" => Ok(ComplexOrderStrategyType::DoubleDiagonal),
265 "UNBALANCED_BUTTERFLY" => Ok(ComplexOrderStrategyType::UnbalancedButterfly),
266 "UNBALANCED_CONDOR" => Ok(ComplexOrderStrategyType::UnbalancedCondor),
267 "UNBALANCED_IRON_CONDOR" => Ok(ComplexOrderStrategyType::UnbalancedIronCondor),
268 "UNBALANCED_VERTICAL_ROLL" => Ok(ComplexOrderStrategyType::UnbalancedVerticalRoll),
269 "MUTUAL_FUND_SWAP" => Ok(ComplexOrderStrategyType::MutualFundSwap),
270 "CUSTOM" => Ok(ComplexOrderStrategyType::Custom),
271 other => bail!("Unknown complexOrderStrategyType `{other}`"),
272 }
273}
274
275pub fn parse_duration(raw: Option<&str>) -> Result<OrderDuration> {
276 match raw
277 .unwrap_or("day")
278 .trim()
279 .to_ascii_uppercase()
280 .as_str()
281 {
282 "DAY" => Ok(OrderDuration::Day),
283 "GTC" | "GOOD_TILL_CANCEL" => Ok(OrderDuration::GoodTillCancel),
284 "FOK" | "FILL_OR_KILL" => Ok(OrderDuration::FillOrKill),
285 other => bail!("Unknown duration `{other}` (use day, gtc, or fok)"),
286 }
287}
288
289pub fn parse_session(raw: Option<&str>) -> Result<OrderSession> {
290 match raw
291 .unwrap_or("normal")
292 .trim()
293 .to_ascii_uppercase()
294 .as_str()
295 {
296 "NORMAL" => Ok(OrderSession::Normal),
297 "AM" => Ok(OrderSession::Am),
298 "PM" => Ok(OrderSession::Pm),
299 "SEAMLESS" => Ok(OrderSession::Seamless),
300 other => bail!("Unknown session `{other}`"),
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn builds_market_buy() {
310 let order = build_equity_order(
311 TradeSide::Buy,
312 "aapl",
313 5.0,
314 TradeOrderType::Market,
315 None,
316 OrderDuration::Day,
317 OrderSession::Normal,
318 )
319 .unwrap();
320 assert_eq!(order["orderType"], "MARKET");
321 assert_eq!(order["complexOrderStrategyType"], "NONE");
322 assert_eq!(order["orderLegCollection"][0]["instruction"], "BUY");
323 assert_eq!(order["orderLegCollection"][0]["instrument"]["symbol"], "AAPL");
324 }
325
326 #[test]
327 fn builds_limit_sell() {
328 let order = build_equity_order(
329 TradeSide::Sell,
330 "MSFT",
331 2.0,
332 TradeOrderType::Limit,
333 Some(350.5),
334 OrderDuration::Day,
335 OrderSession::Normal,
336 )
337 .unwrap();
338 assert_eq!(order["orderType"], "LIMIT");
339 assert_eq!(order["price"], "350.50");
340 }
341
342 #[test]
343 fn builds_vertical_spread_with_cancel_time() {
344 let order = build_complex_option_order(
345 ComplexOrderStrategyType::Vertical,
346 OrderTypeRequest::NetDebit,
347 vec![
348 OrderLegSpec {
349 instruction: OrderInstruction::BuyToOpen,
350 symbol: "AAPL 260620C00180000".into(),
351 asset_type: "OPTION",
352 quantity: 1.0,
353 },
354 OrderLegSpec {
355 instruction: OrderInstruction::SellToOpen,
356 symbol: "AAPL 260620C00185000".into(),
357 asset_type: "OPTION",
358 quantity: 1.0,
359 },
360 ],
361 Some(0.50),
362 OrderDuration::Day,
363 OrderSession::Normal,
364 Some("2026-06-19T16:00:00-04:00".into()),
365 )
366 .unwrap();
367 assert_eq!(order["complexOrderStrategyType"], "VERTICAL");
368 assert_eq!(order["orderType"], "NET_DEBIT");
369 assert_eq!(order["cancelTime"], "2026-06-19T16:00:00-04:00");
370 assert_eq!(order["orderLegCollection"].as_array().unwrap().len(), 2);
371 }
372}