Skip to main content

schwab_cli/
order_builder.rs

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/// Build a single-leg option order (not a spread).
139#[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
168/// Build a multi-leg complex option order (spread, iron condor, etc.).
169pub 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}