AI: Charles Schwab Account Adapter
This commit is contained in:
parent
ccee2a00af
commit
a202be3fc1
|
|
@ -1,10 +1,12 @@
|
||||||
from .base import BaseBrokerageAdapter, BrokerageAdapterError
|
from .base import BaseBrokerageAdapter, BrokerageAdapterError
|
||||||
|
from .charles_schwab import CharlesSchwabBrokerageAdapter
|
||||||
from .registry import get_brokerage_adapter, register_brokerage_adapter
|
from .registry import get_brokerage_adapter, register_brokerage_adapter
|
||||||
from .random import RandomBrokerageAdapter
|
from .random import RandomBrokerageAdapter
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseBrokerageAdapter",
|
"BaseBrokerageAdapter",
|
||||||
"BrokerageAdapterError",
|
"BrokerageAdapterError",
|
||||||
|
"CharlesSchwabBrokerageAdapter",
|
||||||
"RandomBrokerageAdapter",
|
"RandomBrokerageAdapter",
|
||||||
"get_brokerage_adapter",
|
"get_brokerage_adapter",
|
||||||
"register_brokerage_adapter",
|
"register_brokerage_adapter",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
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
|
||||||
|
|
@ -4,11 +4,13 @@ from django.utils.module_loading import import_string
|
||||||
from overview.models import BrokerageConnection
|
from overview.models import BrokerageConnection
|
||||||
|
|
||||||
from .base import BaseBrokerageAdapter, BrokerageAdapterError
|
from .base import BaseBrokerageAdapter, BrokerageAdapterError
|
||||||
|
from .charles_schwab import CharlesSchwabBrokerageAdapter
|
||||||
from .random import RandomBrokerageAdapter
|
from .random import RandomBrokerageAdapter
|
||||||
from .rest import GenericRestBrokerageAdapter
|
from .rest import GenericRestBrokerageAdapter
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_ADAPTERS = {
|
_DEFAULT_ADAPTERS = {
|
||||||
|
CharlesSchwabBrokerageAdapter.provider: CharlesSchwabBrokerageAdapter,
|
||||||
GenericRestBrokerageAdapter.provider: GenericRestBrokerageAdapter,
|
GenericRestBrokerageAdapter.provider: GenericRestBrokerageAdapter,
|
||||||
RandomBrokerageAdapter.provider: RandomBrokerageAdapter,
|
RandomBrokerageAdapter.provider: RandomBrokerageAdapter,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from .brokerage_types import (
|
||||||
BrokerageTransactionPayload,
|
BrokerageTransactionPayload,
|
||||||
)
|
)
|
||||||
from .brokerages.base import BaseBrokerageAdapter, BrokerageAdapterError
|
from .brokerages.base import BaseBrokerageAdapter, BrokerageAdapterError
|
||||||
|
from .brokerages.charles_schwab import CharlesSchwabBrokerageAdapter
|
||||||
from .brokerages.random import RandomBrokerageAdapter
|
from .brokerages.random import RandomBrokerageAdapter
|
||||||
from .brokerages.registry import register_brokerage_adapter
|
from .brokerages.registry import register_brokerage_adapter
|
||||||
from .models import (
|
from .models import (
|
||||||
|
|
@ -55,6 +56,19 @@ class ExplodingBrokerageAdapter(BaseBrokerageAdapter):
|
||||||
raise RuntimeError("brokerage unavailable")
|
raise RuntimeError("brokerage unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCharlesSchwabBrokerageAdapter(CharlesSchwabBrokerageAdapter):
|
||||||
|
def __init__(self, connection, responses):
|
||||||
|
super().__init__(connection)
|
||||||
|
self.responses = responses
|
||||||
|
self.requests = []
|
||||||
|
|
||||||
|
def _request_json(self, endpoint: str, params: dict | None = None):
|
||||||
|
self.requests.append((endpoint, params or {}))
|
||||||
|
if endpoint.startswith("/accounts/") and endpoint.endswith("/transactions"):
|
||||||
|
return self.responses["transactions"]
|
||||||
|
return self.responses[endpoint]
|
||||||
|
|
||||||
|
|
||||||
class BrokerageIngestionServiceTests(TestCase):
|
class BrokerageIngestionServiceTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
register_brokerage_adapter(FakeBrokerageAdapter.provider, FakeBrokerageAdapter)
|
register_brokerage_adapter(FakeBrokerageAdapter.provider, FakeBrokerageAdapter)
|
||||||
|
|
@ -366,6 +380,94 @@ class BrokerageIngestionServiceTests(TestCase):
|
||||||
18,
|
18,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_charles_schwab_adapter_maps_accounts_positions_and_transactions(self):
|
||||||
|
connection = BrokerageConnection.objects.create(
|
||||||
|
provider=CharlesSchwabBrokerageAdapter.provider,
|
||||||
|
name="Schwab",
|
||||||
|
base_url="https://api.schwabapi.com/trader/v1/",
|
||||||
|
credentials={"access_token": "token-1"},
|
||||||
|
settings={
|
||||||
|
"transaction_start_date": "2026-01-01T00:00:00+00:00",
|
||||||
|
"transaction_end_date": "2026-01-31T00:00:00+00:00",
|
||||||
|
"transaction_types": ["TRADE"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
adapter = FakeCharlesSchwabBrokerageAdapter(
|
||||||
|
connection,
|
||||||
|
responses={
|
||||||
|
"/accounts/accountNumbers": [
|
||||||
|
{
|
||||||
|
"accountNumber": "123456789",
|
||||||
|
"hashValue": "encrypted-account-1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"/accounts": [
|
||||||
|
{
|
||||||
|
"securitiesAccount": {
|
||||||
|
"type": "MARGIN",
|
||||||
|
"accountNumber": "123456789",
|
||||||
|
"currentBalances": {
|
||||||
|
"liquidationValue": 25000.49,
|
||||||
|
},
|
||||||
|
"positions": [
|
||||||
|
{
|
||||||
|
"longQuantity": 10,
|
||||||
|
"averagePrice": 150.25,
|
||||||
|
"marketValue": 2000,
|
||||||
|
"instrument": {
|
||||||
|
"symbol": "AAPL",
|
||||||
|
"cusip": "037833100",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transactions": [
|
||||||
|
{
|
||||||
|
"activityId": 123,
|
||||||
|
"type": "TRADE",
|
||||||
|
"description": "SELL AAPL",
|
||||||
|
"time": "2026-01-15T15:30:00+00:00",
|
||||||
|
"netAmount": -2100,
|
||||||
|
"realizedGain": 300.5,
|
||||||
|
"transferItems": [
|
||||||
|
{
|
||||||
|
"quantity": 10,
|
||||||
|
"amount": -2100,
|
||||||
|
"instruction": "SELL",
|
||||||
|
"instrument": {
|
||||||
|
"symbol": "AAPL",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = adapter.fetch_snapshot()
|
||||||
|
|
||||||
|
self.assertEqual(snapshot.accounts[0].external_id, "123456789")
|
||||||
|
self.assertEqual(snapshot.accounts[0].balance, 25000)
|
||||||
|
self.assertEqual(snapshot.assets[0].ticker, "AAPL")
|
||||||
|
self.assertEqual(snapshot.assets[0].price, 200)
|
||||||
|
self.assertEqual(snapshot.holdings[0].amount, 10)
|
||||||
|
self.assertEqual(snapshot.transactions[0].transaction_type, "sell")
|
||||||
|
self.assertEqual(snapshot.transactions[0].realized_gain, 300.5)
|
||||||
|
self.assertEqual(snapshot.transactions[0].quantity, 10)
|
||||||
|
self.assertIn(
|
||||||
|
(
|
||||||
|
"/accounts/encrypted-account-1/transactions",
|
||||||
|
{
|
||||||
|
"startDate": "2026-01-01T00:00:00.000+00:00",
|
||||||
|
"endDate": "2026-01-31T00:00:00.000+00:00",
|
||||||
|
"types": "TRADE",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
adapter.requests,
|
||||||
|
)
|
||||||
|
|
||||||
def test_sync_command_accepts_provider_identifier(self):
|
def test_sync_command_accepts_provider_identifier(self):
|
||||||
random_connection = BrokerageConnection.objects.create(
|
random_connection = BrokerageConnection.objects.create(
|
||||||
provider=RandomBrokerageAdapter.provider,
|
provider=RandomBrokerageAdapter.provider,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue