diff --git a/alpaca/data/enums.py b/alpaca/data/enums.py index 1d99eda9..afa69bc7 100644 --- a/alpaca/data/enums.py +++ b/alpaca/data/enums.py @@ -140,3 +140,39 @@ class NewsImageSize(str, Enum): THUMB = "thumb" SMALL = "small" LARGE = "large" + + +class CorporateActionsType(str, Enum): + """ + The type of corporate action. + ref. https://docs.alpaca.markets/reference/corporateactions-1 + + Attributes: + REVERSE_SPLIT (str): Reverse split + FORWARD_SPLIT (str): Forward split + UNIT_SPLIT (str): Unit split + CASH_DIVIDEND (str): Cash dividend + STOCK_DIVIDEND (str): Stock dividend + SPIN_OFF (str): Spin off + CASH_MERGER (str): Cash merger + STOCK_MERGER (str): Stock merger + STOCK_AND_CASH_MERGER (str): Stock and cash merger + REDEMPTION (str): Redemption + NAME_CHANGE (str): Name change + WORTHLESS_REMOVAL (str): Worthless removal + RIGHTS_DISTRIBUTION (str): Rights distribution + """ + + REVERSE_SPLIT = "reverse_split" + FORWARD_SPLIT = "forward_split" + UNIT_SPLIT = "unit_split" + CASH_DIVIDEND = "cash_dividend" + STOCK_DIVIDEND = "stock_dividend" + SPIN_OFF = "spin_off" + CASH_MERGER = "cash_merger" + STOCK_MERGER = "stock_merger" + STOCK_AND_CASH_MERGER = "stock_and_cash_merger" + REDEMPTION = "redemption" + NAME_CHANGE = "name_change" + WORTHLESS_REMOVAL = "worthless_removal" + RIGHTS_DISTRIBUTION = "rights_distribution" diff --git a/alpaca/data/historical/corporate_actions.py b/alpaca/data/historical/corporate_actions.py new file mode 100644 index 00000000..66cc0bdf --- /dev/null +++ b/alpaca/data/historical/corporate_actions.py @@ -0,0 +1,118 @@ +from collections import defaultdict +from typing import Callable, Optional, Union + +from alpaca.common.enums import BaseURL +from alpaca.common.rest import RESTClient +from alpaca.common.types import RawData +from alpaca.data.historical.utils import get_data_from_response +from alpaca.data.models.corporate_actions import CorporateActionsSet +from alpaca.data.requests import CorporateActionsRequest + + +class CorporateActionsClient(RESTClient): + """ + The REST client for interacting with Alpaca Corporate Actions API endpoints. + """ + + 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 Corporate Actions 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_corporate_actions( + self, request_params: CorporateActionsRequest + ) -> Union[RawData, CorporateActionsSet]: + """Returns corporate actions data + + Args: + request_params (CorporateActionsRequest): The request params to filter the corporate actions data + """ + params = request_params.to_request_fields() + + if request_params.symbols: + params["symbols"] = ",".join(request_params.symbols) + if request_params.types: + params["types"] = ",".join(request_params.types) + + response = self._data_get( + path="/corporate-actions", api_version=self._api_version, **params + ) + if self._use_raw_data: + return response + + return CorporateActionsSet(response) + + # TODO: Refactor data_get (common to all historical data queries!) + def _data_get( + self, + path: str, + limit: Optional[int] = None, + page_limit: int = 1000, + api_version: str = "v1", + **kwargs, + ) -> RawData: + params = kwargs + + # data is grouped by corporate action type (reverse_splits, forward_splits, etc.) + d = 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) + + for ca_type, cas in get_data_from_response(response).items(): + d[ca_type].extend(cas) + + # if we've sent a request with a limit, increment count + if actual_limit: + total_items = sum([len(items) for items in d.values()]) + + page_token = response.get("next_page_token", None) + + if page_token is None: + break + + # users receive Type dict + return dict(d) diff --git a/alpaca/data/historical/utils.py b/alpaca/data/historical/utils.py index 27709a78..18628021 100644 --- a/alpaca/data/historical/utils.py +++ b/alpaca/data/historical/utils.py @@ -51,6 +51,7 @@ def get_data_from_response(response: HTTPResult) -> RawData: "snapshots", "orderbook", "orderbooks", + "corporate_actions", } selected_key = data_keys.intersection(response) diff --git a/alpaca/data/models/base.py b/alpaca/data/models/base.py index c48aae0e..497281f7 100644 --- a/alpaca/data/models/base.py +++ b/alpaca/data/models/base.py @@ -1,6 +1,7 @@ import itertools from typing import Any, Dict, List +import numpy as np import pandas as pd from pandas import DataFrame @@ -20,12 +21,16 @@ def df(self) -> DataFrame: data_list = list(itertools.chain.from_iterable(self.dict().values())) df = pd.DataFrame(data_list) + columns = df.columns # set multi-level index if "news" in self.dict(): # level=0 - id df = df.set_index(["id"]) - if set(["symbol", "timestamp"]).issubset(df.columns): + elif "corporate_action_type" in columns: + # level=0 - corporate_action_type + df = df.set_index(["corporate_action_type"]) + elif set(["symbol", "timestamp"]).issubset(columns): # level=0 - symbol # level=1 - timestamp df = df.set_index(["symbol", "timestamp"]) diff --git a/alpaca/data/models/corporate_actions.py b/alpaca/data/models/corporate_actions.py new file mode 100644 index 00000000..aaccbf62 --- /dev/null +++ b/alpaca/data/models/corporate_actions.py @@ -0,0 +1,288 @@ +from datetime import date +from typing import Dict, List, Optional, Union + +from alpaca.common.models import ValidateBaseModel as BaseModel +from alpaca.common.types import RawData +from alpaca.data.models.base import BaseDataSet, TimeSeriesMixin + + +class ForwardSplit(BaseModel): + corporate_action_type: str + symbol: str + new_rate: float + old_rate: float + process_date: date + ex_date: date + record_date: Optional[date] = None + payable_date: Optional[date] = None + due_bill_redemption_date: Optional[date] = None + + +class ReverseSplit(BaseModel): + corporate_action_type: str + symbol: str + new_rate: float + old_rate: float + process_date: date + ex_date: date + record_date: Optional[date] = None + payable_date: Optional[date] = None + + +class UnitSplit(BaseModel): + corporate_action_type: str + old_symbol: str + old_rate: float + new_symbol: str + new_rate: float + alternate_symbol: str + alternate_rate: float + process_date: date + effective_date: date + payable_date: Optional[date] = None + + +class StockDividend(BaseModel): + corporate_action_type: str + symbol: str + rate: float + process_date: date + ex_date: date + record_date: Optional[date] = None + payable_date: Optional[date] = None + + +class CashDividend(BaseModel): + corporate_action_type: str + symbol: str + rate: float + special: bool + foreign: bool + process_date: date + ex_date: date + record_date: Optional[date] = None + payable_date: Optional[date] = None + due_bill_on_date: Optional[date] = None + due_bill_off_date: Optional[date] = None + + +class SpinOff(BaseModel): + corporate_action_type: str + source_symbol: str + source_rate: float + new_symbol: str + new_rate: float + process_date: date + ex_date: date + payable_date: Optional[date] = None + record_date: Optional[date] = None + payable_date: Optional[date] = None + due_bill_redemption_date: Optional[date] = None + + +class CashMerger(BaseModel): + corporate_action_type: str + acquirer_symbol: Optional[str] = None + acquiree_symbol: str + rate: float + process_date: date + effective_date: date + payable_date: Optional[date] = None + + +class StockMerger(BaseModel): + corporate_action_type: str + acquirer_symbol: str + acquirer_rate: float + acquiree_symbol: str + acquiree_rate: float + process_date: date + effective_date: date + payable_date: Optional[date] = None + + +class StockAndCashMerger(BaseModel): + corporate_action_type: str + acquirer_symbol: str + acquirer_rate: float + acquiree_symbol: str + acquiree_rate: float + cash_rate: float + process_date: date + effective_date: date + payable_date: Optional[date] = None + + +class Redemption(BaseModel): + corporate_action_type: str + symbol: str + rate: float + process_date: date + payable_date: Optional[date] = None + + +class NameChange(BaseModel): + corporate_action_type: str + old_symbol: str + new_symbol: str + process_date: date + + +class WorthlessRemoval(BaseModel): + corporate_action_type: str + symbol: str + process_date: date + + +class RightsDistribution(BaseModel): + corporate_action_type: str + source_symbol: str + new_symbol: str + rate: float + process_date: date + ex_date: date + payable_date: date = None + record_date: Optional[date] = None + expiration_date: Optional[date] = None + + +CorporateAction = Union[ + ForwardSplit, + ReverseSplit, + UnitSplit, + StockDividend, + CashDividend, + SpinOff, + CashMerger, + StockMerger, + StockAndCashMerger, + Redemption, + NameChange, + WorthlessRemoval, + RightsDistribution, +] + + +class CorporateActionsSet(BaseDataSet, TimeSeriesMixin): + """ + A collection of Corporate actions. + ref. https://docs.alpaca.markets/reference/corporateactions-1 + + Attributes: + data (Dict[str, List[CorporateAction]]): The collection of corporate actions. + """ + + data: Dict[ + str, + List[CorporateAction], + ] = {} + + def __init__(self, raw_data: RawData) -> None: + """ + Instantiates a CorporateActionsSet - a collection of CorporateAction. + + Args: + raw_data (RawData): The raw corporate_actions data received from API + """ + parsed_corporate_actions: Dict[ + str, + List[CorporateAction], + ] = {} + + if raw_data is None: + return super().__init__() + + for corporate_action_type, corporate_actions in raw_data.items(): + if corporate_action_type == "forward_splits": + parsed_corporate_actions[corporate_action_type] = [ + ForwardSplit( + corporate_action_type=corporate_action_type, **corporate_action + ) + for corporate_action in corporate_actions + ] + elif corporate_action_type == "reverse_splits": + parsed_corporate_actions[corporate_action_type] = [ + ReverseSplit( + corporate_action_type=corporate_action_type, **corporate_action + ) + for corporate_action in corporate_actions + ] + elif corporate_action_type == "unit_splits": + parsed_corporate_actions[corporate_action_type] = [ + UnitSplit( + corporate_action_type=corporate_action_type, **corporate_action + ) + for corporate_action in corporate_actions + ] + elif corporate_action_type == "stock_dividends": + parsed_corporate_actions[corporate_action_type] = [ + StockDividend( + corporate_action_type=corporate_action_type, **corporate_action + ) + for corporate_action in corporate_actions + ] + elif corporate_action_type == "cash_dividends": + parsed_corporate_actions[corporate_action_type] = [ + CashDividend( + corporate_action_type=corporate_action_type, **corporate_action + ) + for corporate_action in corporate_actions + ] + elif corporate_action_type == "spin_offs": + parsed_corporate_actions[corporate_action_type] = [ + SpinOff( + corporate_action_type=corporate_action_type, **corporate_action + ) + for corporate_action in corporate_actions + ] + elif corporate_action_type == "cash_mergers": + parsed_corporate_actions[corporate_action_type] = [ + CashMerger( + corporate_action_type=corporate_action_type, **corporate_action + ) + for corporate_action in corporate_actions + ] + elif corporate_action_type == "stock_mergers": + parsed_corporate_actions[corporate_action_type] = [ + StockMerger( + corporate_action_type=corporate_action_type, **corporate_action + ) + for corporate_action in corporate_actions + ] + elif corporate_action_type == "stock_and_cash_mergers": + parsed_corporate_actions[corporate_action_type] = [ + StockAndCashMerger( + corporate_action_type=corporate_action_type, **corporate_action + ) + for corporate_action in corporate_actions + ] + elif corporate_action_type == "redemptions": + parsed_corporate_actions[corporate_action_type] = [ + Redemption( + corporate_action_type=corporate_action_type, **corporate_action + ) + for corporate_action in corporate_actions + ] + elif corporate_action_type == "name_changes": + parsed_corporate_actions[corporate_action_type] = [ + NameChange( + corporate_action_type=corporate_action_type, **corporate_action + ) + for corporate_action in corporate_actions + ] + elif corporate_action_type == "worthless_removals": + parsed_corporate_actions[corporate_action_type] = [ + WorthlessRemoval( + corporate_action_type=corporate_action_type, **corporate_action + ) + for corporate_action in corporate_actions + ] + elif corporate_action_type == "rights_distributions": + parsed_corporate_actions[corporate_action_type] = [ + RightsDistribution( + corporate_action_type=corporate_action_type, **corporate_action + ) + for corporate_action in corporate_actions + ] + + super().__init__(data=parsed_corporate_actions) diff --git a/alpaca/data/requests.py b/alpaca/data/requests.py index 0aa896da..f5203cc5 100644 --- a/alpaca/data/requests.py +++ b/alpaca/data/requests.py @@ -8,6 +8,7 @@ from alpaca.common.requests import NonEmptyRequest from alpaca.data.enums import ( Adjustment, + CorporateActionsType, DataFeed, MarketType, MostActivesBy, @@ -520,6 +521,9 @@ class MarketMoversRequest(ScreenerRequest): market_type: MarketType = MarketType.STOCKS +# ############################## News #################################### # + + class NewsRequest(NonEmptyRequest): """ This request class is used to submit a request for most actives screener endpoint. @@ -546,3 +550,28 @@ class NewsRequest(NonEmptyRequest): include_content: Optional[bool] = None exclude_contentless: Optional[bool] = None page_token: Optional[str] = None + + +# ############################## CorporateActions #################################### # + + +class CorporateActionsRequest(NonEmptyRequest): + """ + This request class is used to submit a request for corporate actions data. + ref. https://docs.alpaca.markets/reference/corporateactions-1 + + Attributes: + symbols (Optional[List[str]]): The list of ticker identifiers. + types (Optional[List[CorporateActionsType]]): The types of corporate actions to filter by. (default: all types) + start (Optional[date]): The inclusive start of the interval. Format: YYYY-MM-DD. (default: current day) + end (Optional[date])): The inclusive end of the interval. Format: YYYY-MM-DD. (default: current day) + limit (Optional[int]): Upper limit of number of data points to return. (default: 1000) + sort (Optional[Sort]): The chronological order of response based on the timestamp. Defaults to ASC. + """ + + symbols: Optional[List[str]] = None + types: Optional[List[CorporateActionsType]] = None + start: Optional[date] = None + end: Optional[date] = None + limit: Optional[int] = 1000 + sort: Optional[Sort] = Sort.ASC diff --git a/docs/api_reference/data/corporate_actions.rst b/docs/api_reference/data/corporate_actions.rst new file mode 100644 index 00000000..9f93fb5e --- /dev/null +++ b/docs/api_reference/data/corporate_actions.rst @@ -0,0 +1,9 @@ +================= +Corporate Actions +================= + +.. toctree:: + :maxdepth: 2 + + corporate_actions/historical + corporate_actions/requests diff --git a/docs/api_reference/data/corporate_actions/historical.rst b/docs/api_reference/data/corporate_actions/historical.rst new file mode 100644 index 00000000..778133e6 --- /dev/null +++ b/docs/api_reference/data/corporate_actions/historical.rst @@ -0,0 +1,16 @@ +=============== +Historical Data +=============== + + +CorporateActionsClient +---------------------- + +.. autoclass:: alpaca.data.historical.corporate_actions.CorporateActionsClient + :members: __init__ + + +Get Corporate Actions +--------------------- + +.. automethod:: alpaca.data.historical.corporate_actions.CorporateActionsClient.get_corporate_actions diff --git a/docs/api_reference/data/corporate_actions/requests.rst b/docs/api_reference/data/corporate_actions/requests.rst new file mode 100644 index 00000000..87b41c2d --- /dev/null +++ b/docs/api_reference/data/corporate_actions/requests.rst @@ -0,0 +1,9 @@ +======== +Requests +======== + + +CorporateActionsRequest +----------------------- + +.. autoclass:: alpaca.data.requests.CorporateActionsRequest diff --git a/docs/api_reference/data/enums.rst b/docs/api_reference/data/enums.rst index 1d16786e..29e6fdde 100644 --- a/docs/api_reference/data/enums.rst +++ b/docs/api_reference/data/enums.rst @@ -37,3 +37,9 @@ MarketType ---------- .. autoclass:: alpaca.data.enums.MarketType + + +CorporateActionsType +-------------------- + +.. autoclass:: alpaca.data.enums.CorporateActionsType diff --git a/docs/api_reference/data/models.rst b/docs/api_reference/data/models.rst index 6bc36296..a75bdd22 100644 --- a/docs/api_reference/data/models.rst +++ b/docs/api_reference/data/models.rst @@ -99,3 +99,15 @@ Movers ------ .. autoclass:: alpaca.data.models.screener.Movers + + +CorporateAction +--------------- + +.. autoclass:: alpaca.data.models.corporate_actions.CorporateAction + + +CorporateActionsSet +------------------- + +.. autoclass:: alpaca.data.models.corporate_actions.CorporateActionsSet diff --git a/docs/api_reference/data_api.rst b/docs/api_reference/data_api.rst index b9c7c493..e1cb39f7 100644 --- a/docs/api_reference/data_api.rst +++ b/docs/api_reference/data_api.rst @@ -6,6 +6,7 @@ Market Data Reference :maxdepth: 2 data/common + data/corporate_actions data/stock data/crypto data/option diff --git a/docs/conf.py b/docs/conf.py index 811383d7..8c746978 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,6 +20,7 @@ # ref. https://github.com/pydantic/pydantic/discussions/7763#discussioncomment-8417097 import alpaca.data.models.screener # noqa # pylint: disable=unused-import import alpaca.data.models.news # noqa # pylint: disable=unused-import +import alpaca.data.models.corporate_actions # noqa # pylint: disable=unused-import # -- Project information ----------------------------------------------------- diff --git a/examples/stocks-trading-basic.ipynb b/examples/stocks-trading-basic.ipynb index 17d4bcf7..3429fde1 100644 --- a/examples/stocks-trading-basic.ipynb +++ b/examples/stocks-trading-basic.ipynb @@ -28,11 +28,13 @@ "metadata": {}, "outputs": [], "source": [ + "import os\n", + "\n", "# Please change the following to your own PAPER api key and secret\n", "# You can get them from https://alpaca.markets/\n", - "\n", - "api_key = \"\"\n", - "secret_key = \"\"\n", + "# Alternatively, you can set the APCA_API_KEY_ID and APCA_API_SECRET_KEY environment variables\n", + "api_key = os.getenv(\"APCA_API_KEY_ID\")\n", + "secret_key = os.getenv(\"APCA_API_SECRET_KEY\")\n", "\n", "#### We use paper environment for this example ####\n", "paper = True # Please do not modify this. This example is for paper trading only.\n", @@ -62,44 +64,44 @@ "metadata": {}, "outputs": [], "source": [ - "import json\n", "from datetime import datetime, timedelta\n", "from zoneinfo import ZoneInfo\n", "\n", "import alpaca\n", "from alpaca.trading.client import TradingClient\n", "from alpaca.data.timeframe import TimeFrame, TimeFrameUnit\n", + "from alpaca.data.historical.corporate_actions import CorporateActionsClient\n", "from alpaca.data.historical.stock import StockHistoricalDataClient\n", "from alpaca.trading.stream import TradingStream\n", "from alpaca.data.live.stock import StockDataStream\n", "\n", "from alpaca.data.requests import (\n", + " CorporateActionsRequest,\n", " StockBarsRequest,\n", + " StockQuotesRequest,\n", " StockTradesRequest,\n", - " StockQuotesRequest\n", ")\n", "from alpaca.trading.requests import (\n", - " GetAssetsRequest, \n", - " MarketOrderRequest, \n", - " LimitOrderRequest, \n", - " StopOrderRequest, \n", - " StopLimitOrderRequest, \n", - " TakeProfitRequest, \n", - " StopLossRequest, \n", - " TrailingStopOrderRequest, \n", - " GetOrdersRequest, \n", - " ClosePositionRequest\n", - ")\n", - "from alpaca.trading.enums import ( \n", - " AssetStatus, \n", - " AssetExchange, \n", - " OrderSide, \n", - " OrderType, \n", - " TimeInForce, \n", - " OrderClass, \n", - " QueryOrderStatus\n", + " ClosePositionRequest,\n", + " GetAssetsRequest,\n", + " GetOrdersRequest,\n", + " LimitOrderRequest,\n", + " MarketOrderRequest,\n", + " StopLimitOrderRequest,\n", + " StopLossRequest,\n", + " StopOrderRequest,\n", + " TakeProfitRequest,\n", + " TrailingStopOrderRequest,\n", ")\n", - "from alpaca.common.exceptions import APIError" + "from alpaca.trading.enums import (\n", + " AssetExchange,\n", + " AssetStatus,\n", + " OrderClass,\n", + " OrderSide,\n", + " OrderType,\n", + " QueryOrderStatus,\n", + " TimeInForce,\n", + ")" ] }, { @@ -403,7 +405,7 @@ "metadata": {}, "outputs": [], "source": [ - "# trailing stop order \n", + "# trailing stop order\n", "req = TrailingStopOrderRequest(\n", " symbol = symbol,\n", " qty = 1,\n", @@ -647,23 +649,36 @@ "\n", "symbols = [symbol]\n", "\n", - "stock_data_stream_client.subscribe_quotes(stock_data_stream_handler, *symbols) \n", + "stock_data_stream_client.subscribe_quotes(stock_data_stream_handler, *symbols)\n", "stock_data_stream_client.subscribe_trades(stock_data_stream_handler, *symbols)\n", "\n", "stock_data_stream_client.run()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Corporate actions" + ] + }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [] + "source": [ + "corporate_actions_client = CorporateActionsClient(api_key, secret_key)\n", + "corporate_actions_client.get_corporate_actions(CorporateActionsRequest(\n", + " start=datetime(2020, 1, 1),\n", + " symbols=[symbol]\n", + ")).df" + ] } ], "metadata": { "kernelspec": { - "display_name": ".venv", + "display_name": "alpaca-py", "language": "python", "name": "python3" }, diff --git a/tests/conftest.py b/tests/conftest.py index cafabbc5..3ec5f9a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ from alpaca.broker.client import BrokerClient from alpaca.data.historical import StockHistoricalDataClient +from alpaca.data.historical.corporate_actions import CorporateActionsClient from alpaca.data.historical.crypto import CryptoHistoricalDataClient from alpaca.data.historical.news import NewsClient from alpaca.data.historical.option import OptionHistoricalDataClient @@ -55,6 +56,12 @@ def news_client(): return client +@pytest.fixture +def corporate_actions_client(): + client = CorporateActionsClient("key-id", "secret-key") + return client + + @pytest.fixture def raw_stock_client(): raw_client = StockHistoricalDataClient("key-id", "secret-key", raw_data=True) diff --git a/tests/data/test_corporate_actions.py b/tests/data/test_corporate_actions.py new file mode 100644 index 00000000..44fbaa1c --- /dev/null +++ b/tests/data/test_corporate_actions.py @@ -0,0 +1,272 @@ +import urllib +from datetime import date, datetime, timezone + +from alpaca.data.enums import CorporateActionsType +from alpaca.data.historical.corporate_actions import CorporateActionsClient +from alpaca.data.models.corporate_actions import ( + CashDividend, + CorporateActionsSet, + ForwardSplit, + ReverseSplit, + StockDividend, + UnitSplit, +) +from alpaca.data.requests import CorporateActionsRequest + + +def test_get_corporate_actions( + reqmock, corporate_actions_client: CorporateActionsClient +): + # requests and response may not match as to check requests parameter conversion + symbols = ["AAPL", "TSLA"] + _symbols_in_url = urllib.parse.quote_plus(",".join(symbols)) + types = [CorporateActionsType.CASH_DIVIDEND, CorporateActionsType.FORWARD_SPLIT] + _types_in_url = urllib.parse.quote_plus(",".join(types)) + start = date(2022, 2, 1) + _start_in_url = urllib.parse.quote_plus( + start.isoformat(), + ) + sort = "asc" + limit = 1000 + reqmock.get( + f"https://data.alpaca.markets/v1beta1/corporate-actions?symbols={_symbols_in_url}&types={_types_in_url}&start={_start_in_url}&sort={sort}&limit={limit}", + text=""" +{ + "corporate_actions": { + "reverse_splits": [ + { + "ex_date": "2023-08-24", + "new_rate": 1, + "old_rate": 50, + "process_date": "2023-08-24", + "record_date": "2023-08-24", + "symbol": "MNTS" + } + ], + "forward_splits": [ + { + "due_bill_redemption_date": "2023-08-23", + "ex_date": "2023-08-22", + "new_rate": 2, + "old_rate": 1, + "payable_date": "2023-08-21", + "process_date": "2023-08-22", + "record_date": "2023-08-14", + "symbol": "SRE" + } + ], + "unit_splits": [ + { + "alternate_rate": 0.3333, + "alternate_symbol": "LVROW", + "effective_date": "2023-03-01", + "new_rate": 1, + "new_symbol": "LVRO", + "old_rate": 1, + "old_symbol": "TPBAU", + "process_date": "2023-03-01" + } + ], + "stock_dividends": [ + { + "ex_date": "2023-05-19", + "payable_date": "2023-05-05", + "process_date": "2023-05-19", + "rate": 0.05, + "record_date": "2023-05-22", + "symbol": "MSBC" + } + ], + "cash_dividends": [ + { + "ex_date": "2023-05-04", + "foreign": false, + "payable_date": "2023-05-19", + "process_date": "2023-05-19", + "rate": 0.125, + "record_date": "2023-05-05", + "special": false, + "symbol": "FCF" + } + ], + "spin_offs": [ + { + "ex_date": "2023-08-15", + "new_rate": 1, + "new_symbol": "SRM", + "process_date": "2023-08-15", + "record_date": "2023-08-15", + "source_rate": 19.35, + "source_symbol": "JUPW" + } + ], + "cash_mergers": [ + { + "acquiree_symbol": "GLOP", + "effective_date": "2023-07-17", + "payable_date": "2023-07-17", + "process_date": "2023-07-17", + "rate": 5.37 + } + ], + "stock_mergers": [ + { + "acquiree_rate": 1, + "acquiree_symbol": "LSI", + "acquirer_rate": 0.895, + "acquirer_symbol": "EXR", + "effective_date": "2023-07-20", + "payable_date": "2023-07-20", + "process_date": "2023-07-20" + } + ], + "stock_and_cash_mergers": [ + { + "acquiree_rate": 1, + "acquiree_symbol": "MLVF", + "acquirer_rate": 0.7733, + "acquirer_symbol": "FRBA", + "cash_rate": 7.8, + "effective_date": "2023-07-18", + "payable_date": "2023-07-18", + "process_date": "2023-07-18" + } + ], + "redemptions": [ + { + "payable_date": "2023-06-13", + "process_date": "2023-06-13", + "rate": 0.141134, + "symbol": "ORPHY" + } + ], + "name_changes": [ + { + "new_symbol": "VFS", + "old_symbol": "BSAQ", + "process_date": "2023-08-15" + } + ], + "worthless_removals": [ + { + "symbol": "ATNXQ", + "process_date": "2023-10-11" + } + ], + "rights_distributions": [ + { + "source_symbol": "IFN", + "new_symbol": "IFN.RTWI", + "rate": 1, + "ex_date": "2024-04-17", + "record_date": "2024-04-18", + "payable_date": "2024-04-19", + "process_date": "2024-04-19", + "expiration_date": "2024-05-14" + } + ] + }, + "next_page_token": "MDA3Q1ZSMDIwfDIwMjQtMDItMTV8NjBiZjkxYzItMDc0Ni00ZDliLThjOWUtYTgwYmIzMDhmZDkx" +} + """, + ) + limit2 = 987 + page_token = ( + "MDA3Q1ZSMDIwfDIwMjQtMDItMTV8NjBiZjkxYzItMDc0Ni00ZDliLThjOWUtYTgwYmIzMDhmZDkx" + ) + _page_token_in_url = urllib.parse.quote_plus(page_token) + reqmock.get( + f"https://data.alpaca.markets/v1beta1/corporate-actions?symbols={_symbols_in_url}&types={_types_in_url}&start={_start_in_url}&sort={sort}&limit={limit2}&page_token={_page_token_in_url}", + text=""" +{ + "corporate_actions": { + "cash_dividends": [ + { + "ex_date": "2024-08-07", + "foreign": true, + "payable_date": "2024-08-26", + "process_date": "2024-08-26", + "rate": 0.086928, + "record_date": "2024-08-07", + "special": false, + "symbol": "ZMTBY" + } + ] + }, + "next_page_token": null +} + """, + ) + + req = CorporateActionsRequest( + symbols=symbols, + types=types, + start=start, + ) + + res = corporate_actions_client.get_corporate_actions(request_params=req) + + assert isinstance(res, CorporateActionsSet) + + reverse_split: ReverseSplit = res["reverse_splits"][0] + assert reverse_split.symbol == "MNTS" + assert reverse_split.new_rate == 1 + assert reverse_split.old_rate == 50 + assert reverse_split.ex_date == date(2023, 8, 24) + assert reverse_split.process_date == date(2023, 8, 24) + assert reverse_split.record_date == date(2023, 8, 24) + + forward_split: ForwardSplit = res["forward_splits"][0] + assert forward_split.symbol == "SRE" + assert forward_split.new_rate == 2 + assert forward_split.old_rate == 1 + assert forward_split.record_date == date(2023, 8, 14) + assert forward_split.payable_date == date(2023, 8, 21) + assert forward_split.ex_date == date(2023, 8, 22) + assert forward_split.process_date == date(2023, 8, 22) + assert forward_split.due_bill_redemption_date == date(2023, 8, 23) + + unit_split: UnitSplit = res["unit_splits"][0] + assert unit_split.old_symbol == "TPBAU" + assert unit_split.alternate_rate == 0.3333 + assert unit_split.alternate_symbol == "LVROW" + assert unit_split.effective_date == date(2023, 3, 1) + assert unit_split.new_rate == 1 + assert unit_split.new_symbol == "LVRO" + assert unit_split.old_rate == 1 + assert unit_split.old_symbol == "TPBAU" + assert unit_split.process_date == date(2023, 3, 1) + + stock_dividend: StockDividend = res["stock_dividends"][0] + assert stock_dividend.symbol == "MSBC" + assert stock_dividend.rate == 0.05 + assert stock_dividend.payable_date == date(2023, 5, 5) + assert stock_dividend.ex_date == date(2023, 5, 19) + assert stock_dividend.process_date == date(2023, 5, 19) + assert stock_dividend.record_date == date(2023, 5, 22) + + cash_dividend: CashDividend = res["cash_dividends"][0] + assert cash_dividend.symbol == "FCF" + assert cash_dividend.rate == 0.125 + assert not cash_dividend.foreign + assert not cash_dividend.special + assert cash_dividend.ex_date == date(2023, 5, 4) + assert cash_dividend.record_date == date(2023, 5, 5) + assert cash_dividend.payable_date == date(2023, 5, 19) + assert cash_dividend.process_date == date(2023, 5, 19) + + cash_dividend = res["cash_dividends"][1] + cash_dividend.symbol == "ZMTBY" + + assert res["cash_mergers"][0].acquiree_symbol == "GLOP" + assert res["stock_mergers"][0].acquirer_symbol == "EXR" + assert res["stock_and_cash_mergers"][0].acquirer_symbol == "FRBA" + assert res["redemptions"][0].symbol == "ORPHY" + assert res["name_changes"][0].old_symbol == "BSAQ" + assert res["worthless_removals"][0].symbol == "ATNXQ" + assert res["rights_distributions"][0].source_symbol == "IFN" + assert res["rights_distributions"][0].new_symbol == "IFN.RTWI" + + assert res.df.index[0] == "reverse_splits" + + assert reqmock.call_count == 2