From eea8672646eb233c49c80e2d74ba219c1beb6b24 Mon Sep 17 00:00:00 2001 From: SteveH <94375670+stevehussey@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:49:07 +0100 Subject: [PATCH 1/7] .gitignore updated to exclude uv.lock --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c8de682..c064639 100644 --- a/.gitignore +++ b/.gitignore @@ -79,5 +79,6 @@ trading_ig_config.py .vscode -# poetry +# build system poetry.lock +uv.lock From 6f5f1e338fce7271ed7b9aea44cc49bdace00d98 Mon Sep 17 00:00:00 2001 From: SteveH <94375670+stevehussey@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:06:19 +0100 Subject: [PATCH 2/7] Integration tests marked with PyTest integration marker Allows easy running of unit tests with or without integration tests --- .github/workflows/unit-test.yml | 2 +- pyproject.toml | 8 +++++++- tests/test_integration.py | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 48b0d30..b2589bc 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -39,4 +39,4 @@ jobs: - name: Unit tests with pytest run: | - uv run pytest --ignore=tests/test_integration.py + uv run pytest -m "not integration" diff --git a/pyproject.toml b/pyproject.toml index 1e3628a..2cf5aa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["uv_build>=0.11.6,<0.12.0"] +requires = ["uv_build>=0.11.6,<0.13.0"] build-backend = "uv_build" [project] @@ -69,6 +69,12 @@ dev = [ "ruff==0.16.1", ] +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "integration: Integration tests (requires API credentials)", +] + [tool.uv.build-backend] module-name = "trading_ig" module-root = "" diff --git a/tests/test_integration.py b/tests/test_integration.py index 93e9420..ea163bc 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -135,6 +135,7 @@ def watchlist_id(ig_service: IGService): logger = logging.getLogger(__name__) +@pytest.mark.integration class TestIntegration: def test_create_session_no_encryption(self, retrying): ig_service = IGService( From 904fbac9624980a1c5c6eaf265cc7974b1cf1919 Mon Sep 17 00:00:00 2001 From: SteveH <94375670+stevehussey@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:42:05 +0100 Subject: [PATCH 3/7] Python 3.13 added back to supported versions Also included in unit tests. --- .github/workflows/unit-test.yml | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index b2589bc..cfaed4a 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.10", "3.11", "3.12" ] + python-version: [ "3.10", "3.11", "3.12", "3.13" ] steps: diff --git a/pyproject.toml b/pyproject.toml index 2cf5aa2..96fea58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries", ] From 81fdd665c664ab0461a9ad3abebafe215a8a34e9 Mon Sep 17 00:00:00 2001 From: SteveH <94375670+stevehussey@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:23:09 +0100 Subject: [PATCH 4/7] Add rate limiting to all non-trading API calls Avoids "too many API requests" errors --- trading_ig/rest.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/trading_ig/rest.py b/trading_ig/rest.py index 9f5fadc..61f312d 100644 --- a/trading_ig/rest.py +++ b/trading_ig/rest.py @@ -1808,6 +1808,7 @@ def fetch_historical_prices_by_epic( more_results = True while more_results: + self.non_trading_rate_limit_pause_or_pass() params["pageNumber"] = pagenumber response = self._req(action, endpoint, params, session, version) data = self.parse_response(response.text) @@ -1835,6 +1836,7 @@ def fetch_historical_prices_by_epic_and_num_points( ): """Returns a list of historical prices for the given epic, resolution, number of points""" + self.non_trading_rate_limit_pause_or_pass() version = "2" if self.return_dataframe: resolution = conv_resol(resolution) @@ -1885,6 +1887,7 @@ def fetch_historical_prices_by_epic_and_date_range( :return: historic data :rtype: dict, with 'prices' element as pandas.Dataframe """ + self.non_trading_rate_limit_pause_or_pass() if self.return_dataframe: resolution = conv_resol(resolution) params = {} @@ -2009,6 +2012,7 @@ def remove_market_from_watchlist(self, watchlist_id, epic, session=None): def logout(self, session=None): """Log out of the current session""" + self.non_trading_rate_limit_pause_or_pass() version = "1" params = {} endpoint = "/session" @@ -2019,6 +2023,7 @@ def logout(self, session=None): def get_encryption_key(self, session=None): """Get encryption key to encrypt the password""" + self.non_trading_rate_limit_pause_or_pass() endpoint = "/session/encryptionKey" session = self._get_session(session) response = session.get(self.BASE_URL + endpoint) @@ -2084,6 +2089,7 @@ def refresh_session(self, session=None, version="1"): :return: HTTP status code :rtype: int """ + self.non_trading_rate_limit_pause_or_pass() logger.info(f"Refreshing session '{self.IG_USERNAME}'") params = {"refresh_token": self._refresh_token} endpoint = "/session/refresh-token" @@ -2147,6 +2153,7 @@ def _check_session(self): def switch_account(self, account_id, default_account, session=None): """Switches active accounts, optionally setting the default account""" + self.non_trading_rate_limit_pause_or_pass() version = "1" params = {"accountId": account_id, "defaultAccount": default_account} endpoint = "/session" @@ -2158,6 +2165,7 @@ def switch_account(self, account_id, default_account, session=None): def read_session(self, fetch_session_tokens="false", session=None): """Retrieves current session details""" + self.non_trading_rate_limit_pause_or_pass() version = "1" params = {"fetchSessionTokens": fetch_session_tokens} endpoint = "/session" @@ -2174,6 +2182,7 @@ def read_session(self, fetch_session_tokens="false", session=None): def get_client_apps(self, session=None): """Returns a list of client-owned applications""" + # No rate limit pause as this is called prior to rate limiter setup version = "1" params = {} endpoint = "/operations/application" @@ -2191,6 +2200,7 @@ def update_client_app( session=None, ): """Updates an application""" + self.non_trading_rate_limit_pause_or_pass() version = "1" params = { "allowanceAccountOverall": allowance_account_overall, @@ -2210,6 +2220,7 @@ def disable_client_app_key(self, session=None): Disabled keys may be re-enabled via the My Account section on the IG Web Dealing Platform. """ + self.non_trading_rate_limit_pause_or_pass() version = "1" params = {} endpoint = "/operations/application/disable" From 781533485f96f0d76bbd65f3c38aeedaaec11107 Mon Sep 17 00:00:00 2001 From: SteveH <94375670+stevehussey@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:24:53 +0100 Subject: [PATCH 5/7] Fix fetch_historical_prices_by_epic when return_dataframe on Session is False The resolution value passed was ignored previously when return_dateframe is true, which it is by default when Pandas is present. --- tests/test_historical_prices.py | 78 ++++++++++++++++++++++++++++ tests/test_historical_prices_flat.py | 28 ++++++++++ trading_ig/rest.py | 2 +- trading_ig/stream.py | 4 +- 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/tests/test_historical_prices.py b/tests/test_historical_prices.py index 38064b7..0b937c8 100644 --- a/tests/test_historical_prices.py +++ b/tests/test_historical_prices.py @@ -5,6 +5,7 @@ import pandas as pd import pytest import responses +from responses.matchers import query_param_matcher from trading_ig.rest import IGService @@ -24,6 +25,7 @@ def test_historical_prices_v3_defaults_happy(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json=response_body, status=200, @@ -55,6 +57,17 @@ def test_historical_prices_v3_datetime_happy(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP", + match=[ + query_param_matcher( + { + "resolution": "DAY", + "from": "2020-09-01T00:00:00", + "to": "2020-09-04T23:59:59", + "pageSize": "20", + "pageNumber": "1", + } + ) + ], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json=response_body, status=200, @@ -96,6 +109,16 @@ def test_historical_prices_v3_num_points_happy(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP", + match=[ + query_param_matcher( + { + "resolution": "WEEK", + "max": "10", + "pageSize": "20", + "pageNumber": "1", + } + ) + ], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json=response_body, status=200, @@ -119,6 +142,40 @@ def test_historical_prices_v3_num_points_happy(self): prices["delta"] = prices["tvalue"] - prices["tvalue"].shift() assert any(prices["delta"].dropna() == datetime.timedelta(weeks=1)) + @responses.activate + def test_historical_prices_v3_return_raw_happy(self): + # fetch_historical_prices v3 - number of data points, weekly resolution, but return raw data + + with open("tests/data/historic_prices_num_points.json", "r") as file: + response_body = json.loads(file.read()) + + responses.add( + responses.GET, + "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP", + match=[ + query_param_matcher( + {"resolution": "WEEK", "pageNumber": "1", "pageSize": "20"} + ) + ], + headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, + json=response_body, + status=200, + ) + + ig_service = IGService( + "username", "password", "api_key", "DEMO", return_dataframe=False + ) + result = ig_service.fetch_historical_prices_by_epic( + epic="MT.D.GC.Month2.IP", resolution="W" + ) + + prices = result["prices"] + assert isinstance(result, dict) + assert isinstance(prices, list) + + # assert raw data is passed as is + assert prices == response_body["prices"] + @responses.activate def test_historical_prices_v3_num_points_bad_numpoints(self): # fetch_historical_prices v3 - number of data points, invalid numpoints @@ -126,6 +183,7 @@ def test_historical_prices_v3_num_points_bad_numpoints(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={"errorCode": "Unable to convert value=3.14159 to type= Integer int"}, status=400, @@ -148,6 +206,7 @@ def test_historical_prices_v3_num_points_bad_resolution(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json=response_body, status=200, @@ -167,6 +226,7 @@ def test_historical_prices_v3_bad_epic(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.X.Month1.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={"errorCode": "error.error.price-history.io-error"}, status=404, @@ -184,6 +244,7 @@ def test_historical_prices_v3_bad_date_format(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.XX.Month1.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={"errorCode": "Unable to parse datetime=2020/09/01T00:00:00"}, status=400, @@ -206,6 +267,7 @@ def test_historical_prices_v3_bad_date_order(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.XX.Month1.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={"errorCode": "error.invalid.daterange"}, status=400, @@ -231,6 +293,14 @@ def test_historical_prices_by_epic_and_date_range_v1_happy(self): responses.add( responses.GET, re.compile("https://demo-api.ig.com/gateway/deal/prices/.+"), + match=[ + query_param_matcher( + { + "startdate": "2020:09:01-00:00:00", + "enddate": "2020:09:04-23:59:59", + } + ) + ], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json=response_body, status=200, @@ -268,6 +338,7 @@ def test_historical_prices_by_epic_and_date_range_happy(self): responses.add( responses.GET, re.compile("https://demo-api.ig.com/gateway/deal/prices/.+"), + match=[query_param_matcher({})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json=response_body, status=200, @@ -301,6 +372,7 @@ def test_historical_prices_by_epic_and_date_range_bad_epic(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.X.Month1.IP/DAY", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={"errorCode": "error.error.price-history.io-error"}, status=404, @@ -323,6 +395,7 @@ def test_historical_prices_by_epic_and_date_range_bad_date_format(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.XX.Month1.IP/DAY", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={"errorCode": "Unable to parse datetime=2020/09/01T00:00:00"}, status=400, @@ -345,6 +418,7 @@ def test_historical_prices_by_epic_and_date_range_bad_date_order(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.XX.Month1.IP/DAY", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={"errorCode": "error.invalid.daterange"}, status=400, @@ -370,6 +444,7 @@ def test_historical_prices_by_epic_and_num_points_happy(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP/DAY/10", + match=[query_param_matcher({})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json=response_body, status=200, @@ -400,6 +475,7 @@ def test_historical_prices_by_epic_and_num_points_bad_epic(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.X.Month1.IP/DAY", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={"errorCode": "error.error.price-history.io-error"}, status=404, @@ -419,6 +495,7 @@ def test_historical_prices_by_epic_and_num_points_bad_numpoints(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={"errorCode": "Unable to convert value=3.14159 to type= Integer int"}, status=400, @@ -438,6 +515,7 @@ def test_historical_prices_by_epic_and_num_points_bad_resolution(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={}, status=200, diff --git a/tests/test_historical_prices_flat.py b/tests/test_historical_prices_flat.py index 17edc5b..d8d6b5f 100644 --- a/tests/test_historical_prices_flat.py +++ b/tests/test_historical_prices_flat.py @@ -4,6 +4,7 @@ import pandas as pd import pytest import responses +from responses.matchers import query_param_matcher from trading_ig.rest import IGService @@ -23,6 +24,7 @@ def test_historical_prices_v3_defaults_happy(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json=response_body, status=200, @@ -56,6 +58,17 @@ def test_historical_prices_v3_datetime_happy(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP", + match=[ + query_param_matcher( + { + "resolution": "DAY", + "from": "2020-09-01T00:00:00", + "to": "2020-09-04T23:59:59", + "pageSize": "20", + "pageNumber": "1", + } + ) + ], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json=response_body, status=200, @@ -98,6 +111,16 @@ def test_historical_prices_v3_num_points_happy(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP", + match=[ + query_param_matcher( + { + "resolution": "WEEK", + "max": "10", + "pageSize": "20", + "pageNumber": "1", + } + ) + ], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json=response_body, status=200, @@ -131,6 +154,7 @@ def test_historical_prices_v3_num_points_bad_numpoints(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={"errorCode": "Unable to convert value=3.14159 to type= Integer int"}, status=400, @@ -156,6 +180,7 @@ def test_historical_prices_v3_num_points_bad_resolution(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.GC.Month2.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json=response_body, status=200, @@ -178,6 +203,7 @@ def test_historical_prices_v3_bad_epic(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.X.Month1.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={"errorCode": "error.error.price-history.io-error"}, status=404, @@ -197,6 +223,7 @@ def test_historical_prices_v3_bad_date_format(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.XX.Month1.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={"errorCode": "Unable to parse datetime=2020/09/01T00:00:00"}, status=400, @@ -220,6 +247,7 @@ def test_historical_prices_v3_bad_date_order(self): responses.add( responses.GET, "https://demo-api.ig.com/gateway/deal/prices/MT.D.XX.Month1.IP", + match=[query_param_matcher({"pageNumber": "1", "pageSize": "20"})], headers={"CST": "abc123", "X-SECURITY-TOKEN": "xyz987"}, json={"errorCode": "error.invalid.daterange"}, status=400, diff --git a/trading_ig/rest.py b/trading_ig/rest.py index 61f312d..82793fd 100644 --- a/trading_ig/rest.py +++ b/trading_ig/rest.py @@ -1791,7 +1791,7 @@ def fetch_historical_prices_by_epic( version = "3" params = {} - if resolution and self.return_dataframe: + if resolution: params["resolution"] = conv_resol(resolution) if start_date: params["from"] = start_date diff --git a/trading_ig/stream.py b/trading_ig/stream.py index a681f4a..e6a0e28 100644 --- a/trading_ig/stream.py +++ b/trading_ig/stream.py @@ -1,6 +1,5 @@ import logging import sys -import traceback from lightstreamer.client import ClientListener, LightstreamerClient, Subscription @@ -35,8 +34,7 @@ def create_session(self, encryption=False, version="2"): self.ls_client.connect() return except Exception: - logger.error("Unable to connect to Lightstreamer Server") - logger.error(traceback.format_exc()) + logger.exception("Unable to connect to Lightstreamer Server") sys.exit(1) def subscribe(self, subscription: Subscription): From 6d1ab617a86623c52c693509fb87563e753a71ce Mon Sep 17 00:00:00 2001 From: SteveH <94375670+stevehussey@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:10:10 +0100 Subject: [PATCH 6/7] Instrument navigation methods updated following IG API changes fetch_top_level_navigation_nodes and fetch_sub_nodes_by_node have been replaced by fetch_categories and fetch_category_instruments. The main differences here is that IG appear to have removed the Node ID concept from their public API. Instrument discovery for all instruments is now: call fetch_category_instruments(category) with all categories from fetch_categories(). --- sample/all_nodes.py | 103 ++++++++------------------------ sample/rest_ig.py | 18 +++++- tests/test_integration.py | 25 ++++---- trading_ig/rest.py | 120 +++++++++++++++++++++++--------------- 4 files changed, 127 insertions(+), 139 deletions(-) diff --git a/sample/all_nodes.py b/sample/all_nodes.py index 7e5e0c6..23e0ad7 100644 --- a/sample/all_nodes.py +++ b/sample/all_nodes.py @@ -8,45 +8,31 @@ ) -def display_top_level_nodes(): +def display_categories(): ig_service = get_session() - response = ig_service.fetch_top_level_navigation_nodes() - df = response["nodes"] - for record in df.to_dict("records"): - print(f"{record['name']} [{record['id']}]") + response = ig_service.fetch_categories() + df = response["categories"] + for row in df.itertuples(index=False): + print(f"{row.code}, nonTradeable={row.nonTradeable}") def display_all_epics(): ig_service = get_session() - response = ig_service.fetch_top_level_navigation_nodes() - df = response["nodes"] - for record in df.to_dict("records"): - print(f"{record['name']} [{record['id']}]") - display_epics_for_node(record["id"], space=" ", ig_service=ig_service) + response = ig_service.fetch_categories() + df = response["categories"] + for row in df.itertuples(index=False): + print(f"{row.code}, nonTradeable={row.nonTradeable}") + display_epics_for_category(row.code, space=" ", ig_service=ig_service) -def display_epics_for_node(node_id=0, space="", ig_service=None): +def display_epics_for_category(category: str, space="", ig_service=None): if ig_service is None: ig_service = get_session() - sub_nodes = ig_service.fetch_sub_nodes_by_node(node_id) - - if sub_nodes["nodes"].shape[0] != 0: - rows = sub_nodes["nodes"].to_dict("records") - for record in rows: - print(f"{space}{record['name']} [{record['id']}]") - display_epics_for_node( - record["id"], space=space + " ", ig_service=ig_service - ) - - if sub_nodes["markets"].shape[0] != 0: - cols = sub_nodes["markets"].to_dict("records") - for record in cols: - print( - f"{space}{record['instrumentName']} ({record['expiry']}): " - f"{record['epic']}" - ) + response = ig_service.fetch_category_instruments(category) + for row in response["instruments"].itertuples(index=False): + print(f"{space}{row.instrumentName} ({row.expiry.strip()}): {row.epic}") def get_session(): @@ -63,58 +49,19 @@ def get_session(): if __name__ == "__main__": - display_top_level_nodes() + display_categories() # display_all_epics() """ - Weekend Markets [191926749] - Indices [97601] - Forex [195235] - Commodities Metals Energies [101515] - Cryptocurrency [668394] - Bonds and Moneymarket [108092] - ETFs, ETCs & Trackers [184730] - Shares - UK [180500] - Shares - UK International (IOB) [97695] - Shares - US (All Sessions) [298158] - Shares - US [97477] - Shares - Austria [114058] - Shares - Belgium [114036] - Shares - Canada [103185] - Shares - Denmark [419769] - Shares - Finland [99514] - Shares - France [113659] - Shares - LSE (UK) [172904] - Shares - Germany [97466] - Shares - Greece [100437] - Shares - Hong Kong [105775] - Shares - Ireland (LSE) [421257] - Shares - Ireland (Euronext Dublin) [99578822] - Shares - Netherlands [105509] - Shares - New Zealand [127489792] - Shares - Norway [99808] - Shares - Portugal [99787] - Shares - Singapore [105781] - Shares - South Africa [100066] - Shares - Sweden [113681] - Shares - Switzerland [99814] - IPOs [324080] - Options (Australia 200) [77976799] - Options (Eu Stocks 50) [245938] - Options (France 40) [188760] - Options (FTSE) [122250] - Options (Germany) [97612] - Options (HS 50) [92462573] - Options (Japan 225) [111915265] - Options (Netherlands 25) [236490] - Options (Sweden 30) [319963] - Options (Taiwan Index) [157168018] - Options (US 500) [267039] - Options (US Tech 100) [89291253] - Options (Wall St) [122505] - Options on FX Majors [255072] - Options (Volatility Index) [56719751] - Options on Metals, Energies [195913] + INDICES + FX + CRYPTOCURRENCY + EQUITIES + COMMODITIES + BONDS_RATES + ETF + OPTIONS + IPOS """ - # display_epics_for_node(195913) + # display_epics_for_category("INDICES") diff --git a/sample/rest_ig.py b/sample/rest_ig.py index c9a2b3b..3dc1931 100644 --- a/sample/rest_ig.py +++ b/sample/rest_ig.py @@ -4,6 +4,7 @@ """ import logging +import sys # if you need to cache to DB your requests from datetime import timedelta @@ -18,7 +19,12 @@ def main(): - logging.basicConfig(level=logging.DEBUG) + logging.basicConfig( + format="%(asctime)s %(levelname)s %(name)s.%(funcName)s: %(message)s", + level=logging.DEBUG, + stream=sys.stdout, + ) + logging.captureWarnings(True) expire_after = timedelta(hours=1) session = requests_cache.CachedSession( @@ -63,6 +69,16 @@ def main(): print() + # categories = ig_service.fetch_categories() + # print(f"categories: {categories}") + + print() + + # instruments = ig_service.fetch_category_instruments("INDICES") + # print(f"category instruments: {instruments}") + + print() + # epic = 'CS.D.EURUSD.MINI.IP' epic = "IX.D.ASX.IFM.IP" # US (SPY) - mini # epic = "CS.D.GBPUSD.CFD.IP" # sample CFD epic diff --git a/tests/test_integration.py b/tests/test_integration.py index ea163bc..9ce7286 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -106,12 +106,11 @@ def ig_service(request, retrying): ig_service.logout() -# TODO refactor for new navigation API @pytest.fixture() -def top_level_nodes(ig_service: IGService): - """test fixture gets the top level navigation nodes""" - response = ig_service.fetch_top_level_navigation_nodes() - return response["nodes"] +def categories(ig_service: IGService): + """test fixture gets the top level categories""" + response = ig_service.fetch_categories() + return response["categories"] @pytest.fixture() @@ -300,9 +299,9 @@ def test_create_session_bad_api_key(self, retrying): with pytest.raises(IGException): ig_service.create_session() - @pytest.mark.xfail(reason="Navigation API has been changed by IG") - def test_fetch_top_level_navigation_nodes(self, top_level_nodes): - assert isinstance(top_level_nodes, pd.DataFrame) + def test_fetch_categories(self, categories): + assert isinstance(categories, pd.DataFrame) + assert list(categories.columns) == ["code", "nonTradeable"] def test_create_session_v3_no_acc_num(self, retrying): ig_service = IGService( @@ -433,12 +432,10 @@ def assert_sentiment(response): assert isinstance(short, float) assert long + short == 100.0 - @pytest.mark.xfail(reason="Navigation API has been changed by IG") - def test_fetch_sub_nodes_by_node(self, ig_service: IGService, top_level_nodes): - rand_index = randint(0, len(top_level_nodes) - 1) - response = ig_service.fetch_sub_nodes_by_node(rand_index) - assert isinstance(response["markets"], pd.DataFrame) - assert isinstance(response["nodes"], pd.DataFrame) + def test_fetch_category_instruments(self, ig_service: IGService, categories): + rand_category = choice(categories["code"]) + response = ig_service.fetch_category_instruments(rand_category) + assert isinstance(response["instruments"], pd.DataFrame) def test_fetch_all_watchlists(self, watchlists): assert isinstance(watchlists, pd.DataFrame) diff --git a/trading_ig/rest.py b/trading_ig/rest.py index 82793fd..052ece3 100644 --- a/trading_ig/rest.py +++ b/trading_ig/rest.py @@ -1432,64 +1432,92 @@ def fetch_related_client_sentiment_by_instrument(self, market_id, session=None): data = pd.DataFrame(data["clientSentiments"]) return data - def fetch_top_level_navigation_nodes(self, session=None): - """Returns all top-level nodes (market categories) in the market - navigation hierarchy.""" + def fetch_categories(self, session=None): + """Returns all categories of instruments enabled for the current IG account.""" self.non_trading_rate_limit_pause_or_pass() version = "1" params = {} - endpoint = "/marketnavigation" + endpoint = "/categories" action = "read" response = self._req(action, endpoint, params, session, version) data = self.parse_response(response.text) if self.return_dataframe: - data["markets"] = pd.DataFrame(data["markets"]) - if len(data["markets"]) == 0: - columns = [ - "bid", - "delayTime", - "epic", - "expiry", - "high", - "instrumentName", - "instrumentType", - "lotSize", - "low", - "marketStatus", - "netChange", - "offer", - "otcTradeable", - "percentageChange", - "scalingFactor", - "streamingPricesAvailable", - "updateTime", - ] - data["markets"] = pd.DataFrame(columns=columns) - data["nodes"] = pd.DataFrame(data["nodes"]) - if len(data["nodes"]) == 0: - columns = ["id", "name"] - data["nodes"] = pd.DataFrame(columns=columns) - # if self.return_munch: - # # ToFix: ValueError: The truth value of a DataFrame is ambiguous. - # # Use a.empty, a.bool(), a.item(), a.any() or a.all(). - # from .utils import munchify - # data = munchify(data) + data["categories"] = pd.DataFrame( + data["categories"], columns=["code", "nonTradeable"] + ) return data - def fetch_sub_nodes_by_node(self, node, session=None): - """Returns all sub-nodes of the given node in the market - navigation hierarchy""" - self.non_trading_rate_limit_pause_or_pass() + def fetch_category_instruments( + self, + category_id, + page_size=1000, + reference_epic=None, + maturity_type=None, + wait_secs=1, + session=None, + ): + """Returns all instruments for the given category""" version = "1" params = {} - url_params = {"node": node} - endpoint = "/marketnavigation/{node}".format(**url_params) + if reference_epic: + if category_id != "OPTIONS": + raise ValueError( + "reference_epic is only applicable for OPTIONS category" + ) + params["referenceEpic"] = reference_epic + if maturity_type: + if category_id != "OPTIONS": + raise ValueError( + "maturity_type is only applicable for OPTIONS category" + ) + params["maturityType"] = maturity_type + if page_size: # Defaults to 150 if not present + params["pageSize"] = page_size + endpoint = f"/categories/{category_id}/instruments" action = "read" - response = self._req(action, endpoint, params, session, version) - data = self.parse_response(response.text) + insts = [] + pagenumber = 0 # Pages are zero indexed, different from prices... + more_results = True + + while more_results: + self.non_trading_rate_limit_pause_or_pass() + params["pageNumber"] = pagenumber + response = self._req(action, endpoint, params, session, version) + data = self.parse_response(response.text) + insts.extend(data["instruments"]) + page_data = data["metadata"] + if page_data["totalPages"] == 0 or ( + page_data["pageNumber"] == page_data["totalPages"] - 1 + ): + more_results = False + else: + pagenumber += 1 + time.sleep(wait_secs) + + data = {"instruments": insts} + if self.return_dataframe: - data["markets"] = pd.DataFrame(data["markets"]) - data["nodes"] = pd.DataFrame(data["nodes"]) + col_names = [ + "epic", + "instrumentName", + "expiry", + "instrumentType", + "lotSize", + "otcTradeable", + "marketStatus", + "delayTime", + "bid", + "offer", + "high", + "low", + "netChange", + "percentageChange", + "updateTime", + "scalingFactor", + "underlyingName", + "popularity", + ] + data["instruments"] = pd.DataFrame(data["instruments"], columns=col_names) return data def fetch_market_by_epic(self, epic, session=None): @@ -1820,7 +1848,7 @@ def fetch_historical_prices_by_epic( more_results = False else: pagenumber += 1 - time.sleep(wait) + time.sleep(wait) data["prices"] = prices From 782d371c8d2a286b8266424d7114d5fdf815bfe0 Mon Sep 17 00:00:00 2001 From: SteveH <94375670+stevehussey@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:15:50 +0100 Subject: [PATCH 7/7] requests-cache changed to option dependency This library is only used by sample code. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 96fea58..bec0bd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,12 +40,12 @@ classifiers = [ dependencies = [ "requests>=2.24,<3", "pycryptodome>=3.9,<4", - "requests-cache>1,<2", "six>=1.15,<2", "lightstreamer-client-lib==1.0.3", ] [project.optional-dependencies] +requests-cache = ["requests-cache>1,<2"] pandas = [ "pandas>=2,<3", ]