Skip to main content

schwab_cli/
safety_config.rs

1use std::fs;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8/// Hard trading limits and agent guidance loaded from `safety.json`.
9/// The CLI enforces `limits` regardless of LLM instructions or `--trust`.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(default)]
12pub struct SafetyConfig {
13    pub version: u32,
14    /// Free-form rules surfaced to agents via `schwab instructions --json`.
15    pub agent_rules: Vec<String>,
16    pub limits: SafetyLimits,
17    /// When true, `orders place` / `trade buy|sell` must preview before submitting.
18    pub require_preview_before_place: bool,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(default)]
23pub struct SafetyLimits {
24    /// Maximum estimated notional (USD) for a single order.
25    pub max_trade_value_usd: f64,
26    /// Maximum shares (or contracts) per order leg.
27    pub max_shares_per_order: f64,
28    /// Allow MARKET orders.
29    pub allow_market_orders: bool,
30    /// Allow LIMIT orders.
31    pub allow_limit_orders: bool,
32    /// Allow short-sale instructions (SELL_SHORT, etc.).
33    pub allow_short_sales: bool,
34    /// Allow non-equity asset types (OPTION, etc.).
35    pub allow_option_orders: bool,
36    /// Allow multi-leg / complex orders (spreads, iron condors, etc.).
37    pub allow_complex_orders: bool,
38    /// Allow conditional orders (OCO, TRIGGER, etc.).
39    pub allow_conditional_orders: bool,
40    /// Max legs per order (complex spreads).
41    pub max_legs_per_order: u32,
42    /// If non-empty, only these complexOrderStrategyType values are allowed.
43    pub allowed_complex_strategies: Vec<String>,
44    /// Permitted instructions (e.g. BUY, SELL). Empty = none allowed.
45    pub allowed_instructions: Vec<String>,
46    /// Permitted order types (e.g. MARKET, LIMIT). Empty = none allowed.
47    pub allowed_order_types: Vec<String>,
48    /// If non-empty, only these symbols may be traded (uppercase).
49    pub allowed_symbols: Vec<String>,
50    /// Symbols that may never be traded (uppercase).
51    pub blocked_symbols: Vec<String>,
52    /// Max estimated order value as % of account equity (0 = disabled).
53    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/// Parsed fields extracted from a Schwab order JSON payload.
177#[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}