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
40pub 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 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 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}