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.unwrap_or("day").trim().to_ascii_uppercase().as_str() {
277 "DAY" => Ok(OrderDuration::Day),
278 "GTC" | "GOOD_TILL_CANCEL" => Ok(OrderDuration::GoodTillCancel),
279 "FOK" | "FILL_OR_KILL" => Ok(OrderDuration::FillOrKill),
280 other => bail!("Unknown duration `{other}` (use day, gtc, or fok)"),
281 }
282}
283
284pub fn parse_session(raw: Option<&str>) -> Result<OrderSession> {
285 match raw.unwrap_or("normal").trim().to_ascii_uppercase().as_str() {
286 "NORMAL" => Ok(OrderSession::Normal),
287 "AM" => Ok(OrderSession::Am),
288 "PM" => Ok(OrderSession::Pm),
289 "SEAMLESS" => Ok(OrderSession::Seamless),
290 other => bail!("Unknown session `{other}`"),
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn builds_market_buy() {
300 let order = build_equity_order(
301 TradeSide::Buy,
302 "aapl",
303 5.0,
304 TradeOrderType::Market,
305 None,
306 OrderDuration::Day,
307 OrderSession::Normal,
308 )
309 .unwrap();
310 assert_eq!(order["orderType"], "MARKET");
311 assert_eq!(order["complexOrderStrategyType"], "NONE");
312 assert_eq!(order["orderLegCollection"][0]["instruction"], "BUY");
313 assert_eq!(
314 order["orderLegCollection"][0]["instrument"]["symbol"],
315 "AAPL"
316 );
317 }
318
319 #[test]
320 fn builds_limit_sell() {
321 let order = build_equity_order(
322 TradeSide::Sell,
323 "MSFT",
324 2.0,
325 TradeOrderType::Limit,
326 Some(350.5),
327 OrderDuration::Day,
328 OrderSession::Normal,
329 )
330 .unwrap();
331 assert_eq!(order["orderType"], "LIMIT");
332 assert_eq!(order["price"], "350.50");
333 }
334
335 #[test]
336 fn builds_vertical_spread_with_cancel_time() {
337 let order = build_complex_option_order(
338 ComplexOrderStrategyType::Vertical,
339 OrderTypeRequest::NetDebit,
340 vec![
341 OrderLegSpec {
342 instruction: OrderInstruction::BuyToOpen,
343 symbol: "AAPL 260620C00180000".into(),
344 asset_type: "OPTION",
345 quantity: 1.0,
346 },
347 OrderLegSpec {
348 instruction: OrderInstruction::SellToOpen,
349 symbol: "AAPL 260620C00185000".into(),
350 asset_type: "OPTION",
351 quantity: 1.0,
352 },
353 ],
354 Some(0.50),
355 OrderDuration::Day,
356 OrderSession::Normal,
357 Some("2026-06-19T16:00:00-04:00".into()),
358 )
359 .unwrap();
360 assert_eq!(order["complexOrderStrategyType"], "VERTICAL");
361 assert_eq!(order["orderType"], "NET_DEBIT");
362 assert_eq!(order["cancelTime"], "2026-06-19T16:00:00-04:00");
363 assert_eq!(order["orderLegCollection"].as_array().unwrap().len(), 2);
364 }
365}