Skip to main content

schwab_cli/
portfolio.rs

1use anyhow::{bail, Context, Result};
2use schwab_api::models::account::Account;
3use serde_json::{json, Value};
4
5use crate::safety_config::{estimate_notional, parse_order, ParsedOrder};
6
7#[derive(Debug, Clone, serde::Serialize)]
8pub struct PortfolioSummary {
9    pub total_equity: f64,
10    pub total_positions: usize,
11    pub accounts: Vec<AccountSummary>,
12    pub aggregated_holdings: Vec<AggregatedHolding>,
13}
14
15#[derive(Debug, Clone, serde::Serialize)]
16pub struct AccountSummary {
17    pub account_number: Option<String>,
18    pub account_number_last4: String,
19    pub equity: Option<f64>,
20    pub position_count: usize,
21    pub positions: Vec<PositionSummary>,
22}
23
24#[derive(Debug, Clone, serde::Serialize)]
25pub struct PositionSummary {
26    pub symbol: String,
27    pub description: Option<String>,
28    pub quantity: f64,
29    pub market_value: f64,
30    pub pct_of_account: Option<f64>,
31}
32
33#[derive(Debug, Clone, serde::Serialize)]
34pub struct AggregatedHolding {
35    pub symbol: String,
36    pub total_quantity: f64,
37    pub total_market_value: f64,
38    pub pct_of_portfolio: f64,
39}
40
41pub fn summarize_accounts(accounts: &[Account]) -> PortfolioSummary {
42    let mut account_summaries = Vec::new();
43    let mut agg: std::collections::HashMap<String, (f64, f64)> = std::collections::HashMap::new();
44    let mut total_equity = 0.0;
45    let mut total_positions = 0usize;
46
47    for account in accounts {
48        let sa = match &account.securities_account {
49            Some(sa) => sa,
50            None => continue,
51        };
52
53        let equity = extract_equity(sa.current_balances.as_ref());
54        if let Some(eq) = equity {
55            total_equity += eq;
56        }
57
58        let acct_num = sa.account_number.clone().unwrap_or_default();
59        let last4 = last4(&acct_num);
60
61        let positions = sa.positions.as_deref().unwrap_or_default();
62        total_positions += positions.len();
63
64        let mut pos_summaries = Vec::new();
65        for pos in positions {
66            let symbol = pos
67                .instrument
68                .as_ref()
69                .and_then(|i| i.symbol.clone())
70                .unwrap_or_else(|| "?".into());
71            let description = pos
72                .instrument
73                .as_ref()
74                .and_then(|i| i.description.clone());
75            let quantity = pos.long_quantity.unwrap_or(0.0) - pos.short_quantity.unwrap_or(0.0);
76            let market_value = pos.market_value.unwrap_or(0.0);
77            let pct_of_account = equity.filter(|e| *e > 0.0).map(|e| (market_value / e) * 100.0);
78
79            pos_summaries.push(PositionSummary {
80                symbol: symbol.clone(),
81                description,
82                quantity,
83                market_value,
84                pct_of_account,
85            });
86
87            let entry = agg.entry(symbol).or_insert((0.0, 0.0));
88            entry.0 += quantity;
89            entry.1 += market_value;
90        }
91
92        pos_summaries.sort_by(|a, b| {
93            b.market_value
94                .partial_cmp(&a.market_value)
95                .unwrap_or(std::cmp::Ordering::Equal)
96        });
97
98        account_summaries.push(AccountSummary {
99            account_number: sa.account_number.clone(),
100            account_number_last4: last4,
101            equity,
102            position_count: positions.len(),
103            positions: pos_summaries,
104        });
105    }
106
107    account_summaries.sort_by(|a, b| {
108        b.equity
109            .unwrap_or(0.0)
110            .partial_cmp(&a.equity.unwrap_or(0.0))
111            .unwrap_or(std::cmp::Ordering::Equal)
112    });
113
114    let mut aggregated_holdings: Vec<AggregatedHolding> = agg
115        .into_iter()
116        .map(|(symbol, (total_quantity, total_market_value))| {
117            let pct_of_portfolio = if total_equity > 0.0 {
118                (total_market_value / total_equity) * 100.0
119            } else {
120                0.0
121            };
122            AggregatedHolding {
123                symbol,
124                total_quantity,
125                total_market_value,
126                pct_of_portfolio,
127            }
128        })
129        .collect();
130
131    aggregated_holdings.sort_by(|a, b| {
132        b.total_market_value
133            .partial_cmp(&a.total_market_value)
134            .unwrap_or(std::cmp::Ordering::Equal)
135    });
136
137    PortfolioSummary {
138        total_equity,
139        total_positions,
140        accounts: account_summaries,
141        aggregated_holdings,
142    }
143}
144
145#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
146pub struct BuyingPower {
147    pub cash_available_for_trading: f64,
148    pub cash_balance: f64,
149    pub option_buying_power: Option<f64>,
150    pub liquidation_value: Option<f64>,
151}
152
153pub async fn account_equity(api: &schwab_api::TraderApi, account_hash: &str) -> Result<Option<f64>> {
154    let buying_power = account_buying_power(api, account_hash).await?;
155    Ok(buying_power.liquidation_value)
156}
157
158pub async fn account_buying_power(
159    api: &schwab_api::TraderApi,
160    account_hash: &str,
161) -> Result<BuyingPower> {
162    let account = api.accounts().get(account_hash, None).await?;
163    let sa = account
164        .securities_account
165        .as_ref()
166        .context("Account has no securitiesAccount payload")?;
167
168    Ok(extract_buying_power(
169        sa.current_balances.as_ref(),
170        sa.projected_balances.as_ref(),
171    ))
172}
173
174pub fn extract_buying_power(
175    current: Option<&Value>,
176    projected: Option<&Value>,
177) -> BuyingPower {
178    // Cash accounts expose cashAvailableForTrading; margin accounts use buyingPower /
179    // availableFunds instead. Try each in order so both account types work correctly.
180    let cash_available_for_trading = extract_balance_field(current, "cashAvailableForTrading")
181        .or_else(|| extract_balance_field(projected, "cashAvailableForTrading"))
182        .or_else(|| extract_balance_field(current, "buyingPower"))
183        .or_else(|| extract_balance_field(current, "availableFunds"))
184        .or_else(|| extract_balance_field(projected, "buyingPower"))
185        .or_else(|| extract_balance_field(projected, "availableFunds"))
186        .unwrap_or(0.0);
187    let option_buying_power = extract_balance_field(current, "optionBuyingPower")
188        .or_else(|| extract_balance_field(projected, "optionBuyingPower"));
189    let cash_balance = extract_balance_field(current, "cashBalance")
190        .or_else(|| extract_balance_field(current, "totalCash"))
191        .unwrap_or(0.0);
192    let liquidation_value = extract_balance_field(current, "liquidationValue")
193        .or_else(|| extract_equity(current));
194
195    let effective_available = option_buying_power
196        .unwrap_or(cash_available_for_trading)
197        .max(cash_available_for_trading);
198
199    BuyingPower {
200        cash_available_for_trading: effective_available,
201        cash_balance,
202        option_buying_power,
203        liquidation_value,
204    }
205}
206
207pub fn estimate_equity_buy_cost(
208    quantity: f64,
209    order_type: &str,
210    limit_price: Option<f64>,
211    market_ask: Option<f64>,
212) -> Result<f64> {
213    let order_type = order_type.to_uppercase();
214    match order_type.as_str() {
215        "LIMIT" | "STOP_LIMIT" | "LIMIT_ON_CLOSE" => {
216            let price = limit_price.context("limit price required to estimate buy cost")?;
217            Ok(quantity * price)
218        }
219        "MARKET" => {
220            let ask = market_ask.context(
221                "market ask price required to estimate buy cost for MARKET orders",
222            )?;
223            Ok(quantity * ask)
224        }
225        other => bail!("Cannot estimate buy cost for order type `{other}`"),
226    }
227}
228
229pub fn order_requires_buying_power(parsed: &ParsedOrder) -> bool {
230    if parsed.legs.iter().any(|leg| leg.asset_type == "EQUITY" && leg.instruction == "BUY") {
231        return true;
232    }
233    if parsed.legs.iter().any(|leg| leg.asset_type == "OPTION") {
234        return matches!(
235            parsed.order_type.as_str(),
236            "NET_DEBIT" | "LIMIT" | "MARKET"
237        );
238    }
239    false
240}
241
242pub fn ensure_sufficient_buying_power(
243    buying_power: &BuyingPower,
244    estimated_cost: f64,
245) -> Result<()> {
246    let available = buying_power.cash_available_for_trading;
247    if estimated_cost > available {
248        let shortfall = estimated_cost - available;
249        bail!(
250            "Insufficient buying power: need ${estimated_cost:.2}, available ${available:.2} \
251             (shortfall ${shortfall:.2}). Sell holdings or wait for a prior sell to fill and \
252             settle before placing buys. Check with `schwab portfolio buying-power --account-number <hash> --json`."
253        );
254    }
255    Ok(())
256}
257
258pub async fn validate_buying_power_for_order(
259    api: &schwab_api::TraderApi,
260    account_hash: &str,
261    order: &Value,
262    market_ask: Option<f64>,
263) -> Result<BuyingPower> {
264    let parsed = parse_order(order)?;
265    if !order_requires_buying_power(&parsed) {
266        return account_buying_power(api, account_hash).await;
267    }
268
269    let buying_power = account_buying_power(api, account_hash).await?;
270    if let Some(cost) = estimate_notional(&parsed, None).or_else(|| {
271        parsed.legs.iter().find_map(|leg| {
272            if leg.instruction != "BUY" || leg.asset_type != "EQUITY" {
273                return None;
274            }
275            let price = parsed.limit_price.or(market_ask)?;
276            Some(leg.quantity * price)
277        })
278    }) {
279        ensure_sufficient_buying_power(&buying_power, cost)?;
280    }
281
282    Ok(buying_power)
283}
284
285pub async fn validate_buying_power_after_preview(
286    api: &schwab_api::TraderApi,
287    account_hash: &str,
288    order: &Value,
289    preview: &Value,
290) -> Result<()> {
291    ensure_preview_accepted(preview)?;
292    ensure_preview_buying_power(preview)?;
293
294    let parsed = parse_order(order)?;
295    if !order_requires_buying_power(&parsed) {
296        return Ok(());
297    }
298
299    let buying_power = account_buying_power(api, account_hash).await?;
300    if let Some(cost) = estimate_notional(&parsed, Some(preview)) {
301        ensure_sufficient_buying_power(&buying_power, cost)?;
302    }
303    Ok(())
304}
305
306/// Schwab embeds hard rejects in preview even when the preview HTTP call succeeds.
307pub fn ensure_preview_accepted(preview: &Value) -> Result<()> {
308    let Some(rejects) = preview
309        .pointer("/orderValidationResult/rejects")
310        .and_then(|v| v.as_array())
311    else {
312        return Ok(());
313    };
314    if rejects.is_empty() {
315        return Ok(());
316    }
317    let messages: Vec<String> = rejects
318        .iter()
319        .filter_map(|r| {
320            r.get("activityMessage")
321                .and_then(|m| m.as_str())
322                .map(str::to_string)
323        })
324        .collect();
325    bail!(
326        "Schwab preview rejected order: {}",
327        if messages.is_empty() {
328            "unknown reason".into()
329        } else {
330            messages.join("; ")
331        }
332    );
333}
334
335/// Block orders that would drive projected buying power negative (common on spread margin).
336pub fn ensure_preview_buying_power(preview: &Value) -> Result<()> {
337    let balance = preview
338        .pointer("/orderStrategy/orderBalance")
339        .or_else(|| preview.get("orderBalance"));
340    let Some(balance) = balance else {
341        return Ok(());
342    };
343    for key in ["projectedBuyingPower", "projectedAvailableFund"] {
344        if let Some(v) = balance.get(key).and_then(parse_num) {
345            if v < 0.0 {
346                bail!(
347                    "Schwab preview shows insufficient buying power after order ({key}: ${v:.2})"
348                );
349            }
350        }
351    }
352    Ok(())
353}
354
355pub fn summary_to_json(summary: &PortfolioSummary) -> Value {
356    json!(summary)
357}
358
359fn extract_equity(balances: Option<&Value>) -> Option<f64> {
360    let b = balances?;
361    for key in ["equity", "accountValue", "liquidationValue"] {
362        if let Some(v) = b.get(key).and_then(parse_num) {
363            return Some(v);
364        }
365    }
366    None
367}
368
369fn extract_balance_field(balances: Option<&Value>, key: &str) -> Option<f64> {
370    balances?.get(key).and_then(parse_num)
371}
372
373fn parse_num(v: &Value) -> Option<f64> {
374    v.as_f64()
375        .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
376}
377
378fn last4(acct: &str) -> String {
379    if acct.len() >= 4 {
380        acct[acct.len() - 4..].to_string()
381    } else {
382        acct.to_string()
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use schwab_api::models::account::{Account, AccountsInstrument, Position, SecuritiesAccount};
390    use serde_json::json;
391
392    #[test]
393    fn preview_reject_is_surfaced() {
394        let preview = json!({
395            "orderValidationResult": {
396                "rejects": [{
397                    "activityMessage": "You do not have enough available cash/buying power for this order."
398                }]
399            }
400        });
401        assert!(ensure_preview_accepted(&preview).is_err());
402    }
403
404    #[test]
405    fn preview_negative_projected_buying_power_is_blocked() {
406        let preview = json!({
407            "orderStrategy": {
408                "orderBalance": {
409                    "projectedBuyingPower": -100.0
410                }
411            }
412        });
413        assert!(ensure_preview_buying_power(&preview).is_err());
414    }
415
416    #[test]
417    fn summarizes_positions() {
418        let accounts = vec![Account {
419            securities_account: Some(SecuritiesAccount {
420                account_number: Some("12345678".into()),
421                round_trips: None,
422                is_day_trader: None,
423                is_closing_only_restricted: None,
424                pfcb_flag: None,
425                positions: Some(vec![Position {
426                    short_quantity: None,
427                    average_price: None,
428                    current_day_profit_loss: None,
429                    current_day_profit_loss_percentage: None,
430                    long_quantity: Some(10.0),
431                    settled_long_quantity: None,
432                    settled_short_quantity: None,
433                    aged_quantity: None,
434                    instrument: Some(AccountsInstrument {
435                        cusip: None,
436                        symbol: Some("AAPL".into()),
437                        description: Some("Apple".into()),
438                        instrument_id: None,
439                        net_change: None,
440                        r#type: None,
441                    }),
442                    market_value: Some(1000.0),
443                    maintenance_requirement: None,
444                    average_long_price: None,
445                    average_short_price: None,
446                    tax_lot_average_long_price: None,
447                    tax_lot_average_short_price: None,
448                    long_open_profit_loss: None,
449                    short_open_profit_loss: None,
450                    previous_session_long_quantity: None,
451                    previous_session_short_quantity: None,
452                    current_day_cost: None,
453                }]),
454                initial_balances: None,
455                current_balances: Some(json!({ "equity": 5000.0 })),
456                projected_balances: None,
457            }),
458        }];
459
460        let summary = summarize_accounts(&accounts);
461        assert_eq!(summary.total_equity, 5000.0);
462        assert_eq!(summary.aggregated_holdings[0].symbol, "AAPL");
463    }
464
465    #[test]
466    fn extracts_buying_power() {
467        let current = json!({
468            "cashAvailableForTrading": 78.96,
469            "cashBalance": 78.96,
470            "liquidationValue": 28413.79
471        });
472        let power = extract_buying_power(Some(&current), None);
473        assert_eq!(power.cash_available_for_trading, 78.96);
474        assert_eq!(power.liquidation_value, Some(28413.79));
475    }
476
477    #[test]
478    fn blocks_buy_with_insufficient_funds() {
479        let power = BuyingPower {
480            cash_available_for_trading: 78.96,
481            cash_balance: 78.96,
482            option_buying_power: None,
483            liquidation_value: Some(28413.79),
484        };
485        let err = ensure_sufficient_buying_power(&power, 253.25).unwrap_err();
486        assert!(err.to_string().contains("Insufficient buying power"));
487    }
488
489    #[test]
490    fn estimates_limit_buy_cost() {
491        let cost = estimate_equity_buy_cost(5.0, "limit", Some(50.65), None).unwrap();
492        assert!((cost - 253.25).abs() < 0.01);
493    }
494}