Skip to main content

schwab_cli/
safety.rs

1use anyhow::{bail, Result};
2use serde_json::{json, Value};
3use std::sync::Arc;
4
5use crate::config::RuntimeConfig;
6use crate::portfolio::{
7    account_equity, ensure_preview_accepted, ensure_preview_buying_power,
8    validate_buying_power_after_preview, validate_buying_power_for_order,
9};
10use crate::safety_config::{validate_order, SafetyConfig};
11
12pub fn require_mutation_approval(
13    runtime: &RuntimeConfig,
14    command: &str,
15    summary: &str,
16) -> Result<()> {
17    if runtime.dry_run {
18        return Ok(());
19    }
20
21    if runtime.yes {
22        return Ok(());
23    }
24
25    if runtime.is_interactive() {
26        let confirm = inquire::Confirm::new(&format!("{summary} Proceed?"))
27            .with_default(false)
28            .prompt()?;
29        if confirm {
30            return Ok(());
31        }
32        bail!("Mutation cancelled by user");
33    }
34
35    bail!(
36        "Mutation `{command}` blocked in non-interactive mode. Pass --yes to confirm or --dry-run to validate."
37    );
38}
39
40/// Trading mutations (orders, trade buy/sell) require explicit trust mode for autonomous agents.
41pub fn require_trading_approval(
42    runtime: &RuntimeConfig,
43    command: &str,
44    summary: &str,
45) -> Result<()> {
46    if runtime.dry_run {
47        return Ok(());
48    }
49
50    crate::disclaimer::require_accepted_for_live_trading()?;
51
52    if runtime.is_interactive() {
53        let confirm = inquire::Confirm::new(&format!("{summary} Proceed?"))
54            .with_default(false)
55            .prompt()?;
56        if confirm {
57            return Ok(());
58        }
59        bail!("Trade cancelled by user");
60    }
61
62    // Agent / non-interactive: need both --trust and --yes
63    if runtime.trust && runtime.yes {
64        return Ok(());
65    }
66
67    if runtime.yes && !runtime.trust {
68        bail!(
69            "Trading mutation `{command}` blocked: --yes requires --trust for autonomous agent execution. \
70             Safety limits in safety.json still apply."
71        );
72    }
73
74    bail!(
75        "Trading mutation `{command}` blocked in non-interactive mode. \
76         Pass --trust --yes to execute autonomously, or --dry-run to validate."
77    );
78}
79
80pub async fn execute_trading_order(
81    runtime: &RuntimeConfig,
82    api: &schwab_api::TraderApi,
83    account_number: &str,
84    order: &Value,
85) -> Result<Value> {
86    let equity = account_equity(api, account_number).await.ok().flatten();
87
88    // Static validation before preview (symbol, qty, types)
89    runtime.safety.validate_order(order, None, equity)?;
90
91    validate_buying_power_for_order(api, account_number, order, None).await?;
92
93    let preview = if runtime.safety.require_preview_before_place {
94        Some(api.orders().preview(account_number, order).await?)
95    } else {
96        None
97    };
98
99    if let Some(ref preview_data) = preview {
100        ensure_preview_accepted(preview_data)?;
101        ensure_preview_buying_power(preview_data)?;
102        runtime
103            .safety
104            .validate_order(order, Some(preview_data), equity)?;
105        validate_buying_power_after_preview(api, account_number, order, preview_data).await?;
106    }
107
108    let place = api.orders().place(account_number, order).await?;
109    let order_id = place
110        .location
111        .as_deref()
112        .and_then(crate::order_status::parse_order_id_from_location);
113
114    Ok(json!({
115        "status": place.status,
116        "location": place.location,
117        "order_id": order_id,
118        "previewed": preview.is_some(),
119        "message": "Order accepted; order_id present when Location header is returned",
120    }))
121}
122
123#[derive(Clone, Debug)]
124pub struct SafetyContext {
125    inner: Arc<SafetyConfig>,
126}
127
128impl SafetyContext {
129    pub fn new(config: SafetyConfig) -> Self {
130        Self {
131            inner: Arc::new(config),
132        }
133    }
134
135    pub fn validate_order(
136        &self,
137        order: &Value,
138        preview: Option<&Value>,
139        account_equity: Option<f64>,
140    ) -> Result<()> {
141        validate_order(&self.inner, order, preview, account_equity)
142    }
143}
144
145impl std::ops::Deref for SafetyContext {
146    type Target = SafetyConfig;
147
148    fn deref(&self) -> &Self::Target {
149        &self.inner
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::config::RuntimeConfig;
157    use crate::mode::CliMode;
158    use crate::output::{OutputFormat, OutputSink};
159
160    fn runtime(trust: bool, yes: bool) -> RuntimeConfig {
161        RuntimeConfig {
162            mode: CliMode::Agent,
163            output: OutputFormat::Json,
164            yes,
165            dry_run: false,
166            simulate: false,
167            trust,
168            suppress_tick_output: false,
169            no_audio: true,
170            safety: SafetyContext::new(SafetyConfig::default()),
171            sink: OutputSink::stdout(),
172        }
173    }
174
175    #[test]
176    fn trading_requires_trust_and_yes() {
177        assert!(require_trading_approval(&runtime(false, false), "trade buy", "x").is_err());
178        assert!(require_trading_approval(&runtime(false, true), "trade buy", "x").is_err());
179        assert!(require_trading_approval(&runtime(true, false), "trade buy", "x").is_err());
180        assert!(require_trading_approval(&runtime(true, true), "trade buy", "x").is_ok());
181    }
182
183    #[test]
184    fn dry_run_skips_trading_approval() {
185        let mut rt = runtime(false, false);
186        rt.dry_run = true;
187        assert!(require_trading_approval(&rt, "trade buy", "x").is_ok());
188    }
189}