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![
44                    format!("schwab plan run {} --dry-run --json", file.display()),
45                ]);
46            runtime.emit(envelope);
47        }
48        PlanCommands::Show { file } => {
49            let plan = load_plan(&file)?;
50            runtime.emit(
51                ResponseEnvelope::ok("plan show", json!(plan))
52                    .with_inputs(json!({ "file": file.display().to_string() })),
53            );
54        }
55        PlanCommands::Run {
56            file,
57            step,
58            from_step,
59        } => {
60            let plan = load_plan(&file)?;
61            let steps = plan.steps_filtered(step.as_deref(), from_step.as_deref())?;
62
63            let summary = format!(
64                "Execute trade plan `{}` ({} step(s): {}).",
65                plan.plan_id,
66                steps.len(),
67                steps
68                    .iter()
69                    .map(|s| s.id.as_str())
70                    .collect::<Vec<_>>()
71                    .join(", ")
72            );
73
74            require_trading_approval(runtime, "plan run", &summary)?;
75
76            let api = runtime.build_api()?;
77            let equity = crate::portfolio::account_equity(&api, &plan.account_hash)
78                .await
79                .ok()
80                .flatten();
81
82            let mut results = Vec::new();
83            for (idx, step) in steps.iter().enumerate() {
84                let order = plan.step_order_json(step)?;
85                let wait_opts = plan.wait_options_for_step(step);
86
87                if runtime.dry_run {
88                    runtime.safety.validate_order(&order, None, equity)?;
89                    results.push(json!({
90                        "step_id": step.id,
91                        "status": "dry_run_ok",
92                        "order": order,
93                        "wait_until": wait_opts.condition.as_str(),
94                    }));
95                    continue;
96                }
97
98                match execute_trading_order(runtime, &api, &plan.account_hash, &order).await {
99                    Ok(data) => {
100                        let mut step_result = json!({
101                            "step_id": step.id,
102                            "status": "submitted",
103                            "submit": data,
104                        });
105
106                        if wait_opts.condition != WaitCondition::Accepted {
107                            if let Some(order_id) = data
108                                .get("order_id")
109                                .and_then(|v| v.as_str())
110                                .map(str::to_string)
111                            {
112                                match wait_for_order(&api, &plan.account_hash, &order_id, wait_opts)
113                                    .await
114                                {
115                                    Ok(wait) => {
116                                        step_result["wait"] = wait_result_json(&wait);
117                                        if wait.met {
118                                            step_result["status"] = json!("filled");
119                                        } else {
120                                            step_result["status"] = json!("wait_timeout");
121                                            results.push(step_result);
122                                            if plan.execution.stop_on_error {
123                                                runtime.emit(
124                                                    ResponseEnvelope::err(
125                                                        "plan run",
126                                                        wait.error.unwrap_or_else(|| {
127                                                            "Order wait timed out".into()
128                                                        }),
129                                                    )
130                                                    .with_inputs(json!({
131                                                        "file": file.display().to_string(),
132                                                        "plan_id": plan.plan_id,
133                                                        "completed_steps": results,
134                                                    })),
135                                                );
136                                                return Ok(());
137                                            }
138                                            continue;
139                                        }
140                                    }
141                                    Err(e) => {
142                                        step_result["status"] = json!("wait_failed");
143                                        step_result["error"] = json!(e.to_string());
144                                        results.push(step_result);
145                                        if plan.execution.stop_on_error {
146                                            runtime.emit(
147                                                ResponseEnvelope::err("plan run", e.to_string())
148                                                    .with_inputs(json!({
149                                                        "file": file.display().to_string(),
150                                                        "plan_id": plan.plan_id,
151                                                        "completed_steps": results,
152                                                    })),
153                                            );
154                                            return Ok(());
155                                        }
156                                        continue;
157                                    }
158                                }
159                            } else {
160                                step_result["status"] = json!("submitted_no_order_id");
161                                step_result["warning"] = json!(
162                                    "Could not parse order_id from Location; cannot wait for fill"
163                                );
164                            }
165                        } else {
166                            step_result["status"] = json!("executed");
167                        }
168
169                        results.push(step_result);
170                    }
171                    Err(e) => {
172                        results.push(json!({
173                            "step_id": step.id,
174                            "status": "failed",
175                            "error": e.to_string(),
176                        }));
177                        if plan.execution.stop_on_error {
178                            runtime.emit(
179                                ResponseEnvelope::err("plan run", e.to_string()).with_inputs(json!({
180                                    "file": file.display().to_string(),
181                                    "plan_id": plan.plan_id,
182                                    "completed_steps": results,
183                                })),
184                            );
185                            return Ok(());
186                        }
187                    }
188                }
189
190                if !runtime.dry_run
191                    && plan.execution.pause_seconds_between_steps > 0
192                    && idx + 1 < steps.len()
193                {
194                    tokio::time::sleep(std::time::Duration::from_secs(
195                        plan.execution.pause_seconds_between_steps,
196                    ))
197                    .await;
198                }
199            }
200
201            runtime.emit(
202                ResponseEnvelope::ok(
203                    "plan run",
204                    json!({
205                        "plan_id": plan.plan_id,
206                        "dry_run": runtime.dry_run,
207                        "steps": results,
208                    }),
209                )
210                .with_inputs(json!({
211                    "file": file.display().to_string(),
212                    "step_filter": step,
213                    "from_step": from_step,
214                })),
215            );
216        }
217    }
218
219    Ok(())
220}