1use std::time::{Duration, Instant};
12
13use anyhow::{bail, Context, Result};
14use schwab_api::TraderApi;
15use serde_json::{json, Value};
16
17use schwab_api::models::order::{
18 ComplexOrderStrategyType, OrderDuration, OrderInstruction, OrderSession, OrderTypeRequest,
19};
20
21use crate::config::RuntimeConfig;
22use crate::options::{build_order_for_strategy, StrategyKind};
23use crate::order_builder::{build_complex_option_order, OrderLegSpec};
24use crate::safety::{execute_trading_order, require_trading_approval};
25
26use super::state::AgentState;
27use crate::rules::RulesConfig;
28
29pub fn profit_target_debit(entry_credit: f64, profit_target_pct: f64) -> f64 {
32 let target = entry_credit * (1.0 - profit_target_pct / 100.0);
33 target.max(0.01)
34}
35
36fn invert_instruction(raw: &str) -> Option<OrderInstruction> {
37 match raw {
38 "BUY_TO_OPEN" => Some(OrderInstruction::SellToClose),
39 "SELL_TO_OPEN" => Some(OrderInstruction::BuyToClose),
40 _ => None,
41 }
42}
43
44fn complex_strategy_from_str(raw: &str) -> ComplexOrderStrategyType {
45 match raw {
46 "VERTICAL" => ComplexOrderStrategyType::Vertical,
47 "IRON_CONDOR" => ComplexOrderStrategyType::IronCondor,
48 _ => ComplexOrderStrategyType::Custom,
49 }
50}
51
52pub fn build_profit_target_close_order(
56 entry_order: &Value,
57 entry_credit: f64,
58 profit_target_pct: f64,
59) -> Result<Value> {
60 let legs_json = entry_order
61 .get("orderLegCollection")
62 .and_then(|v| v.as_array())
63 .context("entry order missing orderLegCollection")?;
64 if legs_json.len() < 2 {
65 bail!("profit-target close order requires a multi-leg entry order");
66 }
67
68 let leg_specs: Vec<OrderLegSpec> = legs_json
69 .iter()
70 .map(|leg| {
71 let raw_instruction = leg
72 .get("instruction")
73 .and_then(|v| v.as_str())
74 .context("leg missing instruction")?;
75 let instruction = invert_instruction(raw_instruction).with_context(|| {
76 format!("cannot invert opening instruction `{raw_instruction}`")
77 })?;
78 let symbol = leg
79 .pointer("/instrument/symbol")
80 .and_then(|v| v.as_str())
81 .context("leg missing instrument.symbol")?
82 .to_string();
83 let quantity = leg
84 .get("quantity")
85 .and_then(|v| v.as_f64())
86 .context("leg missing quantity")?;
87 Ok(OrderLegSpec {
88 instruction,
89 symbol,
90 asset_type: "OPTION",
91 quantity,
92 })
93 })
94 .collect::<Result<Vec<_>>>()?;
95
96 let complex_strategy = entry_order
97 .get("complexOrderStrategyType")
98 .and_then(|v| v.as_str())
99 .map(complex_strategy_from_str)
100 .unwrap_or(ComplexOrderStrategyType::Custom);
101
102 let target_debit = profit_target_debit(entry_credit, profit_target_pct);
103
104 build_complex_option_order(
105 complex_strategy,
106 OrderTypeRequest::NetDebit,
107 leg_specs,
108 Some(target_debit),
109 OrderDuration::GoodTillCancel,
110 OrderSession::Normal,
111 None,
112 )
113}
114
115#[derive(Debug, Clone)]
116pub struct ProtectivePlaceResult {
117 pub order: Value,
118 pub order_id: Option<String>,
119 pub attempts: u32,
120}
121
122async fn place_profit_target_order_once(
123 runtime: &RuntimeConfig,
124 trader: &TraderApi,
125 account_hash: &str,
126 order: &Value,
127) -> Result<Value> {
128 require_trading_approval(
129 runtime,
130 "protective order",
131 &format!("Place resting profit-target close on {account_hash}"),
132 )?;
133 runtime.safety.validate_order(order, None, None)?;
134 execute_trading_order(runtime, trader, account_hash, order).await
135}
136
137pub async fn place_profit_target_order_with_retry(
141 runtime: &RuntimeConfig,
142 trader: &TraderApi,
143 account_hash: &str,
144 entry_order: &Value,
145 entry_credit: f64,
146 profit_target_pct: f64,
147 max_attempts: u32,
148 max_seconds: u64,
149) -> Result<ProtectivePlaceResult> {
150 let order = build_profit_target_close_order(entry_order, entry_credit, profit_target_pct)?;
151
152 let started = Instant::now();
153 let deadline = started + Duration::from_secs(max_seconds.max(1));
154 let mut attempts = 0u32;
155 let mut last_err = None;
156
157 while attempts < max_attempts.max(1) && Instant::now() < deadline {
158 attempts += 1;
159 match place_profit_target_order_once(runtime, trader, account_hash, &order).await {
160 Ok(placed) => {
161 let order_id = placed
162 .get("order_id")
163 .and_then(|v| v.as_str())
164 .map(str::to_string);
165 return Ok(ProtectivePlaceResult {
166 order: placed,
167 order_id,
168 attempts,
169 });
170 }
171 Err(e) => {
172 last_err = Some(e);
173 if Instant::now() < deadline && attempts < max_attempts.max(1) {
174 tokio::time::sleep(Duration::from_secs(2)).await;
175 }
176 }
177 }
178 }
179
180 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("profit-target order placement failed")))
181}
182
183#[derive(Debug, Clone, Default, serde::Serialize)]
184pub struct ReconcileProtectiveSummary {
185 pub placed: Vec<String>,
186 pub failed: Vec<String>,
187 pub unprotected_count: usize,
190}
191
192pub async fn reconcile_protective_orders(
199 runtime: &RuntimeConfig,
200 trader: &TraderApi,
201 rules: &RulesConfig,
202 state: &mut AgentState,
203) -> ReconcileProtectiveSummary {
204 let mut summary = ReconcileProtectiveSummary::default();
205 if !rules.execution.protective_order.enabled {
206 return summary;
207 }
208
209 let candidate_ids: Vec<String> = state
210 .open_positions
211 .iter()
212 .filter(|(_, p)| p.protective_order_id.is_none())
213 .map(|(id, _)| id.clone())
214 .collect();
215
216 for position_id in candidate_ids {
217 summary.unprotected_count += 1;
218 let Some(position) = state.open_positions.get(&position_id).cloned() else {
219 continue;
220 };
221
222 let rebuild = (|| -> Result<Value> {
223 let entry_params = position
224 .entry_params
225 .clone()
226 .context("no stored entry_params to rebuild order")?;
227 let entry_credit = position
228 .entry_credit
229 .context("no entry_credit recorded")?;
230 let kind = StrategyKind::parse(&position.strategy)?;
231 let entry_order = build_order_for_strategy(kind, &entry_params)?;
232 build_profit_target_close_order(
233 &entry_order,
234 entry_credit,
235 rules.exit_rules.profit_target_pct,
236 )
237 })();
238
239 let close_order = match rebuild {
240 Ok(order) => order,
241 Err(e) => {
242 summary.failed.push(format!("{position_id}: {e:#}"));
243 continue;
244 }
245 };
246
247 match place_profit_target_order_once(runtime, trader, &position.account_hash, &close_order)
248 .await
249 {
250 Ok(placed) => {
251 let order_id = placed
252 .get("order_id")
253 .and_then(|v| v.as_str())
254 .map(str::to_string);
255 if let Some(p) = state.open_positions.get_mut(&position_id) {
256 p.protective_order_id = order_id.clone();
257 p.protective_order_status = Some("WORKING".to_string());
258 p.protective_order_attempts = 0;
259 }
260 summary.unprotected_count -= 1;
261 summary.placed.push(position_id.clone());
262 state.record_action(
263 "protective_order_reconciled",
264 json!({
265 "position_id": position_id,
266 "order_id": order_id,
267 }),
268 );
269 }
270 Err(e) => {
271 if let Some(p) = state.open_positions.get_mut(&position_id) {
272 p.protective_order_attempts = p.protective_order_attempts.saturating_add(1);
273 }
274 summary.failed.push(format!("{position_id}: {e:#}"));
275 }
276 }
277 }
278
279 summary
280}
281
282pub async fn cancel_protective_order(
285 runtime: &RuntimeConfig,
286 trader: &TraderApi,
287 account_hash: &str,
288 order_id: &str,
289) -> Result<Value> {
290 require_trading_approval(
291 runtime,
292 "cancel protective order",
293 &format!("Cancel resting profit-target order {order_id}"),
294 )?;
295 let result = trader.orders().cancel(account_hash, order_id).await?;
296 Ok(serde_json::to_value(result)?)
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use serde_json::json;
303
304 #[test]
305 fn profit_target_debit_matches_exit_formula() {
306 let debit = profit_target_debit(0.85, 50.0);
308 assert!((debit - 0.425).abs() < 1e-9);
309 }
310
311 #[test]
312 fn profit_target_debit_floors_at_one_cent() {
313 let debit = profit_target_debit(0.85, 100.0);
315 assert!((debit - 0.01).abs() < 1e-9);
316 }
317
318 #[test]
319 fn builds_close_order_inverting_vertical_legs() {
320 let entry_order = json!({
321 "orderType": "NET_CREDIT",
322 "complexOrderStrategyType": "VERTICAL",
323 "orderStrategyType": "SINGLE",
324 "orderLegCollection": [
325 {"instruction": "SELL_TO_OPEN", "quantity": 2.0, "instrument": {"symbol": "SPY 260821P00739000", "assetType": "OPTION"}},
326 {"instruction": "BUY_TO_OPEN", "quantity": 2.0, "instrument": {"symbol": "SPY 260821P00736000", "assetType": "OPTION"}}
327 ]
328 });
329
330 let close = build_profit_target_close_order(&entry_order, 0.85, 50.0).unwrap();
331
332 assert_eq!(close["orderType"], "NET_DEBIT");
333 assert_eq!(close["complexOrderStrategyType"], "VERTICAL");
334 assert_eq!(close["duration"], "GOOD_TILL_CANCEL");
335 let legs = close["orderLegCollection"].as_array().unwrap();
336 assert_eq!(legs.len(), 2);
337 assert_eq!(legs[0]["instruction"], "BUY_TO_CLOSE");
338 assert_eq!(legs[0]["instrument"]["symbol"], "SPY 260821P00739000");
339 assert_eq!(legs[1]["instruction"], "SELL_TO_CLOSE");
340 assert_eq!(legs[1]["instrument"]["symbol"], "SPY 260821P00736000");
341 }
342
343 #[test]
344 fn rejects_single_leg_entry_order() {
345 let entry_order = json!({
346 "complexOrderStrategyType": "NONE",
347 "orderLegCollection": [
348 {"instruction": "BUY_TO_OPEN", "quantity": 1.0, "instrument": {"symbol": "SPY 260821P00739000", "assetType": "OPTION"}}
349 ]
350 });
351 assert!(build_profit_target_close_order(&entry_order, 0.85, 50.0).is_err());
352 }
353}