alpaca-py-nopandas 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alpaca/__init__.py +2 -0
- alpaca/broker/__init__.py +8 -0
- alpaca/broker/client.py +2360 -0
- alpaca/broker/enums.py +528 -0
- alpaca/broker/models/__init__.py +7 -0
- alpaca/broker/models/accounts.py +347 -0
- alpaca/broker/models/cip.py +265 -0
- alpaca/broker/models/documents.py +159 -0
- alpaca/broker/models/funding.py +114 -0
- alpaca/broker/models/journals.py +71 -0
- alpaca/broker/models/rebalancing.py +80 -0
- alpaca/broker/models/trading.py +13 -0
- alpaca/broker/requests.py +1135 -0
- alpaca/common/__init__.py +6 -0
- alpaca/common/constants.py +13 -0
- alpaca/common/enums.py +64 -0
- alpaca/common/exceptions.py +47 -0
- alpaca/common/models.py +21 -0
- alpaca/common/requests.py +82 -0
- alpaca/common/rest.py +438 -0
- alpaca/common/types.py +7 -0
- alpaca/common/utils.py +89 -0
- alpaca/data/__init__.py +5 -0
- alpaca/data/enums.py +184 -0
- alpaca/data/historical/__init__.py +13 -0
- alpaca/data/historical/corporate_actions.py +76 -0
- alpaca/data/historical/crypto.py +299 -0
- alpaca/data/historical/news.py +63 -0
- alpaca/data/historical/option.py +230 -0
- alpaca/data/historical/screener.py +72 -0
- alpaca/data/historical/stock.py +226 -0
- alpaca/data/historical/utils.py +30 -0
- alpaca/data/live/__init__.py +11 -0
- alpaca/data/live/crypto.py +168 -0
- alpaca/data/live/news.py +62 -0
- alpaca/data/live/option.py +88 -0
- alpaca/data/live/stock.py +199 -0
- alpaca/data/live/websocket.py +390 -0
- alpaca/data/mappings.py +84 -0
- alpaca/data/models/__init__.py +7 -0
- alpaca/data/models/bars.py +83 -0
- alpaca/data/models/base.py +45 -0
- alpaca/data/models/corporate_actions.py +309 -0
- alpaca/data/models/news.py +90 -0
- alpaca/data/models/orderbooks.py +59 -0
- alpaca/data/models/quotes.py +78 -0
- alpaca/data/models/screener.py +68 -0
- alpaca/data/models/snapshots.py +132 -0
- alpaca/data/models/trades.py +204 -0
- alpaca/data/requests.py +580 -0
- alpaca/data/timeframe.py +148 -0
- alpaca/py.typed +0 -0
- alpaca/trading/__init__.py +5 -0
- alpaca/trading/client.py +784 -0
- alpaca/trading/enums.py +412 -0
- alpaca/trading/models.py +697 -0
- alpaca/trading/requests.py +604 -0
- alpaca/trading/stream.py +225 -0
- alpaca_py_nopandas-0.1.0.dist-info/LICENSE +201 -0
- alpaca_py_nopandas-0.1.0.dist-info/METADATA +299 -0
- alpaca_py_nopandas-0.1.0.dist-info/RECORD +62 -0
- alpaca_py_nopandas-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,13 @@
|
|
1
|
+
from typing import TypeVar
|
2
|
+
|
3
|
+
DATA_V2_MAX_LIMIT = 10000 # max items per api call
|
4
|
+
|
5
|
+
ACCOUNT_ACTIVITIES_DEFAULT_PAGE_SIZE = 100
|
6
|
+
|
7
|
+
BROKER_DOCUMENT_UPLOAD_LIMIT = 10
|
8
|
+
|
9
|
+
PageItem = TypeVar("PageItem") # Generic type for an item from a paginated request.
|
10
|
+
|
11
|
+
DEFAULT_RETRY_ATTEMPTS = 3
|
12
|
+
DEFAULT_RETRY_WAIT_SECONDS = 3
|
13
|
+
DEFAULT_RETRY_EXCEPTION_CODES = [429, 504]
|
alpaca/common/enums.py
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
from enum import Enum
|
2
|
+
|
3
|
+
|
4
|
+
class BaseURL(str, Enum):
|
5
|
+
"""Base urls for API endpoints"""
|
6
|
+
|
7
|
+
BROKER_SANDBOX = "https://broker-api.sandbox.alpaca.markets"
|
8
|
+
BROKER_PRODUCTION = "https://broker-api.alpaca.markets"
|
9
|
+
TRADING_PAPER = "https://paper-api.alpaca.markets"
|
10
|
+
TRADING_LIVE = "https://api.alpaca.markets"
|
11
|
+
DATA = "https://data.alpaca.markets"
|
12
|
+
DATA_SANDBOX = "https://data.sandbox.alpaca.markets"
|
13
|
+
MARKET_DATA_STREAM = "wss://stream.data.alpaca.markets"
|
14
|
+
OPTION_DATA_STREAM = "wss://stream.data.alpaca.markets" # Deprecated: use MARKET_DATA_STREAM instead!
|
15
|
+
TRADING_STREAM_PAPER = "wss://paper-api.alpaca.markets/stream"
|
16
|
+
TRADING_STREAM_LIVE = "wss://api.alpaca.markets/stream"
|
17
|
+
|
18
|
+
|
19
|
+
class PaginationType(str, Enum):
|
20
|
+
"""
|
21
|
+
An enum for choosing what type of pagination of results you'd like for BrokerClient functions that support
|
22
|
+
pagination.
|
23
|
+
|
24
|
+
Attributes:
|
25
|
+
NONE: Requests that we perform no pagination of results and just return the single response the API gave us.
|
26
|
+
FULL: Requests that we perform all the pagination and return just a single List/dict/etc containing all the
|
27
|
+
results. This is the default for most functions.
|
28
|
+
ITERATOR: Requests that we return an Iterator that yields one "page" of results at a time
|
29
|
+
"""
|
30
|
+
|
31
|
+
NONE = "none"
|
32
|
+
FULL = "full"
|
33
|
+
ITERATOR = "iterator"
|
34
|
+
|
35
|
+
|
36
|
+
class Sort(str, Enum):
|
37
|
+
ASC = "asc"
|
38
|
+
DESC = "desc"
|
39
|
+
|
40
|
+
|
41
|
+
class SupportedCurrencies(str, Enum):
|
42
|
+
"""
|
43
|
+
The various currencies that can be supported for LCT.
|
44
|
+
|
45
|
+
see https://alpaca.markets/support/local-currency-trading-faq
|
46
|
+
"""
|
47
|
+
|
48
|
+
USD = "USD"
|
49
|
+
GBP = "GBP"
|
50
|
+
CHF = "CHF"
|
51
|
+
EUR = "EUR"
|
52
|
+
CAD = "CAD"
|
53
|
+
JPY = "JPY"
|
54
|
+
TRY = "TRY"
|
55
|
+
AUD = "AUD"
|
56
|
+
CZK = "CZK"
|
57
|
+
SEK = "SEK"
|
58
|
+
DKK = "DKK"
|
59
|
+
SGD = "SGD"
|
60
|
+
HKD = "HKD"
|
61
|
+
HUF = "HUF"
|
62
|
+
NZD = "NZD"
|
63
|
+
NOK = "NOK"
|
64
|
+
PLN = "PLN"
|
@@ -0,0 +1,47 @@
|
|
1
|
+
import json
|
2
|
+
|
3
|
+
|
4
|
+
class APIError(Exception):
|
5
|
+
"""
|
6
|
+
Represent API related error.
|
7
|
+
error.status_code will have http status code.
|
8
|
+
"""
|
9
|
+
|
10
|
+
def __init__(self, error, http_error=None):
|
11
|
+
super().__init__(error)
|
12
|
+
self._error = error
|
13
|
+
self._http_error = http_error
|
14
|
+
|
15
|
+
@property
|
16
|
+
def code(self):
|
17
|
+
error = json.loads(self._error)
|
18
|
+
return error["code"]
|
19
|
+
|
20
|
+
@property
|
21
|
+
def message(self):
|
22
|
+
error = json.loads(self._error)
|
23
|
+
return error["message"]
|
24
|
+
|
25
|
+
@property
|
26
|
+
def status_code(self):
|
27
|
+
http_error = self._http_error
|
28
|
+
if http_error is not None and hasattr(http_error, "response"):
|
29
|
+
return http_error.response.status_code
|
30
|
+
|
31
|
+
@property
|
32
|
+
def request(self):
|
33
|
+
if self._http_error is not None:
|
34
|
+
return self._http_error.request
|
35
|
+
|
36
|
+
@property
|
37
|
+
def response(self):
|
38
|
+
if self._http_error is not None:
|
39
|
+
return self._http_error.response
|
40
|
+
|
41
|
+
|
42
|
+
class RetryException(Exception):
|
43
|
+
"""
|
44
|
+
Thrown by RESTClient's internally to represent a request that should be retried.
|
45
|
+
"""
|
46
|
+
|
47
|
+
pass
|
alpaca/common/models.py
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
from uuid import UUID
|
2
|
+
from pydantic import BaseModel
|
3
|
+
import pprint
|
4
|
+
|
5
|
+
|
6
|
+
class ValidateBaseModel(BaseModel, validate_assignment=True):
|
7
|
+
"""
|
8
|
+
This model simply sets up BaseModel with the validate_assignment flag to True, so we don't have to keep specifying
|
9
|
+
it or forget to specify it in our models where we want assignment validation
|
10
|
+
"""
|
11
|
+
|
12
|
+
def __repr__(self):
|
13
|
+
return pprint.pformat(self.model_dump(), indent=4)
|
14
|
+
|
15
|
+
|
16
|
+
class ModelWithID(ValidateBaseModel):
|
17
|
+
"""
|
18
|
+
This is the base model for response models with IDs that are UUIDs.
|
19
|
+
"""
|
20
|
+
|
21
|
+
id: UUID
|
@@ -0,0 +1,82 @@
|
|
1
|
+
from datetime import date, datetime, timezone
|
2
|
+
from ipaddress import IPv4Address, IPv6Address
|
3
|
+
from typing import Any
|
4
|
+
from uuid import UUID
|
5
|
+
|
6
|
+
from alpaca.common.models import ValidateBaseModel as BaseModel
|
7
|
+
|
8
|
+
|
9
|
+
class NonEmptyRequest(BaseModel):
|
10
|
+
"""
|
11
|
+
Mixin for models that represent requests where we don't want to send nulls for optional fields.
|
12
|
+
"""
|
13
|
+
|
14
|
+
def to_request_fields(self) -> dict:
|
15
|
+
"""
|
16
|
+
the equivalent of self::dict but removes empty values and handles converting non json serializable types.
|
17
|
+
|
18
|
+
Ie say we only set trusted_contact.given_name instead of generating a dict like:
|
19
|
+
{contact: {city: None, country: None...}, etc}
|
20
|
+
we generate just:
|
21
|
+
{trusted_contact:{given_name: "new value"}}
|
22
|
+
|
23
|
+
NOTE: This function recurses to handle nested models, so do not use on a self-referential model
|
24
|
+
|
25
|
+
Returns:
|
26
|
+
dict: a dict containing any set fields
|
27
|
+
"""
|
28
|
+
|
29
|
+
def map_values(val: Any) -> Any:
|
30
|
+
"""
|
31
|
+
Some types have issues being json encoded, we convert them here to be encodable
|
32
|
+
|
33
|
+
also handles nested models and lists
|
34
|
+
"""
|
35
|
+
|
36
|
+
if isinstance(val, UUID):
|
37
|
+
return str(val)
|
38
|
+
|
39
|
+
if isinstance(val, NonEmptyRequest):
|
40
|
+
return val.to_request_fields()
|
41
|
+
|
42
|
+
if isinstance(val, dict):
|
43
|
+
return {k: map_values(v) for k, v in val.items()}
|
44
|
+
|
45
|
+
if isinstance(val, list):
|
46
|
+
return [map_values(v) for v in val]
|
47
|
+
|
48
|
+
# RFC 3339
|
49
|
+
if isinstance(val, datetime):
|
50
|
+
# if the datetime is naive, assume it's UTC
|
51
|
+
# https://docs.python.org/3/library/datetime.html#determining-if-an-object-is-aware-or-naive
|
52
|
+
if val.tzinfo is None or val.tzinfo.utcoffset(val) is None:
|
53
|
+
val = val.replace(tzinfo=timezone.utc)
|
54
|
+
return val.isoformat()
|
55
|
+
|
56
|
+
if isinstance(val, date):
|
57
|
+
return val.isoformat()
|
58
|
+
|
59
|
+
if isinstance(val, IPv4Address):
|
60
|
+
return str(val)
|
61
|
+
|
62
|
+
if isinstance(val, IPv6Address):
|
63
|
+
return str(val)
|
64
|
+
|
65
|
+
return val
|
66
|
+
|
67
|
+
d = self.model_dump(exclude_none=True)
|
68
|
+
if "symbol_or_symbols" in d:
|
69
|
+
s = d["symbol_or_symbols"]
|
70
|
+
if isinstance(s, list):
|
71
|
+
s = ",".join(s)
|
72
|
+
d["symbols"] = s
|
73
|
+
del d["symbol_or_symbols"]
|
74
|
+
|
75
|
+
# pydantic almost has what we need by passing exclude_none to dict() but it returns:
|
76
|
+
# {trusted_contact: {}, contact: {}, identity: None, etc}
|
77
|
+
# so we do a simple list comprehension to filter out None and {}
|
78
|
+
return {
|
79
|
+
key: map_values(val)
|
80
|
+
for key, val in d.items()
|
81
|
+
if val is not None and val != {} and len(str(val)) > 0
|
82
|
+
}
|
alpaca/common/rest.py
ADDED
@@ -0,0 +1,438 @@
|
|
1
|
+
from collections import defaultdict
|
2
|
+
from collections.abc import Callable
|
3
|
+
import time
|
4
|
+
import base64
|
5
|
+
from abc import ABC
|
6
|
+
from typing import Any, Dict, List, Optional, Type, Union, Tuple, Iterator
|
7
|
+
|
8
|
+
from pydantic import BaseModel
|
9
|
+
from requests import Session
|
10
|
+
from requests.exceptions import HTTPError
|
11
|
+
from itertools import chain
|
12
|
+
|
13
|
+
from alpaca.common.constants import (
|
14
|
+
DEFAULT_RETRY_ATTEMPTS,
|
15
|
+
DEFAULT_RETRY_WAIT_SECONDS,
|
16
|
+
DEFAULT_RETRY_EXCEPTION_CODES,
|
17
|
+
)
|
18
|
+
|
19
|
+
from alpaca import __version__
|
20
|
+
from alpaca.common.exceptions import APIError, RetryException
|
21
|
+
from alpaca.common.types import RawData, HTTPResult, Credentials
|
22
|
+
from .constants import PageItem
|
23
|
+
from .enums import PaginationType, BaseURL
|
24
|
+
|
25
|
+
|
26
|
+
class RESTClient(ABC):
|
27
|
+
"""Abstract base class for REST clients"""
|
28
|
+
|
29
|
+
def __init__(
|
30
|
+
self,
|
31
|
+
base_url: Union[BaseURL, str],
|
32
|
+
api_key: Optional[str] = None,
|
33
|
+
secret_key: Optional[str] = None,
|
34
|
+
oauth_token: Optional[str] = None,
|
35
|
+
use_basic_auth: bool = False,
|
36
|
+
api_version: str = "v2",
|
37
|
+
sandbox: bool = False,
|
38
|
+
raw_data: bool = False,
|
39
|
+
retry_attempts: Optional[int] = None,
|
40
|
+
retry_wait_seconds: Optional[int] = None,
|
41
|
+
retry_exception_codes: Optional[List[int]] = None,
|
42
|
+
) -> None:
|
43
|
+
"""Abstract base class for REST clients. Handles submitting HTTP requests to
|
44
|
+
Alpaca API endpoints.
|
45
|
+
|
46
|
+
Args:
|
47
|
+
base_url (Union[BaseURL, str]): The base url to target requests to. Should be an instance of BaseURL, but
|
48
|
+
allows for raw str if you need to override
|
49
|
+
api_key (Optional[str]): The api key string for authentication.
|
50
|
+
secret_key (Optional[str]): The corresponding secret key string for the api key.
|
51
|
+
oauth_token (Optional[str]): The oauth token if authenticating via OAuth.
|
52
|
+
use_basic_auth (bool): Whether API requests should use basic authorization headers.
|
53
|
+
api_version (Optional[str]): The API version for the endpoints.
|
54
|
+
sandbox (bool): False if the live API should be used.
|
55
|
+
raw_data (bool): Whether API responses should be wrapped in data models or returned raw.
|
56
|
+
retry_attempts (Optional[int]): The number of times to retry a request that returns a RetryException.
|
57
|
+
retry_wait_seconds (Optional[int]): The number of seconds to wait between requests before retrying.
|
58
|
+
retry_exception_codes (Optional[List[int]]): The API exception codes to retry a request on.
|
59
|
+
"""
|
60
|
+
|
61
|
+
self._api_key, self._secret_key, self._oauth_token = self._validate_credentials(
|
62
|
+
api_key=api_key, secret_key=secret_key, oauth_token=oauth_token
|
63
|
+
)
|
64
|
+
self._api_version: str = api_version
|
65
|
+
self._base_url: Union[BaseURL, str] = base_url
|
66
|
+
self._sandbox: bool = sandbox
|
67
|
+
self._use_basic_auth: bool = use_basic_auth
|
68
|
+
self._use_raw_data: bool = raw_data
|
69
|
+
self._session: Session = Session()
|
70
|
+
|
71
|
+
# setting up request retry configurations
|
72
|
+
self._retry: int = DEFAULT_RETRY_ATTEMPTS
|
73
|
+
self._retry_wait: int = DEFAULT_RETRY_WAIT_SECONDS
|
74
|
+
self._retry_codes: List[int] = DEFAULT_RETRY_EXCEPTION_CODES
|
75
|
+
|
76
|
+
if retry_attempts and retry_attempts > 0:
|
77
|
+
self._retry = retry_attempts
|
78
|
+
|
79
|
+
if retry_wait_seconds and retry_wait_seconds > 0:
|
80
|
+
self._retry_wait = retry_wait_seconds
|
81
|
+
|
82
|
+
if retry_exception_codes:
|
83
|
+
self._retry_codes = retry_exception_codes
|
84
|
+
|
85
|
+
def _request(
|
86
|
+
self,
|
87
|
+
method: str,
|
88
|
+
path: str,
|
89
|
+
data: Optional[Union[dict, str]] = None,
|
90
|
+
base_url: Optional[Union[BaseURL, str]] = None,
|
91
|
+
api_version: Optional[str] = None,
|
92
|
+
) -> HTTPResult:
|
93
|
+
"""Prepares and submits HTTP requests to given API endpoint and returns response.
|
94
|
+
Handles retrying if 429 (Rate Limit) error arises.
|
95
|
+
|
96
|
+
Args:
|
97
|
+
method (str): The API endpoint HTTP method
|
98
|
+
path (str): The API endpoint path
|
99
|
+
data (Optional[Union[dict, str]]): Either the payload in json format, query params urlencoded, or a dict
|
100
|
+
of values to be converted to appropriate format based on `method`. Defaults to None.
|
101
|
+
base_url (Optional[Union[BaseURL, str]]): The base URL of the API. Defaults to None.
|
102
|
+
api_version (Optional[str]): The API version. Defaults to None.
|
103
|
+
|
104
|
+
Returns:
|
105
|
+
HTTPResult: The response from the API
|
106
|
+
"""
|
107
|
+
base_url = base_url or self._base_url
|
108
|
+
version = api_version if api_version else self._api_version
|
109
|
+
url: str = base_url + "/" + version + path
|
110
|
+
|
111
|
+
headers = self._get_default_headers()
|
112
|
+
|
113
|
+
opts = {
|
114
|
+
"headers": headers,
|
115
|
+
# Since we allow users to set endpoint URL via env var,
|
116
|
+
# human error to put non-SSL endpoint could exploit
|
117
|
+
# uncanny issues in non-GET request redirecting http->https.
|
118
|
+
# It's better to fail early if the URL isn't right.
|
119
|
+
"allow_redirects": False,
|
120
|
+
}
|
121
|
+
|
122
|
+
if method.upper() in ["GET", "DELETE"]:
|
123
|
+
opts["params"] = data
|
124
|
+
else:
|
125
|
+
opts["json"] = data
|
126
|
+
|
127
|
+
retry = self._retry
|
128
|
+
|
129
|
+
while retry >= 0:
|
130
|
+
try:
|
131
|
+
return self._one_request(method, url, opts, retry)
|
132
|
+
except RetryException:
|
133
|
+
time.sleep(self._retry_wait)
|
134
|
+
retry -= 1
|
135
|
+
continue
|
136
|
+
|
137
|
+
def _get_default_headers(self) -> dict:
|
138
|
+
"""
|
139
|
+
Returns a dict with some default headers set; ie AUTH headers and such that should be useful on all requests
|
140
|
+
Extracted for cases when using the default request functions are insufficient
|
141
|
+
|
142
|
+
Returns:
|
143
|
+
dict: The resulting dict of headers
|
144
|
+
"""
|
145
|
+
headers = self._get_auth_headers()
|
146
|
+
|
147
|
+
headers["User-Agent"] = "APCA-PY/" + __version__
|
148
|
+
|
149
|
+
return headers
|
150
|
+
|
151
|
+
def _get_auth_headers(self) -> dict:
|
152
|
+
"""
|
153
|
+
Get the auth headers for a request. Meant to be overridden in clients that don't use this format for requests,
|
154
|
+
ie: BrokerClient
|
155
|
+
|
156
|
+
Returns:
|
157
|
+
dict: A dict containing the expected auth headers
|
158
|
+
"""
|
159
|
+
|
160
|
+
headers = {}
|
161
|
+
|
162
|
+
if self._oauth_token:
|
163
|
+
headers["Authorization"] = "Bearer " + self._oauth_token
|
164
|
+
elif self._use_basic_auth:
|
165
|
+
api_key_secret = "{key}:{secret}".format(
|
166
|
+
key=self._api_key, secret=self._secret_key
|
167
|
+
).encode("utf-8")
|
168
|
+
encoded_api_key_secret = base64.b64encode(api_key_secret).decode("utf-8")
|
169
|
+
headers["Authorization"] = "Basic " + encoded_api_key_secret
|
170
|
+
else:
|
171
|
+
headers["APCA-API-KEY-ID"] = self._api_key
|
172
|
+
headers["APCA-API-SECRET-KEY"] = self._secret_key
|
173
|
+
|
174
|
+
return headers
|
175
|
+
|
176
|
+
def _one_request(self, method: str, url: str, opts: dict, retry: int) -> dict:
|
177
|
+
"""Perform one request, possibly raising RetryException in the case
|
178
|
+
the response is 429. Otherwise, if error text contain "code" string,
|
179
|
+
then it decodes to json object and returns APIError.
|
180
|
+
Returns the body json in the 200 status.
|
181
|
+
|
182
|
+
Args:
|
183
|
+
method (str): The HTTP method - GET, POST, etc
|
184
|
+
url (str): The API endpoint URL
|
185
|
+
opts (dict): Contains optional parameters including headers and parameters
|
186
|
+
retry (int): The number of times to retry in case of RetryException
|
187
|
+
|
188
|
+
Raises:
|
189
|
+
RetryException: Raised if request produces 429 error and retry limit has not been reached
|
190
|
+
APIError: Raised if API returns an error
|
191
|
+
|
192
|
+
Returns:
|
193
|
+
dict: The response data
|
194
|
+
"""
|
195
|
+
response = self._session.request(method, url, **opts)
|
196
|
+
|
197
|
+
try:
|
198
|
+
response.raise_for_status()
|
199
|
+
except HTTPError as http_error:
|
200
|
+
# retry if we hit Rate Limit
|
201
|
+
if response.status_code in self._retry_codes and retry > 0:
|
202
|
+
raise RetryException()
|
203
|
+
|
204
|
+
# raise API error for all other errors
|
205
|
+
error = response.text
|
206
|
+
|
207
|
+
raise APIError(error, http_error)
|
208
|
+
|
209
|
+
if response.text != "":
|
210
|
+
return response.json()
|
211
|
+
|
212
|
+
def get(
|
213
|
+
self, path: str, data: Optional[Union[dict, str]] = None, **kwargs
|
214
|
+
) -> HTTPResult:
|
215
|
+
"""Performs a single GET request
|
216
|
+
|
217
|
+
Args:
|
218
|
+
path (str): The API endpoint path
|
219
|
+
data (Union[dict, str], optional): Query parameters to send, either
|
220
|
+
as a str urlencoded, or a dict of values to be converted. Defaults to None.
|
221
|
+
|
222
|
+
Returns:
|
223
|
+
dict: The response
|
224
|
+
"""
|
225
|
+
return self._request("GET", path, data, **kwargs)
|
226
|
+
|
227
|
+
def post(
|
228
|
+
self, path: str, data: Optional[Union[dict, List[dict]]] = None
|
229
|
+
) -> HTTPResult:
|
230
|
+
"""Performs a single POST request
|
231
|
+
|
232
|
+
Args:
|
233
|
+
path (str): The API endpoint path
|
234
|
+
data (Optional[Union[dict, List[dict]]): The json payload as a dict of values to be converted.
|
235
|
+
Defaults to None.
|
236
|
+
|
237
|
+
Returns:
|
238
|
+
dict: The response
|
239
|
+
"""
|
240
|
+
return self._request("POST", path, data)
|
241
|
+
|
242
|
+
def put(self, path: str, data: Optional[dict] = None) -> dict:
|
243
|
+
"""Performs a single PUT request
|
244
|
+
|
245
|
+
Args:
|
246
|
+
path (str): The API endpoint path
|
247
|
+
data (Optional[dict]): The json payload as a dict of values to be converted.
|
248
|
+
Defaults to None.
|
249
|
+
|
250
|
+
Returns:
|
251
|
+
dict: The response
|
252
|
+
"""
|
253
|
+
return self._request("PUT", path, data)
|
254
|
+
|
255
|
+
def patch(self, path: str, data: Optional[dict] = None) -> dict:
|
256
|
+
"""Performs a single PATCH request
|
257
|
+
|
258
|
+
Args:
|
259
|
+
path (str): The API endpoint path
|
260
|
+
data (Optional[dict]): The json payload as a dict of values to be converted.
|
261
|
+
Defaults to None.
|
262
|
+
|
263
|
+
Returns:
|
264
|
+
dict: The response
|
265
|
+
"""
|
266
|
+
return self._request("PATCH", path, data)
|
267
|
+
|
268
|
+
def delete(self, path, data: Optional[Union[dict, str]] = None) -> dict:
|
269
|
+
"""Performs a single DELETE request
|
270
|
+
|
271
|
+
Args:
|
272
|
+
path (str): The API endpoint path
|
273
|
+
data (Union[dict, str], optional): The payload if any. Defaults to None.
|
274
|
+
|
275
|
+
Returns:
|
276
|
+
dict: The response
|
277
|
+
"""
|
278
|
+
return self._request("DELETE", path, data)
|
279
|
+
|
280
|
+
# TODO: Refactor to be able to handle both parsing to types and parsing to collections of types (parse_as_obj)
|
281
|
+
def response_wrapper(
|
282
|
+
self, model: Type[BaseModel], raw_data: RawData, **kwargs
|
283
|
+
) -> Union[BaseModel, RawData]:
|
284
|
+
"""To allow the user to get raw response from the api, we wrap all
|
285
|
+
functions with this method, checking if the user has set raw_data
|
286
|
+
bool. if they didn't, we wrap the response with a BaseModel object.
|
287
|
+
|
288
|
+
Args:
|
289
|
+
model (Type[BaseModel]): Class that response will be wrapped in
|
290
|
+
raw_data (RawData): The raw data from API in dictionary
|
291
|
+
kwargs : Any constructor parameters necessary for the base model
|
292
|
+
|
293
|
+
Returns:
|
294
|
+
Union[BaseModel, RawData]: either raw or parsed data
|
295
|
+
"""
|
296
|
+
if self._use_raw_data:
|
297
|
+
return raw_data
|
298
|
+
else:
|
299
|
+
return model(raw_data=raw_data, **kwargs)
|
300
|
+
|
301
|
+
@staticmethod
|
302
|
+
def _validate_pagination(
|
303
|
+
max_items_limit: Optional[int], handle_pagination: Optional[PaginationType]
|
304
|
+
) -> PaginationType:
|
305
|
+
"""
|
306
|
+
Private method for validating the max_items_limit and handle_pagination arguments, returning the resolved
|
307
|
+
PaginationType.
|
308
|
+
"""
|
309
|
+
if handle_pagination is None:
|
310
|
+
handle_pagination = PaginationType.FULL
|
311
|
+
|
312
|
+
if handle_pagination != PaginationType.FULL and max_items_limit is not None:
|
313
|
+
raise ValueError(
|
314
|
+
"max_items_limit can only be specified for PaginationType.FULL"
|
315
|
+
)
|
316
|
+
return handle_pagination
|
317
|
+
|
318
|
+
@staticmethod
|
319
|
+
def _return_paginated_result(
|
320
|
+
iterator: Iterator[PageItem], handle_pagination: PaginationType
|
321
|
+
) -> Union[List[PageItem], Iterator[List[PageItem]]]:
|
322
|
+
"""
|
323
|
+
Private method for converting an iterator that yields results to the proper pagination type result.
|
324
|
+
"""
|
325
|
+
if handle_pagination == PaginationType.NONE:
|
326
|
+
# user wants no pagination, so just do a single page
|
327
|
+
return next(iterator)
|
328
|
+
elif handle_pagination == PaginationType.FULL:
|
329
|
+
# the iterator returns "pages", so we use chain to flatten them all into 1 list
|
330
|
+
return list(chain.from_iterable(iterator))
|
331
|
+
elif handle_pagination == PaginationType.ITERATOR:
|
332
|
+
return iterator
|
333
|
+
else:
|
334
|
+
raise ValueError(f"Invalid pagination type: {handle_pagination}.")
|
335
|
+
|
336
|
+
@staticmethod
|
337
|
+
def _validate_credentials(
|
338
|
+
api_key: Optional[str] = None,
|
339
|
+
secret_key: Optional[str] = None,
|
340
|
+
oauth_token: Optional[str] = None,
|
341
|
+
) -> Credentials:
|
342
|
+
"""Gathers API credentials from parameters and environment variables, and validates them.
|
343
|
+
Args:
|
344
|
+
api_key (Optional[str]): The API key for authentication. Defaults to None.
|
345
|
+
secret_key (Optional[str]): The secret key for authentication. Defaults to None.
|
346
|
+
oauth_token (Optional[str]): The oauth token if authenticating via OAuth. Defaults to None.
|
347
|
+
Raises:
|
348
|
+
ValueError: If the combination of keys and tokens provided are not valid.
|
349
|
+
Returns:
|
350
|
+
Credentials: The set of validated authentication keys
|
351
|
+
"""
|
352
|
+
|
353
|
+
if not oauth_token and not api_key:
|
354
|
+
raise ValueError("You must supply a method of authentication")
|
355
|
+
|
356
|
+
if oauth_token and (api_key or secret_key):
|
357
|
+
raise ValueError(
|
358
|
+
"Either an oauth_token or an api_key may be supplied, but not both"
|
359
|
+
)
|
360
|
+
|
361
|
+
if not oauth_token and not (api_key and secret_key):
|
362
|
+
raise ValueError(
|
363
|
+
"A corresponding secret_key must be supplied with the api_key"
|
364
|
+
)
|
365
|
+
|
366
|
+
return api_key, secret_key, oauth_token
|
367
|
+
|
368
|
+
def _get_marketdata(
|
369
|
+
self,
|
370
|
+
path: str,
|
371
|
+
params: Dict[str, Any],
|
372
|
+
page_limit: int = 10_000,
|
373
|
+
page_size: Optional[int] = None,
|
374
|
+
no_sub_key: bool = False,
|
375
|
+
) -> Dict[str, List[Any]]:
|
376
|
+
d = defaultdict(list)
|
377
|
+
# Missing page_size indicates that we should not set a limit (e.g. for latest endpoints)
|
378
|
+
actual_limit = min(page_size, page_limit) if page_size else None
|
379
|
+
limit = params.get("limit")
|
380
|
+
total_items = 0
|
381
|
+
page_token = params.get("page_token")
|
382
|
+
|
383
|
+
while True:
|
384
|
+
# adjusts the limit parameter value if it is over the page_limit
|
385
|
+
if limit:
|
386
|
+
# actual_limit is the adjusted total number of items to query per request
|
387
|
+
actual_limit = min(int(limit) - total_items, page_limit)
|
388
|
+
if actual_limit < 1:
|
389
|
+
break
|
390
|
+
params["limit"] = actual_limit
|
391
|
+
params["page_token"] = page_token
|
392
|
+
|
393
|
+
response = self.get(path=path, data=params)
|
394
|
+
|
395
|
+
for k, v in _get_marketdata_entries(response, no_sub_key).items():
|
396
|
+
if isinstance(v, list):
|
397
|
+
d[k].extend(v)
|
398
|
+
else:
|
399
|
+
d[k] = v
|
400
|
+
|
401
|
+
# if we've sent a request with a limit, increment count
|
402
|
+
if actual_limit:
|
403
|
+
total_items = sum([len(items) for items in d.values()])
|
404
|
+
|
405
|
+
page_token = response.get("next_page_token", None)
|
406
|
+
if page_token is None:
|
407
|
+
break
|
408
|
+
return dict(d)
|
409
|
+
|
410
|
+
|
411
|
+
def _get_marketdata_entries(response: HTTPResult, no_sub_key: bool) -> RawData:
|
412
|
+
if no_sub_key:
|
413
|
+
return response
|
414
|
+
|
415
|
+
data_keys = {
|
416
|
+
"bar",
|
417
|
+
"bars",
|
418
|
+
"corporate_actions",
|
419
|
+
"news",
|
420
|
+
"orderbook",
|
421
|
+
"orderbooks",
|
422
|
+
"quote",
|
423
|
+
"quotes",
|
424
|
+
"snapshot",
|
425
|
+
"snapshots",
|
426
|
+
"trade",
|
427
|
+
"trades",
|
428
|
+
}
|
429
|
+
selected_keys = data_keys.intersection(response)
|
430
|
+
# Neither of these should ever happen!
|
431
|
+
if selected_keys is None or len(selected_keys) < 1:
|
432
|
+
raise ValueError("The data in response does not match any known keys.")
|
433
|
+
if len(selected_keys) > 1:
|
434
|
+
raise ValueError("The data in response matches multiple known keys.")
|
435
|
+
selected_key = selected_keys.pop()
|
436
|
+
if selected_key == "news":
|
437
|
+
return {"news": response[selected_key]}
|
438
|
+
return response[selected_key]
|