diff --git a/alpaca/broker/client.py b/alpaca/broker/client.py index b1b94e85..0d055b21 100644 --- a/alpaca/broker/client.py +++ b/alpaca/broker/client.py @@ -61,8 +61,8 @@ from alpaca.common.rest import HTTPResult, RESTClient from alpaca.common.utils import ( validate_symbol_or_asset_id, - validate_uuid_id_param, validate_symbol_or_contract_id, + validate_uuid_id_param, ) from alpaca.trading.enums import ActivityType from alpaca.trading.models import AccountConfiguration as TradeAccountConfiguration @@ -116,6 +116,7 @@ OrderRequest, UpdateAccountRequest, UploadDocumentRequest, + UploadW8BenDocumentRequest, ) @@ -414,7 +415,7 @@ def get_trade_account_by_id( def upload_documents_to_account( self, account_id: Union[UUID, str], - document_data: List[UploadDocumentRequest], + document_data: List[Union[UploadDocumentRequest, UploadW8BenDocumentRequest]], ) -> None: """ Allows you to upload up to 10 documents at a time for an Account. diff --git a/alpaca/broker/models/documents.py b/alpaca/broker/models/documents.py index 11e8265c..0c4a6abf 100644 --- a/alpaca/broker/models/documents.py +++ b/alpaca/broker/models/documents.py @@ -1,4 +1,5 @@ -from datetime import date, datetime +from datetime import date as datetime_date +from datetime import datetime from ipaddress import IPv4Address, IPv6Address from typing import Any, Optional, Union from uuid import UUID @@ -6,7 +7,8 @@ from pydantic import model_validator from alpaca.broker.enums import DocumentType, TradeDocumentSubType, TradeDocumentType -from alpaca.common.models import ModelWithID, ValidateBaseModel as BaseModel +from alpaca.common.models import ModelWithID +from alpaca.common.models import ValidateBaseModel as BaseModel IPAddress = Union[IPv4Address, IPv6Address] @@ -63,7 +65,7 @@ class TradeDocument(ModelWithID): name: str type: TradeDocumentType sub_type: Optional[TradeDocumentSubType] = None - date: date + date: datetime_date def __init__(self, **data: Any) -> None: if "id" in data and isinstance(data["id"], str): @@ -80,7 +82,7 @@ class W8BenDocument(BaseModel): Represents the information normally contained in a W8BEN document as fields for convenience if you don't want to upload a file. - Please see https://alpaca.markets/docs/api-references/broker-api/accounts/accounts/#international-accounts + Please see https://docs.alpaca.markets/docs/international-accounts for more information. TODO: None of the docs or code explain what any of these fields mean. Guessing based on name alone for @@ -114,8 +116,8 @@ class W8BenDocument(BaseModel): """ country_citizen: str - date: date - date_of_birth: date + date: datetime_date + date_of_birth: datetime_date full_name: str ip_address: IPAddress permanent_address_city_state: str diff --git a/alpaca/broker/requests.py b/alpaca/broker/requests.py index fe6fbfdc..5d046efe 100644 --- a/alpaca/broker/requests.py +++ b/alpaca/broker/requests.py @@ -4,15 +4,6 @@ from pydantic import field_validator, model_validator -from alpaca.broker.models.accounts import ( - AccountDocument, - Agreement, - Contact, - Disclosures, - Identity, - TrustedContact, -) -from alpaca.broker.models.documents import W8BenDocument from alpaca.broker.enums import ( AccountEntities, BankAccountType, @@ -36,22 +27,95 @@ VisaType, WeightType, ) -from alpaca.common.models import BaseModel +from alpaca.broker.models.accounts import ( + AccountDocument, + Agreement, + Contact, + Disclosures, + Identity, + TrustedContact, +) +from alpaca.broker.models.documents import W8BenDocument from alpaca.common.enums import Sort, SupportedCurrencies -from alpaca.trading.enums import ActivityType, AccountStatus, OrderType, AssetClass +from alpaca.common.models import BaseModel from alpaca.common.requests import NonEmptyRequest +from alpaca.trading.enums import AccountStatus, ActivityType, AssetClass, OrderType +from alpaca.trading.requests import LimitOrderRequest as BaseLimitOrderRequest +from alpaca.trading.requests import MarketOrderRequest as BaseMarketOrderRequest +from alpaca.trading.requests import OrderRequest as BaseOrderRequest +from alpaca.trading.requests import StopLimitOrderRequest as BaseStopLimitOrderRequest +from alpaca.trading.requests import StopOrderRequest as BaseStopOrderRequest from alpaca.trading.requests import ( - OrderRequest as BaseOrderRequest, - MarketOrderRequest as BaseMarketOrderRequest, - LimitOrderRequest as BaseLimitOrderRequest, - StopOrderRequest as BaseStopOrderRequest, - StopLimitOrderRequest as BaseStopLimitOrderRequest, TrailingStopOrderRequest as BaseTrailingStopOrderRequest, ) # ############################## Accounts ################################# # +class UploadW8BenDocumentRequest(NonEmptyRequest): + """ + Attributes: + content (Optional[str]): A string containing Base64 encoded data to upload. Must be set if `content_data` is not + set. + content_data (Optional[W8BenDocument]): The data representing a W8BEN document in field form. Must be set if + `content` is not set. + mime_type (UploadDocumentMimeType): The mime type of the data in `content`, or if using `content_data` must be + UploadDocumentMimeType.JSON. If `content_data` is set this will default to JSON + """ + + # These 2 are purposely undocumented as they should be here for NonEmptyRequest but they shouldn't be touched or + # set by users since they always need to be set values + document_type: DocumentType + document_sub_type: UploadDocumentSubType + + content: Optional[str] = None + content_data: Optional[W8BenDocument] = None + mime_type: UploadDocumentMimeType + + def __init__(self, **data) -> None: + # Always set these to their expected values + data["document_type"] = DocumentType.W8BEN + data["document_sub_type"] = UploadDocumentSubType.FORM_W8_BEN + + if ( + "mime_type" not in data + and "content_data" in data + and data["content_data"] is not None + ): + data["mime_type"] = UploadDocumentMimeType.JSON + + super().__init__(**data) + + @model_validator(mode="before") + def root_validator(cls, values: dict) -> dict: + content_is_none = values.get("content", None) is None + content_data_is_none = values.get("content_data", None) is None + + if content_is_none and content_data_is_none: + raise ValueError( + "You must specify one of either the `content` or `content_data` fields" + ) + + if not content_is_none and not content_data_is_none: + raise ValueError( + "You can only specify one of either the `content` or `content_data` fields" + ) + + if values["document_type"] != DocumentType.W8BEN: + raise ValueError("document_type must be W8BEN.") + + if values["document_sub_type"] != UploadDocumentSubType.FORM_W8_BEN: + raise ValueError("document_sub_type must be FORM_W8_BEN.") + + if ( + not content_data_is_none + and values["mime_type"] != UploadDocumentMimeType.JSON + ): + raise ValueError("If `content_data` is set then `mime_type` must be JSON") + + return values + + class CreateAccountRequest(NonEmptyRequest): """Class used to format data necessary for making a request to create a brokerage account @@ -60,7 +124,7 @@ class CreateAccountRequest(NonEmptyRequest): identity (Identity): The identity details for the account holder disclosures (Disclosures): The account holder's political disclosures agreements (List[Agreement]): The agreements the account holder has signed - documents (List[AccountDocument]): The documents the account holder has submitted + documents (List[Union[AccountDocument, UploadW8BenDocumentRequest]]): The documents the account holder has submitted trusted_contact (TrustedContact): The account holder's trusted contact details """ @@ -68,7 +132,7 @@ class CreateAccountRequest(NonEmptyRequest): identity: Identity disclosures: Disclosures agreements: List[Agreement] - documents: Optional[List[AccountDocument]] = None + documents: Optional[List[Union[AccountDocument, UploadW8BenDocumentRequest]]] = None trusted_contact: Optional[TrustedContact] = None currency: Optional[SupportedCurrencies] = None # None = USD enabled_assets: Optional[List[AssetClass]] = None # None = Default to server @@ -405,70 +469,6 @@ def root_validator(cls, values: dict) -> dict: return values -class UploadW8BenDocumentRequest(NonEmptyRequest): - """ - Attributes: - content (Optional[str]): A string containing Base64 encoded data to upload. Must be set if `content_data` is not - set. - content_data (Optional[W8BenDocument]): The data representing a W8BEN document in field form. Must be set if - `content` is not set. - mime_type (UploadDocumentMimeType): The mime type of the data in `content`, or if using `content_data` must be - UploadDocumentMimeType.JSON. If `content_data` is set this will default to JSON - """ - - # These 2 are purposely undocumented as they should be here for NonEmptyRequest but they shouldn't be touched or - # set by users since they always need to be set values - document_type: DocumentType - document_sub_type: UploadDocumentSubType - - content: Optional[str] = None - content_data: Optional[W8BenDocument] = None - mime_type: UploadDocumentMimeType - - def __init__(self, **data) -> None: - # Always set these to their expected values - data["document_type"] = DocumentType.W8BEN - data["document_sub_type"] = UploadDocumentSubType.FORM_W8_BEN - - if ( - "mime_type" not in data - and "content_data" in data - and data["content_data"] is not None - ): - data["mime_type"] = UploadDocumentMimeType.JSON - - super().__init__(**data) - - @model_validator(mode="before") - def root_validator(cls, values: dict) -> dict: - content_is_none = values.get("content", None) is None - content_data_is_none = values.get("content_data", None) is None - - if content_is_none and content_data_is_none: - raise ValueError( - "You must specify one of either the `content` or `content_data` fields" - ) - - if not content_is_none and not content_data_is_none: - raise ValueError( - "You can only specify one of either the `content` or `content_data` fields" - ) - - if values["document_type"] != DocumentType.W8BEN: - raise ValueError("document_type must be W8BEN.") - - if values["document_sub_type"] != UploadDocumentSubType.FORM_W8_BEN: - raise ValueError("document_sub_type must be FORM_W8_BEN.") - - if ( - not content_data_is_none - and values["mime_type"] != UploadDocumentMimeType.JSON - ): - raise ValueError("If `content_data` is set then `mime_type` must be JSON") - - return values - - # ############################## Banking and Transfers ################################# # diff --git a/alpaca/common/requests.py b/alpaca/common/requests.py index 06e49951..3e768baf 100644 --- a/alpaca/common/requests.py +++ b/alpaca/common/requests.py @@ -1,4 +1,5 @@ -from datetime import datetime, timezone +from datetime import date, datetime, timezone +from ipaddress import IPv4Address, IPv6Address from typing import Any from uuid import UUID @@ -52,6 +53,15 @@ def map_values(val: Any) -> Any: val = val.replace(tzinfo=timezone.utc) return val.isoformat() + if isinstance(val, date): + return val.isoformat() + + if isinstance(val, IPv4Address): + return str(val) + + if isinstance(val, IPv6Address): + return str(val) + return val # pydantic almost has what we need by passing exclude_none to dict() but it returns: diff --git a/tests/broker/broker_client/test_documents_routes.py b/tests/broker/broker_client/test_documents_routes.py index 391e9405..3f639dc6 100644 --- a/tests/broker/broker_client/test_documents_routes.py +++ b/tests/broker/broker_client/test_documents_routes.py @@ -1,19 +1,21 @@ +import datetime +import ipaddress import os.path import tempfile +from datetime import date from typing import List import pytest -from alpaca.broker.requests import UploadDocumentRequest from alpaca.broker.client import BrokerClient -from alpaca.common.constants import BROKER_DOCUMENT_UPLOAD_LIMIT -from alpaca.broker.enums import ( - TradeDocumentType, - UploadDocumentMimeType, - DocumentType, +from alpaca.broker.enums import DocumentType, TradeDocumentType, UploadDocumentMimeType +from alpaca.broker.models import TradeDocument, W8BenDocument +from alpaca.broker.requests import ( + GetTradeDocumentsRequest, + UploadDocumentRequest, + UploadW8BenDocumentRequest, ) -from alpaca.broker.models import TradeDocument -from alpaca.broker.requests import GetTradeDocumentsRequest +from alpaca.common.constants import BROKER_DOCUMENT_UPLOAD_LIMIT from alpaca.common.enums import BaseURL @@ -101,6 +103,45 @@ def test_upload_documents_to_account(reqmock, client: BrokerClient): ) +def test_upload_documents_to_account_w8ben(reqmock, client: BrokerClient): + account_id = "2a87c088-ffb6-472b-a4a3-cd9305c8605c" + reqmock.post( + BaseURL.BROKER_SANDBOX.value + f"/v1/accounts/{account_id}/documents/upload", + json={}, + status_code=202, + ) + + client.upload_documents_to_account( + account_id=account_id, + document_data=[ + UploadW8BenDocumentRequest( + content_data=W8BenDocument( + country_citizen="JAPAN", + date=date(2022, 2, 28), + date_of_birth=date(1990, 1, 1), + full_name="John Doe", + ip_address=ipaddress.IPv4Address("192.168.0.1"), + permanent_address_city_state="Tokyo", + permanent_address_country="JAPAN", + permanent_address_street="99-99 Miyashita, Shibuya-ku", + revision="October 2021", + signer_full_name="John Doe", + timestamp=datetime.datetime(2022, 2, 28, 15, 0, 0), + foreign_tax_id="123456789", + ) + ) + ], + ) + + assert reqmock.called_once + + # TODO: Add a custom reqmock matcher to ensure format of request rather than this static string check + assert ( + reqmock.request_history[0].text + == '[{"document_type": "w8ben", "document_sub_type": "Form W-8BEN", "content_data": {"country_citizen": "JAPAN", "date": "2022-02-28", "date_of_birth": "1990-01-01", "full_name": "John Doe", "ip_address": "192.168.0.1", "permanent_address_city_state": "Tokyo", "permanent_address_country": "JAPAN", "permanent_address_street": "99-99 Miyashita, Shibuya-ku", "revision": "October 2021", "signer_full_name": "John Doe", "timestamp": "2022-02-28T15:00:00+00:00", "foreign_tax_id": "123456789"}, "mime_type": "application/json"}]' + ) + + def test_upload_documents_to_account_validates_limit(reqmock, client: BrokerClient): with pytest.raises(ValueError) as e: client.upload_documents_to_account(