All checks were successful
Build and Push Docker Image / build (push) Successful in 34s
427 lines
14 KiB
Python
427 lines
14 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import re
|
||
from decimal import Decimal, InvalidOperation
|
||
from typing import Any, Optional, Sequence
|
||
|
||
from ...browser.auth import ensure_cookies
|
||
from ...browser.client import connect, new_context, new_page
|
||
from ...browser.navigation import goto_with_auth_check
|
||
from ...core import AccountOverview, AccountSummary, Envelope, ErrorType, fail, ok
|
||
from ...core.config import get_playwright_url, load_config
|
||
|
||
SUMMARY_URL = "https://client.schwab.com/accounts/summary/summary.aspx/"
|
||
|
||
|
||
def _parse_currency(value: str | None) -> Optional[Decimal]:
|
||
if not value:
|
||
return None
|
||
|
||
cleaned = value.strip()
|
||
if not cleaned or cleaned in {"-", "--"}:
|
||
return None
|
||
|
||
negative = False
|
||
if cleaned.startswith("(") and cleaned.endswith(")"):
|
||
negative = True
|
||
cleaned = cleaned.replace("$", "").replace(",", "")
|
||
cleaned = cleaned.replace("(", "").replace(")", "")
|
||
cleaned = cleaned.replace("−", "-").strip()
|
||
|
||
if not cleaned:
|
||
return None
|
||
|
||
try:
|
||
parsed = Decimal(cleaned)
|
||
if negative or parsed < 0:
|
||
parsed = -abs(parsed)
|
||
return parsed
|
||
except InvalidOperation:
|
||
return None
|
||
|
||
|
||
def _parse_percentage(value: str | None) -> Optional[float]:
|
||
if not value:
|
||
return None
|
||
cleaned = value.strip()
|
||
if not cleaned:
|
||
return None
|
||
|
||
negative = False
|
||
if cleaned.startswith("(") and cleaned.endswith(")"):
|
||
negative = True
|
||
|
||
cleaned = cleaned.replace("%", "").replace("(", "").replace(")", "")
|
||
cleaned = cleaned.replace("−", "-").strip()
|
||
|
||
if not cleaned:
|
||
return None
|
||
|
||
try:
|
||
parsed = float(cleaned)
|
||
except ValueError:
|
||
return None
|
||
|
||
if negative or parsed < 0:
|
||
parsed = -abs(parsed)
|
||
return parsed
|
||
|
||
|
||
def _normalize_account_label(label: str) -> AccountSummary:
|
||
normalized = re.sub(r"\s+", " ", label).strip()
|
||
last4_match = re.search(r"(\d{3,4})\b", normalized.replace(" ", ""))
|
||
last4 = last4_match.group(1)[-4:] if last4_match else None
|
||
|
||
type_match = re.search(r"^[A-Za-z&'\- ]+", normalized)
|
||
account_type = re.sub(r"\s+", "_", type_match.group(0).strip()) if type_match else "Account"
|
||
|
||
account_id = f"{account_type}-{last4}" if last4 else account_type
|
||
|
||
return AccountSummary(
|
||
id=account_id,
|
||
label=normalized,
|
||
type=account_type,
|
||
last4=last4,
|
||
is_margin="margin" in normalized.lower(),
|
||
)
|
||
|
||
|
||
def _match_account(candidate: AccountSummary, requested: AccountSummary | str | None) -> bool:
|
||
if requested is None:
|
||
return True
|
||
if isinstance(requested, AccountSummary):
|
||
requested_values = {
|
||
requested.id.lower(),
|
||
requested.label.lower(),
|
||
}
|
||
if requested.last4:
|
||
requested_values.add(requested.last4.lower())
|
||
else:
|
||
lookup = requested.strip().lower()
|
||
requested_values = {lookup}
|
||
|
||
candidate_values = {candidate.id.lower(), candidate.label.lower()}
|
||
if candidate.last4:
|
||
candidate_values.add(candidate.last4.lower())
|
||
|
||
return bool(candidate_values & requested_values)
|
||
|
||
|
||
def _rows_to_dicts(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> list[dict[str, str]]:
|
||
normalized_headers = [header.strip().lower() for header in headers]
|
||
results: list[dict[str, str]] = []
|
||
for row in rows:
|
||
row_map: dict[str, str] = {}
|
||
for idx, header in enumerate(normalized_headers):
|
||
if idx < len(row):
|
||
row_map[header] = row[idx].strip()
|
||
results.append(row_map)
|
||
return results
|
||
|
||
|
||
async def _extract_table(page) -> dict[str, Any] | None:
|
||
return await page.evaluate(
|
||
"""
|
||
() => {
|
||
const wrapper = document.querySelector('.sdps-tables__wrapper');
|
||
if (!wrapper) {
|
||
return null;
|
||
}
|
||
|
||
const headerRow = wrapper.querySelector('.sdps-tables__row--header');
|
||
const headers = headerRow
|
||
? Array.from(headerRow.querySelectorAll('.sdps-tables__header-text'))
|
||
.map((el) => (el.textContent || '').trim())
|
||
: [];
|
||
|
||
if (!headers.length) {
|
||
const legacyHeaders = wrapper.querySelectorAll('thead th');
|
||
if (legacyHeaders.length) {
|
||
for (const th of legacyHeaders) {
|
||
headers.push((th.textContent || '').trim());
|
||
}
|
||
}
|
||
}
|
||
|
||
const bodyRows = wrapper.querySelectorAll('.sdps-tables__row--body');
|
||
const rows = [];
|
||
if (bodyRows.length) {
|
||
bodyRows.forEach((row) => {
|
||
const cells = Array.from(
|
||
row.querySelectorAll('.sdps-tables__cell, div[role="cell"], td')
|
||
).map((cell) => (cell.textContent || '').trim());
|
||
rows.push(cells);
|
||
});
|
||
}
|
||
|
||
if (!rows.length) {
|
||
const fallbackRows = wrapper.querySelectorAll('tbody tr');
|
||
fallbackRows.forEach((row) => {
|
||
const cells = Array.from(row.querySelectorAll('td')).map((cell) => (cell.textContent || '').trim());
|
||
if (cells.length) {
|
||
rows.push(cells);
|
||
}
|
||
});
|
||
}
|
||
|
||
return { headers, rows };
|
||
}
|
||
"""
|
||
)
|
||
|
||
|
||
async def _extract_totals(page) -> dict[str, str | None]:
|
||
return await page.evaluate(
|
||
r"""
|
||
() => {
|
||
const result = { total: null, dayChange: null, dayChangePct: null, cash: null };
|
||
|
||
const totalLabel = document.querySelector('#total-value-label');
|
||
if (totalLabel) {
|
||
const valueEl = totalLabel.closest('[class*="sdps-panel"], h2, div');
|
||
if (valueEl) {
|
||
const currencyMatch = valueEl.textContent?.match(/\$[\d,]+\.?\d*/);
|
||
if (currencyMatch) {
|
||
result.total = currencyMatch[0];
|
||
}
|
||
}
|
||
}
|
||
|
||
const dayChangeLabel = document.querySelector('#day-change-label');
|
||
if (dayChangeLabel) {
|
||
const container = dayChangeLabel.parentElement;
|
||
if (container) {
|
||
const matchCurrency = container.textContent?.match(/\$[\d,]+\.?\d*/);
|
||
const matchPct = container.textContent?.match(/-?\d+(?:\.\d+)?%/);
|
||
if (matchCurrency) {
|
||
result.dayChange = matchCurrency[0];
|
||
}
|
||
if (matchPct) {
|
||
result.dayChangePct = matchPct[0];
|
||
}
|
||
}
|
||
}
|
||
|
||
const cashLabel = Array.from(document.querySelectorAll('.sdps-tables__header-text')).find((el) =>
|
||
el.textContent?.toLowerCase().includes('cash & cash investments')
|
||
);
|
||
if (cashLabel) {
|
||
const container = cashLabel.closest('div');
|
||
if (container) {
|
||
const matchCurrency = container.textContent?.match(/\$[\d,]+\.?\d*/);
|
||
if (matchCurrency) {
|
||
result.cash = matchCurrency[0];
|
||
}
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
"""
|
||
)
|
||
|
||
|
||
def _row_to_overview(row_map: dict[str, str]) -> tuple[AccountSummary, AccountOverview]:
|
||
label = row_map.get('name') or row_map.get('account') or row_map.get('account name') or row_map.get('', '')
|
||
label = label or "Account"
|
||
|
||
account_summary = _normalize_account_label(label)
|
||
|
||
total_value = _parse_currency(
|
||
row_map.get('account value')
|
||
or row_map.get('total value')
|
||
or row_map.get('market value')
|
||
)
|
||
|
||
day_change = _parse_currency(
|
||
row_map.get('day change $')
|
||
or row_map.get('day change')
|
||
or row_map.get('day change amount')
|
||
)
|
||
|
||
day_change_pct = _parse_percentage(
|
||
row_map.get('day change %')
|
||
or row_map.get('day change percent')
|
||
)
|
||
|
||
cash_value = _parse_currency(
|
||
row_map.get('cash & cash investments')
|
||
or row_map.get('cash')
|
||
)
|
||
|
||
settled_cash = _parse_currency(row_map.get('settled cash'))
|
||
buying_power = _parse_currency(row_map.get('buying power') or row_map.get('available to trade'))
|
||
margin_balance = _parse_currency(row_map.get('margin balance') or row_map.get('margin'))
|
||
|
||
overview = AccountOverview(
|
||
account=account_summary,
|
||
total_value=total_value,
|
||
day_change=day_change,
|
||
day_change_pct=day_change_pct,
|
||
cash=cash_value,
|
||
settled_cash=settled_cash,
|
||
buying_power=buying_power,
|
||
margin_balance=margin_balance,
|
||
)
|
||
|
||
return account_summary, overview
|
||
|
||
|
||
async def get_account_overview(
|
||
account: AccountSummary | str | None = None, *, debug: bool = False
|
||
) -> Envelope[AccountOverview]:
|
||
cookies = await ensure_cookies()
|
||
if not cookies:
|
||
return fail("Unable to establish Schwab session.", ErrorType.AUTHENTICATION, retryable=False)
|
||
|
||
config = load_config()
|
||
playwright_url = get_playwright_url(config)
|
||
|
||
playwright = browser = context = page = None
|
||
try:
|
||
playwright, browser = await connect(playwright_url)
|
||
context = await new_context(browser, cookies=cookies)
|
||
page = await new_page(context)
|
||
|
||
if not await goto_with_auth_check(page, context, SUMMARY_URL, debug=debug):
|
||
return fail("Failed to load Schwab account summary page.", ErrorType.AUTHENTICATION, retryable=True)
|
||
|
||
await asyncio.sleep(1)
|
||
|
||
table_data = await _extract_table(page)
|
||
if not table_data:
|
||
return fail("Unable to locate account overview table.", ErrorType.PARSING, retryable=True)
|
||
|
||
row_dicts = _rows_to_dicts(table_data["headers"], table_data["rows"])
|
||
matched_overviews: list[AccountOverview] = []
|
||
|
||
for row_map in row_dicts:
|
||
# Skip empty rows or totals indicated by lack of numeric data
|
||
values = "".join(row_map.values())
|
||
if not values:
|
||
continue
|
||
|
||
summary, overview = _row_to_overview(row_map)
|
||
if _match_account(summary, account):
|
||
matched_overviews.append(overview)
|
||
|
||
if not matched_overviews:
|
||
return fail("Account not found in overview table.", ErrorType.VALIDATION, retryable=False)
|
||
|
||
if account is None and len(matched_overviews) > 1:
|
||
aggregated = _aggregate_overviews(matched_overviews)
|
||
totals = await _extract_totals(page)
|
||
if totals:
|
||
if totals.get("total"):
|
||
aggregated.total_value = _parse_currency(totals.get("total"))
|
||
if totals.get("dayChange"):
|
||
aggregated.day_change = _parse_currency(totals.get("dayChange"))
|
||
if totals.get("dayChangePct"):
|
||
aggregated.day_change_pct = _parse_percentage(totals.get("dayChangePct"))
|
||
if totals.get("cash"):
|
||
aggregated.cash = _parse_currency(totals.get("cash"))
|
||
return ok(aggregated)
|
||
|
||
return ok(matched_overviews[0])
|
||
except Exception as exc:
|
||
return fail(str(exc), ErrorType.UNKNOWN, retryable=True)
|
||
finally:
|
||
await _safe_close_page(page)
|
||
await _safe_close_context(context)
|
||
await _safe_close_browser(browser)
|
||
await _safe_stop_playwright(playwright)
|
||
|
||
|
||
def _aggregate_overviews(overviews: Sequence[AccountOverview]) -> AccountOverview:
|
||
total_value = Decimal("0")
|
||
day_change = Decimal("0")
|
||
cash_total = Decimal("0")
|
||
settled_total = Decimal("0")
|
||
buying_total = Decimal("0")
|
||
margin_total = Decimal("0")
|
||
|
||
for item in overviews:
|
||
if item.total_value is not None:
|
||
total_value += item.total_value
|
||
if item.day_change is not None:
|
||
day_change += item.day_change
|
||
if item.cash is not None:
|
||
cash_total += item.cash
|
||
if item.settled_cash is not None:
|
||
settled_total += item.settled_cash
|
||
if item.buying_power is not None:
|
||
buying_total += item.buying_power
|
||
if item.margin_balance is not None:
|
||
margin_total += item.margin_balance
|
||
|
||
aggregated_summary = AccountSummary(
|
||
id="AGGREGATE",
|
||
label="All Accounts",
|
||
type="AGGREGATE",
|
||
last4=None,
|
||
is_margin=False,
|
||
)
|
||
|
||
total_value_out = total_value if total_value != 0 else None
|
||
day_change_out = day_change if day_change != 0 else None
|
||
cash_out = cash_total if cash_total != 0 else None
|
||
settled_out = settled_total if settled_total != 0 else None
|
||
buying_out = buying_total if buying_total != 0 else None
|
||
margin_out = margin_total if margin_total != 0 else None
|
||
|
||
day_change_pct: Optional[float] = None
|
||
if total_value_out and day_change_out:
|
||
try:
|
||
day_change_pct = float((day_change_out / total_value_out) * 100)
|
||
except (InvalidOperation, ZeroDivisionError):
|
||
day_change_pct = None
|
||
|
||
return AccountOverview(
|
||
account=aggregated_summary,
|
||
total_value=total_value_out,
|
||
day_change=day_change_out,
|
||
day_change_pct=day_change_pct,
|
||
cash=cash_out,
|
||
settled_cash=settled_out,
|
||
buying_power=buying_out,
|
||
margin_balance=margin_out,
|
||
)
|
||
|
||
|
||
async def _safe_close_page(page) -> None:
|
||
if page is None:
|
||
return
|
||
try:
|
||
await page.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def _safe_close_context(context) -> None:
|
||
if context is None:
|
||
return
|
||
try:
|
||
await context.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def _safe_close_browser(browser) -> None:
|
||
if browser is None:
|
||
return
|
||
try:
|
||
await browser.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def _safe_stop_playwright(playwright) -> None:
|
||
if playwright is None:
|
||
return
|
||
try:
|
||
await playwright.stop()
|
||
except Exception:
|
||
pass
|
||
|