Skip to main content

schwab_cli/commands/
plan.rs

1use anyhow::Result;
2use serde_json::json;
3
4use crate::cli::PlanCommands;
5use crate::config::RuntimeConfig;
6use crate::order_status::{wait_for_order, wait_result_json, WaitCondition};
7use crate::output::ResponseEnvelope;
8use crate::plan::{json_schema, llm_prompt, load_plan};
9use crate::safety::{execute_trading_order, require_trading_approval};
10
11pub async fn run(runtime: &RuntimeConfig, command: PlanCommands) -> Result<()> {
12    match command {
13        PlanCommands::Schema => {
14            runtime.emit(ResponseEnvelope::ok("plan schema", json_schema()));
15        }
16        PlanCommands::Prompt => {
17            runtime.emit(ResponseEnvelope::ok("plan prompt", llm_prompt()));
18        }
19        PlanCommands::Validate { file } => {
20            let plan = load_plan(&file)?;
21            let api = runtime.build_api()?;
22            let report = plan.validate_with_api(&runtime.safety, &api).await?;
23            let mut envelope = if report.all_ok {
24                ResponseEnvelope::ok("plan validate", json!(report))
25            } else {
26                let mut e = ResponseEnvelope::err(
27                    "plan validate",
28                    "One or more steps failed safety validation",
29                );
30                e.data = json!(report);
31                e
32            };
33            envelope = envelope
34                .with_inputs(json!({
35                    "file": file.display().to_string(),
36                    "plan_id": plan.plan_id,
37                }))
38                .with_warnings(if report.all_ok {
39                    vec![]
40                } else {
41                    vec!["Fix failing steps or adjust safety.json before running".into()]
42                })
43                .with_next_actions(vec![format!(
44                    "schwab plan run {} --dry-run --json",
45                    file.display()
46                )]);
47            runtime.emit(envelope);
48        }
49        PlanCommands::Show { file } => {
50            let plan = load_plan(&file)?;
51            runtime.emit(
52                ResponseEnvelope::ok("plan show", json!(plan))
53                    .with_inputs(json!({ "file": file.display().to_string() })),
54            );
55        }
56        PlanCommands::Run {
57            file,
58            step,
59            from_step,
60        } => {
61            let plan = load_plan(&file)?;
62            let steps = plan.steps_filtered(step.as_deref(), from_step.as_deref())?;
63
64            let summary = format!(
65                "Execute trade plan `{}` ({} step(s): {}).",
66                plan.plan_id,
67                steps.len(),
68                steps
69                    .iter()
70                    .map(|s| s.id.as_str())
71                    .collect::<Vec<_>>()
72                    .join(", ")
73            );
74
75            require_trading_approval(runtime, "plan run", &summary)?;
76
77            let api = runtime.build_api()?;
78            let equity = crate::portfolio::account_equity(&api, &plan.account_hash)
79                .await
80                .ok()
81                .flatten();
82
83            let mut results = Vec::new();
84            for (idx, step) in steps.iter().enumerate() {
85                let order = plan.step_order_json(step)?;
86                let wait_opts = plan.wait_options_for_step(step);
87
88                if runtime.dry_run {
89                    runtime.safety.validate_order(&order, None, equity)?;
90                    results.push(json!({
91                        "step_id": step.id,
92                        "status": "dry_run_ok",
93                        "order": order,
94                        "wait_until": wait_opts.condition.as_str(),
95                    }));
96                    continue;
97                }
98
99                match execute_trading_order(runtime, &api, &plan.account_hash, &order).await {
100                    Ok(data) => {
101                        let mut step_result = json!({
102                            "step_id": step.id,
103                            "status": "submitted",
104                            "submit": data,
105                        });
106
107                        if wait_opts.condition != WaitCondition::Accepted {
108                            if let Some(order_id) = data
109                                .get("order_id")
110                                .and_then(|v| v.as_str())
111                                .map(str::to_string)
112                            {
113                                match wait_for_order(&api, &plan.account_hash, &order_id, wait_opts)
114                                    .await
115                                {
116                                    Ok(wait) => {
117                                        step_result["wait"] = wait_result_json(&wait);
118                                        if wait.met {
119                                            step_result["status"] = json!("filled");
120                                        } else {
121                                            step_result["status"] = json!("wait_timeout");
122                                            results.push(step_result);
123                                            if plan.execution.stop_on_error {
124                                                runtime.emit(
125                                                    ResponseEnvelope::err(
126                                                        "plan run",
127                                                        wait.error.unwrap_or_else(|| {
128                                                            "Order wait timed out".into()
129                                                        }),
130                                                    )
131                                                    .with_inputs(json!({
132                                                        "file": file.display().to_string(),
133                                                        "plan_id": plan.plan_id,
134                                                        "completed_steps": results,
135                                                    })),
136                                                );
137                                                return Ok(());
138                                            }
139                                            continue;
140                                        }
141                                    }
142                                    Err(e) => {
143                                        step_result["status"] = json!("wait_failed");
144                                        step_result["error"] = json!(e.to_string());
145                                        results.push(step_result);
146                                        if plan.execution.stop_on_error {
147                                            runtime.emit(
148                                                ResponseEnvelope::err("plan run", e.to_string())
149                                                    .with_inputs(json!({
150                                                        "file": file.display().to_string(),
151                                                        "plan_id": plan.plan_id,
152                                                        "completed_steps": results,
153                                                    })),
154                                            );
155                                            return Ok(());
156                                        }
157                                        continue;
158                                    }
159                                }
160                            } else {
161                                step_result["status"] = json!("submitted_no_order_id");
162                                step_result["warning"] = json!(
163                                    "Could not parse order_id from Location; cannot wait for fill"
164                                );
165                            }
166                        } else {
167                            step_result["status"] = json!("executed");
168                        }
169
170                        results.push(step_result);
171                    }
172                    Err(e) => {
173                        results.push(json!({
174                            "step_id": step.id,
175                            "status": "failed",
176                            "error": e.to_string(),
177                        }));
178                        if plan.execution.stop_on_error {
179                            runtime.emit(
180                                ResponseEnvelope::err("plan run", e.to_string()).with_inputs(
181                                    json!({
182                                        "file": file.display().to_string(),
183                                        "plan_id": plan.plan_id,
184                                        "completed_steps": results,
185                                    }),
186                                ),
187                            );
188                            return Ok(());
189                        }
190                    }
191                }
192
193                if !runtime.dry_run
194                    && plan.execution.pause_seconds_between_steps > 0
195                    && idx + 1 < steps.len()
196                {
197                    tokio::time::sleep(std::time::Duration::from_secs(
198                        plan.execution.pause_seconds_between_steps,
199                    ))
200                    .await;
201                }
202            }
203
204            runtime.emit(
205                ResponseEnvelope::ok(
206                    "plan run",
207                    json!({
208                        "plan_id": plan.plan_id,
209                        "dry_run": runtime.dry_run,
210                        "steps": results,
211                    }),
212                )
213                .with_inputs(json!({
214                    "file": file.display().to_string(),
215                    "step_filter": step,
216                    "from_step": from_step,
217                })),
218            );
219        }
220    }
221
222    Ok(())
223}