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