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 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
306pub 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
335pub 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(¤t), 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}