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