332 lines
12 KiB
Python
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
|