Skip to main content

schwab_cli/agent/
protective.rs

1//! Broker-resident profit-target close order for open option spreads.
2//!
3//! Schwab's live order API rejects a stop trigger on a multi-leg complex option order
4//! (confirmed via `orders preview`: `complexOrderStrategyType: VERTICAL/IRON_CONDOR` +
5//! `orderType: STOP_LIMIT` + `stopPrice` -> HTTP 400 "Stop price must be populated only for
6//! stop orders" — the identical shape works fine on a single-leg option). A plain GTC complex
7//! `NET_DEBIT` limit order to close at the profit target, however, is accepted structurally.
8//! So only the profit-target side of exit protection can live on Schwab's servers; stop-loss
9//! and DTE-close remain dependent on the agent's own tick loop (see `docs/OPTIONS_RULES.md`).
10
11use 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
29/// Debit (per share) at which `profit_target_pct` is captured, given the entry credit.
30/// Mirrors the `profit_pct` formula in `agent::exits`: `((entry - debit) / entry) * 100`.
31pub 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
52/// Build a resting GTC complex `NET_DEBIT` order that closes `entry_order`'s legs at the
53/// profit-target debit, by inverting each leg's opening instruction (`BUY_TO_OPEN` ->
54/// `SELL_TO_CLOSE`, `SELL_TO_OPEN` -> `BUY_TO_CLOSE`) rather than re-deriving strikes/symbols.
55pub 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
137/// Place the profit-target close order with retry. Failure here does NOT block the entry
138/// (the position is still opened without broker-side protection) — callers should record the
139/// failure on `TrackedPosition::protective_order_attempts` and let the reconcile pass retry.
140pub 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    /// Open positions still missing a protective order after this pass (0 attempts left
188    /// pending are still counted — the reconcile pass retries again next tick).
189    pub unprotected_count: usize,
190}
191
192/// Retry placing the profit-target close order for any open position missing one.
193/// Runs every tick (called from `reconcile_open_positions`'s caller in `tick_once`) so a
194/// placement failure right after entry — or a position that predates this feature and has
195/// no `entry_params` to rebuild from — keeps getting retried rather than staying naked
196/// indefinitely. Positions with no stored `entry_params` cannot be rebuilt and are counted
197/// as unprotected but not retried (nothing to retry with).
198pub 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
282/// Cancel a resting protective order (e.g. before the mechanical tick loop closes the
283/// position early on a stop/DTE/thesis exit, to avoid a duplicate-close race).
284pub 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        // entry credit 0.85, 50% target -> close at 0.425 debit
307        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        // 100% target would compute to 0.0 debit; floor to a placeable minimum.
314        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}