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
189            .get("orderType")
190            .and_then(|v| v.as_str())
191            .unwrap_or("");
192        match order_type {
193            "LIMIT" | "STOP_LIMIT" | "NET_DEBIT" | "NET_CREDIT" | "LIMIT_ON_CLOSE" => {
194                if order.get("price").is_none() {
195                    bail!("orderType {order_type} requires top-level price");
196                }
197            }
198            "STOP" | "TRAILING_STOP"
199                if order.get("stopPrice").is_none() && order.get("stopPriceOffset").is_none() =>
200            {
201                bail!("orderType {order_type} requires stopPrice or stopPriceOffset");
202            }
203            _ => {}
204        }
205    }
206
207    Ok(())
208}
209
210fn validate_legs(legs_value: &Value) -> Result<()> {
211    let legs = legs_value
212        .as_array()
213        .filter(|a| !a.is_empty())
214        .context("orderLegCollection must be a non-empty array")?;
215
216    for (idx, leg) in legs.iter().enumerate() {
217        leg.get("instruction")
218            .and_then(|v| v.as_str())
219            .with_context(|| format!("orderLegCollection[{idx}].instruction is required"))?;
220        leg.get("quantity")
221            .and_then(parse_number)
222            .filter(|q| *q > 0.0)
223            .with_context(|| format!("orderLegCollection[{idx}].quantity must be positive"))?;
224        let instrument = leg
225            .get("instrument")
226            .with_context(|| format!("orderLegCollection[{idx}].instrument is required"))?;
227        instrument
228            .get("symbol")
229            .and_then(|v| v.as_str())
230            .filter(|s| !s.is_empty())
231            .with_context(|| format!("orderLegCollection[{idx}].instrument.symbol is required"))?;
232        instrument
233            .get("assetType")
234            .and_then(|v| v.as_str())
235            .with_context(|| {
236                format!("orderLegCollection[{idx}].instrument.assetType is required")
237            })?;
238    }
239
240    Ok(())
241}
242
243pub fn order_examples() -> Value {
244    json!({
245        "equityMarketBuy": {
246            "description": "Buy 15 shares at market, day order",
247            "order": {
248                "orderType": "MARKET",
249                "session": "NORMAL",
250                "duration": "DAY",
251                "orderStrategyType": "SINGLE",
252                "orderLegCollection": [{
253                    "instruction": "BUY",
254                    "quantity": 15,
255                    "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
256                }]
257            }
258        },
259        "singleOptionLimit": {
260            "description": "Buy to open 10 call contracts at limit",
261            "order": {
262                "complexOrderStrategyType": "NONE",
263                "orderType": "LIMIT",
264                "session": "NORMAL",
265                "price": "6.45",
266                "duration": "DAY",
267                "orderStrategyType": "SINGLE",
268                "orderLegCollection": [{
269                    "instruction": "BUY_TO_OPEN",
270                    "quantity": 10,
271                    "instrument": { "symbol": "XYZ   240315C00500000", "assetType": "OPTION" }
272                }]
273            }
274        },
275        "verticalPutSpread": {
276            "description": "Vertical put spread — NET_DEBIT (from Schwab docs)",
277            "order": {
278                "orderType": "NET_DEBIT",
279                "session": "NORMAL",
280                "price": "0.10",
281                "duration": "DAY",
282                "orderStrategyType": "SINGLE",
283                "orderLegCollection": [
284                    {
285                        "instruction": "BUY_TO_OPEN",
286                        "quantity": 2,
287                        "instrument": { "symbol": "XYZ   240315P00045000", "assetType": "OPTION" }
288                    },
289                    {
290                        "instruction": "SELL_TO_OPEN",
291                        "quantity": 2,
292                        "instrument": { "symbol": "XYZ   240315P00043000", "assetType": "OPTION" }
293                    }
294                ]
295            }
296        },
297        "triggerSequence": {
298            "description": "Buy limit triggers sell limit (1st Trigger Sequence)",
299            "order": {
300                "orderType": "LIMIT",
301                "session": "NORMAL",
302                "price": "34.97",
303                "duration": "DAY",
304                "orderStrategyType": "TRIGGER",
305                "orderLegCollection": [{
306                    "instruction": "BUY",
307                    "quantity": 10,
308                    "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
309                }],
310                "childOrderStrategies": [{
311                    "orderType": "LIMIT",
312                    "session": "NORMAL",
313                    "price": "42.03",
314                    "duration": "DAY",
315                    "orderStrategyType": "SINGLE",
316                    "orderLegCollection": [{
317                        "instruction": "SELL",
318                        "quantity": 10,
319                        "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
320                    }]
321                }]
322            }
323        },
324        "oco": {
325            "description": "One-Cancels-Another — limit sell + stop limit sell",
326            "order": {
327                "orderStrategyType": "OCO",
328                "childOrderStrategies": [
329                    {
330                        "orderType": "LIMIT",
331                        "session": "NORMAL",
332                        "price": "45.97",
333                        "duration": "DAY",
334                        "orderStrategyType": "SINGLE",
335                        "orderLegCollection": [{
336                            "instruction": "SELL",
337                            "quantity": 2,
338                            "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
339                        }]
340                    },
341                    {
342                        "orderType": "STOP_LIMIT",
343                        "session": "NORMAL",
344                        "price": "37.00",
345                        "stopPrice": "37.03",
346                        "duration": "DAY",
347                        "orderStrategyType": "SINGLE",
348                        "orderLegCollection": [{
349                            "instruction": "SELL",
350                            "quantity": 2,
351                            "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
352                        }]
353                    }
354                ]
355            }
356        },
357        "trailingStop": {
358            "description": "Trailing stop sell with $10 offset",
359            "order": {
360                "complexOrderStrategyType": "NONE",
361                "orderType": "TRAILING_STOP",
362                "session": "NORMAL",
363                "stopPriceLinkBasis": "BID",
364                "stopPriceLinkType": "VALUE",
365                "stopPriceOffset": 10,
366                "duration": "DAY",
367                "orderStrategyType": "SINGLE",
368                "orderLegCollection": [{
369                    "instruction": "SELL",
370                    "quantity": 10,
371                    "instrument": { "symbol": "XYZ", "assetType": "EQUITY" }
372                }]
373            }
374        },
375        "optionSymbology": {
376            "format": "UNDERLYING(6 chars) | YYMMDD | C/P | Strike(8 digits, 5+3)",
377            "examples": [
378                { "symbol": "XYZ   240315C00500000", "meaning": "XYZ $50 Call exp 2024-03-15" },
379                { "symbol": "XYZ   240315P00045000", "meaning": "XYZ $45 Put exp 2024-03-15" }
380            ]
381        }
382    })
383}
384
385fn enum_strings(values: &[ComplexOrderStrategyType]) -> Vec<&'static str> {
386    values.iter().map(|v| v.as_str()).collect()
387}
388
389fn parse_number(v: &Value) -> Option<f64> {
390    v.as_f64()
391        .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
392        .or_else(|| v.as_i64().map(|n| n as f64))
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use serde_json::json;
399
400    #[test]
401    fn schema_includes_complex_strategies() {
402        let schema = order_request_schema();
403        let strategies = schema["properties"]["complexOrderStrategyType"]["enum"]
404            .as_array()
405            .unwrap();
406        assert!(strategies.iter().any(|v| v == "VERTICAL"));
407        assert!(strategies.iter().any(|v| v == "IRON_CONDOR"));
408    }
409
410    #[test]
411    fn validates_vertical_spread_shape() {
412        let order = json!({
413            "orderType": "NET_DEBIT",
414            "price": "0.50",
415            "complexOrderStrategyType": "VERTICAL",
416            "orderLegCollection": [
417                {
418                    "instruction": "BUY_TO_OPEN",
419                    "quantity": 1,
420                    "instrument": { "symbol": "AAPL  260620C00180000", "assetType": "OPTION" }
421                },
422                {
423                    "instruction": "SELL_TO_OPEN",
424                    "quantity": 1,
425                    "instrument": { "symbol": "AAPL  260620C00185000", "assetType": "OPTION" }
426                }
427            ]
428        });
429        validate_order_shape(&order).unwrap();
430    }
431
432    #[test]
433    fn validates_oco_without_top_level_legs() {
434        let order = order_examples()["oco"]["order"].clone();
435        validate_order_shape(&order).unwrap();
436    }
437}