Stonks/overview/brokerages/rest.py

132 lines
5.3 KiB
Python

import json
from datetime import datetime, timezone
from urllib.parse import urljoin
from urllib.request import Request, urlopen
from overview.brokerage_types import (
BrokerageAccountPayload,
BrokerageAssetPayload,
BrokerageHoldingPayload,
BrokerageSnapshot,
)
from .base import BaseBrokerageAdapter, BrokerageAdapterError
class GenericRestBrokerageAdapter(BaseBrokerageAdapter):
provider = "generic-rest"
def fetch_snapshot(self) -> BrokerageSnapshot:
accounts = self._fetch_collection(
self.connection.get_setting("account_endpoint", "/accounts")
)
assets = self._fetch_collection(
self.connection.get_setting("asset_endpoint", "/assets")
)
holdings = self._fetch_collection(
self.connection.get_setting("holding_endpoint", "/holdings")
)
return BrokerageSnapshot(
accounts=[self._build_account(item) for item in accounts],
assets=[self._build_asset(item) for item in assets],
holdings=[self._build_holding(item) for item in holdings],
)
def _fetch_collection(self, endpoint: str) -> list[dict]:
url = urljoin(self.connection.base_url, endpoint)
request = Request(url, headers=self._headers())
try:
with urlopen(request, timeout=30) as response:
payload = json.load(response)
except Exception as exc:
raise BrokerageAdapterError(
f"Unable to fetch brokerage data from {url}: {exc}"
) from exc
if isinstance(payload, dict):
for key in ("results", "data", "items"):
value = payload.get(key)
if isinstance(value, list):
return value
if not isinstance(payload, list):
raise BrokerageAdapterError(
f"Expected a list payload from {url}, received {type(payload).__name__}"
)
return payload
def _headers(self) -> dict[str, str]:
headers = {"Accept": "application/json"}
token = self.connection.get_credential("access_token")
api_key = self.connection.get_credential("api_key")
api_key_header = self.connection.get_setting("api_key_header", "X-API-Key")
if token:
headers["Authorization"] = f"Bearer {token}"
if api_key:
headers[api_key_header] = api_key
extra_headers = self.connection.get_setting("headers", {})
if isinstance(extra_headers, dict):
headers.update({str(key): str(value) for key, value in extra_headers.items()})
return headers
def _build_account(self, item: dict) -> BrokerageAccountPayload:
account_id_field = self.connection.get_setting("account_id_field", "id")
account_name_field = self.connection.get_setting("account_name_field", "name")
account_number_field = self.connection.get_setting("account_number_field", "number")
account_balance_field = self.connection.get_setting("account_balance_field", "balance")
return BrokerageAccountPayload(
external_id=str(item[account_id_field]),
name=item[account_name_field],
number=item.get(account_number_field, ""),
balance=int(item.get(account_balance_field, 0)),
)
def _build_asset(self, item: dict) -> BrokerageAssetPayload:
asset_id_field = self.connection.get_setting("asset_id_field", "id")
asset_ticker_field = self.connection.get_setting("asset_ticker_field", "ticker")
asset_price_field = self.connection.get_setting("asset_price_field", "price")
return BrokerageAssetPayload(
external_id=str(item[asset_id_field]),
ticker=item[asset_ticker_field],
price=float(item.get(asset_price_field, 0)),
)
def _build_holding(self, item: dict) -> BrokerageHoldingPayload:
holding_id_field = self.connection.get_setting("holding_id_field", "id")
holding_account_field = self.connection.get_setting("holding_account_field", "account_id")
holding_asset_field = self.connection.get_setting("holding_asset_field", "asset_id")
holding_ticker_field = self.connection.get_setting("holding_ticker_field", "ticker")
holding_amount_field = self.connection.get_setting("holding_amount_field", "amount")
holding_cost_basis_field = self.connection.get_setting(
"holding_cost_basis_field", "cost_basis"
)
holding_purchase_date_field = self.connection.get_setting(
"holding_purchase_date_field", "purchase_date"
)
purchase_date = item.get(holding_purchase_date_field)
if purchase_date:
purchase_date = datetime.fromisoformat(
str(purchase_date).replace("Z", "+00:00")
)
else:
purchase_date = datetime.now(timezone.utc)
return BrokerageHoldingPayload(
external_id=str(item[holding_id_field]),
account_external_id=str(item[holding_account_field]),
asset_external_id=str(item[holding_asset_field]),
ticker=item[holding_ticker_field],
amount=float(item.get(holding_amount_field, 0)),
cost_basis=float(item.get(holding_cost_basis_field, 0)),
purchase_date=purchase_date,
)