Skip to main content

schwab_cli/
order_schema.rs

1//! Schwab OrderRequest JSON schema and structural validation for agents.
2
3use anyhow::{bail, Context, Result};
4use schwab_api::models::order::ComplexOrderStrategyType;
5use serde_json::{json, Value};
6
7pub fn order_request_schema() -> Value {
8    json!({
9        "$schema": "https://json-schema.org/draft/2020-12/schema",
10        "$id": "https://schwab-api-cli.local/schemas/order-request.json",
11        "title": "Schwab OrderRequest",
12        "description": "POST /accounts/{accountNumber}/orders request body (Trader API v1)",
13        "type": "object",
14        "required": ["orderType", "orderLegCollection"],
15        "properties": {
16            "session": { "type": "string", "enum": ["NORMAL", "AM", "PM", "SEAMLESS"] },
17            "duration": { "type": "string", "enum": ["DAY", "GOOD_TILL_CANCEL", "FILL_OR_KILL"] },
18            "orderType": {
19                "type": "string",
20                "enum": [
21                    "MARKET", "LIMIT", "STOP", "STOP_LIMIT", "TRAILING_STOP", "CABINET",
22                    "NON_MARKETABLE", "MARKET_ON_CLOSE", "EXERCISE", "TRAILING_STOP_LIMIT",
23                    "NET_DEBIT", "NET_CREDIT", "NET_ZERO", "LIMIT_ON_CLOSE"
24                ]
25            },
26            "cancelTime": { "type": "string", "format": "date-time", "description": "ISO-8601 auto-cancel time" },
27            "complexOrderStrategyType": {
28                "type": "string",
29                "enum": enum_strings(ComplexOrderStrategyType::all_values())
30            },
31            "quantity": { "type": "number" },
32            "stopPrice": { "type": "number" },
33            "price": { "type": ["number", "string"], "description": "Limit/net debit/net credit price" },
34            "taxLotMethod": {
35                "type": "string",
36                "enum": ["FIFO", "LIFO", "HIGH_COST", "LOW_COST", "AVERAGE_COST", "SPECIFIC_LOT", "LOSS_HARVESTER"]
37            },
38            "specialInstruction": {
39                "type": "string",
40                "enum": ["ALL_OR_NONE", "DO_NOT_REDUCE", "ALL_OR_NONE_DO_NOT_REDUCE"]
41            },
42            "orderStrategyType": {
43                "type": "string",
44                "enum": ["SINGLE", "CANCEL", "RECALL", "PAIR", "FLATTEN", "TWO_DAY_SWAP", "BLAST_ALL", "OCO", "TRIGGER"]
45            },
46            "orderLegCollection": {
47                "type": "array",
48                "minItems": 1,
49                "items": { "$ref": "#/$defs/orderLeg" }
50            },
51            "childOrderStrategies": {
52                "type": "array",
53                "items": { "$ref": "#" },
54                "description": "Nested strategies for OCO/TRIGGER"
55            }
56        },
57        "$defs": {
58            "orderLeg": {
59                "type": "object",
60                "required": ["instruction", "quantity", "instrument"],
61                "properties": {
62                    "orderLegType": {
63                        "type": "string",
64                        "enum": ["EQUITY", "OPTION", "INDEX", "MUTUAL_FUND", "CASH_EQUIVALENT", "FIXED_INCOME", "CURRENCY", "COLLECTIVE_INVESTMENT"]
65                    },
66                    "legId": { "type": "integer" },
67                    "instruction": {
68                        "type": "string",
69                        "enum": [
70                            "BUY", "SELL", "BUY_TO_OPEN", "SELL_TO_CLOSE", "SELL_TO_OPEN",
71                            "BUY_TO_CLOSE", "SELL_SHORT", "BUY_TO_COVER"
72                        ]
73                    },
74                    "positionEffect": { "type": "string", "enum": ["OPENING", "CLOSING", "AUTOMATIC"] },
75                    "quantity": { "type": "number", "exclusiveMinimum": 0 },
76                    "instrument": {
77                        "type": "object",
78                        "required": ["symbol", "assetType"],
79                        "properties": {
80                            "symbol": { "type": "string" },
81                            "assetType": {
82                                "type": "string",
83                                "enum": ["EQUITY", "OPTION", "INDEX", "MUTUAL_FUND", "CASH_EQUIVALENT", "FIXED_INCOME", "CURRENCY", "COLLECTIVE_INVESTMENT"]
84                            }
85                        }
86                    }
87                }
88            }
89        },
90        "examples": [
91            {
92                "orderType": "LIMIT",
93                "session": "NORMAL",
94                "duration": "DAY",
95                "price": "100.50",
96                "orderStrategyType": "SINGLE",
97                "complexOrderStrategyType": "NONE",
98                "orderLegCollection": [{
99                    "instruction": "BUY",
100                    "quantity": 10,
101                    "instrument": { "symbol": "AAPL", "assetType": "EQUITY" }
102                }]
103            },
104            {
105                "orderType": "NET_DEBIT",
106                "session": "NORMAL",
107                "duration": "DAY",
108                "price": "0.50",
109                "orderStrategyType": "SINGLE",
110                "complexOrderStrategyType": "VERTICAL",
111                "orderLegCollection": [
112                    {
113                        "instruction": "BUY_TO_OPEN",
114                        "quantity": 1,
115                        "instrument": { "symbol": "AAPL  260620C00180000", "assetType": "OPTION" }
116                    },
117                    {
118                        "instruction": "SELL_TO_OPEN",
119                        "quantity": 1,
120                        "instrument": { "symbol": "AAPL  260620C00185000", "assetType": "OPTION" }
121                    }
122                ]
123            }
124        ]
125    })
126}
127
128pub fn order_schema_meta() -> Value {
129    json!({
130        "endpoint": "POST /accounts/{accountNumber}/orders",
131        "previewEndpoint": "POST /accounts/{accountNumber}/previewOrder",
132        "complexOrderStrategyTypes": enum_strings(ComplexOrderStrategyType::all_values()),
133        "notes": [
134            "Use complexOrderStrategyType for multi-leg spreads (VERTICAL, IRON_CONDOR, etc.)",
135            "Use cancelTime (ISO-8601) for day orders that should auto-cancel",
136            "Spreads typically use orderType NET_DEBIT or NET_CREDIT with top-level price",
137            "Always run `schwab orders preview` before `schwab orders place` for complex orders",
138            "Enable allow_complex_orders and allow_option_orders in safety.json for options/spreads"
139        ]
140    })
141}
142
143pub fn validate_order_shape(order: &Value) -> Result<()> {
144    let has_legs = order
145        .get("orderLegCollection")
146        .and_then(|v| v.as_array())
147        .is_some_and(|a| !a.is_empty());
148    let has_children = order
149        .get("childOrderStrategies")
150        .and_then(|v| v.as_array())
151        .is_some_and(|a| !a.is_empty());
152
153    if !has_legs && !has_children {
154        bail!("Order must include orderLegCollection or childOrderStrategies");
155    }
156
157    if has_legs {
158        validate_legs(order.get("orderLegCollection").unwrap())?;
159        order
160            .get("orderType")
161            .and_then(|v| v.as_str())
162            .context("Missing orderType on order with legs")?;
163    }
164
165    if let Some(children) = order.get("childOrderStrategies").and_then(|v| v.as_array()) {
166        for child in children {
167            validate_order_shape(child)?;
168        }
169    }
170
171    if let Some(strategy) = order
172        .get("complexOrderStrategyType")
173        .and_then(|v| v.as_str())
174    {
175        let valid: Vec<&str> = enum_strings(ComplexOrderStrategyType::all_values());
176        if !valid.contains(&strategy) {
177            bail!("Invalid complexOrderStrategyType `{strategy}`");
178        }
179    }
180
181    if let Some(cancel_time) = order.get("cancelTime").and_then(|v| v.as_str()) {
182        if cancel_time.is_empty() {
183            bail!("cancelTime must be a non-empty ISO-8601 date-time string");
184        }
185    }
186
187    if has_legs {
188        let order_type = order.get("orderType").and_then(|v| v.as_str()).unwrap_or("");
189        match order_type {
190            "LIMIT" | "STOP_LIMIT" | "NET_DEBIT" | "NET_CREDIT" | "LIMIT_ON_CLOSE" => {
191                if order.get("price").is_none() {
192                    bail!("orderType {order_type} requires top-level price");
193                }
194            }
195            "STOP" | "TRAILING_STOP" => {
196                if order.get("stopPrice").is_none() && order.get("stopPriceOffset").is_none() {
197                    bail!("orderType {order_type} requires stopPrice or stopPriceOffset");
198                }
199            }
200            _ => {}
201        }
202    }
203
204    Ok(())
205}
206
207fn validate_legs(legs_value: &Value) -> Result<()> {
208    let legs = legs_value
209        .as_array()
210        .filter(|a| !a.is_empty())
211        .context("orderLegCollection must be a non-empty array")?;
212
213    for (idx, leg) in legs.iter().enumerate() {
214        leg.get("instruction")
215            .and_then(|v| v.as_str())
216            .with_context(|| format!("orderLegCollection[{idx}].instruction is required"))?;
217        leg.get("quantity")
218            .and_then(parse_number)
219            .filter(|q| *q > 0.0)
220            .with_context(|| format!("orderLegCollection[{idx}].quantity must be positive"))?;
221        let instrument = leg
222            .get("instrument")
223            .with_context(|| format!("orderLegCollection[{idx}].instrument is required"))?;
224        instrument
225            .get("symbol")
226            .and_then(|v| v.as_str())
227            .filter(|s| !s.is_empty())
228            .with_context(|| format!("orderLegCollection[{idx}].instrument.symbol is required"))?;
229        instrument
230            .get("assetType")
231            .and_then(|v| v.as_str())
232            .with_context(|| format!("orderLegCollection[{idx}].instrument.assetType is required"))?;
233    }
234
235    Ok(())
236}
237
238pub fn order_examples() -> Value {
239    json!({
240        "equityMarketBuy": {
241            "description": "Buy 15 shares at market, day order",
242            "order": {
243                "orderType": "MARKET",
244                "session": "NORMAL",
245                "duration": "DAY",
246                "orderStrategyType": "SINGLE",
247                "orderLegCollection": [{
248                    "instruction": "BUY",
249                    "quantity": 15,
250                    "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
251                }]
252            }
253        },
254        "singleOptionLimit": {
255            "description": "Buy to open 10 call contracts at limit",
256            "order": {
257                "complexOrderStrategyType": "NONE",
258                "orderType": "LIMIT",
259                "session": "NORMAL",
260                "price": "6.45",
261                "duration": "DAY",
262                "orderStrategyType": "SINGLE",
263                "orderLegCollection": [{
264                    "instruction": "BUY_TO_OPEN",
265                    "quantity": 10,
266                    "instrument": { "symbol": "XYZ   240315C00500000", "assetType": "OPTION" }
267                }]
268            }
269        },
270        "verticalPutSpread": {
271            "description": "Vertical put spread — NET_DEBIT (from Schwab docs)",
272            "order": {
273                "orderType": "NET_DEBIT",
274                "session": "NORMAL",
275                "price": "0.10",
276                "duration": "DAY",
277                "orderStrategyType": "SINGLE",
278                "orderLegCollection": [
279                    {
280                        "instruction": "BUY_TO_OPEN",
281                        "quantity": 2,
282                        "instrument": { "symbol": "XYZ   240315P00045000", "assetType": "OPTION" }
283                    },
284                    {
285                        "instruction": "SELL_TO_OPEN",
286                        "quantity": 2,
287                        "instrument": { "symbol": "XYZ   240315P00043000", "assetType": "OPTION" }
288                    }
289                ]
290            }
291        },
292        "triggerSequence": {
293            "description": "Buy limit triggers sell limit (1st Trigger Sequence)",
294            "order": {
295                "orderType": "LIMIT",
296                "session": "NORMAL",
297                "price": "34.97",
298                "duration": "DAY",
299                "orderStrategyType": "TRIGGER",
300                "orderLegCollection": [{
301                    "instruction": "BUY",
302                    "quantity": 10,
303                    "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
304                }],
305                "childOrderStrategies": [{
306                    "orderType": "LIMIT",
307                    "session": "NORMAL",
308                    "price": "42.03",
309                    "duration": "DAY",
310                    "orderStrategyType": "SINGLE",
311                    "orderLegCollection": [{
312                        "instruction": "SELL",
313                        "quantity": 10,
314                        "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
315                    }]
316                }]
317            }
318        },
319        "oco": {
320            "description": "One-Cancels-Another — limit sell + stop limit sell",
321            "order": {
322                "orderStrategyType": "OCO",
323                "childOrderStrategies": [
324                    {
325                        "orderType": "LIMIT",
326                        "session": "NORMAL",
327                        "price": "45.97",
328                        "duration": "DAY",
329                        "orderStrategyType": "SINGLE",
330                        "orderLegCollection": [{
331                            "instruction": "SELL",
332                            "quantity": 2,
333                            "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
334                        }]
335                    },
336                    {
337                        "orderType": "STOP_LIMIT",
338                        "session": "NORMAL",
339                        "price": "37.00",
340                        "stopPrice": "37.03",
341                        "duration": "DAY",
342                        "orderStrategyType": "SINGLE",
343                        "orderLegCollection": [{
344                            "instruction": "SELL",
345                            "quantity": 2,
346                            "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
347                        }]
348                    }
349                ]
350            }
351        },
352        "trailingStop": {
353            "description": "Trailing stop sell with $10 offset",
354            "order": {
355                "complexOrderStrategyType": "NONE",
356                "orderType": "TRAILING_STOP",
357                "session": "NORMAL",
358                "stopPriceLinkBasis": "BID",
359                "stopPriceLinkType": "VALUE",
360                "stopPriceOffset": 10,
361                "duration": "DAY",
362                "orderStrategyType": "SINGLE",
363                "orderLegCollection": [{
364                    "instruction": "SELL",
365                    "quantity": 10,
366                    "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
367                }]
368            }
369        },
370        "optionSymbology": {
371            "format": "UNDERLYING(6 chars) | YYMMDD | C/P | Strike(8 digits, 5+3)",
372            "examples": [
373                { "symbol": "XYZ   240315C00500000", "meaning": "XYZ $50 Call exp 2024-03-15" },
374                { "symbol": "XYZ   240315P00045000", "meaning": "XYZ $45 Put exp 2024-03-15" }
375            ]
376        }
377    })
378}
379
380fn enum_strings(values: &[ComplexOrderStrategyType]) -> Vec<&'static str> {
381    values.iter().map(|v| v.as_str()).collect()
382}
383
384fn parse_number(v: &Value) -> Option<f64> {
385    v.as_f64()
386        .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
387        .or_else(|| v.as_i64().map(|n| n as f64))
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use serde_json::json;
394
395    #[test]
396    fn schema_includes_complex_strategies() {
397        let schema = order_request_schema();
398        let strategies = schema["properties"]["complexOrderStrategyType"]["enum"]
399            .as_array()
400            .unwrap();
401        assert!(strategies.iter().any(|v| v == "VERTICAL"));
402        assert!(strategies.iter().any(|v| v == "IRON_CONDOR"));
403    }
404
405    #[test]
406    fn validates_vertical_spread_shape() {
407        let order = json!({
408            "orderType": "NET_DEBIT",
409            "price": "0.50",
410            "complexOrderStrategyType": "VERTICAL",
411            "orderLegCollection": [
412                {
413                    "instruction": "BUY_TO_OPEN",
414                    "quantity": 1,
415                    "instrument": { "symbol": "AAPL  260620C00180000", "assetType": "OPTION" }
416                },
417                {
418                    "instruction": "SELL_TO_OPEN",
419                    "quantity": 1,
420                    "instrument": { "symbol": "AAPL  260620C00185000", "assetType": "OPTION" }
421                }
422            ]
423        });
424        validate_order_shape(&order).unwrap();
425    }
426
427    #[test]
428    fn validates_oco_without_top_level_legs() {
429        let order = order_examples()["oco"]["order"].clone();
430        validate_order_shape(&order).unwrap();
431    }
432}