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!(
195 !self.steps.is_empty(),
196 "plan must include at least one step"
197 );
198
199 let mut ids = std::collections::HashSet::new();
200 for step in &self.steps {
201 anyhow::ensure!(!step.id.trim().is_empty(), "each step needs a non-empty id");
202 anyhow::ensure!(
203 ids.insert(step.id.clone()),
204 "duplicate step id `{}`",
205 step.id
206 );
207 anyhow::ensure!(
208 step.quantity > 0.0,
209 "step `{}` quantity must be positive",
210 step.id
211 );
212 let ot = parse_trade_order_type(&step.order_type)?;
213 if ot == TradeOrderType::Limit && step.limit_price.is_none() {
214 anyhow::bail!(
215 "step `{}` is limit order but limit_price is missing",
216 step.id
217 );
218 }
219 }
220 Ok(())
221 }
222
223 pub fn step_order_json(&self, step: &PlanStep) -> Result<Value> {
224 let order_type = parse_trade_order_type(&step.order_type)?;
225 build_equity_order(
226 step.side.as_trade_side(),
227 &step.symbol,
228 step.quantity,
229 order_type,
230 step.limit_price,
231 parse_duration(step.duration.as_deref())?,
232 parse_session(step.session.as_deref())?,
233 )
234 }
235
236 pub fn validate_against_safety(
237 &self,
238 safety: &SafetyContext,
239 account_equity: Option<f64>,
240 ) -> PlanValidationReport {
241 let mut steps = Vec::new();
242 let mut all_ok = true;
243
244 for step in &self.steps {
245 match self.step_order_json(step) {
246 Ok(order) => match safety.validate_order(&order, None, account_equity) {
247 Ok(()) => steps.push(StepValidation {
248 id: step.id.clone(),
249 side: step.side,
250 symbol: step.symbol.clone(),
251 quantity: step.quantity,
252 ok: true,
253 error: None,
254 }),
255 Err(e) => {
256 all_ok = false;
257 steps.push(StepValidation {
258 id: step.id.clone(),
259 side: step.side,
260 symbol: step.symbol.clone(),
261 quantity: step.quantity,
262 ok: false,
263 error: Some(e.to_string()),
264 });
265 }
266 },
267 Err(e) => {
268 all_ok = false;
269 steps.push(StepValidation {
270 id: step.id.clone(),
271 side: step.side,
272 symbol: step.symbol.clone(),
273 quantity: step.quantity,
274 ok: false,
275 error: Some(e.to_string()),
276 });
277 }
278 }
279 }
280
281 PlanValidationReport {
282 plan_id: self.plan_id.clone(),
283 step_count: self.steps.len(),
284 all_ok,
285 steps,
286 }
287 }
288
289 pub async fn validate_with_api(
290 &self,
291 safety: &SafetyContext,
292 api: &schwab_api::TraderApi,
293 ) -> Result<PlanValidationReport> {
294 let equity = account_equity(api, &self.account_hash).await.ok().flatten();
295 Ok(self.validate_against_safety(safety, equity))
296 }
297
298 pub fn step_wait_condition(&self, step: &PlanStep) -> crate::order_status::WaitCondition {
299 if let Some(w) = step.wait_until {
300 return w.into();
301 }
302 if self.execution.wait_for_fill {
303 return PlanWaitUntil::Filled.into();
304 }
305 if let Some(w) = self.execution.default_wait_until {
306 return w.into();
307 }
308 crate::order_status::WaitCondition::Accepted
309 }
310
311 pub fn wait_options_for_step(&self, step: &PlanStep) -> crate::order_status::WaitOptions {
312 crate::order_status::WaitOptions {
313 condition: self.step_wait_condition(step),
314 timeout: std::time::Duration::from_secs(self.execution.fill_timeout_seconds),
315 interval: std::time::Duration::from_secs(self.execution.poll_interval_seconds),
316 proceed_on_partial_fill: self.execution.proceed_on_partial_fill,
317 requested_quantity: Some(step.quantity),
318 }
319 }
320
321 pub fn steps_filtered<'a>(
322 &'a self,
323 step_id: Option<&str>,
324 from_step: Option<&str>,
325 ) -> Result<Vec<&'a PlanStep>> {
326 if let Some(id) = step_id {
327 let step = self
328 .steps
329 .iter()
330 .find(|s| s.id == id)
331 .ok_or_else(|| anyhow::anyhow!("Step `{id}` not found in plan"))?;
332 return Ok(vec![step]);
333 }
334
335 if let Some(from) = from_step {
336 let idx = self
337 .steps
338 .iter()
339 .position(|s| s.id == from)
340 .ok_or_else(|| anyhow::anyhow!("Step `{from}` not found in plan"))?;
341 return Ok(self.steps[idx..].iter().collect());
342 }
343
344 Ok(self.steps.iter().collect())
345 }
346}
347
348pub fn json_schema() -> Value {
349 serde_json::json!({
350 "$schema": "https://json-schema.org/draft/2020-12/schema",
351 "title": "Schwab Trade Plan",
352 "description": "Machine-readable multi-step trade plan for schwab plan run",
353 "type": "object",
354 "required": ["version", "plan_id", "title", "account_hash", "created_at", "steps"],
355 "properties": {
356 "version": { "type": "integer", "const": PLAN_VERSION },
357 "plan_id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]+$" },
358 "title": { "type": "string" },
359 "account_hash": { "type": "string", "minLength": 16 },
360 "account_label": { "type": "string" },
361 "created_at": { "type": "string", "format": "date-time" },
362 "author": { "type": "string" },
363 "rationale": { "type": "string" },
364 "assumptions": {
365 "type": "object",
366 "properties": {
367 "notes": { "type": "string" },
368 "limit_prices": {
369 "type": "object",
370 "additionalProperties": { "type": "number" }
371 }
372 }
373 },
374 "execution": {
375 "type": "object",
376 "properties": {
377 "stop_on_error": { "type": "boolean", "default": true },
378 "pause_seconds_between_steps": { "type": "integer", "minimum": 0, "default": 0 },
379 "default_wait_until": { "enum": ["accepted", "filled", "terminal"] },
380 "wait_for_fill": { "type": "boolean", "default": false },
381 "fill_timeout_seconds": { "type": "integer", "minimum": 1, "default": 3600 },
382 "poll_interval_seconds": { "type": "integer", "minimum": 1, "default": 5 },
383 "proceed_on_partial_fill": { "type": "boolean", "default": false }
384 }
385 },
386 "steps": {
387 "type": "array",
388 "minItems": 1,
389 "items": {
390 "type": "object",
391 "required": ["id", "side", "symbol", "quantity"],
392 "properties": {
393 "id": { "type": "string" },
394 "side": { "enum": ["buy", "sell"] },
395 "symbol": { "type": "string" },
396 "quantity": { "type": "number", "exclusiveMinimum": 0 },
397 "order_type": { "enum": ["market", "limit"], "default": "limit" },
398 "limit_price": { "type": "number" },
399 "duration": { "enum": ["day", "gtc", "fok"] },
400 "session": { "enum": ["normal", "am", "pm", "seamless"] },
401 "note": { "type": "string" },
402 "wait_until": { "enum": ["accepted", "filled", "terminal"] }
403 }
404 }
405 }
406 }
407 })
408}
409
410pub fn llm_prompt() -> Value {
411 serde_json::json!({
412 "purpose": "Generate a trade plan file for `schwab plan validate` and `schwab plan run`",
413 "workflow": [
414 "1. Run `schwab portfolio summary --json` and `schwab safety show --json`",
415 "2. Run `schwab accounts numbers --json` to obtain account_hash values",
416 "3. Split large trades into steps that respect safety.json (max_trade_value_usd, max_shares_per_order, max_trade_pct_of_equity)",
417 "4. Prefer limit orders with explicit limit_price; use sells before buys when rotating cash",
418 "5. Write plan as YAML or JSON matching `schwab plan schema --json`",
419 "6. Validate with `schwab plan validate <file>` before `schwab plan run <file> --dry-run`",
420 "7. For limit orders set execution.wait_for_fill or step wait_until: filled so plan run waits before the next step",
421 "8. Live execution requires user-approved `schwab plan run <file> --trust --yes`"
422 ],
423 "rules": [
424 "Never omit account_hash — use hashValue from accounts numbers, not plain account number",
425 "Each step must have a unique id (e.g. step-01-sell-sgov)",
426 "Use side: buy or sell (lowercase)",
427 "Limit orders must include limit_price",
428 "Keep step sizes under safety.json limits — the CLI validates every step",
429 "Do not include options, short sales, or blocked symbols",
430 "Include rationale explaining the rebalance thesis"
431 ],
432 "example_command_sequence": [
433 "schwab plan schema --json",
434 "schwab plan validate plans/my-plan.yaml",
435 "schwab plan run plans/my-plan.yaml --dry-run --json",
436 "schwab plan run plans/my-plan.yaml --trust --yes --json"
437 ],
438 "schema_command": "schwab plan schema --json",
439 "template_yaml": PLAN_TEMPLATE_YAML,
440 })
441}
442
443pub const PLAN_TEMPLATE_YAML: &str = r#"version: 1
444plan_id: example-rebalance-2026-06-19
445title: Example partial rebalance
446account_hash: "<hash-from-schwab-accounts-numbers>"
447account_label: "My brokerage (...1234)"
448created_at: "2026-06-19T12:00:00Z"
449author: "llm-agent"
450rationale: |
451 Brief thesis for the rebalance.
452assumptions:
453 notes: "Limit prices based on last close; adjust before live run"
454 limit_prices:
455 SGOV: 100.55
456 JPST: 50.50
457execution:
458 stop_on_error: true
459 pause_seconds_between_steps: 2
460 wait_for_fill: true
461 fill_timeout_seconds: 3600
462 poll_interval_seconds: 10
463steps:
464 - id: step-01-sell-sgov
465 side: sell
466 symbol: SGOV
467 quantity: 14
468 order_type: limit
469 limit_price: 100.55
470 wait_until: filled
471 note: "Batch 1 — stay under safety limits"
472 - id: step-02-buy-jpst
473 side: buy
474 symbol: JPST
475 quantity: 28
476 order_type: limit
477 limit_price: 50.50
478 note: "Batch 1 buy"
479"#;
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 fn sample_plan() -> TradePlan {
486 TradePlan {
487 version: PLAN_VERSION,
488 plan_id: "test-plan".into(),
489 title: "Test".into(),
490 account_hash: "ABC123".into(),
491 account_label: None,
492 created_at: Utc::now(),
493 author: None,
494 rationale: None,
495 assumptions: PlanAssumptions::default(),
496 execution: PlanExecution::default(),
497 steps: vec![PlanStep {
498 id: "s1".into(),
499 side: PlanSide::Sell,
500 symbol: "SGOV".into(),
501 quantity: 10.0,
502 order_type: "limit".into(),
503 limit_price: Some(100.0),
504 duration: None,
505 session: None,
506 note: None,
507 wait_until: None,
508 }],
509 }
510 }
511
512 #[test]
513 fn validates_structure() {
514 sample_plan().validate_structure().unwrap();
515 }
516
517 #[test]
518 fn parses_yaml() {
519 let dir = tempfile::tempdir().unwrap();
520 let path = dir.path().join("plan.yaml");
521 let plan = sample_plan();
522 fs::write(&path, serde_yaml::to_string(&plan).unwrap()).unwrap();
523 let loaded = load_plan(&path).unwrap();
524 assert_eq!(loaded.plan_id, "test-plan");
525 }
526}