Stonks/overview/brokerages/charles_schwab.py

332 lines
12 KiB
Python

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