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