schwab_cli/commands/
plan.rs1use 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}