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![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}