From e67c9b7883299788431c68a149649a2a274d903a Mon Sep 17 00:00:00 2001 From: Chihiro Hio Date: Wed, 24 Jan 2024 14:08:55 +0900 Subject: [PATCH] feat: support option historical data --- alpaca/data/historical/option.py | 333 ++++++++++ alpaca/data/historical/utils.py | 2 + alpaca/data/models/quotes.py | 4 +- alpaca/data/models/trades.py | 8 +- alpaca/data/requests.py | 100 +++ tests/conftest.py | 17 +- tests/data/test_historical_option_data.py | 774 ++++++++++++++++++++++ 7 files changed, 1227 insertions(+), 11 deletions(-) create mode 100644 alpaca/data/historical/option.py create mode 100644 tests/data/test_historical_option_data.py diff --git a/alpaca/data/historical/option.py b/alpaca/data/historical/option.py new file mode 100644 index 00000000..0b0bbb14 --- /dev/null +++ b/alpaca/data/historical/option.py @@ -0,0 +1,333 @@ +from collections import defaultdict +from enum import Enum +from typing import Dict, List, Optional, Union + +from alpaca.common.constants import DATA_V2_MAX_LIMIT +from alpaca.common.enums import BaseURL +from alpaca.common.rest import RESTClient +from alpaca.common.types import RawData +from alpaca.data.historical.utils import ( + format_dataset_response, + format_latest_data_response, + format_snapshot_data, + parse_obj_as_symbol_dict, +) +from alpaca.data.models.quotes import Quote, QuoteSet +from alpaca.data.models.snapshots import Snapshot +from alpaca.data.models.trades import Trade, TradeSet +from alpaca.data.requests import ( + OptionChainRequest, + OptionLatestQuoteRequest, + OptionLatestTradeRequest, + OptionQuotesRequest, + OptionSnapshotRequest, + OptionTradesRequest, +) + + +class DataExtensionType(Enum): + """Used to classify the type of endpoint path extensions""" + + LATEST = "latest" + SNAPSHOT = "snapshot" + + +class OptionHistoricalDataClient(RESTClient): + """ + The REST client for interacting with Alpaca Market Data API option data endpoints. + + Learn more on https://docs.alpaca.markets/docs/about-market-data-api + """ + + def __init__( + self, + api_key: Optional[str] = None, + secret_key: Optional[str] = None, + oauth_token: Optional[str] = None, + use_basic_auth: bool = False, + raw_data: bool = False, + url_override: Optional[str] = None, + ) -> None: + """ + Instantiates a Historical Data Client. + + Args: + api_key (Optional[str], optional): Alpaca API key. Defaults to None. + secret_key (Optional[str], optional): Alpaca API secret key. Defaults to None. + oauth_token (Optional[str]): The oauth token if authenticating via OAuth. Defaults to None. + use_basic_auth (bool, optional): If true, API requests will use basic authorization headers. + raw_data (bool, optional): If true, API responses will not be wrapped and raw responses will be returned from + methods. Defaults to False. This has not been implemented yet. + url_override (Optional[str], optional): If specified allows you to override the base url the client points + to for proxy/testing. + """ + super().__init__( + api_key=api_key, + secret_key=secret_key, + oauth_token=oauth_token, + use_basic_auth=use_basic_auth, + api_version="v1beta1", + base_url=url_override if url_override is not None else BaseURL.DATA, + sandbox=False, + raw_data=raw_data, + ) + + def get_option_quotes( + self, request_params: OptionQuotesRequest + ) -> Union[QuoteSet, RawData]: + """Returns level 1 quote data over a given time period for a option or list of options. + + Args: + request_params (GetOptionQuotesRequest): The request object for retrieving option quote data. + + Returns: + Union[QuoteSet, RawData]: The quote data either in raw or wrapped form + """ + params = request_params.to_request_fields() + + # paginated get request for market data api + raw_quotes = self._data_get( + endpoint_data_type="quotes", + endpoint_asset_class="options", + api_version=self._api_version, + **params, + ) + + if self._use_raw_data: + return raw_quotes + + return QuoteSet(raw_quotes) + + def get_option_trades( + self, request_params: OptionTradesRequest + ) -> Union[TradeSet, RawData]: + """Returns the price and sales history over a given time period for a option or list of options. + + Args: + request_params (GetOptionTradesRequest): The request object for retrieving option trade data. + + Returns: + Union[TradeSet, RawData]: The trade data either in raw or wrapped form + """ + params = request_params.to_request_fields() + + # paginated get request for market data api + raw_trades = self._data_get( + endpoint_data_type="trades", + endpoint_asset_class="options", + api_version=self._api_version, + **params, + ) + + if self._use_raw_data: + return raw_trades + + return TradeSet(raw_trades) + + def get_option_latest_quote( + self, request_params: OptionLatestQuoteRequest + ) -> Union[Dict[str, Quote], RawData]: + """Retrieves the latest quote for an option symbol or list of option symbols. + + Args: + request_params (OptionLatestQuoteRequest): The request object for retrieving the latest quote data. + + Returns: + Union[Dict[str, Quote], RawData]: The latest quote in raw or wrapped format + """ + params = request_params.to_request_fields() + + raw_latest_quotes = self._data_get( + endpoint_data_type="quotes", + endpoint_asset_class="options", + api_version=self._api_version, + extension=DataExtensionType.LATEST, + **params, + ) + + if self._use_raw_data: + return raw_latest_quotes + + return parse_obj_as_symbol_dict(Quote, raw_latest_quotes) + + def get_option_latest_trade( + self, request_params: OptionLatestTradeRequest + ) -> Union[Dict[str, Trade], RawData]: + """Retrieves the latest quote for an option symbol or list of option symbols. + + Args: + request_params (OptionLatestQuoteRequest): The request object for retrieving the latest quote data. + + Returns: + Union[Dict[str, Quote], RawData]: The latest quote in raw or wrapped format + """ + params = request_params.to_request_fields() + + raw_latest_quotes = self._data_get( + endpoint_data_type="trades", + endpoint_asset_class="options", + api_version=self._api_version, + extension=DataExtensionType.LATEST, + **params, + ) + + if self._use_raw_data: + return raw_latest_quotes + + return parse_obj_as_symbol_dict(Trade, raw_latest_quotes) + + def get_option_snapshot( + self, request_params: OptionSnapshotRequest + ) -> Union[Dict[str, Snapshot], RawData]: + """Returns snapshots of queried symbols. Snapshots contain latest trade and latest quote for the queried symbols. + + Args: + request_params (OptionSnapshotRequest): The request object for retrieving snapshot data. + + Returns: + Union[SnapshotSet, RawData]: The snapshot data either in raw or wrapped form + """ + + params = request_params.to_request_fields() + + raw_snapshots = self._data_get( + endpoint_asset_class="options", + endpoint_data_type="snapshot", + api_version=self._api_version, + extension=DataExtensionType.SNAPSHOT, + **params, + ) + + if self._use_raw_data: + return raw_snapshots + + return parse_obj_as_symbol_dict(Snapshot, raw_snapshots) + + def get_option_chain( + self, request_params: OptionChainRequest + ) -> Union[Dict[str, Snapshot], RawData]: + """The option chain endpoint for underlying symbol provides the latest trade, latest quote for each contract symbol of the underlying symbol. + + Args: + request_params (OptionSnapshotRequest): The request object for retrieving snapshot data. + + Returns: + Union[SnapshotSet, RawData]: The snapshot data either in raw or wrapped form + """ + + params = request_params.to_request_fields() + + raw_snapshots = self._data_get( + endpoint_asset_class="options", + endpoint_data_type="snapshot", + api_version=self._api_version, + extension=DataExtensionType.SNAPSHOT, + **params, + ) + + if self._use_raw_data: + return raw_snapshots + + return parse_obj_as_symbol_dict(Snapshot, raw_snapshots) + + # TODO: Remove duplication + def _data_get( + self, + endpoint_asset_class: str, + endpoint_data_type: str, + api_version: str, + symbol_or_symbols: Optional[Union[str, List[str]]] = None, + limit: Optional[int] = None, + page_limit: int = DATA_V2_MAX_LIMIT, + extension: Optional[DataExtensionType] = None, + underlying_symbol: Optional[str] = None, + **kwargs, + ) -> RawData: + """Performs Data API GET requests accounting for pagination. Data in responses are limited to the page_limit, + which defaults to 10,000 items. If any more data is requested, the data will be paginated. + + Args: + endpoint_data_type (str): The data API endpoint path - /bars, /quotes, etc + symbol_or_symbols (Union[str, List[str]]): The symbol or list of symbols that we want to query for + endpoint_asset_class (str): The data API security type path. Defaults to 'stocks'. + api_version (str): Data API version. Defaults to "v2". + limit (Optional[int]): The maximum number of items to query. Defaults to None. + page_limit (Optional[int]): The maximum number of items returned per page - different from limit. Defaults to DATA_V2_MAX_LIMIT. + + Returns: + RawData: Raw Market data from API + """ + # params contains the payload data + params = kwargs + + # stocks, crypto, etc + path = f"/{endpoint_asset_class}" + + multi_symbol = not isinstance(symbol_or_symbols, str) + + if underlying_symbol is not None: + pass + # multiple symbols passed as query params + # single symbols are path params + elif not multi_symbol: + params["symbols"] = symbol_or_symbols + else: + params["symbols"] = ",".join(symbol_or_symbols) + + # TODO: Improve this mess if possible + if extension == DataExtensionType.LATEST: + path += f"/{endpoint_data_type}" + path += "/latest" + elif extension == DataExtensionType.SNAPSHOT: + path += "/snapshots" + else: + # bars, trades, quotes, etc + path += f"/{endpoint_data_type}" + + if underlying_symbol is not None: + path += f"/{underlying_symbol}" + # data_by_symbol is in format of + # { + # "symbol1": [ "data1", "data2", ... ], + # "symbol2": [ "data1", "data2", ... ], + # .... + # } + data_by_symbol = defaultdict(list) + + total_items = 0 + page_token = None + + while True: + actual_limit = None + + # adjusts the limit parameter value if it is over the page_limit + if limit: + # actual_limit is the adjusted total number of items to query per request + actual_limit = min(int(limit) - total_items, page_limit) + if actual_limit < 1: + break + + params["limit"] = actual_limit + params["page_token"] = page_token + + response = self.get(path=path, data=params, api_version=api_version) + + # TODO: Merge parsing if possible + if extension == DataExtensionType.SNAPSHOT: + format_snapshot_data(response, data_by_symbol) + elif extension == DataExtensionType.LATEST: + format_latest_data_response(response, data_by_symbol) + else: + format_dataset_response(response, data_by_symbol) + + # if we've sent a request with a limit, increment count + if actual_limit: + total_items += actual_limit + + page_token = response.get("next_page_token", None) + + if page_token is None: + break + + # users receive Type dict + return dict(data_by_symbol) diff --git a/alpaca/data/historical/utils.py b/alpaca/data/historical/utils.py index a1613eba..a7a6e514 100644 --- a/alpaca/data/historical/utils.py +++ b/alpaca/data/historical/utils.py @@ -148,6 +148,8 @@ def format_snapshot_data( del response["symbol"] data_by_symbol[symbol] = response else: + if "snapshots" in response: + response = response["snapshots"] for symbol, data in response.items(): data_by_symbol[symbol] = data diff --git a/alpaca/data/models/quotes.py b/alpaca/data/models/quotes.py index b8dc7c44..530db8a1 100644 --- a/alpaca/data/models/quotes.py +++ b/alpaca/data/models/quotes.py @@ -22,7 +22,7 @@ class Quote(BaseModel): bid_exchange (Optional[str, Exchange]): The exchange the quote bid originates. Defaults to None. bid_price (float): The bidding price of the quote. bid_size (float): The size of the quote bid. - conditions (Optional[List[str]]): The quote conditions. Defaults to None. + conditions (Optional[Union[List[str], str]]): The quote conditions. Defaults to None. tape (Optional[str]): The quote tape. Defaults to None. """ @@ -34,7 +34,7 @@ class Quote(BaseModel): bid_exchange: Optional[Union[str, Exchange]] = None bid_price: float bid_size: float - conditions: Optional[List[str]] = None + conditions: Optional[Union[List[str], str]] = None tape: Optional[str] = None model_config = ConfigDict(protected_namespaces=tuple()) diff --git a/alpaca/data/models/trades.py b/alpaca/data/models/trades.py index 17092418..b34e2703 100644 --- a/alpaca/data/models/trades.py +++ b/alpaca/data/models/trades.py @@ -19,8 +19,8 @@ class Trade(BaseModel): exchange (Optional[Exchange]): The exchange the trade occurred. price (float): The price that the transaction occurred at. size (float): The quantity traded - id (int): The trade ID - conditions (Optional[List[str]]): The trade conditions. Defaults to None. + id (Optional[int]): The trade ID + conditions (Optional[Union[List[str], str]]): The trade conditions. Defaults to None. tape (Optional[str]): The trade tape. Defaults to None. """ @@ -29,8 +29,8 @@ class Trade(BaseModel): exchange: Optional[Union[Exchange, str]] = None price: float size: float - id: int - conditions: Optional[List[str]] = None + id: Optional[int] + conditions: Optional[Union[List[str], str]] = None tape: Optional[str] = None model_config = ConfigDict(protected_namespaces=tuple()) diff --git a/alpaca/data/requests.py b/alpaca/data/requests.py index 829e4032..1aefde7d 100644 --- a/alpaca/data/requests.py +++ b/alpaca/data/requests.py @@ -133,6 +133,23 @@ class StockQuotesRequest(BaseTimeseriesDataRequest): feed: Optional[DataFeed] = None +class OptionQuotesRequest(BaseTimeseriesDataRequest): + """ + This request class is used to submit a request for option quote data. + + See BaseTimeseriesDataRequest for more information on available parameters. + + Attributes: + symbol_or_symbols (Union[str, List[str]]): The option identifier or list of option identifiers. + start (Optional[datetime]): The beginning of the time interval for desired data. Timezone naive inputs assumed to be in UTC. + end (Optional[datetime]): The end of the time interval for desired data. Defaults to now. Timezone naive inputs assumed to be in UTC. + limit (Optional[int]): Upper limit of number of data points to return. Defaults to None. + sort (Optional[Sort]): The chronological order of response based on the timestamp. Defaults to ASC. + """ + + pass + + # ############################## Trades ################################# # @@ -171,6 +188,23 @@ class CryptoTradesRequest(BaseTimeseriesDataRequest): pass +class OptionTradesRequest(BaseTimeseriesDataRequest): + """ + This request class is used to submit a request for option trade data. + + See BaseTimeseriesDataRequest for more information on available parameters. + + Attributes: + symbol_or_symbols (Union[str, List[str]]): The option identifier or list of option identifiers. + start (Optional[datetime]): The beginning of the time interval for desired data. Timezone naive inputs assumed to be in UTC. + end (Optional[datetime]): The end of the time interval for desired data. Defaults to now. Timezone naive inputs assumed to be in UTC. + limit (Optional[int]): Upper limit of number of data points to return. Defaults to None. + sort (Optional[Sort]): The chronological order of response based on the timestamp. Defaults to ASC. + """ + + pass + + # ############################## Latest Endpoints ################################# # @@ -287,6 +321,46 @@ class CryptoLatestBarRequest(BaseCryptoLatestDataRequest): pass +class BaseOptionLatestDataRequest(NonEmptyRequest): + """ + A base request object for retrieving the latest data for options. You most likely should not use this directly and + instead use the asset class specific request objects. + + Attributes: + symbol_or_symbols (Union[str, List[str]]): The option identifier or list of option identifiers. + """ + + symbol_or_symbols: Union[str, List[str]] + + model_config = ConfigDict(protected_namespaces=tuple()) + + +class OptionLatestQuoteRequest(BaseOptionLatestDataRequest): + """ + This request class is used to submit a request for the latest option quote data. + + See BaseOptionLatestDataRequest for more information on available parameters. + + Attributes: + symbol_or_symbols (Union[str, List[str]]): The option identifier or list of option identifiers. + """ + + pass + + +class OptionLatestTradeRequest(BaseOptionLatestDataRequest): + """ + This request class is used to submit a request for the latest option trade data. + + See BaseOptionLatestDataRequest for more information on available parameters. + + Attributes: + symbol_or_symbols (Union[str, List[str]]): The option identifier or list of option identifiers. + """ + + pass + + # ############################## Snapshots ################################# # @@ -320,6 +394,32 @@ class CryptoSnapshotRequest(NonEmptyRequest): model_config = ConfigDict(protected_namespaces=tuple()) +class OptionSnapshotRequest(NonEmptyRequest): + """ + This request class is used to submit a request for snapshot data for options. + + Attributes: + symbol_or_symbols (Union[str, List[str]]): The option identifier or list of option identifiers. + """ + + symbol_or_symbols: Union[str, List[str]] + + model_config = ConfigDict(protected_namespaces=tuple()) + + +class OptionChainRequest(NonEmptyRequest): + """ + This request class is used to submit a request for option chain data for options. + + Attributes: + symbol_or_symbols (Union[str, List[str]]): The option identifier or list of option identifiers. + """ + + underlying_symbol: str + + model_config = ConfigDict(protected_namespaces=tuple()) + + # ############################## Orderbooks ################################# # diff --git a/tests/conftest.py b/tests/conftest.py index 08972da2..f7368d10 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,15 @@ -import pytest -from requests_mock import Mocker from typing import Iterator -from alpaca.broker.client import BrokerClient + +import pytest import requests_mock +from requests_mock import Mocker +from alpaca.broker.client import BrokerClient from alpaca.data.historical import StockHistoricalDataClient from alpaca.data.historical.crypto import CryptoHistoricalDataClient -from alpaca.trading.client import TradingClient - +from alpaca.data.historical.option import OptionHistoricalDataClient from alpaca.data.historical.screener import ScreenerClient +from alpaca.trading.client import TradingClient @pytest.fixture @@ -57,6 +58,12 @@ def crypto_client(): return client +@pytest.fixture +def option_client() -> OptionHistoricalDataClient: + client = OptionHistoricalDataClient("key-id", "secret-key") + return client + + @pytest.fixture def screener_client(): return ScreenerClient("key-id", "secret-key") diff --git a/tests/data/test_historical_option_data.py b/tests/data/test_historical_option_data.py new file mode 100644 index 00000000..5695a4a3 --- /dev/null +++ b/tests/data/test_historical_option_data.py @@ -0,0 +1,774 @@ +import urllib.parse +from datetime import datetime, timezone +from typing import Dict + +from alpaca.data import Quote, Snapshot, Trade +from alpaca.data.enums import DataFeed, Exchange +from alpaca.data.historical.option import OptionHistoricalDataClient +from alpaca.data.models import QuoteSet, TradeSet +from alpaca.data.requests import ( + OptionLatestQuoteRequest, + OptionLatestTradeRequest, + OptionQuotesRequest, + OptionSnapshotRequest, + OptionTradesRequest, +) + + +def test_get_quotes(reqmock, option_client: OptionHistoricalDataClient): + # Test single symbol request + + symbol = "AAPL240126P00050000" + start = datetime(2024, 1, 24) + limit = 2 + + _start_in_url = urllib.parse.quote_plus( + start.replace(tzinfo=timezone.utc).isoformat() + ) + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/quotes?symbols={symbol}&start={_start_in_url}&limit={limit}", + text=""" + { + "quotes": [ + { + "t": "2024-01-24T09:00:00.000059Z", + "ax": "K", + "ap": 158.65, + "as": 1, + "bx": "Q", + "bp": 159.52, + "bs": 4, + "c": [ + "R" + ], + "z": "C" + }, + { + "t": "2024-01-25T09:00:00.000059Z", + "ax": "K", + "ap": 158.8, + "as": 1, + "bx": "Q", + "bp": 159.52, + "bs": 4, + "c": [ + "R" + ], + "z": "C" + } + ], + "symbol": "AAPL240126P00050000", + "next_page_token": "QUFQTHwyMDIyLTAzLTA5VDA5OjAwOjAwLjAwMDA1OTAwMFp8Q0ZEQUU5QTg=" + } + """, + ) + request = OptionQuotesRequest(symbol_or_symbols=symbol, start=start, limit=limit) + + quoteset = option_client.get_option_quotes(request_params=request) + + assert isinstance(quoteset, QuoteSet) + + assert quoteset[symbol][0].ask_price == 158.65 + assert quoteset[symbol][0].bid_size == 4 + + assert quoteset[symbol][0].ask_exchange == "K" + + assert quoteset.df.index.nlevels == 2 + + assert reqmock.called_once + + +def test_multisymbol_quotes(reqmock, option_client: OptionHistoricalDataClient): + # test multisymbol request + symbols = ["AAPL240126P00050000", "AAPL240126P00100000"] + start = datetime(2024, 1, 24) + _symbols_in_url = "%2C".join(s for s in symbols) + + _start_in_url = urllib.parse.quote_plus( + start.replace(tzinfo=timezone.utc).isoformat() + ) + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/quotes?start={_start_in_url}&symbols={_symbols_in_url}", + text=""" + { + "quotes": { + "AAPL240126P00050000": [ + { + "t": "2024-01-24T09:00:00.000059Z", + "ax": "K", + "ap": 158.65, + "as": 1, + "bx": "Q", + "bp": 159.52, + "bs": 4, + "c": [ + "R" + ], + "z": "C" + } + ], + "AAPL240126P00100000": [ + { + "t": "2024-01-24T09:00:00.000805Z", + "ax": "K", + "ap": 830, + "as": 1, + "bx": "P", + "bp": 840.75, + "bs": 1, + "c": [ + "R" + ], + "z": "C" + } + ] + }, + "next_page_token": null + } + """, + ) + + request = OptionQuotesRequest(symbol_or_symbols=symbols, start=start) + + quoteset = option_client.get_option_quotes(request_params=request) + + assert isinstance(quoteset, QuoteSet) + + assert quoteset["AAPL240126P00050000"][0].ask_size == 1 + assert quoteset["AAPL240126P00100000"][0].bid_price == 840.75 + + assert quoteset["AAPL240126P00050000"][0].bid_exchange == "Q" + + assert quoteset.df.index.nlevels == 2 + + assert reqmock.called_once + + +def test_get_quotes_single_empty_response( + reqmock, option_client: OptionHistoricalDataClient +): + symbol = "AAPL240126P00050000" + start = datetime(2024, 1, 24) + limit = 2 + + _start_in_url = urllib.parse.quote_plus( + start.replace(tzinfo=timezone.utc).isoformat() + ) + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/quotes?symbols={symbol}&start={_start_in_url}&limit={limit}", + text=""" + { + "next_page_token": null, + "quotes": null, + "symbol": "AAPL240126P00050000" + } + """, + ) + request = OptionQuotesRequest(symbol_or_symbols=symbol, start=start, limit=limit) + + quoteset = option_client.get_option_quotes(request_params=request) + + assert isinstance(quoteset, QuoteSet) + + assert quoteset.dict() == {"AAPL240126P00050000": []} + + assert len(quoteset.df) == 0 + + assert reqmock.called_once + + +def test_get_quotes_multi_empty_response( + reqmock, option_client: OptionHistoricalDataClient +): + symbol = "AAPL240126P00050000" + start = datetime(2024, 1, 24) + limit = 2 + + _start_in_url = urllib.parse.quote_plus( + start.replace(tzinfo=timezone.utc).isoformat() + ) + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/quotes?symbols={symbol}&start={_start_in_url}&limit={limit}", + text=""" + { + "next_page_token": null, + "quotes": {} + } + """, + ) + request = OptionQuotesRequest(symbol_or_symbols=[symbol], start=start, limit=limit) + + quoteset = option_client.get_option_quotes(request_params=request) + + assert isinstance(quoteset, QuoteSet) + + assert quoteset.dict() == {} + + assert len(quoteset.df) == 0 + + assert reqmock.called_once + + +def test_get_trades(reqmock, option_client: OptionHistoricalDataClient): + # Test single symbol request + symbol = "AAPL240126P00050000" + start = datetime(2024, 1, 24) + limit = 2 + + _start_in_url = urllib.parse.quote_plus( + start.replace(tzinfo=timezone.utc).isoformat() + ) + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/trades?symbols={symbol}&start={_start_in_url}&limit={limit}", + text=""" + { + "trades": [ + { + "t": "2024-01-24T05:00:02.183Z", + "x": "D", + "p": 159.07, + "s": 1, + "c": [ + "@", + "T", + "I" + ], + "i": 151, + "z": "C" + }, + { + "t": "2024-01-24T05:00:16.91Z", + "x": "D", + "p": 159.07, + "s": 2, + "c": [ + "@", + "T", + "I" + ], + "i": 168, + "z": "C" + } + ], + "symbol": "AAPL240126P00050000", + "next_page_token": "QUFQTHwyMDIyLTAzLTA5VDA1OjAwOjE2LjkxMDAwMDAwMFp8RHwwOTIyMzM3MjAzNjg1NDc3NTk3Ng==" + } + """, + ) + + request = OptionTradesRequest(symbol_or_symbols=symbol, start=start, limit=limit) + + tradeset = option_client.get_option_trades(request_params=request) + + assert isinstance(tradeset, TradeSet) + + assert tradeset[symbol][0].price == 159.07 + assert tradeset[symbol][0].size == 1 + + assert tradeset[symbol][0].exchange == Exchange.D + + assert tradeset.df.index.nlevels == 2 + + assert reqmock.called_once + + +def test_multisymbol_get_trades(reqmock, option_client: OptionHistoricalDataClient): + # test multisymbol request + symbols = ["AAPL240126P00050000", "AAPL240126P00100000"] + start = datetime(2024, 1, 24) + _symbols_in_url = "%2C".join(s for s in symbols) + + _start_in_url = urllib.parse.quote_plus( + start.replace(tzinfo=timezone.utc).isoformat() + ) + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/trades?start={_start_in_url}&symbols={_symbols_in_url}", + text=""" + { + "trades": { + "AAPL240126P00050000": [ + { + "t": "2024-01-24T05:00:02.183Z", + "x": "D", + "p": 159.07, + "s": 1, + "c": [ + "@", + "T", + "I" + ], + "i": 151, + "z": "C" + } + ], + "AAPL240126P00100000": [ + { + "t": "2024-01-24T05:08:03.035Z", + "x": "D", + "p": 833, + "s": 1, + "c": [ + "@", + "T", + "I" + ], + "i": 145, + "z": "C" + } + ] + }, + "next_page_token": null + } + """, + ) + + request = OptionTradesRequest(symbol_or_symbols=symbols, start=start) + + tradeset = option_client.get_option_trades(request_params=request) + + assert isinstance(tradeset, TradeSet) + + assert tradeset["AAPL240126P00050000"][0].size == 1 + assert tradeset["AAPL240126P00100000"][0].price == 833 + + assert tradeset["AAPL240126P00050000"][0].exchange == Exchange.D + + assert tradeset.df.index[0][1].day == 24 + assert tradeset.df.index.nlevels == 2 + + assert reqmock.called_once + + +def test_get_trades_single_empty_response( + reqmock, option_client: OptionHistoricalDataClient +): + symbol = "AAPL240126P00050000" + start = datetime(2024, 1, 24) + limit = 2 + + _start_in_url = urllib.parse.quote_plus( + start.replace(tzinfo=timezone.utc).isoformat() + ) + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/trades?symbols={symbol}&start={_start_in_url}&limit={limit}", + text=""" + { + "next_page_token": null, + "trades": null, + "symbol": "AAPL240126P00050000" + } + """, + ) + + request = OptionTradesRequest(symbol_or_symbols=symbol, start=start, limit=limit) + + tradeset = option_client.get_option_trades(request_params=request) + + assert isinstance(tradeset, TradeSet) + + assert tradeset.dict() == {"AAPL240126P00050000": []} + + assert len(tradeset.df) == 0 + + assert reqmock.called_once + + +def test_get_trades_multi_empty_response( + reqmock, option_client: OptionHistoricalDataClient +): + symbol = "AAPL240126P00050000" + start = datetime(2024, 1, 24) + limit = 2 + + _start_in_url = urllib.parse.quote_plus( + start.replace(tzinfo=timezone.utc).isoformat() + ) + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/trades?symbols={symbol}&start={_start_in_url}&limit={limit}", + text=""" + { + "next_page_token": null, + "trades": {} + } + """, + ) + + request = OptionTradesRequest(symbol_or_symbols=[symbol], start=start, limit=limit) + + tradeset = option_client.get_option_trades(request_params=request) + + assert isinstance(tradeset, TradeSet) + + assert tradeset.dict() == {} + + assert len(tradeset.df) == 0 + + assert reqmock.called_once + + +def test_get_latest_trade(reqmock, option_client: OptionHistoricalDataClient): + # Test single symbol request + symbol = "AAPL240126P00050000" + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/trades/latest?symbols={symbol}", + text=""" + { + "symbol": "AAPL240126P00050000", + "trade": { + "t": "2024-01-24T14:02:09.722539521Z", + "x": "D", + "p": 161.2958, + "s": 100, + "c": [ + "@" + ], + "i": 22730, + "z": "C" + } + } + """, + ) + request = OptionLatestTradeRequest(symbol_or_symbols=symbol, feed=DataFeed.IEX) + + trades = option_client.get_option_latest_trade(request_params=request) + + assert isinstance(trades, Dict) + + trade = trades[symbol] + + assert isinstance(trade, Trade) + + assert trade.price == 161.2958 + assert trade.size == 100 + + assert trade.exchange == Exchange.D + + assert reqmock.called_once + + +def test_get_multisymbol_latest_trade( + reqmock, option_client: OptionHistoricalDataClient +): + symbols = ["AAPL240126P00050000", "AAPL240126P00100000"] + _symbols_in_url = "%2C".join(s for s in symbols) + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/trades/latest?symbols={_symbols_in_url}", + text=""" + { + "trades": { + "AAPL240126P00050000": { + "t": "2024-01-24T14:02:09.722539521Z", + "x": "D", + "p": 161.2958, + "s": 100, + "c": [ + "@" + ], + "i": 22730, + "z": "C" + }, + "AAPL240126P00100000": { + "t": "2024-01-24T19:59:59.405545378Z", + "x": "V", + "p": 720.19, + "s": 100, + "c": [ + "@" + ], + "i": 11017, + "z": "C" + } + } + } + """, + ) + request = OptionLatestTradeRequest(symbol_or_symbols=symbols, feed=DataFeed.IEX) + + trades = option_client.get_option_latest_trade(request_params=request) + + assert isinstance(trades, Dict) + + trade = trades["AAPL240126P00050000"] + + assert isinstance(trade, Trade) + + assert trade.price == 161.2958 + assert trade.size == 100 + + assert trade.exchange == Exchange.D + + assert reqmock.called_once + + +def test_get_latest_trade_multi_not_found( + reqmock, option_client: OptionHistoricalDataClient +): + symbol = "AAPL240126P00050000" + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/trades/latest?symbols={symbol}", + text=""" + { + "next_page_token": null + "trade": null, + "symbol": "AAPL240126P00050000" + } + """, + ) + request = OptionLatestTradeRequest(symbol_or_symbols=symbol, feed=DataFeed.IEX) + + trade = option_client.get_option_latest_trade(request_params=request) + + assert isinstance(trade, Dict) + + assert trade == {} + + assert reqmock.called_once + + +def test_get_latest_trade_multi_not_found( + reqmock, option_client: OptionHistoricalDataClient +): + symbol = "AAAAPL240126P00050000PL" + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/trades/latest?symbols={symbol}", + text=""" + { + "trades": {} + } + """, + ) + request = OptionLatestTradeRequest(symbol_or_symbols=[symbol], feed=DataFeed.IEX) + + trades = option_client.get_option_latest_trade(request_params=request) + + assert isinstance(trades, Dict) + + assert trades == {} + + assert reqmock.called_once + + +def test_get_latest_quote(reqmock, option_client: OptionHistoricalDataClient): + # Test single symbol request + symbol = "AAPL240126P00050000" + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/quotes/latest?symbols={symbol}", + text=""" + { + "symbol": "AAPL240126P00050000", + "quote": { + "t": "2024-01-24T14:02:43.651613184Z", + "ax": "P", + "ap": 161.11, + "as": 13, + "bx": "K", + "bp": 161.1, + "bs": 2, + "c": [ + "R" + ], + "z": "C" + } + } + """, + ) + + request = OptionLatestQuoteRequest(symbol_or_symbols=symbol) + + quotes = option_client.get_option_latest_quote(request) + + assert isinstance(quotes, Dict) + + quote = quotes[symbol] + + assert isinstance(quote, Quote) + + assert quote.ask_price == 161.11 + assert quote.bid_size == 2 + + assert quote.bid_exchange == "K" + + assert reqmock.called_once + + +def test_get_latest_quote_single_empty_response( + reqmock, option_client: OptionHistoricalDataClient +): + # Test single symbol request + symbol = "AAPL240126P00050000" + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/quotes/latest?symbols={symbol}", + text=""" + { + "next_page_token": null, + "quote": null, + "symbol": "AAPL240126P00050000" + } + """, + ) + + request = OptionLatestQuoteRequest(symbol_or_symbols=symbol) + + quote = option_client.get_option_latest_quote(request) + + assert isinstance(quote, Dict) + + assert quote == {} + + assert reqmock.called_once + + +def test_get_latest_quote_multi_empty_response( + reqmock, option_client: OptionHistoricalDataClient +): + symbol = "AAPL240126P00050000" + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/quotes/latest?symbols={symbol}", + text=""" + { + "quotes": {} + } + """, + ) + + request = OptionLatestQuoteRequest(symbol_or_symbols=[symbol]) + + quotes = option_client.get_option_latest_quote(request) + + assert isinstance(quotes, Dict) + + assert quotes == {} + + assert reqmock.called_once + + +def test_get_snapshot(reqmock, option_client: OptionHistoricalDataClient): + # Test single symbol request + symbol = "AAPL240126P00050000" + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/snapshots?symbols={symbol}", + text=""" + { + "symbol": "AAPL240126P00050000", + "latestTrade": { + "t": "2024-01-24T14:33:58.448432206Z", + "x": "D", + "p": 161.1998, + "s": 200, + "c": [ + "@" + ], + "i": 39884, + "z": "C" + }, + "latestQuote": { + "t": "2022-03-18T14:33:58.547942Z", + "ax": "K", + "ap": 161.2, + "as": 2, + "bx": "K", + "bp": 161.19, + "bs": 5, + "c": [ + "R" + ], + "z": "C" + } + } + """, + ) + + request = OptionSnapshotRequest(symbol_or_symbols=symbol) + + snapshots = option_client.get_option_snapshot(request) + + assert isinstance(snapshots, Dict) + + snapshot = snapshots[symbol] + + assert isinstance(snapshot, Snapshot) + + assert snapshot.latest_trade.price == 161.1998 + assert snapshot.latest_quote.bid_size == 5 + assert snapshot.minute_bar is None + assert snapshot.daily_bar is None + assert snapshot.previous_daily_bar is None + + assert reqmock.called_once + + +def test_get_snapshot_single_empty_response( + reqmock, option_client: OptionHistoricalDataClient +): + symbol = "AAPL240126P00050000" + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/snapshots?symbols={symbol}", + text=""" + { + "symbol": "AAPL240126P00050000", + "latestTrade": null, + "latestQuote": null, + "minuteBar": null, + "dailyBar": null, + "prevDailyBar": null + } + """, + ) + + request = OptionSnapshotRequest(symbol_or_symbols=symbol) + + snapshot = option_client.get_option_snapshot(request) + + assert isinstance(snapshot, Dict) + + assert "AAPL240126P00050000" in snapshot + + assert snapshot["AAPL240126P00050000"].model_dump() == { + "daily_bar": None, + "latest_quote": None, + "latest_trade": None, + "minute_bar": None, + "previous_daily_bar": None, + "symbol": "AAPL240126P00050000", + } + + assert reqmock.called_once + + +def test_get_snapshot_multi_empty_response( + reqmock, option_client: OptionHistoricalDataClient +): + symbol = "AAPL240126P00050000" + + reqmock.get( + f"https://data.alpaca.markets/v1beta1/options/snapshots?symbols={symbol}", + text=""" + {} + """, + ) + + request = OptionSnapshotRequest(symbol_or_symbols=[symbol]) + + snapshots = option_client.get_option_snapshot(request) + + assert isinstance(snapshots, Dict) + + assert snapshots == {} + + assert reqmock.called_once