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