import json from datetime import datetime, timedelta, timezone from urllib.parse import urlencode, urljoin from urllib.request import Request, urlopen from overview.brokerage_types import ( BrokerageAccountPayload, BrokerageAssetPayload, BrokerageHoldingPayload, BrokerageSnapshot, BrokerageTransactionPayload, ) from .base import BaseBrokerageAdapter, BrokerageAdapterError class CharlesSchwabBrokerageAdapter(BaseBrokerageAdapter): provider = "charles-schwab" default_base_url = "https://api.schwabapi.com/trader/v1/" default_transaction_types = [ "TRADE", "DIVIDEND_OR_INTEREST", "CASH_RECEIPT", "CASH_DISBURSEMENT", "ACH_RECEIPT", "ACH_DISBURSEMENT", "RECEIVE_AND_DELIVER", ] def fetch_snapshot(self) -> BrokerageSnapshot: account_hashes = self._fetch_account_hashes() account_items = self._as_list( self._request_json("/accounts", params={"fields": "positions"}) ) accounts = [] assets_by_external_id = {} holdings = [] transactions = [] for item in account_items: account_data = item.get("securitiesAccount", item) account_payload = self._build_account(account_data) accounts.append(account_payload) for position in account_data.get("positions", []): asset_payload = self._build_asset(position) assets_by_external_id[asset_payload.external_id] = asset_payload holdings.append( self._build_holding( account_payload=account_payload, position=position, asset_payload=asset_payload, ) ) account_hash = account_hashes.get(account_payload.number) if account_hash and self.connection.get_setting("include_transactions", True): transactions.extend( self._fetch_transactions_for_account( account_payload=account_payload, account_hash=account_hash, assets_by_external_id=assets_by_external_id, ) ) return BrokerageSnapshot( accounts=accounts, assets=list(assets_by_external_id.values()), holdings=holdings, transactions=transactions, ) def _fetch_account_hashes(self) -> dict[str, str]: payload = self._request_json("/accounts/accountNumbers") account_numbers = self._as_list(payload) hashes = {} for item in account_numbers: account_number = item.get("accountNumber") hash_value = item.get("hashValue") if account_number and hash_value: hashes[str(account_number)] = str(hash_value) return hashes def _fetch_transactions_for_account( self, account_payload: BrokerageAccountPayload, account_hash: str, assets_by_external_id: dict[str, BrokerageAssetPayload], ) -> list[BrokerageTransactionPayload]: transactions = [] start_date, end_date = self._transaction_date_window() transaction_types = self.connection.get_setting( "transaction_types", self.default_transaction_types, ) for transaction_type in transaction_types: payload = self._request_json( f"/accounts/{account_hash}/transactions", params={ "startDate": self._format_schwab_datetime(start_date), "endDate": self._format_schwab_datetime(end_date), "types": transaction_type, }, ) for item in self._as_list(payload): transaction = self._build_transaction( account_payload=account_payload, item=item, assets_by_external_id=assets_by_external_id, ) if transaction: transactions.append(transaction) return transactions def _request_json(self, endpoint: str, params: dict | None = None): base_url = self.connection.base_url or self.default_base_url if not base_url.endswith("/"): base_url = f"{base_url}/" url = urljoin(base_url, endpoint.lstrip("/")) if params: url = f"{url}?{urlencode(params)}" request = Request(url, headers=self._headers()) try: with urlopen(request, timeout=30) as response: return json.load(response) except Exception as exc: raise BrokerageAdapterError( f"Unable to fetch Charles Schwab data from {url}: {exc}" ) from exc def _headers(self): access_token = self.connection.get_credential("access_token") if not access_token: raise BrokerageAdapterError( "Charles Schwab ingestion requires credentials.access_token." ) return { "Accept": "application/json", "Authorization": f"Bearer {access_token}", } def _build_account(self, account_data: dict) -> BrokerageAccountPayload: account_number = str(account_data.get("accountNumber", "")) account_name = account_data.get("type") or account_data.get("accountType") current_balances = account_data.get("currentBalances", {}) projected_balances = account_data.get("projectedBalances", {}) balance = self._first_number( current_balances, ["liquidationValue", "accountValue", "cashBalance"], default=self._first_number( projected_balances, ["liquidationValue", "accountValue", "cashBalance"], default=0, ), ) return BrokerageAccountPayload( external_id=account_number, name=f"Schwab {account_name or 'Account'} {account_number[-4:]}", number=account_number, balance=int(round(balance)), ) def _build_asset(self, position: dict) -> BrokerageAssetPayload: instrument = position.get("instrument", {}) symbol = str(instrument.get("symbol") or instrument.get("cusip") or "UNKNOWN") quantity = self._position_quantity(position) market_value = float(position.get("marketValue") or 0) average_price = float(position.get("averagePrice") or 0) price = market_value / quantity if quantity else average_price return BrokerageAssetPayload( external_id=symbol, ticker=symbol, price=price, ) def _build_holding( self, account_payload: BrokerageAccountPayload, position: dict, asset_payload: BrokerageAssetPayload, ) -> BrokerageHoldingPayload: return BrokerageHoldingPayload( external_id=f"{account_payload.external_id}:{asset_payload.external_id}", account_external_id=account_payload.external_id, asset_external_id=asset_payload.external_id, ticker=asset_payload.ticker, amount=self._position_quantity(position), cost_basis=float(position.get("averagePrice") or 0), purchase_date=datetime.now(timezone.utc), ) def _build_transaction( self, account_payload: BrokerageAccountPayload, item: dict, assets_by_external_id: dict[str, BrokerageAssetPayload], ) -> BrokerageTransactionPayload | None: transaction_id = item.get("activityId") or item.get("transactionId") if not transaction_id: return None transaction_date = self._parse_datetime( item.get("time") or item.get("settlementDate") or item.get("date") ) transfer_items = item.get("transferItems") or [] first_transfer_item = transfer_items[0] if transfer_items else {} transaction_type = self._map_transaction_type( str(item.get("type", "")), item, first_transfer_item, ) instrument = first_transfer_item.get("instrument", {}) symbol = instrument.get("symbol") or instrument.get("cusip") amount = self._first_number( first_transfer_item, ["amount", "price", "cost"], default=self._first_number(item, ["netAmount"], default=0), ) quantity = self._first_number( first_transfer_item, ["quantity"], default=None, ) if symbol and symbol not in assets_by_external_id: assets_by_external_id[str(symbol)] = BrokerageAssetPayload( external_id=str(symbol), ticker=str(symbol), price=0, ) return BrokerageTransactionPayload( external_id=str(transaction_id), account_external_id=account_payload.external_id, asset_external_id=str(symbol) if symbol else None, transaction_type=transaction_type, date=transaction_date, value=float(amount or 0), name=item.get("description") or item.get("subAccount") or transaction_type, quantity=float(quantity) if quantity is not None else None, realized_gain=self._extract_realized_gain(item), ) def _transaction_date_window(self): end_date = self._setting_datetime("transaction_end_date") or datetime.now( timezone.utc ) start_date = self._setting_datetime("transaction_start_date") if start_date: return start_date, end_date days = int(self.connection.get_setting("transaction_history_days", 59)) return end_date - timedelta(days=days), end_date def _setting_datetime(self, key): value = self.connection.get_setting(key) if not value: return None return self._parse_datetime(value) def _format_schwab_datetime(self, value: datetime): return value.astimezone(timezone.utc).isoformat(timespec="milliseconds") def _parse_datetime(self, value): if isinstance(value, datetime): return value if value: return datetime.fromisoformat(str(value).replace("Z", "+00:00")) return datetime.now(timezone.utc) def _as_list(self, payload): if isinstance(payload, list): return payload if isinstance(payload, dict): for key in ("accounts", "results", "data", "items"): if isinstance(payload.get(key), list): return payload[key] return [] def _first_number(self, values: dict, keys: list[str], default=0): for key in keys: value = values.get(key) if value is not None: return float(value) return default def _position_quantity(self, position: dict): long_quantity = float(position.get("longQuantity") or 0) short_quantity = float(position.get("shortQuantity") or 0) return long_quantity - short_quantity def _map_transaction_type(self, schwab_type: str, item: dict, transfer_item: dict): normalized = schwab_type.upper() if normalized == "DIVIDEND_OR_INTEREST": return "dividend" if normalized in {"ACH_RECEIPT", "CASH_RECEIPT", "WIRE_IN"}: return "deposit" if normalized in {"ACH_DISBURSEMENT", "CASH_DISBURSEMENT", "WIRE_OUT"}: return "withdrawal" if normalized == "TRADE": trade_text = " ".join( str(value) for value in [ item.get("description"), item.get("transactionSubType"), transfer_item.get("instruction"), ] if value ).upper() if "SELL" in trade_text or "SOLD" in trade_text: return "sell" if "BUY" in trade_text or "BOUGHT" in trade_text: return "buy" return "trade" return normalized.lower() def _extract_realized_gain(self, item: dict): for key in ("realizedGain", "realizedGainLoss", "gainLoss"): value = item.get(key) if value is not None: return float(value) return None