From e1dc688735ce54f7b4ce0ef138bd52a482890bad Mon Sep 17 00:00:00 2001 From: "quantconnect-kronos[bot]" <346399058+quantconnect-kronos[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:18:43 +0000 Subject: [PATCH 01/16] feature: let oauth-token modules name their auth0 brokerage - the cli read and authorized an oauth-token module under its lowercased display name, which only matches the api's brokerage name when the two are the same word, as for CharlesSchwab or TradeStation - Interactive Brokers (FIX) is displayed with a space and a suffix but authorizes as interactivebrokers, so its modules json entry needs to name it: an optional auth0-brokerage key on the oauth-token configuration does, and modules without it keep using the display name Co-Authored-By: Claude Opus 5.5 --- lean/models/configuration.py | 1 + lean/models/json_module.py | 3 +- tests/models/test_json_module.py | 58 ++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/lean/models/configuration.py b/lean/models/configuration.py index 6415544d..853ce269 100644 --- a/lean/models/configuration.py +++ b/lean/models/configuration.py @@ -437,6 +437,7 @@ def __init__(self, config_json_object): super().__init__(config_json_object) self.require_project_id = config_json_object.get("require-project-id", False) self.require_user_name = config_json_object.get("require-user-name", False) + self.auth0_brokerage = config_json_object.get("auth0-brokerage") def factory(config_json_object) -> 'AuthConfiguration': """Creates an instance of the child classes. diff --git a/lean/models/json_module.py b/lean/models/json_module.py index 3110da27..50691f45 100644 --- a/lean/models/json_module.py +++ b/lean/models/json_module.py @@ -315,7 +315,8 @@ def config_build(self, user_name = self.get_user_name(lean_config, configuration, user_provided_options, configuration.require_user_name, interactive) logger.debug(f'user_name: {user_name}') - auth_authorizations = get_authorization(container.api_client.auth0, self._display_name.lower(), + auth0_brokerage = configuration.auth0_brokerage or self._display_name.lower() + auth_authorizations = get_authorization(container.api_client.auth0, auth0_brokerage, logger, lean_config["project-id"], no_browser=no_browser, user_name=user_name) logger.debug(f'auth: {auth_authorizations}') diff --git a/tests/models/test_json_module.py b/tests/models/test_json_module.py index 7949add1..e9825b15 100644 --- a/tests/models/test_json_module.py +++ b/tests/models/test_json_module.py @@ -14,6 +14,7 @@ from json import loads from pathlib import Path from typing import Any, Dict +from unittest import mock import pytest from click import Command, Context, command, echo @@ -21,6 +22,7 @@ from lean.constants import MODULE_BROKERAGE, MODULE_CLI_PLATFORM from lean.container import container +from lean.models.api import QCAuth0Authorization from lean.models.json_module import JsonModule from tests.models.test_configuration import TIME_REGEX, TIME_REGEX_MESSAGE from tests.test_helpers import create_fake_lean_cli_directory @@ -100,3 +102,59 @@ def test_config_build_accepts_any_lean_config_value_when_the_module_has_no_regex module = build_config({"my-time": value}, regex=False) assert module.get_config_value_from_name("my-time") == value + + +def create_oauth_module(auth0_brokerage: str = None) -> JsonModule: + oauth_configuration = { + "id": "my-oauth-token", + "type": "oauth-token", + "require-user-name": True, + "value": "Yes" + } + if auth0_brokerage is not None: + oauth_configuration["auth0-brokerage"] = auth0_brokerage + + return JsonModule({ + "id": "MyBrokerage", + "display-id": "My Brokerage (FIX)", + "configurations": [ + oauth_configuration, + { + "id": "my-user-name", + "type": "input", + "input-method": "prompt", + "prompt-info": "Username" + }, + { + "id": "my-account", + "type": "input", + "input-method": "choice", + "prompt-info": "Account", + "filters": [{"condition": {"dependent-config-id": "my-oauth-token", + "pattern": "^(?!\\s*$).+", + "type": "regex"}}] + } + ] + }, MODULE_BROKERAGE, MODULE_CLI_PLATFORM) + + +@pytest.mark.parametrize("auth0_brokerage,expected_auth0_brokerage", [(None, "my brokerage (fix)"), + ("mybrokerage", "mybrokerage")]) +def test_config_build_authorizes_with_the_auth0_brokerage_of_the_oauth_token(auth0_brokerage: str, + expected_auth0_brokerage: str) -> None: + api_client = mock.Mock() + api_client.auth0.read.return_value = QCAuth0Authorization( + authorization={"my-refresh-token": "token", "accounts": [{"id": "U1234567", "name": "U1234567 : Me"}]}) + container.api_client = api_client + + with Context(Command("test")): + module = create_oauth_module(auth0_brokerage).config_build( + {"project-id": 123, "my-user-name": "someuser", "my-account": "U1234567"}, container.logger, + interactive=False) + + api_client.auth0.read.assert_called_once_with(expected_auth0_brokerage, user_name="someuser") + api_client.auth0.authorize.assert_not_called() + settings = module.get_settings() + assert settings["my-refresh-token"] == "token" + assert settings["my-user-name"] == "someuser" + assert settings["my-account"] == "U1234567" From e6cf8a42310578e99184f7246eacc919b7c766e3 Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 2 Nov 2026 19:21:42 +0200 Subject: [PATCH 02/16] refactor: resolve the auth0 brokerage id in the module - the server authorizes the Interactive Brokers (FIX) module under `interactivebrokers`, the only name `live/auth0/authorize` accepts for it, while every other oauth module authorizes under its lowercased display name; one exception in the CLI is less to get wrong than a new json key every module publisher has to know - without the key the modules json entry needs no new field, and the entry works the same on older CLI versions that never read it --- lean/models/configuration.py | 1 - lean/models/json_module.py | 13 ++++++++++-- tests/models/test_json_module.py | 35 +++++++++++++++----------------- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/lean/models/configuration.py b/lean/models/configuration.py index 853ce269..6415544d 100644 --- a/lean/models/configuration.py +++ b/lean/models/configuration.py @@ -437,7 +437,6 @@ def __init__(self, config_json_object): super().__init__(config_json_object) self.require_project_id = config_json_object.get("require-project-id", False) self.require_user_name = config_json_object.get("require-user-name", False) - self.auth0_brokerage = config_json_object.get("auth0-brokerage") def factory(config_json_object) -> 'AuthConfiguration': """Creates an instance of the child classes. diff --git a/lean/models/json_module.py b/lean/models/json_module.py index 50691f45..adcd5812 100644 --- a/lean/models/json_module.py +++ b/lean/models/json_module.py @@ -224,6 +224,16 @@ def get_project_id(self, default_project_id: int, require_project_id: bool) -> i -1, show_default=False) return project_id + def get_auth0_brokerage_id(self) -> str: + """Returns the brokerage name the live/auth0 endpoints authorize this module under. + + It is the lowercased display name. The Interactive Brokers FIX module is the exception: it trades through + the Interactive Brokers login, so it authorizes under the Interactive Brokers name. + """ + if self._id == "InteractiveBrokersFixBrokerage": + return "interactivebrokers" + return self._display_name.lower() + def _ask_user_for_input(self, configuration: Configuration, logger: Logger, hide_input: bool): """Prompts the user for the value of a configuration and saves it in the Lean config. @@ -315,8 +325,7 @@ def config_build(self, user_name = self.get_user_name(lean_config, configuration, user_provided_options, configuration.require_user_name, interactive) logger.debug(f'user_name: {user_name}') - auth0_brokerage = configuration.auth0_brokerage or self._display_name.lower() - auth_authorizations = get_authorization(container.api_client.auth0, auth0_brokerage, + auth_authorizations = get_authorization(container.api_client.auth0, self.get_auth0_brokerage_id(), logger, lean_config["project-id"], no_browser=no_browser, user_name=user_name) logger.debug(f'auth: {auth_authorizations}') diff --git a/tests/models/test_json_module.py b/tests/models/test_json_module.py index e9825b15..b7ab315c 100644 --- a/tests/models/test_json_module.py +++ b/tests/models/test_json_module.py @@ -104,21 +104,17 @@ def test_config_build_accepts_any_lean_config_value_when_the_module_has_no_regex assert module.get_config_value_from_name("my-time") == value -def create_oauth_module(auth0_brokerage: str = None) -> JsonModule: - oauth_configuration = { - "id": "my-oauth-token", - "type": "oauth-token", - "require-user-name": True, - "value": "Yes" - } - if auth0_brokerage is not None: - oauth_configuration["auth0-brokerage"] = auth0_brokerage - +def create_oauth_module(module_id: str, display_id: str) -> JsonModule: return JsonModule({ - "id": "MyBrokerage", - "display-id": "My Brokerage (FIX)", + "id": module_id, + "display-id": display_id, "configurations": [ - oauth_configuration, + { + "id": "my-oauth-token", + "type": "oauth-token", + "require-user-name": True, + "value": "Yes" + }, { "id": "my-user-name", "type": "input", @@ -138,21 +134,22 @@ def create_oauth_module(auth0_brokerage: str = None) -> JsonModule: }, MODULE_BROKERAGE, MODULE_CLI_PLATFORM) -@pytest.mark.parametrize("auth0_brokerage,expected_auth0_brokerage", [(None, "my brokerage (fix)"), - ("mybrokerage", "mybrokerage")]) -def test_config_build_authorizes_with_the_auth0_brokerage_of_the_oauth_token(auth0_brokerage: str, - expected_auth0_brokerage: str) -> None: +@pytest.mark.parametrize("module_id,display_id,expected_auth0_brokerage_id", + [("MyBrokerage", "My Brokerage", "my brokerage"), + ("InteractiveBrokersFixBrokerage", "InteractiveBrokersFix", "interactivebrokers")]) +def test_config_build_authorizes_with_the_auth0_brokerage_id_of_the_module(module_id: str, display_id: str, + expected_auth0_brokerage_id: str) -> None: api_client = mock.Mock() api_client.auth0.read.return_value = QCAuth0Authorization( authorization={"my-refresh-token": "token", "accounts": [{"id": "U1234567", "name": "U1234567 : Me"}]}) container.api_client = api_client with Context(Command("test")): - module = create_oauth_module(auth0_brokerage).config_build( + module = create_oauth_module(module_id, display_id).config_build( {"project-id": 123, "my-user-name": "someuser", "my-account": "U1234567"}, container.logger, interactive=False) - api_client.auth0.read.assert_called_once_with(expected_auth0_brokerage, user_name="someuser") + api_client.auth0.read.assert_called_once_with(expected_auth0_brokerage_id, user_name="someuser") api_client.auth0.authorize.assert_not_called() settings = module.get_settings() assert settings["my-refresh-token"] == "token" From 321dc9c954002f7659db8499a7e7247e0ceaa743 Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 2 Nov 2026 19:59:37 +0200 Subject: [PATCH 03/16] docs: add the interactive brokers fix options to the readme - the readme is generated from the hosted modules json, which gets the Interactive Brokers (FIX) entry with the release of this change; adding the two options now keeps the readme in step with the entry in the PR description instead of a follow-up commit after the publish - the readme check compares against the hosted json, so it fails until the entry is published --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a11715de..f772ceea 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,7 @@ Usage: lean cloud live deploy [OPTIONS] PROJECT --notify-insights. Options: - --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX|Webull|Public|ClearStreet] + --brokerage [Paper Trading|Interactive Brokers|Interactive Brokers (FIX)|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX|Webull|Public|ClearStreet] The brokerage to use --data-provider-live [QuantConnect|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Polygon|CoinApi|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX] The live data provider to use @@ -436,6 +436,8 @@ Options: --ib-financial-advisors-group-filter TEXT Use this field to filter data by a specific financial advisor group within your Interactive Brokers account. Leave blank to include all groups. (Optional). + --ib-fix-user-name TEXT Your Interactive Brokers user name + --ib-fix-account TEXT Your Interactive Brokers account id --tradier-account-id TEXT Your Tradier account id --tradier-access-token TEXT Your Tradier access token --tradier-environment [live|paper] From 72415329a307d1594ccce10d472df4de2f2e0d92 Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 2 Nov 2026 22:07:55 +0200 Subject: [PATCH 04/16] feature: let an internal-input configuration alias another configuration - live/create rejects an Interactive Brokers (FIX) deployment unless the settings carry the linked user name a second time, under `ib-fix-linked-account` ("No linked account user id found for this user"), and the modules json had no way to send one value under two keys - `alias-of` on an internal-input copies the value of the named configuration at settings time, so the entry declares the second key itself and the CLI keeps no brokerage-specific code for it; older CLI versions ignore the key and send the setting empty --- lean/models/configuration.py | 1 + lean/models/json_module.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/lean/models/configuration.py b/lean/models/configuration.py index 6415544d..d892b7e8 100644 --- a/lean/models/configuration.py +++ b/lean/models/configuration.py @@ -262,6 +262,7 @@ class InternalInputUserInput(UserInputConfiguration): def __init__(self, config_json_object): super().__init__(config_json_object) + self._alias_of: str = config_json_object.get("alias-of") self._is_conditional: bool = False value_options: List[ConditionalValueOption] = [] if "value-options" in config_json_object.keys(): diff --git a/lean/models/json_module.py b/lean/models/json_module.py index adcd5812..df27a5d6 100644 --- a/lean/models/json_module.py +++ b/lean/models/json_module.py @@ -122,6 +122,8 @@ def get_settings(self) -> Dict[str, str]: # we build these after the rest, because they might depend on their values for config in self._lean_configs: if type(config) is InternalInputUserInput: + if config._alias_of: + config._value = self.get_config_value_from_name(config._alias_of) if config._is_conditional: for option in config._value_options: if option._condition.check(self.get_config_value_from_name(option._condition._dependent_config_id)): From de0e12b42c965f4fd1c4289ee54a07f9a6ed57ab Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 2 Nov 2026 22:07:55 +0200 Subject: [PATCH 05/16] fix: read the interactive brokers fix authorization under the module id - live/auth0/authorize knows the Interactive Brokers login as `interactivebrokers`, but live/auth0/read keys the stored token by the module id; with one name for both calls the CLI never found the token after the login, polled for 7 minutes and ended with "Authorization failed" - the two names are resolved in one private method on the module, and the helper takes both so it never has to guess one from the other --- lean/components/util/auth0_helper.py | 9 ++++--- lean/models/json_module.py | 31 ++++++++++++++---------- tests/commands/data/test_download.py | 2 +- tests/models/test_json_module.py | 36 ++++++++++++++++++++++++---- 4 files changed, 56 insertions(+), 22 deletions(-) diff --git a/lean/components/util/auth0_helper.py b/lean/components/util/auth0_helper.py index fb2d1e5f..c8c809db 100644 --- a/lean/components/util/auth0_helper.py +++ b/lean/components/util/auth0_helper.py @@ -16,19 +16,22 @@ from lean.components.util.logger import Logger -def get_authorization(auth0_client: Auth0Client, brokerage_id: str, logger: Logger, project_id: int, no_browser: bool = False, user_name: str = None) -> QCAuth0Authorization: +def get_authorization(auth0_client: Auth0Client, brokerage_id: str, read_brokerage_id: str, logger: Logger, + project_id: int, no_browser: bool = False, user_name: str = None) -> QCAuth0Authorization: """Gets the authorization data for a brokerage, authorizing if necessary. :param auth0_client: An instance of Auth0Client, containing methods to interact with live/auth0/* API endpoints. :param brokerage_id: The ID of the brokerage to get the authorization data for. + :param read_brokerage_id: The ID live/auth0/read knows the stored authorization by. :param logger: An instance of Logger, handling all output printing. :param project_id: The local or cloud project_id. :param no_browser: whether to disable opening the browser + :param user_name: the login ID the authorization is stored under :return: The authorization data for the specified brokerage. """ from time import time, sleep - data = auth0_client.read(brokerage_id, user_name=user_name) + data = auth0_client.read(read_brokerage_id, user_name=user_name) if data.authorization is not None: return data @@ -39,7 +42,7 @@ def get_authorization(auth0_client: Auth0Client, brokerage_id: str, logger: Logg while time() - start_time < 420: logger.debug("Will sleep 5 seconds and retry fetching authorization...") sleep(5) - data = auth0_client.read(brokerage_id, user_name=user_name) + data = auth0_client.read(read_brokerage_id, user_name=user_name) if data.authorization is None: continue return data diff --git a/lean/models/json_module.py b/lean/models/json_module.py index df27a5d6..3ce38fd7 100644 --- a/lean/models/json_module.py +++ b/lean/models/json_module.py @@ -21,6 +21,7 @@ from lean.components.util.logger import Logger from lean.constants import MODULE_TYPE, MODULE_PLATFORM, MODULE_CLI_PLATFORM from lean.container import container +from lean.models.api import QCAuth0Authorization from lean.models.configuration import BrokerageEnvConfiguration, Configuration, InternalInputUserInput, \ PathParameterUserInput, AuthConfiguration, ChoiceUserInput from copy import copy @@ -226,16 +227,6 @@ def get_project_id(self, default_project_id: int, require_project_id: bool) -> i -1, show_default=False) return project_id - def get_auth0_brokerage_id(self) -> str: - """Returns the brokerage name the live/auth0 endpoints authorize this module under. - - It is the lowercased display name. The Interactive Brokers FIX module is the exception: it trades through - the Interactive Brokers login, so it authorizes under the Interactive Brokers name. - """ - if self._id == "InteractiveBrokersFixBrokerage": - return "interactivebrokers" - return self._display_name.lower() - def _ask_user_for_input(self, configuration: Configuration, logger: Logger, hide_input: bool): """Prompts the user for the value of a configuration and saves it in the Lean config. @@ -275,6 +266,22 @@ def _validate(self, configuration: Configuration, user_choice, logger: Logger, i logger.info(str(e)) user_choice = self._ask_user_for_input(configuration, logger, hide_input) + def _get_auth0_authorization(self, logger: Logger, project_id: int, no_browser: bool, + user_name: str) -> QCAuth0Authorization: + """Reads the stored brokerage authorization, or starts the authorization in the browser and waits for it. + + The live/auth0 endpoints know a brokerage by its lowercased display name. The Interactive Brokers FIX module + is the exception: it trades through the Interactive Brokers login, which live/auth0/authorize knows as + `interactivebrokers`, while live/auth0/read keys the stored token by the module id. + """ + brokerage_id = self._display_name.lower() + read_brokerage_id = brokerage_id + if self._id == "InteractiveBrokersFixBrokerage": + brokerage_id = "interactivebrokers" + read_brokerage_id = self._id + return get_authorization(container.api_client.auth0, brokerage_id, read_brokerage_id, logger, project_id, + no_browser=no_browser, user_name=user_name) + def config_build(self, lean_config: Dict[str, Any], logger: Logger, @@ -327,9 +334,7 @@ def config_build(self, user_name = self.get_user_name(lean_config, configuration, user_provided_options, configuration.require_user_name, interactive) logger.debug(f'user_name: {user_name}') - auth_authorizations = get_authorization(container.api_client.auth0, self.get_auth0_brokerage_id(), - logger, lean_config["project-id"], no_browser=no_browser, - user_name=user_name) + auth_authorizations = self._get_auth0_authorization(logger, lean_config["project-id"], no_browser, user_name) logger.debug(f'auth: {auth_authorizations}') configuration._value = auth_authorizations.get_authorization_config_without_account() for inner_config in self._lean_configs: diff --git a/tests/commands/data/test_download.py b/tests/commands/data/test_download.py index 6372b25f..17c094b9 100644 --- a/tests/commands/data/test_download.py +++ b/tests/commands/data/test_download.py @@ -218,7 +218,7 @@ def _run_oauth_download(extra_run_command: List[str], cli_input: str = "\n"): captured_project_ids = [] - def _fake_get_authorization(auth0_client, brokerage_id, logger, project_id, *args, **kwargs): + def _fake_get_authorization(auth0_client, brokerage_id, read_brokerage_id, logger, project_id, *args, **kwargs): captured_project_ids.append(project_id) auth = MagicMock() auth.get_authorization_config_without_account.return_value = {} diff --git a/tests/models/test_json_module.py b/tests/models/test_json_module.py index b7ab315c..248373b0 100644 --- a/tests/models/test_json_module.py +++ b/tests/models/test_json_module.py @@ -129,16 +129,21 @@ def create_oauth_module(module_id: str, display_id: str) -> JsonModule: "filters": [{"condition": {"dependent-config-id": "my-oauth-token", "pattern": "^(?!\\s*$).+", "type": "regex"}}] + }, + { + "id": "my-linked-account", + "type": "internal-input", + "alias-of": "my-user-name" } ] }, MODULE_BROKERAGE, MODULE_CLI_PLATFORM) -@pytest.mark.parametrize("module_id,display_id,expected_auth0_brokerage_id", +@pytest.mark.parametrize("module_id,display_id,expected_read_brokerage_id", [("MyBrokerage", "My Brokerage", "my brokerage"), - ("InteractiveBrokersFixBrokerage", "InteractiveBrokersFix", "interactivebrokers")]) -def test_config_build_authorizes_with_the_auth0_brokerage_id_of_the_module(module_id: str, display_id: str, - expected_auth0_brokerage_id: str) -> None: + ("InteractiveBrokersFixBrokerage", "InteractiveBrokersFix", "InteractiveBrokersFixBrokerage")]) +def test_config_build_reads_the_authorization_under_the_brokerage_id_of_the_module(module_id: str, display_id: str, + expected_read_brokerage_id: str) -> None: api_client = mock.Mock() api_client.auth0.read.return_value = QCAuth0Authorization( authorization={"my-refresh-token": "token", "accounts": [{"id": "U1234567", "name": "U1234567 : Me"}]}) @@ -149,9 +154,30 @@ def test_config_build_authorizes_with_the_auth0_brokerage_id_of_the_module(modul {"project-id": 123, "my-user-name": "someuser", "my-account": "U1234567"}, container.logger, interactive=False) - api_client.auth0.read.assert_called_once_with(expected_auth0_brokerage_id, user_name="someuser") + api_client.auth0.read.assert_called_once_with(expected_read_brokerage_id, user_name="someuser") api_client.auth0.authorize.assert_not_called() settings = module.get_settings() assert settings["my-refresh-token"] == "token" assert settings["my-user-name"] == "someuser" assert settings["my-account"] == "U1234567" + assert settings["my-linked-account"] == "someuser" + + +@pytest.mark.parametrize("module_id,display_id,expected_authorize_brokerage_id", + [("MyBrokerage", "My Brokerage", "my brokerage"), + ("InteractiveBrokersFixBrokerage", "InteractiveBrokersFix", "interactivebrokers")]) +def test_config_build_authorizes_under_the_login_brokerage_id_of_the_module(module_id: str, display_id: str, + expected_authorize_brokerage_id: str) -> None: + api_client = mock.Mock() + stored_authorization = QCAuth0Authorization( + authorization={"my-refresh-token": "token", "accounts": [{"id": "U1234567", "name": "U1234567 : Me"}]}) + api_client.auth0.read.side_effect = [QCAuth0Authorization(authorization=None), stored_authorization] + container.api_client = api_client + + with mock.patch("time.sleep"), Context(Command("test")): + create_oauth_module(module_id, display_id).config_build( + {"project-id": 123, "my-user-name": "someuser", "my-account": "U1234567"}, container.logger, + interactive=False) + + api_client.auth0.authorize.assert_called_once_with(expected_authorize_brokerage_id, container.logger, 123, False, + user_name="someuser") From 554cf752b5bc8f4d9fb1517c9a88ec9c940dd704 Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 2 Nov 2026 22:11:17 +0200 Subject: [PATCH 06/16] docs: list the interactive brokers fix options after clear street - the modules json entry moved from after the Interactive Brokers entry to the end of the brokerage list, after ClearStreet, and the readme generator prints the brokerage choice and the options in module order, so the readme follows --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f772ceea..b0481139 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,7 @@ Usage: lean cloud live deploy [OPTIONS] PROJECT --notify-insights. Options: - --brokerage [Paper Trading|Interactive Brokers|Interactive Brokers (FIX)|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX|Webull|Public|ClearStreet] + --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX|Webull|Public|ClearStreet|Interactive Brokers (FIX)] The brokerage to use --data-provider-live [QuantConnect|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Polygon|CoinApi|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX] The live data provider to use @@ -436,8 +436,6 @@ Options: --ib-financial-advisors-group-filter TEXT Use this field to filter data by a specific financial advisor group within your Interactive Brokers account. Leave blank to include all groups. (Optional). - --ib-fix-user-name TEXT Your Interactive Brokers user name - --ib-fix-account TEXT Your Interactive Brokers account id --tradier-account-id TEXT Your Tradier account id --tradier-access-token TEXT Your Tradier access token --tradier-environment [live|paper] @@ -546,6 +544,8 @@ Options: --clearstreet-access-token TEXT Your Clear Street API access token --clearstreet-account-id TEXT Your Clear Street account id, the numeric id of the account the algorithm trades + --ib-fix-user-name TEXT Your Interactive Brokers user name + --ib-fix-account TEXT Your Interactive Brokers account id --polygon-api-key TEXT Your Polygon.io API Key --polygon-license-type [Individual|Business] Select your Polygon.io subscription plan (Optional). From 49957d4056de9beaf3b81e83c398d4e66babf483 Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 2 Nov 2026 22:36:42 +0200 Subject: [PATCH 07/16] docs: describe the authorize name in get_authorization - `brokerage_id` is the name live/auth0/authorize opens the login for since the read name became its own parameter; the old wording described both calls at once --- lean/components/util/auth0_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lean/components/util/auth0_helper.py b/lean/components/util/auth0_helper.py index c8c809db..3b51a6d6 100644 --- a/lean/components/util/auth0_helper.py +++ b/lean/components/util/auth0_helper.py @@ -21,7 +21,7 @@ def get_authorization(auth0_client: Auth0Client, brokerage_id: str, read_brokera """Gets the authorization data for a brokerage, authorizing if necessary. :param auth0_client: An instance of Auth0Client, containing methods to interact with live/auth0/* API endpoints. - :param brokerage_id: The ID of the brokerage to get the authorization data for. + :param brokerage_id: The ID live/auth0/authorize opens the brokerage login for. :param read_brokerage_id: The ID live/auth0/read knows the stored authorization by. :param logger: An instance of Logger, handling all output printing. :param project_id: The local or cloud project_id. From b1e490cbf066fa3b4a25402ccb080c577c8902ee Mon Sep 17 00:00:00 2001 From: Romazes Date: Mon, 2 Nov 2026 22:36:42 +0200 Subject: [PATCH 08/16] fix: name the missing alias-of target instead of an unpacking error - a typo in an `alias-of` value failed inside `get_config_value_from_name` with "not enough values to unpack", with no configuration id in the message, so the json author had nothing to search for - the alias lookup now raises a message with the alias id, the target id and the module id --- lean/models/json_module.py | 6 +++++- tests/models/test_json_module.py | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lean/models/json_module.py b/lean/models/json_module.py index 3ce38fd7..9eda7a61 100644 --- a/lean/models/json_module.py +++ b/lean/models/json_module.py @@ -124,7 +124,11 @@ def get_settings(self) -> Dict[str, str]: for config in self._lean_configs: if type(config) is InternalInputUserInput: if config._alias_of: - config._value = self.get_config_value_from_name(config._alias_of) + target = next((c for c in self._lean_configs if c._id == config._alias_of), None) + if target is None: + raise ValueError(f'"{config._id}" is an alias of "{config._alias_of}", ' + f'which is not a configuration of {self._id}') + config._value = target._value if config._is_conditional: for option in config._value_options: if option._condition.check(self.get_config_value_from_name(option._condition._dependent_config_id)): diff --git a/tests/models/test_json_module.py b/tests/models/test_json_module.py index 248373b0..e3162dfc 100644 --- a/tests/models/test_json_module.py +++ b/tests/models/test_json_module.py @@ -181,3 +181,14 @@ def test_config_build_authorizes_under_the_login_brokerage_id_of_the_module(modu api_client.auth0.authorize.assert_called_once_with(expected_authorize_brokerage_id, container.logger, 123, False, user_name="someuser") + + +def test_get_settings_names_the_alias_of_target_when_it_is_not_a_configuration() -> None: + module = JsonModule({ + "id": "MyBrokerage", + "display-id": "My Brokerage", + "configurations": [{"id": "my-linked-account", "type": "internal-input", "alias-of": "my-username"}] + }, MODULE_BROKERAGE, MODULE_CLI_PLATFORM) + + with pytest.raises(ValueError, match='"my-linked-account" is an alias of "my-username"'): + module.get_settings() From 3edeb25d189faff731273413af0481f73edd52e7 Mon Sep 17 00:00:00 2001 From: "quantconnect-kronos[bot]" <346399058+quantconnect-kronos[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:53:51 +0000 Subject: [PATCH 09/16] docs: leave the readme to regenerate once the modules json lists the brokerage Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index b0481139..a11715de 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,7 @@ Usage: lean cloud live deploy [OPTIONS] PROJECT --notify-insights. Options: - --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX|Webull|Public|ClearStreet|Interactive Brokers (FIX)] + --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX|Webull|Public|ClearStreet] The brokerage to use --data-provider-live [QuantConnect|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Polygon|CoinApi|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX] The live data provider to use @@ -544,8 +544,6 @@ Options: --clearstreet-access-token TEXT Your Clear Street API access token --clearstreet-account-id TEXT Your Clear Street account id, the numeric id of the account the algorithm trades - --ib-fix-user-name TEXT Your Interactive Brokers user name - --ib-fix-account TEXT Your Interactive Brokers account id --polygon-api-key TEXT Your Polygon.io API Key --polygon-license-type [Individual|Business] Select your Polygon.io subscription plan (Optional). From affa1370f5ef63d918bd1a6a367fd85865269218 Mon Sep 17 00:00:00 2001 From: "quantconnect-kronos[bot]" <346399058+quantconnect-kronos[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:53:51 +0000 Subject: [PATCH 10/16] fix: let alias-of name any setting the module sends, so the linked account takes the authorized user id The authorization returns the user id as the cloud stored it, lowercased, and the deployment compares the linked account against that id, so an alias of the user name as typed failed for names with capitals. Also inlines the single-use authorization name lookup. Co-Authored-By: Claude Opus 5.5 --- lean/models/json_module.py | 44 +++++++++++++++----------------- tests/models/test_json_module.py | 28 +++++++++++++++----- 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/lean/models/json_module.py b/lean/models/json_module.py index 9eda7a61..a67b604a 100644 --- a/lean/models/json_module.py +++ b/lean/models/json_module.py @@ -21,7 +21,6 @@ from lean.components.util.logger import Logger from lean.constants import MODULE_TYPE, MODULE_PLATFORM, MODULE_CLI_PLATFORM from lean.container import container -from lean.models.api import QCAuth0Authorization from lean.models.configuration import BrokerageEnvConfiguration, Configuration, InternalInputUserInput, \ PathParameterUserInput, AuthConfiguration, ChoiceUserInput from copy import copy @@ -123,12 +122,6 @@ def get_settings(self) -> Dict[str, str]: # we build these after the rest, because they might depend on their values for config in self._lean_configs: if type(config) is InternalInputUserInput: - if config._alias_of: - target = next((c for c in self._lean_configs if c._id == config._alias_of), None) - if target is None: - raise ValueError(f'"{config._id}" is an alias of "{config._alias_of}", ' - f'which is not a configuration of {self._id}') - config._value = target._value if config._is_conditional: for option in config._value_options: if option._condition.check(self.get_config_value_from_name(option._condition._dependent_config_id)): @@ -161,6 +154,16 @@ def get_settings(self) -> Dict[str, str]: # more consistent across different operating systems. settings[configuration._id] = str(configuration._value).replace("\\n", "\n").replace("\\", "/") + # aliases take the value as sent, which includes the settings an authorization adds + for config in self._lean_configs: + if type(config) is not InternalInputUserInput or not config._alias_of: + continue + if config._alias_of not in settings: + raise ValueError(f'"{config._id}" is an alias of "{config._alias_of}", ' + f'which is not a setting of {self._id}') + config._value = settings[config._alias_of] + settings[config._id] = config._value + return settings def get_all_input_configs(self, filters: List[Type[Configuration]] = []) -> List[Configuration]: @@ -270,22 +273,6 @@ def _validate(self, configuration: Configuration, user_choice, logger: Logger, i logger.info(str(e)) user_choice = self._ask_user_for_input(configuration, logger, hide_input) - def _get_auth0_authorization(self, logger: Logger, project_id: int, no_browser: bool, - user_name: str) -> QCAuth0Authorization: - """Reads the stored brokerage authorization, or starts the authorization in the browser and waits for it. - - The live/auth0 endpoints know a brokerage by its lowercased display name. The Interactive Brokers FIX module - is the exception: it trades through the Interactive Brokers login, which live/auth0/authorize knows as - `interactivebrokers`, while live/auth0/read keys the stored token by the module id. - """ - brokerage_id = self._display_name.lower() - read_brokerage_id = brokerage_id - if self._id == "InteractiveBrokersFixBrokerage": - brokerage_id = "interactivebrokers" - read_brokerage_id = self._id - return get_authorization(container.api_client.auth0, brokerage_id, read_brokerage_id, logger, project_id, - no_browser=no_browser, user_name=user_name) - def config_build(self, lean_config: Dict[str, Any], logger: Logger, @@ -338,7 +325,16 @@ def config_build(self, user_name = self.get_user_name(lean_config, configuration, user_provided_options, configuration.require_user_name, interactive) logger.debug(f'user_name: {user_name}') - auth_authorizations = self._get_auth0_authorization(logger, lean_config["project-id"], no_browser, user_name) + # the Interactive Brokers FIX module trades through the Interactive Brokers login, which + # live/auth0/authorize knows as `interactivebrokers`, while live/auth0/read keys it by the module id + brokerage_id = self._display_name.lower() + read_brokerage_id = brokerage_id + if self._id == "InteractiveBrokersFixBrokerage": + brokerage_id = "interactivebrokers" + read_brokerage_id = self._id + auth_authorizations = get_authorization(container.api_client.auth0, brokerage_id, read_brokerage_id, + logger, lean_config["project-id"], no_browser=no_browser, + user_name=user_name) logger.debug(f'auth: {auth_authorizations}') configuration._value = auth_authorizations.get_authorization_config_without_account() for inner_config in self._lean_configs: diff --git a/tests/models/test_json_module.py b/tests/models/test_json_module.py index e3162dfc..e43822b1 100644 --- a/tests/models/test_json_module.py +++ b/tests/models/test_json_module.py @@ -133,7 +133,7 @@ def create_oauth_module(module_id: str, display_id: str) -> JsonModule: { "id": "my-linked-account", "type": "internal-input", - "alias-of": "my-user-name" + "alias-of": "userId" } ] }, MODULE_BROKERAGE, MODULE_CLI_PLATFORM) @@ -146,19 +146,20 @@ def test_config_build_reads_the_authorization_under_the_brokerage_id_of_the_modu expected_read_brokerage_id: str) -> None: api_client = mock.Mock() api_client.auth0.read.return_value = QCAuth0Authorization( - authorization={"my-refresh-token": "token", "accounts": [{"id": "U1234567", "name": "U1234567 : Me"}]}) + authorization={"my-refresh-token": "token", "userId": "someuser", + "accounts": [{"id": "U1234567", "name": "U1234567 : Me"}]}) container.api_client = api_client with Context(Command("test")): module = create_oauth_module(module_id, display_id).config_build( - {"project-id": 123, "my-user-name": "someuser", "my-account": "U1234567"}, container.logger, + {"project-id": 123, "my-user-name": "SomeUser", "my-account": "U1234567"}, container.logger, interactive=False) - api_client.auth0.read.assert_called_once_with(expected_read_brokerage_id, user_name="someuser") + api_client.auth0.read.assert_called_once_with(expected_read_brokerage_id, user_name="SomeUser") api_client.auth0.authorize.assert_not_called() settings = module.get_settings() assert settings["my-refresh-token"] == "token" - assert settings["my-user-name"] == "someuser" + assert settings["my-user-name"] == "SomeUser" assert settings["my-account"] == "U1234567" assert settings["my-linked-account"] == "someuser" @@ -183,7 +184,22 @@ def test_config_build_authorizes_under_the_login_brokerage_id_of_the_module(modu user_name="someuser") -def test_get_settings_names_the_alias_of_target_when_it_is_not_a_configuration() -> None: +def test_get_settings_gives_an_alias_the_value_of_another_configuration() -> None: + module = JsonModule({ + "id": "MyBrokerage", + "display-id": "My Brokerage", + "configurations": [ + {"id": "my-user-name", "type": "input", "input-method": "prompt", "prompt-info": "Username"}, + {"id": "my-linked-account", "type": "internal-input", "alias-of": "my-user-name"} + ] + }, MODULE_BROKERAGE, MODULE_CLI_PLATFORM) + [user_name] = [config for config in module._lean_configs if config._id == "my-user-name"] + user_name._value = "someuser" + + assert module.get_settings()["my-linked-account"] == "someuser" + + +def test_get_settings_names_the_alias_of_target_when_it_is_not_a_setting() -> None: module = JsonModule({ "id": "MyBrokerage", "display-id": "My Brokerage", From a70ad35491a6dc7dd9d10bf8e175a47c28646d49 Mon Sep 17 00:00:00 2001 From: Romazes Date: Tue, 3 Nov 2026 18:21:46 +0200 Subject: [PATCH 11/16] fix: follow the merged auth0 contract for interactive brokers fix - the API now knows the Interactive Brokers FIX login as InteractiveBrokersFixBrokerage on both live/auth0/authorize and live/auth0/read, so one name serves both calls and the helper goes back to a single brokerage id; the module still has to pick that name, since every other oauth module authorizes under its lowercased display name - live/create no longer requires ib-fix-linked-account, so the entry sends the user name once and the alias-of feature, which only existed to send it twice, goes --- lean/components/util/auth0_helper.py | 11 ++--- lean/models/configuration.py | 1 - lean/models/json_module.py | 33 +++++-------- tests/commands/data/test_download.py | 2 +- tests/models/test_json_module.py | 73 +++++----------------------- 5 files changed, 29 insertions(+), 91 deletions(-) diff --git a/lean/components/util/auth0_helper.py b/lean/components/util/auth0_helper.py index 3b51a6d6..fb2d1e5f 100644 --- a/lean/components/util/auth0_helper.py +++ b/lean/components/util/auth0_helper.py @@ -16,22 +16,19 @@ from lean.components.util.logger import Logger -def get_authorization(auth0_client: Auth0Client, brokerage_id: str, read_brokerage_id: str, logger: Logger, - project_id: int, no_browser: bool = False, user_name: str = None) -> QCAuth0Authorization: +def get_authorization(auth0_client: Auth0Client, brokerage_id: str, logger: Logger, project_id: int, no_browser: bool = False, user_name: str = None) -> QCAuth0Authorization: """Gets the authorization data for a brokerage, authorizing if necessary. :param auth0_client: An instance of Auth0Client, containing methods to interact with live/auth0/* API endpoints. - :param brokerage_id: The ID live/auth0/authorize opens the brokerage login for. - :param read_brokerage_id: The ID live/auth0/read knows the stored authorization by. + :param brokerage_id: The ID of the brokerage to get the authorization data for. :param logger: An instance of Logger, handling all output printing. :param project_id: The local or cloud project_id. :param no_browser: whether to disable opening the browser - :param user_name: the login ID the authorization is stored under :return: The authorization data for the specified brokerage. """ from time import time, sleep - data = auth0_client.read(read_brokerage_id, user_name=user_name) + data = auth0_client.read(brokerage_id, user_name=user_name) if data.authorization is not None: return data @@ -42,7 +39,7 @@ def get_authorization(auth0_client: Auth0Client, brokerage_id: str, read_brokera while time() - start_time < 420: logger.debug("Will sleep 5 seconds and retry fetching authorization...") sleep(5) - data = auth0_client.read(read_brokerage_id, user_name=user_name) + data = auth0_client.read(brokerage_id, user_name=user_name) if data.authorization is None: continue return data diff --git a/lean/models/configuration.py b/lean/models/configuration.py index d892b7e8..6415544d 100644 --- a/lean/models/configuration.py +++ b/lean/models/configuration.py @@ -262,7 +262,6 @@ class InternalInputUserInput(UserInputConfiguration): def __init__(self, config_json_object): super().__init__(config_json_object) - self._alias_of: str = config_json_object.get("alias-of") self._is_conditional: bool = False value_options: List[ConditionalValueOption] = [] if "value-options" in config_json_object.keys(): diff --git a/lean/models/json_module.py b/lean/models/json_module.py index a67b604a..7d53ba98 100644 --- a/lean/models/json_module.py +++ b/lean/models/json_module.py @@ -21,6 +21,7 @@ from lean.components.util.logger import Logger from lean.constants import MODULE_TYPE, MODULE_PLATFORM, MODULE_CLI_PLATFORM from lean.container import container +from lean.models.api import QCAuth0Authorization from lean.models.configuration import BrokerageEnvConfiguration, Configuration, InternalInputUserInput, \ PathParameterUserInput, AuthConfiguration, ChoiceUserInput from copy import copy @@ -154,16 +155,6 @@ def get_settings(self) -> Dict[str, str]: # more consistent across different operating systems. settings[configuration._id] = str(configuration._value).replace("\\n", "\n").replace("\\", "/") - # aliases take the value as sent, which includes the settings an authorization adds - for config in self._lean_configs: - if type(config) is not InternalInputUserInput or not config._alias_of: - continue - if config._alias_of not in settings: - raise ValueError(f'"{config._id}" is an alias of "{config._alias_of}", ' - f'which is not a setting of {self._id}') - config._value = settings[config._alias_of] - settings[config._id] = config._value - return settings def get_all_input_configs(self, filters: List[Type[Configuration]] = []) -> List[Configuration]: @@ -273,6 +264,17 @@ def _validate(self, configuration: Configuration, user_choice, logger: Logger, i logger.info(str(e)) user_choice = self._ask_user_for_input(configuration, logger, hide_input) + def _get_auth0_authorization(self, logger: Logger, project_id: int, no_browser: bool, + user_name: str) -> QCAuth0Authorization: + """Reads the stored brokerage authorization, or starts the authorization in the browser and waits for it. + + The live/auth0 endpoints know a brokerage by its lowercased display name, except the Interactive Brokers FIX + module, which they know by its module id. + """ + brokerage_id = self._id if self._id == "InteractiveBrokersFixBrokerage" else self._display_name.lower() + return get_authorization(container.api_client.auth0, brokerage_id, logger, project_id, no_browser=no_browser, + user_name=user_name) + def config_build(self, lean_config: Dict[str, Any], logger: Logger, @@ -325,16 +327,7 @@ def config_build(self, user_name = self.get_user_name(lean_config, configuration, user_provided_options, configuration.require_user_name, interactive) logger.debug(f'user_name: {user_name}') - # the Interactive Brokers FIX module trades through the Interactive Brokers login, which - # live/auth0/authorize knows as `interactivebrokers`, while live/auth0/read keys it by the module id - brokerage_id = self._display_name.lower() - read_brokerage_id = brokerage_id - if self._id == "InteractiveBrokersFixBrokerage": - brokerage_id = "interactivebrokers" - read_brokerage_id = self._id - auth_authorizations = get_authorization(container.api_client.auth0, brokerage_id, read_brokerage_id, - logger, lean_config["project-id"], no_browser=no_browser, - user_name=user_name) + auth_authorizations = self._get_auth0_authorization(logger, lean_config["project-id"], no_browser, user_name) logger.debug(f'auth: {auth_authorizations}') configuration._value = auth_authorizations.get_authorization_config_without_account() for inner_config in self._lean_configs: diff --git a/tests/commands/data/test_download.py b/tests/commands/data/test_download.py index 17c094b9..6372b25f 100644 --- a/tests/commands/data/test_download.py +++ b/tests/commands/data/test_download.py @@ -218,7 +218,7 @@ def _run_oauth_download(extra_run_command: List[str], cli_input: str = "\n"): captured_project_ids = [] - def _fake_get_authorization(auth0_client, brokerage_id, read_brokerage_id, logger, project_id, *args, **kwargs): + def _fake_get_authorization(auth0_client, brokerage_id, logger, project_id, *args, **kwargs): captured_project_ids.append(project_id) auth = MagicMock() auth.get_authorization_config_without_account.return_value = {} diff --git a/tests/models/test_json_module.py b/tests/models/test_json_module.py index e43822b1..b3b6e6c8 100644 --- a/tests/models/test_json_module.py +++ b/tests/models/test_json_module.py @@ -129,46 +129,16 @@ def create_oauth_module(module_id: str, display_id: str) -> JsonModule: "filters": [{"condition": {"dependent-config-id": "my-oauth-token", "pattern": "^(?!\\s*$).+", "type": "regex"}}] - }, - { - "id": "my-linked-account", - "type": "internal-input", - "alias-of": "userId" } ] }, MODULE_BROKERAGE, MODULE_CLI_PLATFORM) -@pytest.mark.parametrize("module_id,display_id,expected_read_brokerage_id", +@pytest.mark.parametrize("module_id,display_id,expected_brokerage_id", [("MyBrokerage", "My Brokerage", "my brokerage"), - ("InteractiveBrokersFixBrokerage", "InteractiveBrokersFix", "InteractiveBrokersFixBrokerage")]) -def test_config_build_reads_the_authorization_under_the_brokerage_id_of_the_module(module_id: str, display_id: str, - expected_read_brokerage_id: str) -> None: - api_client = mock.Mock() - api_client.auth0.read.return_value = QCAuth0Authorization( - authorization={"my-refresh-token": "token", "userId": "someuser", - "accounts": [{"id": "U1234567", "name": "U1234567 : Me"}]}) - container.api_client = api_client - - with Context(Command("test")): - module = create_oauth_module(module_id, display_id).config_build( - {"project-id": 123, "my-user-name": "SomeUser", "my-account": "U1234567"}, container.logger, - interactive=False) - - api_client.auth0.read.assert_called_once_with(expected_read_brokerage_id, user_name="SomeUser") - api_client.auth0.authorize.assert_not_called() - settings = module.get_settings() - assert settings["my-refresh-token"] == "token" - assert settings["my-user-name"] == "SomeUser" - assert settings["my-account"] == "U1234567" - assert settings["my-linked-account"] == "someuser" - - -@pytest.mark.parametrize("module_id,display_id,expected_authorize_brokerage_id", - [("MyBrokerage", "My Brokerage", "my brokerage"), - ("InteractiveBrokersFixBrokerage", "InteractiveBrokersFix", "interactivebrokers")]) -def test_config_build_authorizes_under_the_login_brokerage_id_of_the_module(module_id: str, display_id: str, - expected_authorize_brokerage_id: str) -> None: + ("InteractiveBrokersFixBrokerage", "Interactive Brokers (FIX)", "InteractiveBrokersFixBrokerage")]) +def test_config_build_authorizes_under_the_brokerage_id_of_the_module(module_id: str, display_id: str, + expected_brokerage_id: str) -> None: api_client = mock.Mock() stored_authorization = QCAuth0Authorization( authorization={"my-refresh-token": "token", "accounts": [{"id": "U1234567", "name": "U1234567 : Me"}]}) @@ -176,35 +146,14 @@ def test_config_build_authorizes_under_the_login_brokerage_id_of_the_module(modu container.api_client = api_client with mock.patch("time.sleep"), Context(Command("test")): - create_oauth_module(module_id, display_id).config_build( + module = create_oauth_module(module_id, display_id).config_build( {"project-id": 123, "my-user-name": "someuser", "my-account": "U1234567"}, container.logger, interactive=False) - api_client.auth0.authorize.assert_called_once_with(expected_authorize_brokerage_id, container.logger, 123, False, + assert api_client.auth0.read.call_args_list == [mock.call(expected_brokerage_id, user_name="someuser")] * 2 + api_client.auth0.authorize.assert_called_once_with(expected_brokerage_id, container.logger, 123, False, user_name="someuser") - - -def test_get_settings_gives_an_alias_the_value_of_another_configuration() -> None: - module = JsonModule({ - "id": "MyBrokerage", - "display-id": "My Brokerage", - "configurations": [ - {"id": "my-user-name", "type": "input", "input-method": "prompt", "prompt-info": "Username"}, - {"id": "my-linked-account", "type": "internal-input", "alias-of": "my-user-name"} - ] - }, MODULE_BROKERAGE, MODULE_CLI_PLATFORM) - [user_name] = [config for config in module._lean_configs if config._id == "my-user-name"] - user_name._value = "someuser" - - assert module.get_settings()["my-linked-account"] == "someuser" - - -def test_get_settings_names_the_alias_of_target_when_it_is_not_a_setting() -> None: - module = JsonModule({ - "id": "MyBrokerage", - "display-id": "My Brokerage", - "configurations": [{"id": "my-linked-account", "type": "internal-input", "alias-of": "my-username"}] - }, MODULE_BROKERAGE, MODULE_CLI_PLATFORM) - - with pytest.raises(ValueError, match='"my-linked-account" is an alias of "my-username"'): - module.get_settings() + settings = module.get_settings() + assert settings["my-refresh-token"] == "token" + assert settings["my-user-name"] == "someuser" + assert settings["my-account"] == "U1234567" From 42fbdbd98b2969eb1ada40e82487af0a268f85d1 Mon Sep 17 00:00:00 2001 From: "quantconnect-kronos[bot]" <346399058+quantconnect-kronos[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:02:57 +0000 Subject: [PATCH 12/16] refactor: choose the auth0 brokerage id where config_build authorizes Co-Authored-By: Claude Opus 5.5 --- lean/models/json_module.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/lean/models/json_module.py b/lean/models/json_module.py index 7d53ba98..0fd85b1c 100644 --- a/lean/models/json_module.py +++ b/lean/models/json_module.py @@ -21,7 +21,6 @@ from lean.components.util.logger import Logger from lean.constants import MODULE_TYPE, MODULE_PLATFORM, MODULE_CLI_PLATFORM from lean.container import container -from lean.models.api import QCAuth0Authorization from lean.models.configuration import BrokerageEnvConfiguration, Configuration, InternalInputUserInput, \ PathParameterUserInput, AuthConfiguration, ChoiceUserInput from copy import copy @@ -264,17 +263,6 @@ def _validate(self, configuration: Configuration, user_choice, logger: Logger, i logger.info(str(e)) user_choice = self._ask_user_for_input(configuration, logger, hide_input) - def _get_auth0_authorization(self, logger: Logger, project_id: int, no_browser: bool, - user_name: str) -> QCAuth0Authorization: - """Reads the stored brokerage authorization, or starts the authorization in the browser and waits for it. - - The live/auth0 endpoints know a brokerage by its lowercased display name, except the Interactive Brokers FIX - module, which they know by its module id. - """ - brokerage_id = self._id if self._id == "InteractiveBrokersFixBrokerage" else self._display_name.lower() - return get_authorization(container.api_client.auth0, brokerage_id, logger, project_id, no_browser=no_browser, - user_name=user_name) - def config_build(self, lean_config: Dict[str, Any], logger: Logger, @@ -327,7 +315,11 @@ def config_build(self, user_name = self.get_user_name(lean_config, configuration, user_provided_options, configuration.require_user_name, interactive) logger.debug(f'user_name: {user_name}') - auth_authorizations = self._get_auth0_authorization(logger, lean_config["project-id"], no_browser, user_name) + # The live/auth0 endpoints know the Interactive Brokers FIX module by its module id + brokerage_id = self._id if self._id == "InteractiveBrokersFixBrokerage" else self._display_name.lower() + auth_authorizations = get_authorization(container.api_client.auth0, brokerage_id, logger, + lean_config["project-id"], no_browser=no_browser, + user_name=user_name) logger.debug(f'auth: {auth_authorizations}') configuration._value = auth_authorizations.get_authorization_config_without_account() for inner_config in self._lean_configs: From 4d32f2bea18869a9dc1b6b66a6e3a91f43960e94 Mon Sep 17 00:00:00 2001 From: Romazes Date: Tue, 3 Nov 2026 21:33:50 +0200 Subject: [PATCH 13/16] docs: list the interactive brokers fix options in the readme - the readme is generated from the hosted modules json, which gets the Interactive Brokers (FIX) entry with the release of this change; adding the brokerage choice and the two options now keeps the readme in step with the entry in the PR description instead of a follow-up commit after the publish - the readme check compares against the hosted json, so it fails until the entry is published --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a11715de..b0481139 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,7 @@ Usage: lean cloud live deploy [OPTIONS] PROJECT --notify-insights. Options: - --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX|Webull|Public|ClearStreet] + --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX|Webull|Public|ClearStreet|Interactive Brokers (FIX)] The brokerage to use --data-provider-live [QuantConnect|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Polygon|CoinApi|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX] The live data provider to use @@ -544,6 +544,8 @@ Options: --clearstreet-access-token TEXT Your Clear Street API access token --clearstreet-account-id TEXT Your Clear Street account id, the numeric id of the account the algorithm trades + --ib-fix-user-name TEXT Your Interactive Brokers user name + --ib-fix-account TEXT Your Interactive Brokers account id --polygon-api-key TEXT Your Polygon.io API Key --polygon-license-type [Individual|Business] Select your Polygon.io subscription plan (Optional). From 045babe7e91e65c253d388005ce38e7ec7844f82 Mon Sep 17 00:00:00 2001 From: Romazes Date: Tue, 3 Nov 2026 21:38:59 +0200 Subject: [PATCH 14/16] refactor: drop the comment above the auth0 brokerage id choice --- lean/models/json_module.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lean/models/json_module.py b/lean/models/json_module.py index 0fd85b1c..8297f23e 100644 --- a/lean/models/json_module.py +++ b/lean/models/json_module.py @@ -315,7 +315,6 @@ def config_build(self, user_name = self.get_user_name(lean_config, configuration, user_provided_options, configuration.require_user_name, interactive) logger.debug(f'user_name: {user_name}') - # The live/auth0 endpoints know the Interactive Brokers FIX module by its module id brokerage_id = self._id if self._id == "InteractiveBrokersFixBrokerage" else self._display_name.lower() auth_authorizations = get_authorization(container.api_client.auth0, brokerage_id, logger, lean_config["project-id"], no_browser=no_browser, From 9f141df9c1699ae555d01ddbd2b67c6ffb941bfb Mon Sep 17 00:00:00 2001 From: Romazes Date: Wed, 4 Nov 2026 15:53:26 +0200 Subject: [PATCH 15/16] refactor: authorize under the lowercased display name again - the Interactive Brokers (FIX) entry now carries its module id as display id, so the rule every other oauth module uses sends a name the API knows and the CLI needs no case for it --- lean/models/json_module.py | 5 ++- tests/models/test_json_module.py | 57 -------------------------------- 2 files changed, 2 insertions(+), 60 deletions(-) diff --git a/lean/models/json_module.py b/lean/models/json_module.py index 8297f23e..3110da27 100644 --- a/lean/models/json_module.py +++ b/lean/models/json_module.py @@ -315,9 +315,8 @@ def config_build(self, user_name = self.get_user_name(lean_config, configuration, user_provided_options, configuration.require_user_name, interactive) logger.debug(f'user_name: {user_name}') - brokerage_id = self._id if self._id == "InteractiveBrokersFixBrokerage" else self._display_name.lower() - auth_authorizations = get_authorization(container.api_client.auth0, brokerage_id, logger, - lean_config["project-id"], no_browser=no_browser, + auth_authorizations = get_authorization(container.api_client.auth0, self._display_name.lower(), + logger, lean_config["project-id"], no_browser=no_browser, user_name=user_name) logger.debug(f'auth: {auth_authorizations}') configuration._value = auth_authorizations.get_authorization_config_without_account() diff --git a/tests/models/test_json_module.py b/tests/models/test_json_module.py index b3b6e6c8..7949add1 100644 --- a/tests/models/test_json_module.py +++ b/tests/models/test_json_module.py @@ -14,7 +14,6 @@ from json import loads from pathlib import Path from typing import Any, Dict -from unittest import mock import pytest from click import Command, Context, command, echo @@ -22,7 +21,6 @@ from lean.constants import MODULE_BROKERAGE, MODULE_CLI_PLATFORM from lean.container import container -from lean.models.api import QCAuth0Authorization from lean.models.json_module import JsonModule from tests.models.test_configuration import TIME_REGEX, TIME_REGEX_MESSAGE from tests.test_helpers import create_fake_lean_cli_directory @@ -102,58 +100,3 @@ def test_config_build_accepts_any_lean_config_value_when_the_module_has_no_regex module = build_config({"my-time": value}, regex=False) assert module.get_config_value_from_name("my-time") == value - - -def create_oauth_module(module_id: str, display_id: str) -> JsonModule: - return JsonModule({ - "id": module_id, - "display-id": display_id, - "configurations": [ - { - "id": "my-oauth-token", - "type": "oauth-token", - "require-user-name": True, - "value": "Yes" - }, - { - "id": "my-user-name", - "type": "input", - "input-method": "prompt", - "prompt-info": "Username" - }, - { - "id": "my-account", - "type": "input", - "input-method": "choice", - "prompt-info": "Account", - "filters": [{"condition": {"dependent-config-id": "my-oauth-token", - "pattern": "^(?!\\s*$).+", - "type": "regex"}}] - } - ] - }, MODULE_BROKERAGE, MODULE_CLI_PLATFORM) - - -@pytest.mark.parametrize("module_id,display_id,expected_brokerage_id", - [("MyBrokerage", "My Brokerage", "my brokerage"), - ("InteractiveBrokersFixBrokerage", "Interactive Brokers (FIX)", "InteractiveBrokersFixBrokerage")]) -def test_config_build_authorizes_under_the_brokerage_id_of_the_module(module_id: str, display_id: str, - expected_brokerage_id: str) -> None: - api_client = mock.Mock() - stored_authorization = QCAuth0Authorization( - authorization={"my-refresh-token": "token", "accounts": [{"id": "U1234567", "name": "U1234567 : Me"}]}) - api_client.auth0.read.side_effect = [QCAuth0Authorization(authorization=None), stored_authorization] - container.api_client = api_client - - with mock.patch("time.sleep"), Context(Command("test")): - module = create_oauth_module(module_id, display_id).config_build( - {"project-id": 123, "my-user-name": "someuser", "my-account": "U1234567"}, container.logger, - interactive=False) - - assert api_client.auth0.read.call_args_list == [mock.call(expected_brokerage_id, user_name="someuser")] * 2 - api_client.auth0.authorize.assert_called_once_with(expected_brokerage_id, container.logger, 123, False, - user_name="someuser") - settings = module.get_settings() - assert settings["my-refresh-token"] == "token" - assert settings["my-user-name"] == "someuser" - assert settings["my-account"] == "U1234567" From e2ee1ed0b2e49fc0c3ad0c5ab97f9f208179d23e Mon Sep 17 00:00:00 2001 From: Romazes Date: Wed, 4 Nov 2026 15:53:27 +0200 Subject: [PATCH 16/16] docs: list the interactive brokers fix brokerage under its display id - the hosted modules json entry uses `InteractiveBrokersFixBrokerage` as display id, so the brokerage choice and the two options follow the generator output for that entry --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b0481139..da51361f 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,7 @@ Usage: lean cloud live deploy [OPTIONS] PROJECT --notify-insights. Options: - --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX|Webull|Public|ClearStreet|Interactive Brokers (FIX)] + --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX|Webull|Public|ClearStreet|InteractiveBrokersFixBrokerage] The brokerage to use --data-provider-live [QuantConnect|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Quant Bridge|Trading Technologies|Kraken|CharlesSchwab|Polygon|CoinApi|Bybit|TradeStation|Alpaca|Tastytrade|Eze|dYdX] The live data provider to use