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.".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/// 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.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}