schwab-api-cli 0.1.0

schwab-api-cli — agent-first CLI for Charles Schwab Trader API (experimental — use at your own risk)
Documentation
use anyhow::Result;
use schwab_api::TraderApi;
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::options::symbology::{parse_option_symbol, ParsedOptionSymbol};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OptionPositionLeg {
    pub symbol: String,
    pub underlying: String,
    pub quantity: f64,
    pub market_value: f64,
    pub average_price: Option<f64>,
    pub parsed: Option<ParsedOptionSymbol>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OptionPositionGroup {
    pub id: String,
    pub underlying: String,
    pub expiry: String,
    pub strategy_hint: String,
    pub legs: Vec<OptionPositionLeg>,
    pub net_market_value: f64,
}

pub async fn list_option_positions(
    api: &TraderApi,
    account_hash: Option<&str>,
) -> Result<Vec<OptionPositionLeg>> {
    let accounts = if let Some(hash) = account_hash {
        vec![api.accounts().get(hash, Some("positions")).await?]
    } else {
        api.accounts().list(Some("positions")).await?
    };

    let mut legs = Vec::new();
    for account in accounts {
        let positions = account
            .securities_account
            .as_ref()
            .and_then(|sa| sa.positions.as_ref());

        let Some(positions) = positions else {
            continue;
        };

        for pos in positions {
            let instrument = match &pos.instrument {
                Some(i) => i,
                None => continue,
            };
            let asset_type = instrument
                .r#type
                .as_deref()
                .unwrap_or("")
                .to_ascii_uppercase();
            let symbol = instrument.symbol.as_deref().unwrap_or("").to_string();
            if asset_type != "OPTION" && !looks_like_option_symbol(&symbol) {
                continue;
            }

            let long_qty = pos.long_quantity.unwrap_or(0.0);
            let short_qty = pos.short_quantity.unwrap_or(0.0);
            let net_qty = long_qty - short_qty;
            if net_qty.abs() < f64::EPSILON {
                continue;
            }

            let parsed = parse_option_symbol(&symbol).ok();
            let underlying = parsed
                .as_ref()
                .map(|p| p.underlying.clone())
                .unwrap_or_else(|| symbol.split_whitespace().next().unwrap_or("").to_string());

            legs.push(OptionPositionLeg {
                symbol,
                underlying,
                quantity: net_qty,
                market_value: pos.market_value.unwrap_or(0.0),
                average_price: pos.average_price,
                parsed,
            });
        }
    }

    Ok(legs)
}

pub fn group_option_legs(legs: &[OptionPositionLeg]) -> Vec<OptionPositionGroup> {
    use std::collections::HashMap;

    let mut by_key: HashMap<String, Vec<&OptionPositionLeg>> = HashMap::new();
    for leg in legs {
        let expiry = leg
            .parsed
            .as_ref()
            .map(|p| p.expiry.to_string())
            .unwrap_or_else(|| "unknown".into());
        let key = format!("{}|{}", leg.underlying, expiry);
        by_key.entry(key).or_default().push(leg);
    }

    by_key
        .into_iter()
        .map(|(key, group_legs)| {
            let net_mv: f64 = group_legs.iter().map(|l| l.market_value).sum();
            let parts: Vec<&str> = key.split('|').collect();
            let underlying = parts.first().copied().unwrap_or("").to_string();
            let expiry = parts.get(1).copied().unwrap_or("").to_string();
            let strategy_hint = infer_strategy_hint(&group_legs);
            OptionPositionGroup {
                id: key.clone(),
                underlying,
                expiry,
                strategy_hint,
                legs: group_legs.into_iter().cloned().collect(),
                net_market_value: net_mv,
            }
        })
        .collect()
}

fn looks_like_option_symbol(symbol: &str) -> bool {
    symbol.len() >= 15
        && symbol
            .chars()
            .nth(12)
            .is_some_and(|c| c == 'C' || c == 'P')
}

fn infer_strategy_hint(legs: &[&OptionPositionLeg]) -> String {
    match legs.len() {
        2 => "vertical".into(),
        4 => "iron_condor".into(),
        1 => "single_leg".into(),
        n => format!("{n}_legs"),
    }
}

pub fn find_position_group<'a>(
    groups: &'a [OptionPositionGroup],
    position_id: &str,
) -> Option<&'a OptionPositionGroup> {
    groups.iter().find(|g| g.id == position_id)
}

pub fn build_close_order_for_group(group: &OptionPositionGroup) -> Result<Value> {
    use schwab_api::models::order::{
        ComplexOrderStrategyType, OrderDuration, OrderInstruction, OrderSession, OrderTypeRequest,
    };

    use crate::order_builder::{build_complex_option_order, build_single_option_order, OrderLegSpec};

    if group.legs.is_empty() {
        anyhow::bail!("position group has no legs");
    }

    let leg_specs: Vec<OrderLegSpec> = group
        .legs
        .iter()
        .map(|leg| {
            let instruction = if leg.quantity > 0.0 {
                OrderInstruction::SellToClose
            } else {
                OrderInstruction::BuyToClose
            };
            Ok(OrderLegSpec {
                instruction,
                symbol: leg.symbol.clone(),
                asset_type: "OPTION",
                quantity: leg.quantity.abs(),
            })
        })
        .collect::<Result<Vec<_>>>()?;

    let complex = match group.legs.len() {
        2 => ComplexOrderStrategyType::Vertical,
        4 => ComplexOrderStrategyType::IronCondor,
        _ => ComplexOrderStrategyType::Custom,
    };

    if group.legs.len() == 1 {
        let leg = &group.legs[0];
        let instruction = if leg.quantity > 0.0 {
            OrderInstruction::SellToClose
        } else {
            OrderInstruction::BuyToClose
        };
        return build_single_option_order(
            instruction,
            &leg.symbol,
            leg.quantity.abs(),
            OrderTypeRequest::Market,
            None,
            OrderDuration::Day,
            OrderSession::Normal,
            None,
        );
    }

    let order_type = if group.net_market_value >= 0.0 {
        OrderTypeRequest::NetCredit
    } else {
        OrderTypeRequest::NetDebit
    };

    build_complex_option_order(
        complex,
        order_type,
        leg_specs,
        None,
        OrderDuration::Day,
        OrderSession::Normal,
        None,
    )
}