From ac5f5786e988f70cd98c61e140ff200c12e4c0a2 Mon Sep 17 00:00:00 2001 From: Alexandre Catarino Date: Fri, 14 Aug 2026 23:50:01 +0100 Subject: [PATCH] bug: reject IB weekly restart times later than 23:30 UTC Interactive Brokers doesn't support weekly restart times later than 23:30 UTC, but the CLI happily accepted any value and only failed once the deployment reached the brokerage. Add a TimeParameter click type enforcing the hh:mm:ss format and an optional upper bound, and bind it to ib-weekly-restart-utc-time. It covers the command line option, the interactive prompt and the values read from an existing Lean config, which don't go through click. Input without seconds is accepted as well, but the value is always normalized to hh:mm:ss, the format the API expects. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 12 ++--- lean/click.py | 45 ++++++++++++++++ lean/models/configuration.py | 35 ++++++++++++- lean/models/json_module.py | 3 +- .../cloud/live/test_cloud_live_commands.py | 51 +++++++++++++++++++ tests/commands/test_live.py | 22 ++++++++ tests/test_click.py | 46 ++++++++++++++++- 7 files changed, 204 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2ce9acad..f5f07bc5 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ Options: --ib-user-name TEXT Your Interactive Brokers username --ib-account TEXT Your Interactive Brokers account id --ib-password TEXT Your Interactive Brokers password - --ib-weekly-restart-utc-time TEXT + --ib-weekly-restart-utc-time [hh:mm:ss] Weekly restart UTC time (hh:mm:ss). Each week on Sunday your algorithm is restarted at this time, and will require 2FA verification. This is required by Interactive Brokers. Use this option explicitly to override the default value. (Optional). @@ -424,7 +424,7 @@ Options: --ib-user-name TEXT Your Interactive Brokers username --ib-account TEXT Your Interactive Brokers account id --ib-password TEXT Your Interactive Brokers password - --ib-weekly-restart-utc-time TEXT + --ib-weekly-restart-utc-time [hh:mm:ss] Weekly restart UTC time (hh:mm:ss). Each week on Sunday your algorithm is restarted at this time, and will require 2FA verification. This is required by Interactive Brokers. Use this option explicitly to override the default value. (Optional). @@ -952,7 +952,7 @@ Options: --ib-user-name TEXT Your Interactive Brokers username --ib-account TEXT Your Interactive Brokers account id --ib-password TEXT Your Interactive Brokers password - --ib-weekly-restart-utc-time TEXT + --ib-weekly-restart-utc-time [hh:mm:ss] Weekly restart UTC time (hh:mm:ss). Each week on Sunday your algorithm is restarted at this time, and will require 2FA verification. This is required by Interactive Brokers. Use this option explicitly to override the default value. (Optional). @@ -1417,7 +1417,7 @@ Options: --ib-user-name TEXT Your Interactive Brokers username --ib-account TEXT Your Interactive Brokers account id --ib-password TEXT Your Interactive Brokers password - --ib-weekly-restart-utc-time TEXT + --ib-weekly-restart-utc-time [hh:mm:ss] Weekly restart UTC time (hh:mm:ss). Each week on Sunday your algorithm is restarted at this time, and will require 2FA verification. This is required by Interactive Brokers. Use this option explicitly to override the default value. (Optional). @@ -1898,7 +1898,7 @@ Options: --ib-user-name TEXT Your Interactive Brokers username --ib-account TEXT Your Interactive Brokers account id --ib-password TEXT Your Interactive Brokers password - --ib-weekly-restart-utc-time TEXT + --ib-weekly-restart-utc-time [hh:mm:ss] Weekly restart UTC time (hh:mm:ss). Each week on Sunday your algorithm is restarted at this time, and will require 2FA verification. This is required by Interactive Brokers. Use this option explicitly to override the default value. (Optional). @@ -2167,7 +2167,7 @@ Options: --ib-user-name TEXT Your Interactive Brokers username --ib-account TEXT Your Interactive Brokers account id --ib-password TEXT Your Interactive Brokers password - --ib-weekly-restart-utc-time TEXT + --ib-weekly-restart-utc-time [hh:mm:ss] Weekly restart UTC time (hh:mm:ss). Each week on Sunday your algorithm is restarted at this time, and will require 2FA verification. This is required by Interactive Brokers. Use this option explicitly to override the default value. (Optional). diff --git a/lean/click.py b/lean/click.py index abef2ee5..1c165eab 100644 --- a/lean/click.py +++ b/lean/click.py @@ -383,6 +383,51 @@ def convert(self, value: str, param: Parameter, ctx: Context): self.fail(f"'{value}' does not match the yyyyMMdd format.", param, ctx) +class TimeParameter(ParamType): + """A click parameter which returns hh:mm:ss strings, the format expected by the API. + + Input without seconds is accepted as well, in which case they default to zero. + """ + + name = "time" + + def __init__(self, maximum: str = None): + """Creates a new TimeParameter instance. + + :param maximum: the latest time that is accepted in hh:mm:ss format, or None if there is no upper bound + """ + self._maximum = maximum + + def get_metavar(self, param: Parameter, ctx=None) -> str: + return "[hh:mm:ss]" + + def convert(self, value: str, param: Parameter, ctx: Context) -> str: + parsed_value = self._parse(value) + + if parsed_value is None: + self.fail(f"'{value}' does not match the hh:mm:ss format.", param, ctx) + + if self._maximum is not None and parsed_value > self._parse(self._maximum): + self.fail(f"'{value}' is later than the latest supported time of {self._maximum}.", param, ctx) + + return parsed_value.strftime("%H:%M:%S") + + @staticmethod + def _parse(value: str): + """Parses a time, returning None when the given value is not formatted as hh:mm:ss or hh:mm. + + :param value: the value to parse + :return: the parsed datetime.time, or None when the value cannot be parsed + """ + from datetime import datetime + for time_format in ["%H:%M:%S", "%H:%M"]: + try: + return datetime.strptime(str(value), time_format).time() + except ValueError: + pass + return None + + def ensure_options(options: List[str]) -> None: """Ensures certain options have values, raises an error if not. diff --git a/lean/models/configuration.py b/lean/models/configuration.py index e7a61e27..387d83ad 100644 --- a/lean/models/configuration.py +++ b/lean/models/configuration.py @@ -13,11 +13,11 @@ from pathlib import Path from typing import Any, Dict, List -from click import prompt +from click import prompt, ClickException, ParamType from lean.click import CaseInsensitiveChoice from abc import ABC, abstractmethod from lean.components.util.logger import Logger -from lean.click import PathParameter +from lean.click import PathParameter, TimeParameter class BaseCondition(ABC): @@ -128,6 +128,14 @@ def factory(config_json_object) -> 'Configuration': raise ValueError( f'Undefined input method type {config_json_object["type"]}') + def validate(self, value): + """Validates a value which didn't go through a click prompt or option, like the ones read from the Lean config. + + :param value: the value to validate + :return: the validated value + """ + return value + def __repr__(self): return f'{self._id}: {self._value}' @@ -255,6 +263,13 @@ class PromptUserInput(UserInputConfiguration): "integer": int } + # Configurations that need stricter validation than the modules json describes. + # The modules json is fetched from the CDN, so these overrides live here instead of in the json itself. + map_id_to_types = { + # Interactive Brokers doesn't support weekly restart times later than 23:30 UTC + "ib-weekly-restart-utc-time": TimeParameter(maximum="23:30:00") + } + def __init__(self, config_json_object): super().__init__(config_json_object) self._input_type: str = "string" @@ -273,8 +288,24 @@ def ask_user_for_input(self, default_value, logger: Logger, hide_input: bool = F return prompt(self._prompt_info, default_value, type=self.get_input_type()) def get_input_type(self): + if self._id in self.map_id_to_types: + return self.map_id_to_types[self._id] return self.map_to_types.get(self._input_type, self._input_type) + def validate(self, value): + """Validates a value which didn't go through a click prompt or option, like the ones read from the Lean config. + + :param value: the value to validate + :return: the validated value + """ + input_type = self.get_input_type() + if value is None or not isinstance(input_type, ParamType): + return value + try: + return input_type.convert(value, None, None) + except ClickException as e: + raise RuntimeError(f"Invalid value for '{self._id}': {e.message}") + class ChoiceUserInput(UserInputConfiguration): def __init__(self, config_json_object): diff --git a/lean/models/json_module.py b/lean/models/json_module.py index e1001406..fa6c0560 100644 --- a/lean/models/json_module.py +++ b/lean/models/json_module.py @@ -330,7 +330,8 @@ def config_build(self, else: missing_options.append(f"--{configuration._id}") - configuration._value = user_choice + # the values that come from the Lean config didn't go through click, so they are validated here + configuration._value = configuration.validate(user_choice) if len(missing_options) > 0: raise RuntimeError(f"""You are missing the following option{"s" if len(missing_options) > 1 else ""}: {', ' diff --git a/tests/commands/cloud/live/test_cloud_live_commands.py b/tests/commands/cloud/live/test_cloud_live_commands.py index c6740e27..62bc7a71 100644 --- a/tests/commands/cloud/live/test_cloud_live_commands.py +++ b/tests/commands/cloud/live/test_cloud_live_commands.py @@ -205,6 +205,57 @@ def test_cloud_live_deploy_with_ib_using_hybrid_datafeed() -> None: assert result.exit_code == 0 assert "Live data providers: QuantConnectBrokerage, InteractiveBrokersBrokerage" in result.output.replace("\n", "") +@pytest.mark.skipif( + sys.platform =="darwin", reason="MacOS does not support IB tests." +) +@pytest.mark.parametrize("weekly_restart_time", ["23:30:01", "23:50:00", "23:50", "invalid"]) +def test_cloud_live_deploy_with_ib_fails_when_weekly_restart_time_not_supported(weekly_restart_time: str) -> None: + create_fake_lean_cli_directory() + + result = CliRunner().invoke(lean, ["cloud", "live", "Python Project", "--brokerage", "Interactive Brokers", "--node", "live", + "--auto-restart", "yes", "--notify-order-events", "no", "--notify-insights", "no", + "--ib-user-name", "test_user", "--ib-account", "DU2366417", "--ib-password", "test_password", + "--ib-weekly-restart-utc-time", weekly_restart_time, + "--data-provider-live", "Interactive Brokers"]) + + assert result.exit_code != 0 + assert weekly_restart_time in result.output + + +@pytest.mark.skipif( + sys.platform =="darwin", reason="MacOS does not support IB tests." +) +@pytest.mark.parametrize("weekly_restart_time", ["21:00", "21:00:00"]) +def test_cloud_live_deploy_with_ib_sends_weekly_restart_time_with_seconds(weekly_restart_time: str) -> None: + create_fake_lean_cli_directory() + + api_client = mock.Mock() + api_client.nodes.get_all.return_value = create_qc_nodes() + api_client.get.return_value = { + "status": "stopped", + "stopped": "2024-07-10 19:12:20", + "success": True, + "portfolio": {"holdings": {}, "cash": {}, "success": True}} + container.api_client = api_client + + cloud_project_manager = mock.Mock() + container.cloud_project_manager = cloud_project_manager + + cloud_runner = mock.Mock() + container.cloud_runner = cloud_runner + + result = CliRunner().invoke(lean, ["cloud", "live", "Python Project", "--brokerage", "Interactive Brokers", "--node", "live", + "--auto-restart", "yes", "--notify-order-events", "no", "--notify-insights", "no", + "--ib-user-name", "test_user", "--ib-account", "DU2366417", "--ib-password", "test_password", + "--ib-weekly-restart-utc-time", weekly_restart_time, + "--data-provider-live", "Interactive Brokers"]) + + assert result.exit_code == 0 + + args, _ = api_client.live.start.call_args + assert args[3]["ib-weekly-restart-utc-time"] == "21:00:00" + + def test_cloud_live_deploy_with_tradier_using_tradier_datafeed() -> None: create_fake_lean_cli_directory() diff --git a/tests/commands/test_live.py b/tests/commands/test_live.py index 6a4cbc3f..6594694e 100644 --- a/tests/commands/test_live.py +++ b/tests/commands/test_live.py @@ -334,6 +334,28 @@ def test_live_aborts_when_lean_config_is_missing_properties(target: str, replace lean_runner.run_lean.assert_not_called() +@pytest.mark.skipif( + sys.platform == "darwin", reason="MacOS does not support IB tests." +) +@pytest.mark.parametrize("weekly_restart_time", ["23:50:00", "invalid"]) +def test_live_aborts_when_lean_config_has_unsupported_ib_weekly_restart_time(weekly_restart_time: str) -> None: + create_fake_lean_cli_directory() + create_fake_environment("live-paper", True) + lean_runner = container.lean_runner + + config_path = Path.cwd() / "lean.json" + config = config_path.read_text(encoding="utf-8") + config_path.write_text(config.replace("21:00:00", weekly_restart_time), encoding="utf-8") + + result = CliRunner().invoke(lean, ["live", "Python Project", "--environment", "live-paper"]) + + assert result.exit_code != 0 + assert "Invalid value for 'ib-weekly-restart-utc-time'" in str(result.exception) + assert weekly_restart_time in str(result.exception) + + lean_runner.run_lean.assert_not_called() + + def test_live_sets_dependent_configurations_from_modules_json_based_on_environment() -> None: create_fake_lean_cli_directory() create_fake_binance_environment("live-binance", True) diff --git a/tests/test_click.py b/tests/test_click.py index 2b9c6f61..84a65fa0 100644 --- a/tests/test_click.py +++ b/tests/test_click.py @@ -22,7 +22,7 @@ import pytest from click.testing import CliRunner -from lean.click import DateParameter, LeanCommand, PathParameter +from lean.click import DateParameter, LeanCommand, PathParameter, TimeParameter from lean.container import container from tests.test_helpers import create_fake_lean_cli_directory @@ -209,3 +209,47 @@ def command(arg: datetime) -> None: result = CliRunner().invoke(command, [input]) assert result.exit_code != 0 + + +@pytest.mark.parametrize("input,expected", [("21:00:00", "21:00:00"), ("21:00", "21:00:00"), ("9:30", "09:30:00"), + ("23:30", "23:30:00")]) +def test_time_parameter_returns_time_with_seconds(input: str, expected: str) -> None: + given_arg: Optional[str] = None + + @click.command() + @click.argument("arg", type=TimeParameter(maximum="23:30:00")) + def command(arg: str) -> None: + nonlocal given_arg + given_arg = arg + + result = CliRunner().invoke(command, [input]) + + assert result.exit_code == 0 + + assert given_arg == expected + + +@pytest.mark.parametrize("input", ["25:00:00", "21:60", "hh:mm:ss", "210000", "this is invalid input"]) +def test_time_parameter_fails_when_input_not_formatted_as_hhmmss(input: str) -> None: + @click.command() + @click.argument("arg", type=TimeParameter()) + def command(arg: str) -> None: + pass + + result = CliRunner().invoke(command, [input]) + + assert result.exit_code != 0 + assert "does not match the hh:mm:ss format" in result.output + + +@pytest.mark.parametrize("input", ["23:30:01", "23:31", "23:59:59"]) +def test_time_parameter_fails_when_input_later_than_maximum(input: str) -> None: + @click.command() + @click.argument("arg", type=TimeParameter(maximum="23:30:00")) + def command(arg: str) -> None: + pass + + result = CliRunner().invoke(command, [input]) + + assert result.exit_code != 0 + assert "later than the latest supported time of 23:30:00" in result.output