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.".into(),
65 ],
66 limits: SafetyLimits::default(),
67 require_preview_before_place: true,
68 }
69 }
70}
71
72impl Default for SafetyLimits {
73 fn default() -> Self {
74 Self {
75 max_trade_value_usd: 5_000.0,
76 max_shares_per_order: 100.0,
77 allow_market_orders: true,
78 allow_limit_orders: true,
79 allow_short_sales: false,
80 allow_option_orders: false,
81 allow_complex_orders: false,
82 allow_conditional_orders: false,
83 max_legs_per_order: 4,
84 allowed_complex_strategies: vec![],
85 allowed_instructions: vec!["BUY".into(), "SELL".into()],
86 allowed_order_types: vec!["MARKET".into(), "LIMIT".into()],
87 allowed_symbols: vec![],
88 blocked_symbols: vec![],
89 max_trade_pct_of_equity: 10.0,
90 }
91 }
92}
93
94impl SafetyConfig {
95 pub fn load() -> Result<Self> {
96 let path = config_path();
97 if path.is_file() {
98 let raw = fs::read_to_string(&path)
99 .with_context(|| format!("Failed to read safety config at {}", path.display()))?;
100 let mut cfg: SafetyConfig = serde_json::from_str(&raw)
101 .with_context(|| format!("Invalid safety.json at {}", path.display()))?;
102 cfg.normalize();
103 Ok(cfg)
104 } else {
105 Ok(Self::default())
106 }
107 }
108
109 pub fn init_at_default_path() -> Result<PathBuf> {
110 let path = config_path();
111 if path.is_file() {
112 return Ok(path);
113 }
114 if let Some(parent) = path.parent() {
115 fs::create_dir_all(parent)
116 .with_context(|| format!("Failed to create config dir {}", parent.display()))?;
117 }
118 let cfg = Self::default();
119 let pretty = serde_json::to_string_pretty(&cfg)?;
120 fs::write(&path, pretty)
121 .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.split_whitespace().next().unwrap_or(symbol).to_string()
274 } else {
275 symbol.to_string()
276 }
277}
278
279pub fn estimate_notional(parsed: &ParsedOrder, preview: Option<&Value>) -> Option<f64> {
280 let net_types = ["NET_DEBIT", "NET_CREDIT", "NET_ZERO"];
281 if net_types.contains(&parsed.order_type.as_str()) {
282 if let Some(price) = parsed.limit_price {
283 let contracts = parsed
284 .legs
285 .iter()
286 .map(|leg| leg.quantity)
287 .fold(0.0_f64, f64::max);
288 return Some(price.abs() * contracts * 100.0);
289 }
290 }
291
292 if let Some(price) = parsed.limit_price {
293 let total_qty: f64 = parsed.legs.iter().map(|leg| leg.quantity).sum();
294 return Some(total_qty * price);
295 }
296
297 if parsed.order_type == "MARKET" && parsed.legs.len() == 1 {
298 return None;
299 }
300
301 if let Some(preview) = preview {
302 for key in [
303 "orderValue",
304 "estimatedTotalAmount",
305 "totalOrderAmount",
306 "netAmount",
307 "totalCost",
308 ] {
309 if let Some(v) = find_f64_by_key(preview, key) {
310 return Some(v.abs());
311 }
312 }
313 if let Some(strategy) = preview.get("orderStrategy") {
314 for key in ["orderValue", "estimatedTotalAmount", "totalOrderAmount"] {
315 if let Some(v) = find_f64_by_key(strategy, key) {
316 return Some(v.abs());
317 }
318 }
319 }
320 }
321
322 None
323}
324
325pub fn validate_order(
326 config: &SafetyConfig,
327 order: &Value,
328 preview: Option<&Value>,
329 account_equity: Option<f64>,
330) -> Result<()> {
331 validate_order_tree(config, order, preview, account_equity)
332}
333
334fn validate_order_tree(
335 config: &SafetyConfig,
336 order: &Value,
337 preview: Option<&Value>,
338 account_equity: Option<f64>,
339) -> Result<()> {
340 let has_legs = order
341 .get("orderLegCollection")
342 .and_then(|v| v.as_array())
343 .is_some_and(|a| !a.is_empty());
344
345 if has_legs {
346 let parsed = parse_order(order)?;
347 validate_parsed_order(config, &parsed, preview, account_equity)?;
348 }
349
350 let strategy = order
351 .get("orderStrategyType")
352 .and_then(|v| v.as_str())
353 .unwrap_or("SINGLE")
354 .to_uppercase();
355
356 if matches!(strategy.as_str(), "OCO" | "TRIGGER" | "BLAST_ALL")
357 && !config.limits.allow_conditional_orders
358 {
359 anyhow::bail!(
360 "orderStrategyType `{strategy}` requires allow_conditional_orders=true in safety.json"
361 );
362 }
363
364 if let Some(children) = order.get("childOrderStrategies").and_then(|v| v.as_array()) {
365 for child in children {
366 validate_order_tree(config, child, preview, account_equity)?;
367 }
368 }
369
370 Ok(())
371}
372
373fn validate_parsed_order(
374 config: &SafetyConfig,
375 parsed: &ParsedOrder,
376 preview: Option<&Value>,
377 account_equity: Option<f64>,
378) -> Result<()> {
379 let limits = &config.limits;
380
381 if parsed.legs.len() as u32 > limits.max_legs_per_order {
382 anyhow::bail!(
383 "Order has {} legs; max_legs_per_order is {}",
384 parsed.legs.len(),
385 limits.max_legs_per_order
386 );
387 }
388
389 if is_complex_order(parsed) && !limits.allow_complex_orders {
390 anyhow::bail!("Multi-leg/complex orders require allow_complex_orders=true in safety.json");
391 }
392
393 if let Some(strategy) = &parsed.complex_order_strategy_type {
394 if !limits.allowed_complex_strategies.is_empty()
395 && !limits.allowed_complex_strategies.contains(strategy)
396 {
397 anyhow::bail!(
398 "complexOrderStrategyType `{strategy}` is not in allowed_complex_strategies"
399 );
400 }
401 }
402
403 if !limits.allowed_order_types.contains(&parsed.order_type) {
404 anyhow::bail!(
405 "Order type `{}` is not allowed (allowed: {:?})",
406 parsed.order_type,
407 limits.allowed_order_types
408 );
409 }
410
411 match parsed.order_type.as_str() {
412 "MARKET" if !limits.allow_market_orders => {
413 anyhow::bail!("MARKET orders are disabled in safety.json");
414 }
415 "LIMIT" | "NET_DEBIT" | "NET_CREDIT" | "LIMIT_ON_CLOSE" if !limits.allow_limit_orders => {
416 anyhow::bail!("Limit-style orders are disabled in safety.json");
417 }
418 _ => {}
419 }
420
421 for leg in &parsed.legs {
422 if !limits.allowed_instructions.contains(&leg.instruction) {
423 anyhow::bail!(
424 "Instruction `{}` is not allowed (allowed: {:?})",
425 leg.instruction,
426 limits.allowed_instructions
427 );
428 }
429
430 if leg.instruction.contains("SHORT") && !limits.allow_short_sales {
431 anyhow::bail!("Short sales are disabled in safety.json");
432 }
433
434 if leg.asset_type != "EQUITY" && !limits.allow_option_orders {
435 anyhow::bail!(
436 "Asset type `{}` is not allowed (allow_option_orders=false)",
437 leg.asset_type
438 );
439 }
440
441 let check_symbol = underlying_symbol(&leg.symbol, &leg.asset_type);
442 if limits.blocked_symbols.contains(&check_symbol) {
443 anyhow::bail!("Symbol `{check_symbol}` is blocked in safety.json");
444 }
445
446 if !limits.allowed_symbols.is_empty() && !limits.allowed_symbols.contains(&check_symbol) {
447 anyhow::bail!(
448 "Symbol `{check_symbol}` is not in the allowed_symbols whitelist"
449 );
450 }
451
452 if leg.quantity > limits.max_shares_per_order {
453 anyhow::bail!(
454 "Leg quantity {} exceeds max_shares_per_order ({})",
455 leg.quantity,
456 limits.max_shares_per_order
457 );
458 }
459 }
460
461 if let Some(notional) = estimate_notional(parsed, preview) {
462 if notional > limits.max_trade_value_usd {
463 anyhow::bail!(
464 "Estimated order value ${notional:.2} exceeds max_trade_value_usd ({})",
465 limits.max_trade_value_usd
466 );
467 }
468
469 if limits.max_trade_pct_of_equity > 0.0 {
470 if let Some(equity) = account_equity {
471 if equity > 0.0 {
472 let pct = (notional / equity) * 100.0;
473 if pct > limits.max_trade_pct_of_equity {
474 anyhow::bail!(
475 "Order is {pct:.1}% of account equity; max_trade_pct_of_equity is {}",
476 limits.max_trade_pct_of_equity
477 );
478 }
479 }
480 }
481 }
482 } else if (limits.max_trade_value_usd > 0.0 || limits.max_trade_pct_of_equity > 0.0)
483 && parsed.order_type != "MARKET"
484 {
485 anyhow::bail!(
486 "Could not estimate order notional; provide a limit/net price or preview data"
487 );
488 }
489
490 Ok(())
491}
492
493fn parse_number(v: &Value) -> Option<f64> {
494 v.as_f64()
495 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
496 .or_else(|| v.as_i64().map(|n| n as f64))
497}
498
499fn find_f64_by_key(value: &Value, key: &str) -> Option<f64> {
500 match value {
501 Value::Object(map) => {
502 if let Some(v) = map.get(key).and_then(parse_number) {
503 return Some(v);
504 }
505 for child in map.values() {
506 if let Some(v) = find_f64_by_key(child, key) {
507 return Some(v);
508 }
509 }
510 None
511 }
512 Value::Array(items) => items.iter().find_map(|item| find_f64_by_key(item, key)),
513 _ => None,
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use serde_json::json;
521
522 #[test]
523 fn default_limits_are_conservative() {
524 let cfg = SafetyConfig::default();
525 assert_eq!(cfg.limits.max_trade_value_usd, 5_000.0);
526 assert!(!cfg.limits.allow_short_sales);
527 }
528
529 #[test]
530 fn validate_blocks_excess_shares() {
531 let cfg = SafetyConfig::default();
532 let order = json!({
533 "orderType": "MARKET",
534 "orderLegCollection": [{
535 "instruction": "BUY",
536 "quantity": 500,
537 "instrument": { "symbol": "AAPL", "assetType": "EQUITY" }
538 }]
539 });
540 let err = validate_order(&cfg, &order, None, None).unwrap_err();
541 assert!(err.to_string().contains("max_shares_per_order"));
542 }
543
544 #[test]
545 fn validate_blocks_symbol() {
546 let mut cfg = SafetyConfig::default();
547 cfg.limits.blocked_symbols = vec!["TSLA".into()];
548 let order = json!({
549 "orderType": "LIMIT",
550 "price": "100",
551 "orderLegCollection": [{
552 "instruction": "BUY",
553 "quantity": 1,
554 "instrument": { "symbol": "TSLA", "assetType": "EQUITY" }
555 }]
556 });
557 let err = validate_order(&cfg, &order, None, Some(100_000.0)).unwrap_err();
558 assert!(err.to_string().contains("blocked"));
559 }
560
561 #[test]
562 fn limit_notional_from_price() {
563 let cfg = SafetyConfig::default();
564 let order = json!({
565 "orderType": "LIMIT",
566 "price": "600",
567 "orderLegCollection": [{
568 "instruction": "BUY",
569 "quantity": 10,
570 "instrument": { "symbol": "AAPL", "assetType": "EQUITY" }
571 }]
572 });
573 let err = validate_order(&cfg, &order, None, None).unwrap_err();
574 assert!(err.to_string().contains("max_trade_value_usd"));
575 }
576
577 #[test]
578 fn blocks_multi_leg_without_complex_flag() {
579 let mut cfg = SafetyConfig::default();
580 cfg.limits.allow_option_orders = true;
581 cfg.limits.allowed_instructions = vec![
582 "BUY_TO_OPEN".into(),
583 "SELL_TO_OPEN".into(),
584 ];
585 cfg.limits.allowed_order_types = vec!["NET_DEBIT".into()];
586 let order = json!({
587 "orderType": "NET_DEBIT",
588 "price": "0.10",
589 "orderLegCollection": [
590 {
591 "instruction": "BUY_TO_OPEN",
592 "quantity": 2,
593 "instrument": { "symbol": "XYZ 240315P00045000", "assetType": "OPTION" }
594 },
595 {
596 "instruction": "SELL_TO_OPEN",
597 "quantity": 2,
598 "instrument": { "symbol": "XYZ 240315P00043000", "assetType": "OPTION" }
599 }
600 ]
601 });
602 let err = validate_order(&cfg, &order, None, None).unwrap_err();
603 assert!(err.to_string().contains("allow_complex_orders"));
604 }
605
606 #[test]
607 fn allows_vertical_spread_when_enabled() {
608 let mut cfg = SafetyConfig::default();
609 cfg.limits.allow_option_orders = true;
610 cfg.limits.allow_complex_orders = true;
611 cfg.limits.allowed_instructions = vec![
612 "BUY_TO_OPEN".into(),
613 "SELL_TO_OPEN".into(),
614 ];
615 cfg.limits.allowed_order_types = vec!["NET_DEBIT".into()];
616 let order = json!({
617 "orderType": "NET_DEBIT",
618 "price": "0.10",
619 "orderLegCollection": [
620 {
621 "instruction": "BUY_TO_OPEN",
622 "quantity": 2,
623 "instrument": { "symbol": "XYZ 240315P00045000", "assetType": "OPTION" }
624 },
625 {
626 "instruction": "SELL_TO_OPEN",
627 "quantity": 2,
628 "instrument": { "symbol": "XYZ 240315P00043000", "assetType": "OPTION" }
629 }
630 ]
631 });
632 validate_order(&cfg, &order, None, None).unwrap();
633 }
634}