Skip to main content

schwab_cli/
plan.rs

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    /// Default wait policy for steps that do not set `wait_until`.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub default_wait_until: Option<PlanWaitUntil>,
73    /// Shorthand: set default_wait_until to filled when true.
74    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    /// Wait for order status before advancing to the next plan step.
111    #[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}