From ee239fef526a5511c1004f7a54374fba1fc28c10 Mon Sep 17 00:00:00 2001 From: alexpipipi Date: Mon, 13 Jul 2026 17:13:00 +0200 Subject: [PATCH] feat: add ASX corporate actions endpoint Add ASXCorporateActionsAPI wrapping GET /api/asx-corporate-actions (JSON:API { data, meta, links } envelope, bare query params type/symbol/date_from/date_to + page[offset]/page[limit] pagination). Registered in eodhd/APIs/__init__.py and exposed via APIClient.get_asx_corporate_actions(). Adds tests/test_asx_corporate_actions.py. --- eodhd/APIs/ASXCorporateActionsAPI.py | 106 ++++++++++++++++++++ eodhd/APIs/__init__.py | 1 + eodhd/apiclient.py | 30 ++++++ tests/test_asx_corporate_actions.py | 142 +++++++++++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 eodhd/APIs/ASXCorporateActionsAPI.py create mode 100644 tests/test_asx_corporate_actions.py diff --git a/eodhd/APIs/ASXCorporateActionsAPI.py b/eodhd/APIs/ASXCorporateActionsAPI.py new file mode 100644 index 0000000..56d3869 --- /dev/null +++ b/eodhd/APIs/ASXCorporateActionsAPI.py @@ -0,0 +1,106 @@ +# APIs/ASXCorporateActionsAPI.py + +from urllib.parse import quote + +from .BaseAPI import BaseAPI + + +class ASXCorporateActionsAPI(BaseAPI): + """ + Wrapper for the ASX Corporate Actions endpoint: + + GET /api/asx-corporate-actions + + Notes: + - Returns a JSON:API envelope { data, meta, links }. + meta has { total, page: { offset, limit } }; links has { next }. + - Filtering uses BARE query params (type=, symbol=, date_from=, date_to=), + NOT filter[...]. Pagination uses page[offset] and page[limit]. + - data[] items vary by action type (dividends carry code/date/value/currency/ + exchange/recordDate/paymentDate/period/_asx_extra with franking fields; + splits carry code/date/split/exchange; etc.). + - 1 API call per request. + """ + + _TYPE_VALUES = ( + "dividends", + "splits", + "bonus-issues", + "rights-issues", + "buybacks", + "capital-returns", + "spp", + "other", + ) + + @staticmethod + def _param(name: str, value) -> str: + """Build a URL-encoded &name=value fragment. + + Returns "" for None or blank/whitespace-only values (so an empty string + is treated as "omit the param", not "send &name="). Values are + URL-encoded; the key is a bare query param (not filter[...]).""" + if value is None: + return "" + text = str(value).strip() + if text == "": + return "" + return f"&{name}={quote(text, safe='')}" + + @staticmethod + def _pagination(page_offset: int = None, page_limit: int = None) -> str: + query_string = "" + if page_offset is not None: + page_offset = int(page_offset) + if page_offset < 0: + raise ValueError("page_offset must be >= 0.") + query_string += f"&page[offset]={page_offset}" + if page_limit is not None: + page_limit = int(page_limit) + if page_limit < 1 or page_limit > 1000: + raise ValueError("page_limit must be between 1 and 1000.") + query_string += f"&page[limit]={page_limit}" + return query_string + + def get_corporate_actions( + self, + api_token: str, + type: str = None, + symbol: str = None, + date_from: str = None, + date_to: str = None, + page_offset: int = None, + page_limit: int = None, + ): + """ + GET /api/asx-corporate-actions + + Query params (bare, not filter[...]): + type - action type: "dividends" | "splits" | "bonus-issues" | + "rights-issues" | "buybacks" | "capital-returns" | + "spp" | "other" + symbol - ASX ticker with .AU suffix, e.g. "PMV.AU" + date_from - start date, YYYY-MM-DD + date_to - end date, YYYY-MM-DD + page[offset] - pagination offset (default 0) + page[limit] - pagination limit, 1-1000 (default 100) + + Returns: dict envelope { data, meta, links }. + """ + if type is not None and str(type).strip().lower() not in self._TYPE_VALUES: + raise ValueError(f"type must be one of {self._TYPE_VALUES}.") + + query_string = "" + if type is not None: + query_string += self._param("type", str(type).strip().lower()) + query_string += self._param("symbol", symbol) + query_string += self._param("date_from", date_from) + query_string += self._param("date_to", date_to) + query_string += self._pagination(page_offset, page_limit) + + return self._rest_get_method( + api_key=api_token, + endpoint="asx-corporate-actions", + uri="", + querystring=query_string, + ) diff --git a/eodhd/APIs/__init__.py b/eodhd/APIs/__init__.py index 0798a7e..1e60aa0 100644 --- a/eodhd/APIs/__init__.py +++ b/eodhd/APIs/__init__.py @@ -34,6 +34,7 @@ from .BulkFundamentalsAPI import BulkFundamentalsAPI from .TreasuryAPI import TreasuryAPI from .ExchangeDetailsV2API import ExchangeDetailsV2API +from .ASXCorporateActionsAPI import ASXCorporateActionsAPI #Marketplace endpoints from .MPIndexComponentsAPI import MPIndexComponentsAPI diff --git a/eodhd/apiclient.py b/eodhd/apiclient.py index b380002..fbbe70a 100644 --- a/eodhd/apiclient.py +++ b/eodhd/apiclient.py @@ -51,6 +51,7 @@ from eodhd.APIs import BulkFundamentalsAPI from eodhd.APIs import TreasuryAPI from eodhd.APIs import ExchangeDetailsV2API +from eodhd.APIs import ASXCorporateActionsAPI #Marketplace endpoints from eodhd.APIs import MPIndexComponentsAPI @@ -1586,6 +1587,35 @@ def get_treasury_real_yield_rates(self, from_date=None, to_date=None): api_call = TreasuryAPI(session=self._session, timeout=self._timeout) return api_call.get_treasury_real_yield_rates(api_token=self._api_key, from_date=from_date, to_date=to_date) + # ------------------------------------------------------------------ + # ASX Corporate Actions API (/api/asx-corporate-actions) + # ------------------------------------------------------------------ + def get_asx_corporate_actions(self, type=None, symbol=None, date_from=None, + date_to=None, page_offset=None, page_limit=None): + """ + ASX Corporate Actions + Endpoint: GET /api/asx-corporate-actions + + Note: this endpoint uses bare query params, NOT filter[...]. + + Args: + type [OPTIONAL] - action type: "dividends"|"splits"|"bonus-issues"| + "rights-issues"|"buybacks"|"capital-returns"| + "spp"|"other" + symbol [OPTIONAL] - ASX ticker with .AU suffix, e.g. "PMV.AU" + date_from [OPTIONAL] - start date, YYYY-MM-DD + date_to [OPTIONAL] - end date, YYYY-MM-DD + page_offset [OPTIONAL] - pagination offset (default 0) + page_limit [OPTIONAL] - pagination limit, 1-1000 (default 100) + Returns: dict envelope { data, meta, links } + """ + api_call = ASXCorporateActionsAPI(session=self._session, timeout=self._timeout) + return api_call.get_corporate_actions( + api_token=self._api_key, type=type, symbol=symbol, + date_from=date_from, date_to=date_to, + page_offset=page_offset, page_limit=page_limit, + ) + # ── Phase 2: Marketplace ────────────────────────────────────── # --- InvestVerte ESG (6 methods) --- diff --git a/tests/test_asx_corporate_actions.py b/tests/test_asx_corporate_actions.py new file mode 100644 index 0000000..ad05b41 --- /dev/null +++ b/tests/test_asx_corporate_actions.py @@ -0,0 +1,142 @@ +"""Tests for ASXCorporateActionsAPI. + +Note: the ASX corporate actions endpoint uses BARE query params +(type=, symbol=, date_from=, date_to=), NOT filter[...]. Pagination still +uses page[offset]/page[limit]. +""" + +import pytest +from unittest.mock import MagicMock + +from eodhd.APIs.ASXCorporateActionsAPI import ASXCorporateActionsAPI + + +@pytest.fixture +def mock_session(): + return MagicMock() + + +def _make_api(session): + return ASXCorporateActionsAPI(session=session) + + +def _mock_response(session, data=None): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = ( + data + if data is not None + else {"data": [], "meta": {"total": 0, "page": {"offset": 0, "limit": 100}}, "links": {}} + ) + session.get.return_value = resp + + +def test_corporate_actions_url(mock_session): + _mock_response(mock_session) + api = _make_api(mock_session) + api.get_corporate_actions(api_token="test1234567890123456") + + call_url = mock_session.get.call_args[0][0] + assert "/asx-corporate-actions" in call_url + assert "api_token=test1234567890123456" in call_url + assert "fmt=json" in call_url + + +def test_corporate_actions_bare_params(mock_session): + _mock_response(mock_session) + api = _make_api(mock_session) + api.get_corporate_actions( + api_token="test1234567890123456", type="dividends", symbol="PMV.AU", + date_from="2026-01-01", date_to="2026-12-31", + page_offset=0, page_limit=25, + ) + + call_url = mock_session.get.call_args[0][0] + # bare keys, not filter[...] + assert "&type=dividends" in call_url + assert "&symbol=PMV.AU" in call_url + assert "&date_from=2026-01-01" in call_url + assert "&date_to=2026-12-31" in call_url + assert "page[offset]=0" in call_url + assert "page[limit]=25" in call_url + # must NOT use filter[...] style + assert "filter[" not in call_url + + +def test_corporate_actions_returns_envelope(mock_session): + _mock_response(mock_session) + api = _make_api(mock_session) + result = api.get_corporate_actions(api_token="test1234567890123456") + + assert "data" in result + assert "meta" in result + assert "links" in result + + +def test_type_normalised_lowercase(mock_session): + _mock_response(mock_session) + api = _make_api(mock_session) + api.get_corporate_actions(api_token="test1234567890123456", type="Dividends") + + call_url = mock_session.get.call_args[0][0] + assert "&type=dividends" in call_url + + +def test_type_hyphenated_value(mock_session): + _mock_response(mock_session) + api = _make_api(mock_session) + api.get_corporate_actions(api_token="test1234567890123456", type="capital-returns") + + call_url = mock_session.get.call_args[0][0] + assert "&type=capital-returns" in call_url + + +def test_invalid_type(mock_session): + api = _make_api(mock_session) + with pytest.raises(ValueError): + api.get_corporate_actions(api_token="test1234567890123456", type="mergers") + + +def test_symbol_url_encoded(mock_session): + _mock_response(mock_session) + api = _make_api(mock_session) + api.get_corporate_actions(api_token="test1234567890123456", symbol="A & B.AU") + + call_url = mock_session.get.call_args[0][0] + assert "&symbol=A%20%26%20B.AU" in call_url + assert "&symbol=A & B.AU" not in call_url + + +def test_blank_value_omitted(mock_session): + _mock_response(mock_session) + api = _make_api(mock_session) + # empty/whitespace-only values are omitted, not sent as &symbol= + api.get_corporate_actions(api_token="test1234567890123456", symbol=" ") + + call_url = mock_session.get.call_args[0][0] + assert "&symbol=" not in call_url + + +def test_invalid_pagination(mock_session): + api = _make_api(mock_session) + with pytest.raises(ValueError): + api.get_corporate_actions(api_token="test1234567890123456", page_offset=-1) + with pytest.raises(ValueError): + api.get_corporate_actions(api_token="test1234567890123456", page_limit=0) + with pytest.raises(ValueError): + api.get_corporate_actions(api_token="test1234567890123456", page_limit=1001) + + +def test_client_facade_delegates(mock_session): + _mock_response(mock_session) + from eodhd.apiclient import APIClient + + client = APIClient("test1234567890123456") + client._session = mock_session + client.get_asx_corporate_actions(type="splits", symbol="BHP.AU", page_limit=10) + + call_url = mock_session.get.call_args[0][0] + assert "/asx-corporate-actions" in call_url + assert "&type=splits" in call_url + assert "&symbol=BHP.AU" in call_url + assert "page[limit]=10" in call_url