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 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
305pub 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
334pub 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(¤t), 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}