1use 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}