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 fn legacy_position_id(underlying: &str, expiry: &str) -> String {
29    format!("{underlying}|{expiry}")
30}
31
32pub fn position_group_id(account_hash: &str, group: &OptionPositionGroup) -> String {
33    format!(
34        "{}|{}|{}|{}|{}",
35        account_hash,
36        group.underlying,
37        group.expiry,
38        group.strategy_hint,
39        group_leg_signature(group)
40    )
41}
42
43pub fn candidate_position_id(
44    account_hash: &str,
45    underlying: &str,
46    expiry: &str,
47    strategy: &str,
48    legs: Vec<(char, f64, &str)>,
49) -> String {
50    let mut parts: Vec<String> = legs
51        .into_iter()
52        .map(|(put_call, strike, side)| {
53            format!(
54                "{}{}{}",
55                put_call.to_ascii_uppercase(),
56                format_strike(strike),
57                side.to_ascii_uppercase()
58            )
59        })
60        .collect();
61    parts.sort();
62    format!(
63        "{}|{}|{}|{}|{}",
64        account_hash,
65        underlying.to_uppercase(),
66        expiry,
67        strategy,
68        parts.join("_")
69    )
70}
71
72pub async fn list_option_positions(
73    api: &TraderApi,
74    account_hash: Option<&str>,
75) -> Result<Vec<OptionPositionLeg>> {
76    let accounts = if let Some(hash) = account_hash {
77        vec![api.accounts().get(hash, Some("positions")).await?]
78    } else {
79        api.accounts().list(Some("positions")).await?
80    };
81
82    let mut legs = Vec::new();
83    for account in accounts {
84        let positions = account
85            .securities_account
86            .as_ref()
87            .and_then(|sa| sa.positions.as_ref());
88
89        let Some(positions) = positions else {
90            continue;
91        };
92
93        for pos in positions {
94            let instrument = match &pos.instrument {
95                Some(i) => i,
96                None => continue,
97            };
98            let asset_type = instrument
99                .r#type
100                .as_deref()
101                .unwrap_or("")
102                .to_ascii_uppercase();
103            let symbol = instrument.symbol.as_deref().unwrap_or("").to_string();
104            if asset_type != "OPTION" && !looks_like_option_symbol(&symbol) {
105                continue;
106            }
107
108            let long_qty = pos.long_quantity.unwrap_or(0.0);
109            let short_qty = pos.short_quantity.unwrap_or(0.0);
110            let net_qty = long_qty - short_qty;
111            if net_qty.abs() < f64::EPSILON {
112                continue;
113            }
114
115            let parsed = parse_option_symbol(&symbol).ok();
116            let underlying = parsed
117                .as_ref()
118                .map(|p| p.underlying.clone())
119                .unwrap_or_else(|| symbol.split_whitespace().next().unwrap_or("").to_string());
120
121            legs.push(OptionPositionLeg {
122                symbol,
123                underlying,
124                quantity: net_qty,
125                market_value: pos.market_value.unwrap_or(0.0),
126                average_price: pos.average_price,
127                parsed,
128            });
129        }
130    }
131
132    Ok(legs)
133}
134
135/// Number of spreads in a grouped position (max abs leg quantity).
136pub fn spread_contract_count(group: &OptionPositionGroup) -> u32 {
137    group
138        .legs
139        .iter()
140        .map(|l| l.quantity.abs())
141        .fold(0.0_f64, f64::max)
142        .round()
143        .max(1.0) as u32
144}
145
146pub fn group_option_legs(legs: &[OptionPositionLeg]) -> Vec<OptionPositionGroup> {
147    use std::collections::HashMap;
148
149    let mut by_key: HashMap<String, Vec<&OptionPositionLeg>> = HashMap::new();
150    for leg in legs {
151        let expiry = leg
152            .parsed
153            .as_ref()
154            .map(|p| p.expiry.to_string())
155            .unwrap_or_else(|| "unknown".into());
156        let key = legacy_position_id(&leg.underlying, &expiry);
157        by_key.entry(key).or_default().push(leg);
158    }
159
160    by_key
161        .into_iter()
162        .map(|(key, group_legs)| {
163            let net_mv: f64 = group_legs.iter().map(|l| l.market_value).sum();
164            let parts: Vec<&str> = key.split('|').collect();
165            let underlying = parts.first().copied().unwrap_or("").to_string();
166            let expiry = parts.get(1).copied().unwrap_or("").to_string();
167            let strategy_hint = infer_strategy_hint(&group_legs);
168            OptionPositionGroup {
169                id: key.clone(),
170                underlying,
171                expiry,
172                strategy_hint,
173                legs: group_legs.into_iter().cloned().collect(),
174                net_market_value: net_mv,
175            }
176        })
177        .collect()
178}
179
180fn looks_like_option_symbol(symbol: &str) -> bool {
181    symbol.len() >= 15 && symbol.chars().nth(12).is_some_and(|c| c == 'C' || c == 'P')
182}
183
184fn infer_strategy_hint(legs: &[&OptionPositionLeg]) -> String {
185    match legs.len() {
186        2 => "vertical".into(),
187        4 => "iron_condor".into(),
188        1 => "single_leg".into(),
189        n => format!("{n}_legs"),
190    }
191}
192
193pub fn find_position_group<'a>(
194    groups: &'a [OptionPositionGroup],
195    position_id: &str,
196) -> Option<&'a OptionPositionGroup> {
197    groups.iter().find(|g| g.id == position_id)
198}
199
200pub fn build_close_order_for_group(group: &OptionPositionGroup) -> Result<Value> {
201    let price = close_limit_from_market_value(group);
202    build_close_order_for_group_with_limit(group, price)
203}
204
205pub fn build_close_order_for_group_with_limit(
206    group: &OptionPositionGroup,
207    limit_price: Option<f64>,
208) -> Result<Value> {
209    use schwab_api::models::order::{
210        ComplexOrderStrategyType, OrderDuration, OrderInstruction, OrderSession, OrderTypeRequest,
211    };
212
213    use crate::order_builder::{
214        build_complex_option_order, build_single_option_order, OrderLegSpec,
215    };
216
217    if group.legs.is_empty() {
218        anyhow::bail!("position group has no legs");
219    }
220
221    let leg_specs: Vec<OrderLegSpec> = group
222        .legs
223        .iter()
224        .map(|leg| {
225            let instruction = if leg.quantity > 0.0 {
226                OrderInstruction::SellToClose
227            } else {
228                OrderInstruction::BuyToClose
229            };
230            Ok(OrderLegSpec {
231                instruction,
232                symbol: leg.symbol.clone(),
233                asset_type: "OPTION",
234                quantity: leg.quantity.abs(),
235            })
236        })
237        .collect::<Result<Vec<_>>>()?;
238
239    let complex = match group.legs.len() {
240        2 => ComplexOrderStrategyType::Vertical,
241        4 => ComplexOrderStrategyType::IronCondor,
242        _ => ComplexOrderStrategyType::Custom,
243    };
244
245    if group.legs.len() == 1 {
246        let leg = &group.legs[0];
247        let instruction = if leg.quantity > 0.0 {
248            OrderInstruction::SellToClose
249        } else {
250            OrderInstruction::BuyToClose
251        };
252        return build_single_option_order(
253            instruction,
254            &leg.symbol,
255            leg.quantity.abs(),
256            OrderTypeRequest::Market,
257            None,
258            OrderDuration::Day,
259            OrderSession::Normal,
260            None,
261        );
262    }
263
264    let order_type = if group.net_market_value >= 0.0 {
265        OrderTypeRequest::NetCredit
266    } else {
267        OrderTypeRequest::NetDebit
268    };
269
270    build_complex_option_order(
271        complex,
272        order_type,
273        leg_specs,
274        limit_price,
275        OrderDuration::Day,
276        OrderSession::Normal,
277        None,
278    )
279}
280
281fn close_limit_from_market_value(group: &OptionPositionGroup) -> Option<f64> {
282    if group.legs.len() < 2 {
283        return None;
284    }
285    let contracts = spread_contract_count(group) as f64;
286    if contracts <= 0.0 {
287        return None;
288    }
289    let per_share = (group.net_market_value.abs() / contracts / 100.0).max(0.01);
290    Some(per_share)
291}
292
293pub fn group_leg_signature(group: &OptionPositionGroup) -> String {
294    let mut parts: Vec<String> = group
295        .legs
296        .iter()
297        .map(|leg| {
298            let side = if leg.quantity < 0.0 { "S" } else { "L" };
299            if let Some(parsed) = leg.parsed.as_ref() {
300                format!(
301                    "{}{}{}",
302                    parsed.put_call.to_ascii_uppercase(),
303                    format_strike(parsed.strike),
304                    side
305                )
306            } else {
307                format!(
308                    "{}{}",
309                    leg.symbol.trim().to_uppercase().replace(' ', ""),
310                    side
311                )
312            }
313        })
314        .collect();
315    parts.sort();
316    parts.join("_")
317}
318
319fn format_strike(strike: f64) -> String {
320    if (strike.fract()).abs() < f64::EPSILON {
321        format!("{strike:.0}")
322    } else {
323        format!("{strike:.2}")
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn spread_contract_count_uses_max_leg_quantity() {
333        let group = OptionPositionGroup {
334            id: "IWM|2026-07-31".into(),
335            underlying: "IWM".into(),
336            expiry: "2026-07-31".into(),
337            strategy_hint: "vertical".into(),
338            legs: vec![
339                OptionPositionLeg {
340                    symbol: "IWM   260731P00282000".into(),
341                    underlying: "IWM".into(),
342                    quantity: -2.0,
343                    market_value: -632.0,
344                    average_price: Some(3.16),
345                    parsed: None,
346                },
347                OptionPositionLeg {
348                    symbol: "IWM   260731P00280000".into(),
349                    underlying: "IWM".into(),
350                    quantity: 2.0,
351                    market_value: 565.0,
352                    average_price: Some(2.825),
353                    parsed: None,
354                },
355            ],
356            net_market_value: -67.0,
357        };
358        assert_eq!(spread_contract_count(&group), 2);
359    }
360
361    #[test]
362    fn candidate_and_live_position_ids_match_vertical_signature() {
363        let group = OptionPositionGroup {
364            id: "IWM|2026-07-31".into(),
365            underlying: "IWM".into(),
366            expiry: "2026-07-31".into(),
367            strategy_hint: "vertical".into(),
368            legs: vec![
369                OptionPositionLeg {
370                    symbol: "IWM   260731P00282000".into(),
371                    underlying: "IWM".into(),
372                    quantity: -1.0,
373                    market_value: -32.0,
374                    average_price: Some(0.25),
375                    parsed: parse_option_symbol("IWM   260731P00282000").ok(),
376                },
377                OptionPositionLeg {
378                    symbol: "IWM   260731P00280000".into(),
379                    underlying: "IWM".into(),
380                    quantity: 1.0,
381                    market_value: 10.0,
382                    average_price: Some(0.05),
383                    parsed: parse_option_symbol("IWM   260731P00280000").ok(),
384                },
385            ],
386            net_market_value: -22.0,
387        };
388        let candidate = candidate_position_id(
389            "acct",
390            "IWM",
391            "2026-07-31",
392            "vertical",
393            vec![('P', 282.0, "S"), ('P', 280.0, "L")],
394        );
395        assert_eq!(position_group_id("acct", &group), candidate);
396    }
397}