Skip to main content

schwab_cli/options/
positions.rs

1use anyhow::Result;
2use schwab_api::TraderApi;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::options::symbology::{parse_option_symbol, ParsedOptionSymbol};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct OptionPositionLeg {
10    pub symbol: String,
11    pub underlying: String,
12    pub quantity: f64,
13    pub market_value: f64,
14    pub average_price: Option<f64>,
15    pub parsed: Option<ParsedOptionSymbol>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct OptionPositionGroup {
20    pub id: String,
21    pub underlying: String,
22    pub expiry: String,
23    pub strategy_hint: String,
24    pub legs: Vec<OptionPositionLeg>,
25    pub net_market_value: f64,
26}
27
28pub async fn list_option_positions(
29    api: &TraderApi,
30    account_hash: Option<&str>,
31) -> Result<Vec<OptionPositionLeg>> {
32    let accounts = if let Some(hash) = account_hash {
33        vec![api.accounts().get(hash, Some("positions")).await?]
34    } else {
35        api.accounts().list(Some("positions")).await?
36    };
37
38    let mut legs = Vec::new();
39    for account in accounts {
40        let positions = account
41            .securities_account
42            .as_ref()
43            .and_then(|sa| sa.positions.as_ref());
44
45        let Some(positions) = positions else {
46            continue;
47        };
48
49        for pos in positions {
50            let instrument = match &pos.instrument {
51                Some(i) => i,
52                None => continue,
53            };
54            let asset_type = instrument
55                .r#type
56                .as_deref()
57                .unwrap_or("")
58                .to_ascii_uppercase();
59            let symbol = instrument.symbol.as_deref().unwrap_or("").to_string();
60            if asset_type != "OPTION" && !looks_like_option_symbol(&symbol) {
61                continue;
62            }
63
64            let long_qty = pos.long_quantity.unwrap_or(0.0);
65            let short_qty = pos.short_quantity.unwrap_or(0.0);
66            let net_qty = long_qty - short_qty;
67            if net_qty.abs() < f64::EPSILON {
68                continue;
69            }
70
71            let parsed = parse_option_symbol(&symbol).ok();
72            let underlying = parsed
73                .as_ref()
74                .map(|p| p.underlying.clone())
75                .unwrap_or_else(|| symbol.split_whitespace().next().unwrap_or("").to_string());
76
77            legs.push(OptionPositionLeg {
78                symbol,
79                underlying,
80                quantity: net_qty,
81                market_value: pos.market_value.unwrap_or(0.0),
82                average_price: pos.average_price,
83                parsed,
84            });
85        }
86    }
87
88    Ok(legs)
89}
90
91pub fn group_option_legs(legs: &[OptionPositionLeg]) -> Vec<OptionPositionGroup> {
92    use std::collections::HashMap;
93
94    let mut by_key: HashMap<String, Vec<&OptionPositionLeg>> = HashMap::new();
95    for leg in legs {
96        let expiry = leg
97            .parsed
98            .as_ref()
99            .map(|p| p.expiry.to_string())
100            .unwrap_or_else(|| "unknown".into());
101        let key = format!("{}|{}", leg.underlying, expiry);
102        by_key.entry(key).or_default().push(leg);
103    }
104
105    by_key
106        .into_iter()
107        .map(|(key, group_legs)| {
108            let net_mv: f64 = group_legs.iter().map(|l| l.market_value).sum();
109            let parts: Vec<&str> = key.split('|').collect();
110            let underlying = parts.first().copied().unwrap_or("").to_string();
111            let expiry = parts.get(1).copied().unwrap_or("").to_string();
112            let strategy_hint = infer_strategy_hint(&group_legs);
113            OptionPositionGroup {
114                id: key.clone(),
115                underlying,
116                expiry,
117                strategy_hint,
118                legs: group_legs.into_iter().cloned().collect(),
119                net_market_value: net_mv,
120            }
121        })
122        .collect()
123}
124
125fn looks_like_option_symbol(symbol: &str) -> bool {
126    symbol.len() >= 15
127        && symbol
128            .chars()
129            .nth(12)
130            .is_some_and(|c| c == 'C' || c == 'P')
131}
132
133fn infer_strategy_hint(legs: &[&OptionPositionLeg]) -> String {
134    match legs.len() {
135        2 => "vertical".into(),
136        4 => "iron_condor".into(),
137        1 => "single_leg".into(),
138        n => format!("{n}_legs"),
139    }
140}
141
142pub fn find_position_group<'a>(
143    groups: &'a [OptionPositionGroup],
144    position_id: &str,
145) -> Option<&'a OptionPositionGroup> {
146    groups.iter().find(|g| g.id == position_id)
147}
148
149pub fn build_close_order_for_group(group: &OptionPositionGroup) -> Result<Value> {
150    use schwab_api::models::order::{
151        ComplexOrderStrategyType, OrderDuration, OrderInstruction, OrderSession, OrderTypeRequest,
152    };
153
154    use crate::order_builder::{build_complex_option_order, build_single_option_order, OrderLegSpec};
155
156    if group.legs.is_empty() {
157        anyhow::bail!("position group has no legs");
158    }
159
160    let leg_specs: Vec<OrderLegSpec> = group
161        .legs
162        .iter()
163        .map(|leg| {
164            let instruction = if leg.quantity > 0.0 {
165                OrderInstruction::SellToClose
166            } else {
167                OrderInstruction::BuyToClose
168            };
169            Ok(OrderLegSpec {
170                instruction,
171                symbol: leg.symbol.clone(),
172                asset_type: "OPTION",
173                quantity: leg.quantity.abs(),
174            })
175        })
176        .collect::<Result<Vec<_>>>()?;
177
178    let complex = match group.legs.len() {
179        2 => ComplexOrderStrategyType::Vertical,
180        4 => ComplexOrderStrategyType::IronCondor,
181        _ => ComplexOrderStrategyType::Custom,
182    };
183
184    if group.legs.len() == 1 {
185        let leg = &group.legs[0];
186        let instruction = if leg.quantity > 0.0 {
187            OrderInstruction::SellToClose
188        } else {
189            OrderInstruction::BuyToClose
190        };
191        return build_single_option_order(
192            instruction,
193            &leg.symbol,
194            leg.quantity.abs(),
195            OrderTypeRequest::Market,
196            None,
197            OrderDuration::Day,
198            OrderSession::Normal,
199            None,
200        );
201    }
202
203    let order_type = if group.net_market_value >= 0.0 {
204        OrderTypeRequest::NetCredit
205    } else {
206        OrderTypeRequest::NetDebit
207    };
208
209    build_complex_option_order(
210        complex,
211        order_type,
212        leg_specs,
213        None,
214        OrderDuration::Day,
215        OrderSession::Normal,
216        None,
217    )
218}