diff --git a/README.md b/README.md index dd9477ad..640be648 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,8 @@ Options: --extra-docker-config TEXT Extra docker configuration as a JSON string. For more information https://docker- py.readthedocs.io/en/stable/containers.html --no-update Use the local LEAN engine image instead of pulling the latest version + --parameter ... Key-value pairs to pass as backtest parameters. Values can be string, int, or float. + Example: --parameter symbol AAPL --parameter period 10 --parameter threshold 0.05 --lean-config FILE The Lean configuration file that should be used (defaults to the nearest lean.json) --verbose Enable debug logging --help Show this message and exit. @@ -308,11 +310,13 @@ Usage: lean cloud backtest [OPTIONS] PROJECT use the --push option to push local modifications to the cloud before running the backtest. Options: - --name TEXT The name of the backtest (a random one is generated if not specified) - --push Push local modifications to the cloud before running the backtest - --open Automatically open the results in the browser when the backtest is finished - --verbose Enable debug logging - --help Show this message and exit. + --name TEXT The name of the backtest (a random one is generated if not specified) + --push Push local modifications to the cloud before running the backtest + --open Automatically open the results in the browser when the backtest is finished + --parameter ... Key-value pairs to pass as backtest parameters. Values can be string, int, or float. + Example: --parameter symbol AAPL --parameter period 10 --parameter threshold 0.05 + --verbose Enable debug logging + --help Show this message and exit. ``` _See code: [lean/commands/cloud/backtest.py](lean/commands/cloud/backtest.py)_ diff --git a/lean/click.py b/lean/click.py index b67b3936..0eb4e86d 100644 --- a/lean/click.py +++ b/lean/click.py @@ -404,3 +404,24 @@ def ensure_options(options: List[str]) -> None: You are missing the following option{"s" if len(missing_options) > 1 else ""}: {''.join(help_formatter.buffer)} """.strip()) + +def backtest_parameter_option(func: FC) -> FC: + """Decorator that adds the --parameter option to Click commands. + + This decorator can be used with both cloud and local backtest commands + to add support for passing parameters via command line. + + Example usage: + @parameter_option + def backtest(...): + ... + """ + func = option( + "--parameter", + type=(str, str), + multiple=True, + help="Key-value pairs to pass as backtest parameters. " + "Values can be string, int, or float.\n" + "Example: --parameter symbol AAPL --parameter period 10 --parameter threshold 0.05" + )(func) + return func \ No newline at end of file diff --git a/lean/commands/backtest.py b/lean/commands/backtest.py index 82f53efd..98d6fb3e 100644 --- a/lean/commands/backtest.py +++ b/lean/commands/backtest.py @@ -15,7 +15,7 @@ from typing import List, Optional, Tuple from click import command, option, argument, Choice -from lean.click import LeanCommand, PathParameter +from lean.click import LeanCommand, PathParameter, backtest_parameter_option from lean.constants import DEFAULT_ENGINE_IMAGE, LEAN_ROOT_PATH from lean.container import container, Logger from lean.models.utils import DebuggingMethod @@ -282,6 +282,7 @@ def _migrate_csharp_csproj(project_dir: Path) -> None: is_flag=True, default=False, help="Use the local LEAN engine image instead of pulling the latest version") +@backtest_parameter_option def backtest(project: Path, output: Optional[Path], detach: bool, @@ -298,6 +299,7 @@ def backtest(project: Path, extra_config: Optional[Tuple[str, str]], extra_docker_config: Optional[str], no_update: bool, + parameter: List[Tuple[str, str]], **kwargs) -> None: """Backtest a project locally using Docker. @@ -396,6 +398,10 @@ def backtest(project: Path, build_and_configure_modules(addon_module, cli_addon_modules, organization_id, lean_config, kwargs, logger, environment_name, container_module_version) + if parameter: + # Override existing parameters if any are provided via --parameter + lean_config["parameters"] = lean_config_manager.get_parameters(parameter) + lean_runner = container.lean_runner lean_runner.run_lean(lean_config, environment_name, diff --git a/lean/commands/cloud/backtest.py b/lean/commands/cloud/backtest.py index 415d435b..15238b68 100644 --- a/lean/commands/cloud/backtest.py +++ b/lean/commands/cloud/backtest.py @@ -11,9 +11,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Optional +from typing import List, Optional, Tuple from click import command, argument, option -from lean.click import LeanCommand +from lean.click import LeanCommand, backtest_parameter_option from lean.container import container @command(cls=LeanCommand) @@ -27,7 +27,8 @@ is_flag=True, default=False, help="Automatically open the results in the browser when the backtest is finished") -def backtest(project: str, name: Optional[str], push: bool, open_browser: bool) -> None: +@backtest_parameter_option +def backtest(project: str, name: Optional[str], push: bool, open_browser: bool, parameter: List[Tuple[str, str]]) -> None: """Backtest a project in the cloud. PROJECT must be the name or id of the project to run a backtest for. @@ -54,8 +55,9 @@ def backtest(project: str, name: Optional[str], push: bool, open_browser: bool) if name is None: name = container.name_generator.generate_name() + parameters = container.lean_config_manager.get_parameters(parameter) cloud_runner = container.cloud_runner - finished_backtest = cloud_runner.run_backtest(cloud_project, name) + finished_backtest = cloud_runner.run_backtest(cloud_project, name, parameters) if finished_backtest.error is None and finished_backtest.stacktrace is None: logger.info(finished_backtest.get_statistics_table()) diff --git a/lean/components/api/backtest_client.py b/lean/components/api/backtest_client.py index 1f3abc6d..5a662911 100644 --- a/lean/components/api/backtest_client.py +++ b/lean/components/api/backtest_client.py @@ -39,21 +39,27 @@ def get(self, project_id: int, backtest_id: str) -> QCBacktest: return QCBacktest(**data["backtest"]) - def create(self, project_id: int, compile_id: str, name: str) -> QCBacktest: + def create(self, project_id: int, compile_id: str, name: str, parameters: Dict[str, str] = None) -> QCBacktest: """Creates a new backtest. :param project_id: the id of the project to create a backtest for :param compile_id: the id of a compilation of the given project :param name: the name of the new backtest + :param parameters: optional key-value parameters for the backtest :return: the created backtest """ from lean import __version__ - data = self._api.post("backtests/create", { + payload = { "projectId": project_id, "compileId": compile_id, "backtestName": name, "requestSource": f"CLI {__version__}" - }) + } + + if parameters: + payload["parameters"] = parameters + + data = self._api.post("backtests/create", payload) return QCBacktest(**data["backtest"]) diff --git a/lean/components/cloud/cloud_runner.py b/lean/components/cloud/cloud_runner.py index 8aa939be..d0fe47c4 100644 --- a/lean/components/cloud/cloud_runner.py +++ b/lean/components/cloud/cloud_runner.py @@ -11,7 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import List +from typing import List, Dict from click import confirm @@ -75,15 +75,16 @@ def is_backtest_done(self, backtest_data: QCBacktest, delay: float = 10.0): self._logger.error(f"Error checking backtest completion status for ID {backtest_data.backtestId}: {e}") raise - def run_backtest(self, project: QCProject, name: str) -> QCBacktest: + def run_backtest(self, project: QCProject, name: str, parameters: Dict[str, str] = None) -> QCBacktest: """Runs a backtest in the cloud. :param project: the project to backtest :param name: the name of the backtest + :param parameters: optional key-value parameters for the backtest :return: the completed backtest """ finished_compile = self.compile_project(project) - created_backtest = self._api_client.backtests.create(project.projectId, finished_compile.compileId, name) + created_backtest = self._api_client.backtests.create(project.projectId, finished_compile.compileId, name, parameters) self._logger.info(f"Started backtest named '{name}' for project '{project.name}'") self._logger.info(f"Backtest url: {created_backtest.get_url()}") diff --git a/lean/components/config/lean_config_manager.py b/lean/components/config/lean_config_manager.py index 6c579227..2eb3616c 100644 --- a/lean/components/config/lean_config_manager.py +++ b/lean/components/config/lean_config_manager.py @@ -13,8 +13,7 @@ from os.path import normcase, normpath from pathlib import Path -from typing import Any, Dict, Optional, List - +from typing import Any, Dict, Optional, List, Tuple from lean.components.cloud.module_manager import ModuleManager from lean.components.config.cli_config_manager import CLIConfigManager @@ -353,3 +352,10 @@ def parse_json(self, content) -> Dict[str, Any]: # just in case slower fallback from json5 import loads return loads(content) + + def get_parameters(self, parameters: List[Tuple[str, str]]) -> Dict[str, str]: + """Convert a list of (key, value) pairs into a dictionary.""" + params_dict = dict(parameters) + if parameters: + self._logger.debug(f"Using parameters: {params_dict}") + return params_dict diff --git a/tests/commands/cloud/test_backtest.py b/tests/commands/cloud/test_backtest.py index 7770c1ed..30402c92 100644 --- a/tests/commands/cloud/test_backtest.py +++ b/tests/commands/cloud/test_backtest.py @@ -53,7 +53,7 @@ def test_cloud_backtest_runs_project_by_id() -> None: assert result.exit_code == 0 - cloud_runner.run_backtest.assert_called_once_with(project, mock.ANY) + cloud_runner.run_backtest.assert_called_once_with(project, mock.ANY, mock.ANY) def test_cloud_backtest_runs_project_by_name() -> None: @@ -73,7 +73,7 @@ def test_cloud_backtest_runs_project_by_name() -> None: assert result.exit_code == 0 - cloud_runner.run_backtest.assert_called_once_with(project, mock.ANY) + cloud_runner.run_backtest.assert_called_once_with(project, mock.ANY, mock.ANY) def test_cloud_backtest_uses_given_name() -> None: @@ -240,3 +240,38 @@ def test_cloud_backtest_aborts_when_input_matches_no_cloud_project() -> None: assert result.exit_code != 0 cloud_runner.run_backtest.assert_not_called() + + +def test_cloud_backtest_with_parameters() -> None: + create_fake_lean_cli_directory() + + project = create_api_project(1, "My Project") + backtest = create_api_backtest() + + api_client = mock.Mock() + api_client.projects.get_all.return_value = [project] + + cloud_runner = mock.Mock() + cloud_runner.run_backtest.return_value = backtest + initialize_container(api_client_to_use=api_client, cloud_runner_to_use=cloud_runner) + + # Run cloud backtest with --parameter option + result = CliRunner().invoke(lean, [ + "cloud", "backtest", "My Project", + "--parameter", "integer", "123", + "--parameter", "float", "456.789", + "--parameter", "string", "hello world", + "--parameter", "negative", "-42.5" + ]) + + assert result.exit_code == 0 + + cloud_runner.run_backtest.assert_called_once() + args, _ = cloud_runner.run_backtest.call_args + parameters = args[2] + + # --parameter values should be parsed correctly + assert parameters["integer"] == "123" + assert parameters["float"] == "456.789" + assert parameters["string"] == "hello world" + assert parameters["negative"] == "-42.5" diff --git a/tests/commands/test_backtest.py b/tests/commands/test_backtest.py index e52461f1..0d258571 100644 --- a/tests/commands/test_backtest.py +++ b/tests/commands/test_backtest.py @@ -727,3 +727,85 @@ def test_backtest_calls_lean_runner_with_paths_to_mount() -> None: False, {}, {"some-config": "/path/to/file.json"}) + + +def test_backtest_with_parameters() -> None: + create_fake_lean_cli_directory() + + # Run backtest with --parameter option + result = CliRunner().invoke(lean, [ + "backtest", "Python Project", + "--parameter", "integer", "123", + "--parameter", "float", "456.789", + "--parameter", "string", "hello world", + "--parameter", "negative", "-42.5" + ]) + + assert result.exit_code == 0 + + container.lean_runner.run_lean.assert_called_once() + args, _ = container.lean_runner.run_lean.call_args + + lean_config = args[0] + parameters = lean_config["parameters"] + + # --parameter values should be parsed correctly + assert parameters["integer"] == "123" + assert parameters["float"] == "456.789" + assert parameters["string"] == "hello world" + assert parameters["negative"] == "-42.5" + + +def test_backtest_parameters_override_config_json() -> None: + create_fake_lean_cli_directory() + + # Add parameters in config.json + project_config_path = Path.cwd() / "Python Project" / "config.json" + current_content = project_config_path.read_text(encoding="utf-8") + config_dict = json.loads(current_content) + config_dict["parameters"] = { + "param1": 789, + "param2": 789.12 + } + project_config_path.write_text(json.dumps(config_dict, indent=4)) + + # Run backtest without --parameter -> uses config.json parameters + result = CliRunner().invoke(lean, [ + "backtest", "Python Project", + ]) + + assert result.exit_code == 0 + assert container.lean_runner.run_lean.call_count == 1 + + args, _ = container.lean_runner.run_lean.call_args + + lean_config = args[0] + parameters = lean_config["parameters"] + + # parameters from config.json should be used + assert parameters["param1"] == 789 + assert parameters["param2"] == 789.12 + + # Run backtest with --parameter -> should override config.json + result = CliRunner().invoke(lean, [ + "backtest", "Python Project", + "--parameter", "integer", "123", + "--parameter", "float", "456.789", + "--parameter", "string", "hello world", + "--parameter", "negative", "-42.5" + ]) + + assert result.exit_code == 0 + assert container.lean_runner.run_lean.call_count == 2 + + args, _ = container.lean_runner.run_lean.call_args + lean_config = args[0] + parameters = lean_config["parameters"] + + # Only CLI --parameter values should remain + assert "param1" not in parameters + assert "param2" not in parameters + assert parameters["integer"] == "123" + assert parameters["float"] == "456.789" + assert parameters["string"] == "hello world" + assert parameters["negative"] == "-42.5"