1use std::fs;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(default)]
12pub struct SafetyConfig {
13 pub version: u32,
14 pub agent_rules: Vec<String>,
16 pub limits: SafetyLimits,
17 pub require_preview_before_place: bool,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(default)]
23pub struct SafetyLimits {
24 pub max_trade_value_usd: f64,
26 pub max_shares_per_order: f64,
28 pub allow_market_orders: bool,
30 pub allow_limit_orders: bool,
32 pub allow_short_sales: bool,
34 pub allow_option_orders: bool,
36 pub allow_complex_orders: bool,
38 pub allow_conditional_orders: bool,
40 pub max_legs_per_order: u32,
42 pub allowed_complex_strategies: Vec<String>,
44 pub allowed_instructions: Vec<String>,
46 pub allowed_order_types: Vec<String>,
48 pub allowed_symbols: Vec<String>,
50 pub blocked_symbols: Vec<String>,
52 pub max_trade_pct_of_equity: f64,
54}
55
56impl Default for SafetyConfig {
57 fn default() -> Self {
58 Self {
59 version: 1,
60 agent_rules: vec![
61 "Always run `schwab portfolio summary --json` before rebalancing.".into(),
62 "Preview every order before placement unless dry-run.".into(),
63 "Respect safety.json limits; the CLI will reject orders that exceed them.".into(),
64 "Never enable --trust unless the user explicitly requests autonomous trading."
65 .into(),
66 ],
67 limits: SafetyLimits::default(),
68 require_preview_before_place: true,
69 }
70 }
71}
72
73impl Default for SafetyLimits {
74 fn default() -> Self {
75 Self {
76 max_trade_value_usd: 5_000.0,
77 max_shares_per_order: 100.0,
78 allow_market_orders: true,
79 allow_limit_orders: true,
80 allow_short_sales: false,
81 allow_option_orders: false,
82 allow_complex_orders: false,
83 allow_conditional_orders: false,
84 max_legs_per_order: 4,
85 allowed_complex_strategies: vec![],
86 allowed_instructions: vec!["BUY".into(), "SELL".into()],
87 allowed_order_types: vec!["MARKET".into(), "LIMIT".into()],
88 allowed_symbols: vec![],
89 blocked_symbols: vec![],
90 max_trade_pct_of_equity: 10.0,
91 }
92 }
93}
94
95impl SafetyConfig {
96 pub fn load() -> Result<Self> {
97 let path = config_path();
98 if path.is_file() {
99 let raw = fs::read_to_string(&path)
100 .with_context(|| format!("Failed to read safety config at {}", path.display()))?;
101 let mut cfg: SafetyConfig = serde_json::from_str(&raw)
102 .with_context(|| format!("Invalid safety.json at {}", path.display()))?;
103 cfg.normalize();
104 Ok(cfg)
105 } else {
106 Ok(Self::default())
107 }
108 }
109
110 pub fn init_at_default_path() -> Result<PathBuf> {
111 let path = config_path();
112 if path.is_file() {
113 return Ok(path);
114 }
115 if let Some(parent) = path.parent() {
116 fs::create_dir_all(parent)
117 .with_context(|| format!("Failed to create config dir {}", parent.display()))?;
118 }
119 let cfg = Self::default();
120 let pretty = serde_json::to_string_pretty(&cfg)?;
121 fs::write(&path, pretty).with_context(|| format!("Failed to write {}", path.display()))?;
122 Ok(path)
123 }
124
125 fn normalize(&mut self) {
126 self.limits.allowed_instructions = self
127 .limits
128 .allowed_instructions
129 .iter()
130 .map(|s| s.trim().to_uppercase())
131 .filter(|s| !s.is_empty())
132 .collect();
133 self.limits.allowed_order_types = self
134 .limits
135 .allowed_order_types
136 .iter()
137 .map(|s| s.trim().to_uppercase())
138 .filter(|s| !s.is_empty())
139 .collect();
140 self.limits.allowed_symbols = self
141 .limits
142 .allowed_symbols
143 .iter()
144 .map(|s| s.trim().to_uppercase())
145 .filter(|s| !s.is_empty())
146 .collect();
147 self.limits.allowed_complex_strategies = self
148 .limits
149 .allowed_complex_strategies
150 .iter()
151 .map(|s| s.trim().to_uppercase())
152 .filter(|s| !s.is_empty())
153 .collect();
154 self.limits.blocked_symbols = self
155 .limits
156 .blocked_symbols
157 .iter()
158 .map(|s| s.trim().to_uppercase())
159 .filter(|s| !s.is_empty())
160 .collect();
161 }
162}
163
164pub fn config_path() -> PathBuf {
165 std::env::var("SCHWAB_SAFETY_CONFIG")
166 .map(PathBuf::from)
167 .unwrap_or_else(|_| default_config_path())
168}
169
170fn default_config_path() -> PathBuf {
171 directories::ProjectDirs::from("", "", "schwabinvestbot")
172 .map(|dirs| dirs.config_dir().join("safety.json"))
173 .unwrap_or_else(|| PathBuf::from(".schwabinvestbot/safety.json"))
174}
175
176#[derive(Debug, Clone, PartialEq)]
178pub struct ParsedOrderLeg {
179 pub instruction: String,
180 pub symbol: String,
181 pub asset_type: String,
182 pub quantity: f64,
183}
184
185#[derive(Debug, Clone, PartialEq)]
186pub struct ParsedOrder {
187 pub order_type: String,
188 pub order_strategy_type: Option<String>,
189 pub complex_order_strategy_type: Option<String>,
190 pub limit_price: Option<f64>,
191 pub legs: Vec<ParsedOrderLeg>,
192}
193
194pub fn parse_order(order: &Value) -> Result<ParsedOrder> {
195 let legs_raw = order
196 .get("orderLegCollection")
197 .and_then(|v| v.as_array())
198 .filter(|a| !a.is_empty())
199 .context("orderLegCollection must contain at least one leg")?;
200
201 let mut legs = Vec::with_capacity(legs_raw.len());
202 for (idx, leg) in legs_raw.iter().enumerate() {
203 let instruction = leg
204 .get("instruction")
205 .and_then(|v| v.as_str())
206 .with_context(|| format!("Missing orderLegCollection[{idx}].instruction"))?
207 .to_uppercase();
208 let quantity = leg
209 .get("quantity")
210 .and_then(parse_number)
211 .with_context(|| format!("Missing or invalid orderLegCollection[{idx}].quantity"))?;
212 let instrument = leg
213 .get("instrument")
214 .with_context(|| format!("Missing orderLegCollection[{idx}].instrument"))?;
215 let symbol = instrument
216 .get("symbol")
217 .and_then(|v| v.as_str())
218 .with_context(|| format!("Missing orderLegCollection[{idx}].instrument.symbol"))?
219 .to_uppercase();
220 let asset_type = instrument
221 .get("assetType")
222 .and_then(|v| v.as_str())
223 .unwrap_or("EQUITY")
224 .to_uppercase();
225
226 legs.push(ParsedOrderLeg {
227 instruction,
228 symbol,
229 asset_type,
230 quantity,
231 });
232 }
233
234 let order_type = order
235 .get("orderType")
236 .and_then(|v| v.as_str())
237 .context("Missing orderType")?
238 .to_uppercase();
239
240 let order_strategy_type = order
241 .get("orderStrategyType")
242 .and_then(|v| v.as_str())
243 .map(str::to_uppercase);
244
245 let complex_order_strategy_type = order
246 .get("complexOrderStrategyType")
247 .and_then(|v| v.as_str())
248 .map(str::to_uppercase);
249
250 let limit_price = order.get("price").and_then(parse_number);
251
252 Ok(ParsedOrder {
253 order_type,
254 order_strategy_type,
255 complex_order_strategy_type,
256 limit_price,
257 legs,
258 })
259}
260
261fn is_complex_order(parsed: &ParsedOrder) -> bool {
262 if parsed.legs.len() > 1 {
263 return true;
264 }
265 parsed
266 .complex_order_strategy_type
267 .as_deref()
268 .is_some_and(|s| s != "NONE")
269}
270
271fn underlying_symbol(symbol: &str, asset_type: &str) -> String {
272 if asset_type == "OPTION" {
273 symbol
274 .split_whitespace()
275 .next()
276 .unwrap_or(symbol)
277 .to_string()
278 } else {
279 symbol.to_string()
280 }
281}
282
283pub fn estimate_notional(parsed: &ParsedOrder, preview: Option<&Value>) -> Option<f64> {
284 let net_types = ["NET_DEBIT", "NET_CREDIT", "NET_ZERO"];
285 if net_types.contains(&parsed.order_type.as_str()) {
286 if let Some(price) = parsed.limit_price {
287 let contracts = parsed
288 .legs
289 .iter()
290 .map(|leg| leg.quantity)
291 .fold(0.0_f64, f64::max);
292 return Some(price.abs() * contracts * 100.0);
293 }
294 }
295
296 if let Some(price) = parsed.limit_price {
297 let total_qty: f64 = parsed.legs.iter().map(|leg| leg.quantity).sum();
298 return Some(total_qty * price);
299 }
300
301 if parsed.order_type == "MARKET" && parsed.legs.len() == 1 {
302 return None;
303 }
304
305 if let Some(preview) = preview {
306 for key in [
307 "orderValue",
308 "estimatedTotalAmount",
309 "totalOrderAmount",
310 "netAmount",
311 "totalCost",
312 ] {
313 if let Some(v) = find_f64_by_key(preview, key) {
314 return Some(v.abs());
315 }
316 }
317 if let Some(strategy) = preview.get("orderStrategy") {
318 for key in ["orderValue", "estimatedTotalAmount", "totalOrderAmount"] {
319 if let Some(v) = find_f64_by_key(strategy, key) {
320 return Some(v.abs());
321 }
322 }
323 }
324 }
325
326 None
327}
328
329pub fn validate_order(
330 config: &SafetyConfig,
331 order: &Value,
332 preview: Option<&Value>,
333 account_equity: Option<f64>,
334) -> Result<()> {
335 validate_order_tree(config, order, preview, account_equity)
336}
337
338fn validate_order_tree(
339 config: &SafetyConfig,
340 order: &Value,
341 preview: Option<&Value>,
342 account_equity: Option<f64>,
343) -> Result<()> {
344 let has_legs = order
345 .get("orderLegCollection")
346 .and_then(|v| v.as_array())
347 .is_some_and(|a| !a.is_empty());
348
349 if has_legs {
350 let parsed = parse_order(order)?;
351 validate_parsed_order(config, &parsed, preview, account_equity)?;
352 }
353
354 let strategy = order
355 .get("orderStrategyType")
356 .and_then(|v| v.as_str())
357 .unwrap_or("SINGLE")
358 .to_uppercase();
359
360 if matches!(strategy.as_str(), "OCO" | "TRIGGER" | "BLAST_ALL")
361 && !config.limits.allow_conditional_orders
362 {
363 anyhow::bail!(
364 "orderStrategyType `{strategy}` requires allow_conditional_orders=true in safety.json"
365 );
366 }
367
368 if let Some(children) = order.get("childOrderStrategies").and_then(|v| v.as_array()) {
369 for child in children {
370 validate_order_tree(config, child, preview, account_equity)?;
371 }
372 }
373
374 Ok(())
375}
376
377fn validate_parsed_order(
378 config: &SafetyConfig,
379 parsed: &ParsedOrder,
380 preview: Option<&Value>,
381 account_equity: Option<f64>,
382) -> Result<()> {
383 let limits = &config.limits;
384
385 if parsed.legs.len() as u32 > limits.max_legs_per_order {
386 anyhow::bail!(
387 "Order has {} legs; max_legs_per_order is {}",
388 parsed.legs.len(),
389 limits.max_legs_per_order
390 );
391 }
392
393 if is_complex_order(parsed) && !limits.allow_complex_orders {
394 anyhow::bail!("Multi-leg/complex orders require allow_complex_orders=true in safety.json");
395 }
396
397 if let Some(strategy) = &parsed.complex_order_strategy_type {
398 if !limits.allowed_complex_strategies.is_empty()
399 && !limits.allowed_complex_strategies.contains(strategy)
400 {
401 anyhow::bail!(
402 "complexOrderStrategyType `{strategy}` is not in allowed_complex_strategies"
403 );
404 }
405 }
406
407 if !limits.allowed_order_types.contains(&parsed.order_type) {
408 anyhow::bail!(
409 "Order type `{}` is not allowed (allowed: {:?})",
410 parsed.order_type,
411 limits.allowed_order_types
412 );
413 }
414
415 match parsed.order_type.as_str() {
416 "MARKET" if !limits.allow_market_orders => {
417 anyhow::bail!("MARKET orders are disabled in safety.json");
418 }
419 "LIMIT" | "NET_DEBIT" | "NET_CREDIT" | "LIMIT_ON_CLOSE" if !limits.allow_limit_orders => {
420 anyhow::bail!("Limit-style orders are disabled in safety.json");
421 }
422 _ => {}
423 }
424
425 for leg in &parsed.legs {
426 if !limits.allowed_instructions.contains(&leg.instruction) {
427 anyhow::bail!(
428 "Instruction `{}` is not allowed (allowed: {:?})",
429 leg.instruction,
430 limits.allowed_instructions
431 );
432 }
433
434 if leg.instruction.contains("SHORT") && !limits.allow_short_sales {
435 anyhow::bail!("Short sales are disabled in safety.json");
436 }
437
438 if leg.asset_type != "EQUITY" && !limits.allow_option_orders {
439 anyhow::bail!(
440 "Asset type `{}` is not allowed (allow_option_orders=false)",
441 leg.asset_type
442 );
443 }
444
445 let check_symbol = underlying_symbol(&leg.symbol, &leg.asset_type);
446 if limits.blocked_symbols.contains(&check_symbol) {
447 anyhow::bail!("Symbol `{check_symbol}` is blocked in safety.json");
448 }
449
450 if !limits.allowed_symbols.is_empty() && !limits.allowed_symbols.contains(&check_symbol) {
451 anyhow::bail!("Symbol `{check_symbol}` is not in the allowed_symbols whitelist");
452 }
453
454 if leg.quantity > limits.max_shares_per_order {
455 anyhow::bail!(
456 "Leg quantity {} exceeds max_shares_per_order ({})",
457 leg.quantity,
458 limits.max_shares_per_order
459 );
460 }
461 }
462
463 if let Some(notional) = estimate_notional(parsed, preview) {
464 if notional > limits.max_trade_value_usd {
465 anyhow::bail!(
466 "Estimated order value ${notional:.2} exceeds max_trade_value_usd ({})",
467 limits.max_trade_value_usd
468 );
469 }
470
471 if limits.max_trade_pct_of_equity > 0.0 {
472 if let Some(equity) = account_equity {
473 if equity > 0.0 {
474 let pct = (notional / equity) * 100.0;
475 if pct > limits.max_trade_pct_of_equity {
476 anyhow::bail!(
477 "Order is {pct:.1}% of account equity; max_trade_pct_of_equity is {}",
478 limits.max_trade_pct_of_equity
479 );
480 }
481 }
482 }
483 }
484 } else if (limits.max_trade_value_usd > 0.0 || limits.max_trade_pct_of_equity > 0.0)
485 && parsed.order_type != "MARKET"
486 {
487 anyhow::bail!(
488 "Could not estimate order notional; provide a limit/net price or preview data"
489 );
490 }
491
492 Ok(())
493}
494
495fn parse_number(v: &Value) -> Option<f64> {
496 v.as_f64()
497 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
498 .or_else(|| v.as_i64().map(|n| n as f64))
499}
500
501fn find_f64_by_key(value: &Value, key: &str) -> Option<f64> {
502 match value {
503 Value::Object(map) => {
504 if let Some(v) = map.get(key).and_then(parse_number) {
505 return Some(v);
506 }
507 for child in map.values() {
508 if let Some(v) = find_f64_by_key(child, key) {
509 return Some(v);
510 }
511 }
512 None
513 }
514 Value::Array(items) => items.iter().find_map(|item| find_f64_by_key(item, key)),
515 _ => None,
516 }
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use serde_json::json;
523
524 #[test]
525 fn default_limits_are_conservative() {
526 let cfg = SafetyConfig::default();
527 assert_eq!(cfg.limits.max_trade_value_usd, 5_000.0);
528 assert!(!cfg.limits.allow_short_sales);
529 }
530
531 #[test]
532 fn validate_blocks_excess_shares() {
533 let cfg = SafetyConfig::default();
534 let order = json!({
535 "orderType": "MARKET",
536 "orderLegCollection": [{
537 "instruction": "BUY",
538 "quantity": 500,
539 "instrument": { "symbol": "AAPL", "assetType": "EQUITY" }
540 }]
541 });
542 let err = validate_order(&cfg, &order, None, None).unwrap_err();
543 assert!(err.to_string().contains("max_shares_per_order"));
544 }
545
546 #[test]
547 fn validate_blocks_symbol() {
548 let mut cfg = SafetyConfig::default();
549 cfg.limits.blocked_symbols = vec!["TSLA".into()];
550 let order = json!({
551 "orderType": "LIMIT",
552 "price": "100",
553 "orderLegCollection": [{
554 "instruction": "BUY",
555 "quantity": 1,
556 "instrument": { "symbol": "TSLA", "assetType": "EQUITY" }
557 }]
558 });
559 let err = validate_order(&cfg, &order, None, Some(100_000.0)).unwrap_err();
560 assert!(err.to_string().contains("blocked"));
561 }
562
563 #[test]
564 fn limit_notional_from_price() {
565 let cfg = SafetyConfig::default();
566 let order = json!({
567 "orderType": "LIMIT",
568 "price": "600",
569 "orderLegCollection": [{
570 "instruction": "BUY",
571 "quantity": 10,
572 "instrument": { "symbol": "AAPL", "assetType": "EQUITY" }
573 }]
574 });
575 let err = validate_order(&cfg, &order, None, None).unwrap_err();
576 assert!(err.to_string().contains("max_trade_value_usd"));
577 }
578
579 #[test]
580 fn blocks_multi_leg_without_complex_flag() {
581 let mut cfg = SafetyConfig::default();
582 cfg.limits.allow_option_orders = true;
583 cfg.limits.allowed_instructions = vec!["BUY_TO_OPEN".into(), "SELL_TO_OPEN".into()];
584 cfg.limits.allowed_order_types = vec!["NET_DEBIT".into()];
585 let order = json!({
586 "orderType": "NET_DEBIT",
587 "price": "0.10",
588 "orderLegCollection": [
589 {
590 "instruction": "BUY_TO_OPEN",
591 "quantity": 2,
592 "instrument": { "symbol": "XYZ 240315P00045000", "assetType": "OPTION" }
593 },
594 {
595 "instruction": "SELL_TO_OPEN",
596 "quantity": 2,
597 "instrument": { "symbol": "XYZ 240315P00043000", "assetType": "OPTION" }
598 }
599 ]
600 });
601 let err = validate_order(&cfg, &order, None, None).unwrap_err();
602 assert!(err.to_string().contains("allow_complex_orders"));
603 }
604
605 #[test]
606 fn allows_vertical_spread_when_enabled() {
607 let mut cfg = SafetyConfig::default();
608 cfg.limits.allow_option_orders = true;
609 cfg.limits.allow_complex_orders = true;
610 cfg.limits.allowed_instructions = vec!["BUY_TO_OPEN".into(), "SELL_TO_OPEN".into()];
611 cfg.limits.allowed_order_types = vec!["NET_DEBIT".into()];
612 let order = json!({
613 "orderType": "NET_DEBIT",
614 "price": "0.10",
615 "orderLegCollection": [
616 {
617 "instruction": "BUY_TO_OPEN",
618 "quantity": 2,
619 "instrument": { "symbol": "XYZ 240315P00045000", "assetType": "OPTION" }
620 },
621 {
622 "instruction": "SELL_TO_OPEN",
623 "quantity": 2,
624 "instrument": { "symbol": "XYZ 240315P00043000", "assetType": "OPTION" }
625 }
626 ]
627 });
628 validate_order(&cfg, &order, None, None).unwrap();
629 }
630}