schwab_cli/options/
positions.rs1use 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}