1use std::fs;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use crate::order_builder::{
10 build_equity_order, parse_duration, parse_session, parse_trade_order_type, TradeOrderType,
11 TradeSide,
12};
13use crate::portfolio::account_equity;
14use crate::safety::SafetyContext;
15
16pub const PLAN_VERSION: u32 = 1;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct TradePlan {
20 pub version: u32,
21 pub plan_id: String,
22 pub title: String,
23 pub account_hash: String,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub account_label: Option<String>,
26 pub created_at: DateTime<Utc>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub author: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub rationale: Option<String>,
31 #[serde(default)]
32 pub assumptions: PlanAssumptions,
33 #[serde(default)]
34 pub execution: PlanExecution,
35 pub steps: Vec<PlanStep>,
36}
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39pub struct PlanAssumptions {
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub notes: Option<String>,
42 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
43 pub limit_prices: std::collections::HashMap<String, f64>,
44}
45
46#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
47#[serde(rename_all = "lowercase")]
48pub enum PlanWaitUntil {
49 #[default]
50 Accepted,
51 Filled,
52 Terminal,
53}
54
55impl From<PlanWaitUntil> for crate::order_status::WaitCondition {
56 fn from(value: PlanWaitUntil) -> Self {
57 match value {
58 PlanWaitUntil::Accepted => Self::Accepted,
59 PlanWaitUntil::Filled => Self::Filled,
60 PlanWaitUntil::Terminal => Self::Terminal,
61 }
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(default)]
67pub struct PlanExecution {
68 pub stop_on_error: bool,
69 pub pause_seconds_between_steps: u64,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub default_wait_until: Option<PlanWaitUntil>,
73 pub wait_for_fill: bool,
75 pub fill_timeout_seconds: u64,
76 pub poll_interval_seconds: u64,
77 pub proceed_on_partial_fill: bool,
78}
79
80impl Default for PlanExecution {
81 fn default() -> Self {
82 Self {
83 stop_on_error: true,
84 pause_seconds_between_steps: 0,
85 default_wait_until: None,
86 wait_for_fill: false,
87 fill_timeout_seconds: 3600,
88 poll_interval_seconds: 5,
89 proceed_on_partial_fill: false,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct PlanStep {
96 pub id: String,
97 pub side: PlanSide,
98 pub symbol: String,
99 pub quantity: f64,
100 #[serde(default = "default_order_type")]
101 pub order_type: String,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub limit_price: Option<f64>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub duration: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub session: Option<String>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub note: Option<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub wait_until: Option<PlanWaitUntil>,
113}
114
115fn default_order_type() -> String {
116 "limit".into()
117}
118
119#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "lowercase")]
121pub enum PlanSide {
122 Buy,
123 Sell,
124}
125
126impl PlanSide {
127 pub fn as_trade_side(self) -> TradeSide {
128 match self {
129 PlanSide::Buy => TradeSide::Buy,
130 PlanSide::Sell => TradeSide::Sell,
131 }
132 }
133}
134
135#[derive(Debug, Clone, Serialize)]
136pub struct StepValidation {
137 pub id: String,
138 pub side: PlanSide,
139 pub symbol: String,
140 pub quantity: f64,
141 pub ok: bool,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub error: Option<String>,
144}
145
146#[derive(Debug, Clone, Serialize)]
147pub struct PlanValidationReport {
148 pub plan_id: String,
149 pub step_count: usize,
150 pub all_ok: bool,
151 pub steps: Vec<StepValidation>,
152}
153
154pub fn load_plan(path: &Path) -> Result<TradePlan> {
155 let raw = fs::read_to_string(path)
156 .with_context(|| format!("Failed to read plan file {}", path.display()))?;
157 let ext = path
158 .extension()
159 .and_then(|e| e.to_str())
160 .unwrap_or("")
161 .to_ascii_lowercase();
162
163 let plan: TradePlan = match ext.as_str() {
164 "yaml" | "yml" => serde_yaml::from_str(&raw)
165 .with_context(|| format!("Invalid YAML in {}", path.display()))?,
166 "json" => serde_json::from_str(&raw)
167 .with_context(|| format!("Invalid JSON in {}", path.display()))?,
168 _ => serde_json::from_str(&raw)
169 .or_else(|_| serde_yaml::from_str(&raw))
170 .with_context(|| {
171 format!(
172 "Could not parse {} as JSON or YAML (use .json, .yaml, or .yml)",
173 path.display()
174 )
175 })?,
176 };
177
178 plan.validate_structure()?;
179 Ok(plan)
180}
181
182impl TradePlan {
183 pub fn validate_structure(&self) -> Result<()> {
184 anyhow::ensure!(
185 self.version == PLAN_VERSION,
186 "Unsupported plan version {} (expected {PLAN_VERSION})",
187 self.version
188 );
189 anyhow::ensure!(!self.plan_id.trim().is_empty(), "plan_id is required");
190 anyhow::ensure!(
191 !self.account_hash.trim().is_empty(),
192 "account_hash is required"
193 );
194 anyhow::ensure!(!self.steps.is_empty(), "plan must include at least one step");
195
196 let mut ids = std::collections::HashSet::new();
197 for step in &self.steps {
198 anyhow::ensure!(!step.id.trim().is_empty(), "each step needs a non-empty id");
199 anyhow::ensure!(
200 ids.insert(step.id.clone()),
201 "duplicate step id `{}`",
202 step.id
203 );
204 anyhow::ensure!(step.quantity > 0.0, "step `{}` quantity must be positive", step.id);
205 let ot = parse_trade_order_type(&step.order_type)?;
206 if ot == TradeOrderType::Limit && step.limit_price.is_none() {
207 anyhow::bail!("step `{}` is limit order but limit_price is missing", step.id);
208 }
209 }
210 Ok(())
211 }
212
213 pub fn step_order_json(&self, step: &PlanStep) -> Result<Value> {
214 let order_type = parse_trade_order_type(&step.order_type)?;
215 build_equity_order(
216 step.side.as_trade_side(),
217 &step.symbol,
218 step.quantity,
219 order_type,
220 step.limit_price,
221 parse_duration(step.duration.as_deref())?,
222 parse_session(step.session.as_deref())?,
223 )
224 }
225
226 pub fn validate_against_safety(
227 &self,
228 safety: &SafetyContext,
229 account_equity: Option<f64>,
230 ) -> PlanValidationReport {
231 let mut steps = Vec::new();
232 let mut all_ok = true;
233
234 for step in &self.steps {
235 match self.step_order_json(step) {
236 Ok(order) => match safety.validate_order(&order, None, account_equity) {
237 Ok(()) => steps.push(StepValidation {
238 id: step.id.clone(),
239 side: step.side,
240 symbol: step.symbol.clone(),
241 quantity: step.quantity,
242 ok: true,
243 error: None,
244 }),
245 Err(e) => {
246 all_ok = false;
247 steps.push(StepValidation {
248 id: step.id.clone(),
249 side: step.side,
250 symbol: step.symbol.clone(),
251 quantity: step.quantity,
252 ok: false,
253 error: Some(e.to_string()),
254 });
255 }
256 },
257 Err(e) => {
258 all_ok = false;
259 steps.push(StepValidation {
260 id: step.id.clone(),
261 side: step.side,
262 symbol: step.symbol.clone(),
263 quantity: step.quantity,
264 ok: false,
265 error: Some(e.to_string()),
266 });
267 }
268 }
269 }
270
271 PlanValidationReport {
272 plan_id: self.plan_id.clone(),
273 step_count: self.steps.len(),
274 all_ok,
275 steps,
276 }
277 }
278
279 pub async fn validate_with_api(
280 &self,
281 safety: &SafetyContext,
282 api: &schwab_api::TraderApi,
283 ) -> Result<PlanValidationReport> {
284 let equity = account_equity(api, &self.account_hash).await.ok().flatten();
285 Ok(self.validate_against_safety(safety, equity))
286 }
287
288 pub fn step_wait_condition(&self, step: &PlanStep) -> crate::order_status::WaitCondition {
289 if let Some(w) = step.wait_until {
290 return w.into();
291 }
292 if self.execution.wait_for_fill {
293 return PlanWaitUntil::Filled.into();
294 }
295 if let Some(w) = self.execution.default_wait_until {
296 return w.into();
297 }
298 crate::order_status::WaitCondition::Accepted
299 }
300
301 pub fn wait_options_for_step(&self, step: &PlanStep) -> crate::order_status::WaitOptions {
302 crate::order_status::WaitOptions {
303 condition: self.step_wait_condition(step),
304 timeout: std::time::Duration::from_secs(self.execution.fill_timeout_seconds),
305 interval: std::time::Duration::from_secs(self.execution.poll_interval_seconds),
306 proceed_on_partial_fill: self.execution.proceed_on_partial_fill,
307 requested_quantity: Some(step.quantity),
308 }
309 }
310
311 pub fn steps_filtered<'a>(
312 &'a self,
313 step_id: Option<&str>,
314 from_step: Option<&str>,
315 ) -> Result<Vec<&'a PlanStep>> {
316 if let Some(id) = step_id {
317 let step = self
318 .steps
319 .iter()
320 .find(|s| s.id == id)
321 .ok_or_else(|| anyhow::anyhow!("Step `{id}` not found in plan"))?;
322 return Ok(vec![step]);
323 }
324
325 if let Some(from) = from_step {
326 let idx = self
327 .steps
328 .iter()
329 .position(|s| s.id == from)
330 .ok_or_else(|| anyhow::anyhow!("Step `{from}` not found in plan"))?;
331 return Ok(self.steps[idx..].iter().collect());
332 }
333
334 Ok(self.steps.iter().collect())
335 }
336}
337
338pub fn json_schema() -> Value {
339 serde_json::json!({
340 "$schema": "https://json-schema.org/draft/2020-12/schema",
341 "title": "Schwab Trade Plan",
342 "description": "Machine-readable multi-step trade plan for schwab plan run",
343 "type": "object",
344 "required": ["version", "plan_id", "title", "account_hash", "created_at", "steps"],
345 "properties": {
346 "version": { "type": "integer", "const": PLAN_VERSION },
347 "plan_id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]+$" },
348 "title": { "type": "string" },
349 "account_hash": { "type": "string", "minLength": 16 },
350 "account_label": { "type": "string" },
351 "created_at": { "type": "string", "format": "date-time" },
352 "author": { "type": "string" },
353 "rationale": { "type": "string" },
354 "assumptions": {
355 "type": "object",
356 "properties": {
357 "notes": { "type": "string" },
358 "limit_prices": {
359 "type": "object",
360 "additionalProperties": { "type": "number" }
361 }
362 }
363 },
364 "execution": {
365 "type": "object",
366 "properties": {
367 "stop_on_error": { "type": "boolean", "default": true },
368 "pause_seconds_between_steps": { "type": "integer", "minimum": 0, "default": 0 },
369 "default_wait_until": { "enum": ["accepted", "filled", "terminal"] },
370 "wait_for_fill": { "type": "boolean", "default": false },
371 "fill_timeout_seconds": { "type": "integer", "minimum": 1, "default": 3600 },
372 "poll_interval_seconds": { "type": "integer", "minimum": 1, "default": 5 },
373 "proceed_on_partial_fill": { "type": "boolean", "default": false }
374 }
375 },
376 "steps": {
377 "type": "array",
378 "minItems": 1,
379 "items": {
380 "type": "object",
381 "required": ["id", "side", "symbol", "quantity"],
382 "properties": {
383 "id": { "type": "string" },
384 "side": { "enum": ["buy", "sell"] },
385 "symbol": { "type": "string" },
386 "quantity": { "type": "number", "exclusiveMinimum": 0 },
387 "order_type": { "enum": ["market", "limit"], "default": "limit" },
388 "limit_price": { "type": "number" },
389 "duration": { "enum": ["day", "gtc", "fok"] },
390 "session": { "enum": ["normal", "am", "pm", "seamless"] },
391 "note": { "type": "string" },
392 "wait_until": { "enum": ["accepted", "filled", "terminal"] }
393 }
394 }
395 }
396 }
397 })
398}
399
400pub fn llm_prompt() -> Value {
401 serde_json::json!({
402 "purpose": "Generate a trade plan file for `schwab plan validate` and `schwab plan run`",
403 "workflow": [
404 "1. Run `schwab portfolio summary --json` and `schwab safety show --json`",
405 "2. Run `schwab accounts numbers --json` to obtain account_hash values",
406 "3. Split large trades into steps that respect safety.json (max_trade_value_usd, max_shares_per_order, max_trade_pct_of_equity)",
407 "4. Prefer limit orders with explicit limit_price; use sells before buys when rotating cash",
408 "5. Write plan as YAML or JSON matching `schwab plan schema --json`",
409 "6. Validate with `schwab plan validate <file>` before `schwab plan run <file> --dry-run`",
410 "7. For limit orders set execution.wait_for_fill or step wait_until: filled so plan run waits before the next step",
411 "8. Live execution requires user-approved `schwab plan run <file> --trust --yes`"
412 ],
413 "rules": [
414 "Never omit account_hash — use hashValue from accounts numbers, not plain account number",
415 "Each step must have a unique id (e.g. step-01-sell-sgov)",
416 "Use side: buy or sell (lowercase)",
417 "Limit orders must include limit_price",
418 "Keep step sizes under safety.json limits — the CLI validates every step",
419 "Do not include options, short sales, or blocked symbols",
420 "Include rationale explaining the rebalance thesis"
421 ],
422 "example_command_sequence": [
423 "schwab plan schema --json",
424 "schwab plan validate plans/my-plan.yaml",
425 "schwab plan run plans/my-plan.yaml --dry-run --json",
426 "schwab plan run plans/my-plan.yaml --trust --yes --json"
427 ],
428 "schema_command": "schwab plan schema --json",
429 "template_yaml": PLAN_TEMPLATE_YAML,
430 })
431}
432
433pub const PLAN_TEMPLATE_YAML: &str = r#"version: 1
434plan_id: example-rebalance-2026-06-19
435title: Example partial rebalance
436account_hash: "<hash-from-schwab-accounts-numbers>"
437account_label: "My brokerage (...1234)"
438created_at: "2026-06-19T12:00:00Z"
439author: "llm-agent"
440rationale: |
441 Brief thesis for the rebalance.
442assumptions:
443 notes: "Limit prices based on last close; adjust before live run"
444 limit_prices:
445 SGOV: 100.55
446 JPST: 50.50
447execution:
448 stop_on_error: true
449 pause_seconds_between_steps: 2
450 wait_for_fill: true
451 fill_timeout_seconds: 3600
452 poll_interval_seconds: 10
453steps:
454 - id: step-01-sell-sgov
455 side: sell
456 symbol: SGOV
457 quantity: 14
458 order_type: limit
459 limit_price: 100.55
460 wait_until: filled
461 note: "Batch 1 — stay under safety limits"
462 - id: step-02-buy-jpst
463 side: buy
464 symbol: JPST
465 quantity: 28
466 order_type: limit
467 limit_price: 50.50
468 note: "Batch 1 buy"
469"#;
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 fn sample_plan() -> TradePlan {
476 TradePlan {
477 version: PLAN_VERSION,
478 plan_id: "test-plan".into(),
479 title: "Test".into(),
480 account_hash: "ABC123".into(),
481 account_label: None,
482 created_at: Utc::now(),
483 author: None,
484 rationale: None,
485 assumptions: PlanAssumptions::default(),
486 execution: PlanExecution::default(),
487 steps: vec![PlanStep {
488 id: "s1".into(),
489 side: PlanSide::Sell,
490 symbol: "SGOV".into(),
491 quantity: 10.0,
492 order_type: "limit".into(),
493 limit_price: Some(100.0),
494 duration: None,
495 session: None,
496 note: None,
497 wait_until: None,
498 }],
499 }
500 }
501
502 #[test]
503 fn validates_structure() {
504 sample_plan().validate_structure().unwrap();
505 }
506
507 #[test]
508 fn parses_yaml() {
509 let dir = tempfile::tempdir().unwrap();
510 let path = dir.path().join("plan.yaml");
511 let plan = sample_plan();
512 fs::write(&path, serde_yaml::to_string(&plan).unwrap()).unwrap();
513 let loaded = load_plan(&path).unwrap();
514 assert_eq!(loaded.plan_id, "test-plan");
515 }
516}