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 serde_json::json;

use crate::cli::{PortfolioCommands, SafetyCommands, TradeCommands};
use crate::config::RuntimeConfig;
use crate::human;
use crate::order_builder::{
    build_equity_order, parse_duration, parse_session, parse_trade_order_type,
    TradeSide,
};
use crate::output::ResponseEnvelope;
use crate::portfolio::{
    account_buying_power, account_equity, ensure_sufficient_buying_power,
    estimate_equity_buy_cost, summarize_accounts, summary_to_json,
};
use crate::safety::{execute_trading_order, require_trading_approval};
use crate::safety_config::{config_path, SafetyConfig};

pub async fn run_portfolio(runtime: &RuntimeConfig, command: PortfolioCommands) -> Result<()> {
    let api = runtime.build_api()?;

    match command {
        PortfolioCommands::Summary => {
            let accounts = api.accounts().list(Some("positions")).await?;
            let summary = summarize_accounts(&accounts);
            runtime.emit(
                ResponseEnvelope::ok("portfolio summary", summary_to_json(&summary))
                    .with_next_actions(vec![
                        "schwab portfolio buying-power --account-number <hash> --json".into(),
                        "schwab trade buy --help".into(),
                    ]),
            );
        }
        PortfolioCommands::BuyingPower { account_number } => {
            let mut account_number = account_number;
            if runtime.is_interactive() && account_number.is_empty() {
                account_number = human::pick_account_hash(runtime, &api).await?;
            }
            let buying_power = account_buying_power(&api, &account_number).await?;
            runtime.emit(
                ResponseEnvelope::ok(
                    "portfolio buying-power",
                    json!({
                        "account_number": account_number,
                        "buying_power": buying_power,
                    }),
                )
                .with_next_actions(vec![
                    "schwab market quotes --symbols <SYMBOL> --fields quote --json".into(),
                    "schwab trade buy --account-number <hash> --symbol <SYMBOL> --quantity <N> --order-type limit --price <ASK> --dry-run --json".into(),
                ]),
            );
        }
    }

    Ok(())
}

pub async fn run_trade(runtime: &RuntimeConfig, command: TradeCommands) -> Result<()> {
    let api = runtime.build_api()?;

    match command {
        TradeCommands::Buy {
            mut account_number,
            symbol,
            quantity,
            order_type,
            price,
            duration,
            session,
        } => {
            run_side(
                runtime,
                &api,
                TradeRequest {
                    side: TradeSide::Buy,
                    account_number: &mut account_number,
                    symbol: &symbol,
                    quantity,
                    order_type_raw: &order_type,
                    limit_price: price,
                    duration_raw: duration.as_deref(),
                    session_raw: session.as_deref(),
                },
            )
            .await
        }
        TradeCommands::Sell {
            mut account_number,
            symbol,
            quantity,
            order_type,
            price,
            duration,
            session,
        } => {
            run_side(
                runtime,
                &api,
                TradeRequest {
                    side: TradeSide::Sell,
                    account_number: &mut account_number,
                    symbol: &symbol,
                    quantity,
                    order_type_raw: &order_type,
                    limit_price: price,
                    duration_raw: duration.as_deref(),
                    session_raw: session.as_deref(),
                },
            )
            .await
        }
    }
}

struct TradeRequest<'a> {
    side: TradeSide,
    account_number: &'a mut String,
    symbol: &'a str,
    quantity: f64,
    order_type_raw: &'a str,
    limit_price: Option<f64>,
    duration_raw: Option<&'a str>,
    session_raw: Option<&'a str>,
}

async fn run_side(runtime: &RuntimeConfig, api: &schwab_api::TraderApi, req: TradeRequest<'_>) -> Result<()> {
    let TradeRequest {
        side,
        account_number,
        symbol,
        quantity,
        order_type_raw,
        limit_price,
        duration_raw,
        session_raw,
    } = req;
    let side_label = if side == TradeSide::Buy { "buy" } else { "sell" };
    require_trading_approval(
        runtime,
        &format!("trade {side_label}"),
        &format!("Place a live {side_label} order for {quantity} shares of {symbol}."),
    )?;

    if runtime.is_interactive() && account_number.is_empty() {
        *account_number = human::pick_account_hash(runtime, api).await?;
    }

    let order_type = parse_trade_order_type(order_type_raw)?;
    let duration = parse_duration(duration_raw)?;
    let session = parse_session(session_raw)?;
    let order = build_equity_order(
        side,
        symbol,
        quantity,
        order_type,
        limit_price,
        duration,
        session,
    )?;

    let mut buying_power_check = None;
    if side == TradeSide::Buy {
        let buying_power = account_buying_power(api, account_number).await?;
        let market_ask = if order_type == crate::order_builder::TradeOrderType::Market {
            Some(fetch_equity_ask(runtime, symbol).await?)
        } else {
            None
        };
        let estimated_cost = estimate_equity_buy_cost(
            quantity,
            order_type_raw,
            limit_price,
            market_ask,
        )?;
        ensure_sufficient_buying_power(&buying_power, estimated_cost)?;
        buying_power_check = Some(json!({
            "cash_available_for_trading": buying_power.cash_available_for_trading,
            "estimated_cost": estimated_cost,
            "remaining_after_trade": buying_power.cash_available_for_trading - estimated_cost,
        }));
    }

    let inputs = json!({
        "account_number": account_number,
        "symbol": symbol.to_uppercase(),
        "quantity": quantity,
        "order_type": order_type_raw,
        "price": limit_price,
        "side": side_label,
        "buying_power_check": buying_power_check,
    });

    if runtime.dry_run {
        let equity = account_equity(api, account_number).await.ok().flatten();
        runtime.safety.validate_order(&order, None, equity)?;
        runtime.emit(
            ResponseEnvelope::ok(
                format!("trade {side_label}"),
                json!({ "dry_run": true, "order": order, "buying_power_check": buying_power_check }),
            )
            .with_inputs(inputs),
        );
        return Ok(());
    }

    let result = execute_trading_order(runtime, api, account_number, &order).await?;
    runtime.emit(
        ResponseEnvelope::ok(format!("trade {side_label}"), result).with_inputs(inputs),
    );
    Ok(())
}

async fn fetch_equity_ask(runtime: &RuntimeConfig, symbol: &str) -> Result<f64> {
    use anyhow::Context;

    let market_api = runtime.build_market_api()?;
    let symbol = symbol.trim().to_uppercase();
    let quotes = market_api
        .quotes()
        .get_quotes(&symbol, Some("quote"), None)
        .await
        .context("Failed to fetch market quote for buying-power estimate")?;
    let quote = quotes
        .get(&symbol)
        .or_else(|| quotes.as_object().and_then(|m| m.values().next()))
        .and_then(|entry| entry.get("quote"))
        .with_context(|| format!("Quote payload missing for symbol {symbol}"))?;
    quote
        .get("askPrice")
        .and_then(|v| v.as_f64())
        .context("Quote missing askPrice for buying-power estimate")
}

pub async fn run_safety(runtime: &RuntimeConfig, command: SafetyCommands) -> Result<()> {
    match command {
        SafetyCommands::Show => {
            let path = config_path();
            let loaded_from_file = path.is_file();
            runtime.emit(ResponseEnvelope::ok(
                "safety show",
                json!({
                    "path": path,
                    "loaded_from_file": loaded_from_file,
                    "config": &*runtime.safety,
                    "trust_mode": runtime.trust,
                }),
            ));
        }
        SafetyCommands::Init => {
            crate::safety::require_mutation_approval(
                runtime,
                "safety init",
                "Write default safety.json to the config directory.",
            )?;
            let path = SafetyConfig::init_at_default_path()?;
            let cfg = SafetyConfig::load()?;
            runtime.emit(
                ResponseEnvelope::ok(
                    "safety init",
                    json!({
                        "path": path,
                        "created": true,
                        "config": cfg,
                    }),
                )
                .with_next_actions(vec!["Edit safety.json limits before enabling --trust".into()]),
            );
        }
        SafetyCommands::Path => {
            runtime.emit(ResponseEnvelope::ok(
                "safety path",
                json!({ "path": config_path() }),
            ));
        }
    }
    Ok(())
}