1use 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 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}