Skip to content

Commit

Permalink
feat: Support w8ben (#520)
Browse files Browse the repository at this point in the history
* feat: support w8ben

* fix: update expected value

* fix: map date to YYYY-MM-DD format

* fix: handle ip address

* fix: make lint
  • Loading branch information
hiohiohio authored Nov 13, 2024
1 parent 370b86b commit 6ba117b
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 99 deletions.
5 changes: 3 additions & 2 deletions alpaca/broker/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -116,6 +116,7 @@
OrderRequest,
UpdateAccountRequest,
UploadDocumentRequest,
UploadW8BenDocumentRequest,
)


Expand Down Expand Up @@ -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.
Expand Down
14 changes: 8 additions & 6 deletions alpaca/broker/models/documents.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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

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]

Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
164 changes: 82 additions & 82 deletions alpaca/broker/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -60,15 +124,15 @@ 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
"""

contact: Contact
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
Expand Down Expand Up @@ -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 ################################# #


Expand Down
12 changes: 11 additions & 1 deletion alpaca/common/requests.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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:
Expand Down
57 changes: 49 additions & 8 deletions tests/broker/broker_client/test_documents_routes.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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(
Expand Down

0 comments on commit 6ba117b

Please sign in to comment.