Skip to main content

schwab_cli/
flatten.rs

1//! Close-all for agent-managed positions only (never whole-account flatten).
2
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use schwab_api::TraderApi;
7use serde_json::{json, Value};
8
9use crate::agent::exits::{load_live_position_groups, option_group_from_tracked};
10use crate::agent::paths::load_agent_state;
11use crate::agent::state::AgentState;
12use crate::config::RuntimeConfig;
13use crate::options::{build_close_order_for_group, OptionPositionGroup};
14use crate::rules::RulesConfig;
15use crate::safety::{execute_trading_order, require_trading_approval};
16
17#[derive(Debug, Clone, serde::Serialize)]
18pub struct ManagedOptionClose {
19    pub position_id: String,
20    pub account_hash: String,
21    pub underlying: String,
22    pub expiry: String,
23    pub strategy: String,
24    pub contracts: u32,
25    pub group: OptionPositionGroup,
26}
27
28#[derive(Debug, Clone, serde::Serialize)]
29pub struct AgentFlattenPlan {
30    pub agent_id: String,
31    pub managed_closes: Vec<ManagedOptionClose>,
32    pub skipped: Vec<Value>,
33}
34
35impl AgentFlattenPlan {
36    pub fn is_empty(&self) -> bool {
37        self.managed_closes.is_empty()
38    }
39
40    pub fn summary(&self) -> String {
41        if self.managed_closes.is_empty() {
42            return "no agent-managed spreads".into();
43        }
44        self.managed_closes
45            .iter()
46            .map(|c| format!("{} {} {}", c.underlying, c.expiry, c.strategy))
47            .collect::<Vec<_>>()
48            .join(", ")
49    }
50}
51
52pub async fn build_agent_options_flatten_plan(
53    api: &TraderApi,
54    rules: &RulesConfig,
55    state: &AgentState,
56) -> Result<AgentFlattenPlan> {
57    let live = load_live_position_groups(api, rules).await?;
58    let mut managed_closes = Vec::new();
59    let mut skipped = Vec::new();
60
61    let mut tracked: Vec<_> = state.open_positions.values().cloned().collect();
62    tracked.sort_by(|a, b| a.position_id.cmp(&b.position_id));
63
64    for pos in tracked {
65        let group = live
66            .get(&pos.position_id)
67            .cloned()
68            .or_else(|| option_group_from_tracked(&pos));
69        let Some(group) = group else {
70            skipped.push(json!({
71                "position_id": pos.position_id,
72                "underlying": pos.underlying,
73                "expiry": pos.expiry,
74                "reason": "no matching broker spread (already closed or not on Schwab)",
75            }));
76            continue;
77        };
78        managed_closes.push(ManagedOptionClose {
79            position_id: pos.position_id.clone(),
80            account_hash: pos.account_hash.clone(),
81            underlying: pos.underlying.clone(),
82            expiry: pos.expiry.clone(),
83            strategy: pos.strategy.clone(),
84            contracts: pos.contracts,
85            group,
86        });
87    }
88
89    Ok(AgentFlattenPlan {
90        agent_id: rules.agent_id.clone(),
91        managed_closes,
92        skipped,
93    })
94}
95
96pub async fn execute_agent_options_flatten(
97    runtime: &RuntimeConfig,
98    api: &TraderApi,
99    rules_path: &Path,
100    rules: &RulesConfig,
101    plan: &AgentFlattenPlan,
102) -> Result<Value> {
103    if plan.is_empty() {
104        return Ok(json!({
105            "flattened": false,
106            "agent_id": plan.agent_id,
107            "message": "no agent-managed spreads to close",
108            "skipped": plan.skipped,
109            "closes": [],
110        }));
111    }
112
113    require_trading_approval(
114        runtime,
115        "agent close-all",
116        &format!(
117            "Close {} agent-managed spread(s) for `{}` (NOT whole account): {}",
118            plan.managed_closes.len(),
119            plan.agent_id,
120            plan.summary()
121        ),
122    )?;
123
124    if runtime.dry_run {
125        return Ok(json!({
126            "dry_run": true,
127            "agent_id": plan.agent_id,
128            "rules": rules_path,
129            "managed_closes": plan.managed_closes,
130            "skipped": plan.skipped,
131        }));
132    }
133
134    let mut closes = Vec::new();
135    for target in &plan.managed_closes {
136        let order = build_close_order_for_group(&target.group)?;
137        let result = execute_trading_order(runtime, api, &target.account_hash, &order)
138            .await
139            .with_context(|| format!("close {}", target.position_id))?;
140        closes.push(json!({
141            "position_id": target.position_id,
142            "underlying": target.underlying,
143            "expiry": target.expiry,
144            "strategy": target.strategy,
145            "contracts": target.contracts,
146            "result": result,
147        }));
148    }
149
150    // Drop closed positions from agent state when not simulating.
151    if !runtime.simulate {
152        let mut state = load_agent_state(rules_path, &rules.agent_id);
153        for target in &plan.managed_closes {
154            state.open_positions.remove(&target.position_id);
155        }
156        let state_path = crate::agent::paths::active_state_path(rules_path, runtime.simulate);
157        crate::agent::save_state(&state_path, &state)?;
158    }
159
160    Ok(json!({
161        "flattened": true,
162        "agent_id": plan.agent_id,
163        "closes": closes,
164        "skipped": plan.skipped,
165    }))
166}