Skip to main content

schwab_cli/commands/
trading.rs

1use anyhow::Result;
2use serde_json::json;
3
4use crate::cli::{PortfolioCommands, SafetyCommands, TradeCommands};
5use crate::config::RuntimeConfig;
6use crate::human;
7use crate::order_builder::{
8    build_equity_order, parse_duration, parse_session, parse_trade_order_type, TradeSide,
9};
10use crate::output::ResponseEnvelope;
11use crate::portfolio::{
12    account_buying_power, account_equity, ensure_sufficient_buying_power, estimate_equity_buy_cost,
13    summarize_accounts, summary_to_json,
14};
15use crate::safety::{execute_trading_order, require_trading_approval};
16use crate::safety_config::{config_path, SafetyConfig};
17
18pub async fn run_portfolio(runtime: &RuntimeConfig, command: PortfolioCommands) -> Result<()> {
19    let api = runtime.build_api()?;
20
21    match command {
22        PortfolioCommands::Summary => {
23            let accounts = api.accounts().list(Some("positions")).await?;
24            let summary = summarize_accounts(&accounts);
25            runtime.emit(
26                ResponseEnvelope::ok("portfolio summary", summary_to_json(&summary))
27                    .with_next_actions(vec![
28                        "schwab portfolio buying-power --account-number <hash> --json".into(),
29                        "schwab trade buy --help".into(),
30                    ]),
31            );
32        }
33        PortfolioCommands::BuyingPower { account_number } => {
34            let mut account_number = account_number;
35            if runtime.is_interactive() && account_number.is_empty() {
36                account_number = human::pick_account_hash(runtime, &api).await?;
37            }
38            let buying_power = account_buying_power(&api, &account_number).await?;
39            runtime.emit(
40                ResponseEnvelope::ok(
41                    "portfolio buying-power",
42                    json!({
43                        "account_number": account_number,
44                        "buying_power": buying_power,
45                    }),
46                )
47                .with_next_actions(vec![
48                    "schwab market quotes --symbols <SYMBOL> --fields quote --json".into(),
49                    "schwab trade buy --account-number <hash> --symbol <SYMBOL> --quantity <N> --order-type limit --price <ASK> --dry-run --json".into(),
50                ]),
51            );
52        }
53    }
54
55    Ok(())
56}
57
58pub async fn run_trade(runtime: &RuntimeConfig, command: TradeCommands) -> Result<()> {
59    let api = runtime.build_api()?;
60
61    match command {
62        TradeCommands::Buy {
63            mut account_number,
64            symbol,
65            quantity,
66            order_type,
67            price,
68            duration,
69            session,
70        } => {
71            run_side(
72                runtime,
73                &api,
74                TradeRequest {
75                    side: TradeSide::Buy,
76                    account_number: &mut account_number,
77                    symbol: &symbol,
78                    quantity,
79                    order_type_raw: &order_type,
80                    limit_price: price,
81                    duration_raw: duration.as_deref(),
82                    session_raw: session.as_deref(),
83                },
84            )
85            .await
86        }
87        TradeCommands::Sell {
88            mut account_number,
89            symbol,
90            quantity,
91            order_type,
92            price,
93            duration,
94            session,
95        } => {
96            run_side(
97                runtime,
98                &api,
99                TradeRequest {
100                    side: TradeSide::Sell,
101                    account_number: &mut account_number,
102                    symbol: &symbol,
103                    quantity,
104                    order_type_raw: &order_type,
105                    limit_price: price,
106                    duration_raw: duration.as_deref(),
107                    session_raw: session.as_deref(),
108                },
109            )
110            .await
111        }
112    }
113}
114
115struct TradeRequest<'a> {
116    side: TradeSide,
117    account_number: &'a mut String,
118    symbol: &'a str,
119    quantity: f64,
120    order_type_raw: &'a str,
121    limit_price: Option<f64>,
122    duration_raw: Option<&'a str>,
123    session_raw: Option<&'a str>,
124}
125
126async fn run_side(
127    runtime: &RuntimeConfig,
128    api: &schwab_api::TraderApi,
129    req: TradeRequest<'_>,
130) -> Result<()> {
131    let TradeRequest {
132        side,
133        account_number,
134        symbol,
135        quantity,
136        order_type_raw,
137        limit_price,
138        duration_raw,
139        session_raw,
140    } = req;
141    let side_label = if side == TradeSide::Buy {
142        "buy"
143    } else {
144        "sell"
145    };
146    require_trading_approval(
147        runtime,
148        &format!("trade {side_label}"),
149        &format!("Place a live {side_label} order for {quantity} shares of {symbol}."),
150    )?;
151
152    if runtime.is_interactive() && account_number.is_empty() {
153        *account_number = human::pick_account_hash(runtime, api).await?;
154    }
155
156    let order_type = parse_trade_order_type(order_type_raw)?;
157    let duration = parse_duration(duration_raw)?;
158    let session = parse_session(session_raw)?;
159    let order = build_equity_order(
160        side,
161        symbol,
162        quantity,
163        order_type,
164        limit_price,
165        duration,
166        session,
167    )?;
168
169    let mut buying_power_check = None;
170    if side == TradeSide::Buy {
171        let buying_power = account_buying_power(api, account_number).await?;
172        let market_ask = if order_type == crate::order_builder::TradeOrderType::Market {
173            Some(fetch_equity_ask(runtime, symbol).await?)
174        } else {
175            None
176        };
177        let estimated_cost =
178            estimate_equity_buy_cost(quantity, order_type_raw, limit_price, market_ask)?;
179        ensure_sufficient_buying_power(&buying_power, estimated_cost)?;
180        buying_power_check = Some(json!({
181            "cash_available_for_trading": buying_power.cash_available_for_trading,
182            "estimated_cost": estimated_cost,
183            "remaining_after_trade": buying_power.cash_available_for_trading - estimated_cost,
184        }));
185    }
186
187    let inputs = json!({
188        "account_number": account_number,
189        "symbol": symbol.to_uppercase(),
190        "quantity": quantity,
191        "order_type": order_type_raw,
192        "price": limit_price,
193        "side": side_label,
194        "buying_power_check": buying_power_check,
195    });
196
197    if runtime.dry_run {
198        let equity = account_equity(api, account_number).await.ok().flatten();
199        runtime.safety.validate_order(&order, None, equity)?;
200        runtime.emit(
201            ResponseEnvelope::ok(
202                format!("trade {side_label}"),
203                json!({ "dry_run": true, "order": order, "buying_power_check": buying_power_check }),
204            )
205            .with_inputs(inputs),
206        );
207        return Ok(());
208    }
209
210    let result = execute_trading_order(runtime, api, account_number, &order).await?;
211    runtime.emit(ResponseEnvelope::ok(format!("trade {side_label}"), result).with_inputs(inputs));
212    Ok(())
213}
214
215async fn fetch_equity_ask(runtime: &RuntimeConfig, symbol: &str) -> Result<f64> {
216    use anyhow::Context;
217
218    let market_api = runtime.build_market_api()?;
219    let symbol = symbol.trim().to_uppercase();
220    let quotes = market_api
221        .quotes()
222        .get_quotes(&symbol, Some("quote"), None)
223        .await
224        .context("Failed to fetch market quote for buying-power estimate")?;
225    let quote = quotes
226        .get(&symbol)
227        .or_else(|| quotes.as_object().and_then(|m| m.values().next()))
228        .and_then(|entry| entry.get("quote"))
229        .with_context(|| format!("Quote payload missing for symbol {symbol}"))?;
230    quote
231        .get("askPrice")
232        .and_then(|v| v.as_f64())
233        .context("Quote missing askPrice for buying-power estimate")
234}
235
236pub async fn run_safety(runtime: &RuntimeConfig, command: SafetyCommands) -> Result<()> {
237    match command {
238        SafetyCommands::Show => {
239            let path = config_path();
240            let loaded_from_file = path.is_file();
241            runtime.emit(ResponseEnvelope::ok(
242                "safety show",
243                json!({
244                    "path": path,
245                    "loaded_from_file": loaded_from_file,
246                    "config": &*runtime.safety,
247                    "trust_mode": runtime.trust,
248                }),
249            ));
250        }
251        SafetyCommands::Init => {
252            crate::safety::require_mutation_approval(
253                runtime,
254                "safety init",
255                "Write default safety.json to the config directory.",
256            )?;
257            let path = SafetyConfig::init_at_default_path()?;
258            let cfg = SafetyConfig::load()?;
259            runtime.emit(
260                ResponseEnvelope::ok(
261                    "safety init",
262                    json!({
263                        "path": path,
264                        "created": true,
265                        "config": cfg,
266                    }),
267                )
268                .with_next_actions(vec![
269                    "Edit safety.json limits before enabling --trust".into(),
270                ]),
271            );
272        }
273        SafetyCommands::Path => {
274            runtime.emit(ResponseEnvelope::ok(
275                "safety path",
276                json!({ "path": config_path() }),
277            ));
278        }
279    }
280    Ok(())
281}