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
135pub 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}