Skip to content

Commit 79e421b

Browse files
authored
Add --parameter option to backtest commands (#596)
* Add --parameter option to backtest commands * Solve review comments * Update README.md * Add reusable --parameter decorator * Resolve review comments * Centralize logs in a single location
1 parent e1c8cb0 commit 79e421b

File tree

9 files changed

+183
-20
lines changed

9 files changed

+183
-20
lines changed

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ Options:
253253
--extra-docker-config TEXT Extra docker configuration as a JSON string. For more information https://docker-
254254
py.readthedocs.io/en/stable/containers.html
255255
--no-update Use the local LEAN engine image instead of pulling the latest version
256+
--parameter <TEXT TEXT>... Key-value pairs to pass as backtest parameters. Values can be string, int, or float.
257+
Example: --parameter symbol AAPL --parameter period 10 --parameter threshold 0.05
256258
--lean-config FILE The Lean configuration file that should be used (defaults to the nearest lean.json)
257259
--verbose Enable debug logging
258260
--help Show this message and exit.
@@ -308,11 +310,13 @@ Usage: lean cloud backtest [OPTIONS] PROJECT
308310
use the --push option to push local modifications to the cloud before running the backtest.
309311
310312
Options:
311-
--name TEXT The name of the backtest (a random one is generated if not specified)
312-
--push Push local modifications to the cloud before running the backtest
313-
--open Automatically open the results in the browser when the backtest is finished
314-
--verbose Enable debug logging
315-
--help Show this message and exit.
313+
--name TEXT The name of the backtest (a random one is generated if not specified)
314+
--push Push local modifications to the cloud before running the backtest
315+
--open Automatically open the results in the browser when the backtest is finished
316+
--parameter <TEXT TEXT>... Key-value pairs to pass as backtest parameters. Values can be string, int, or float.
317+
Example: --parameter symbol AAPL --parameter period 10 --parameter threshold 0.05
318+
--verbose Enable debug logging
319+
--help Show this message and exit.
316320
```
317321

318322
_See code: [lean/commands/cloud/backtest.py](lean/commands/cloud/backtest.py)_

lean/click.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,3 +404,24 @@ def ensure_options(options: List[str]) -> None:
404404
You are missing the following option{"s" if len(missing_options) > 1 else ""}:
405405
{''.join(help_formatter.buffer)}
406406
""".strip())
407+
408+
def backtest_parameter_option(func: FC) -> FC:
409+
"""Decorator that adds the --parameter option to Click commands.
410+
411+
This decorator can be used with both cloud and local backtest commands
412+
to add support for passing parameters via command line.
413+
414+
Example usage:
415+
@parameter_option
416+
def backtest(...):
417+
...
418+
"""
419+
func = option(
420+
"--parameter",
421+
type=(str, str),
422+
multiple=True,
423+
help="Key-value pairs to pass as backtest parameters. "
424+
"Values can be string, int, or float.\n"
425+
"Example: --parameter symbol AAPL --parameter period 10 --parameter threshold 0.05"
426+
)(func)
427+
return func

lean/commands/backtest.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from typing import List, Optional, Tuple
1616
from click import command, option, argument, Choice
1717

18-
from lean.click import LeanCommand, PathParameter
18+
from lean.click import LeanCommand, PathParameter, backtest_parameter_option
1919
from lean.constants import DEFAULT_ENGINE_IMAGE, LEAN_ROOT_PATH
2020
from lean.container import container, Logger
2121
from lean.models.utils import DebuggingMethod
@@ -282,6 +282,7 @@ def _migrate_csharp_csproj(project_dir: Path) -> None:
282282
is_flag=True,
283283
default=False,
284284
help="Use the local LEAN engine image instead of pulling the latest version")
285+
@backtest_parameter_option
285286
def backtest(project: Path,
286287
output: Optional[Path],
287288
detach: bool,
@@ -298,6 +299,7 @@ def backtest(project: Path,
298299
extra_config: Optional[Tuple[str, str]],
299300
extra_docker_config: Optional[str],
300301
no_update: bool,
302+
parameter: List[Tuple[str, str]],
301303
**kwargs) -> None:
302304
"""Backtest a project locally using Docker.
303305
@@ -396,6 +398,10 @@ def backtest(project: Path,
396398
build_and_configure_modules(addon_module, cli_addon_modules, organization_id, lean_config,
397399
kwargs, logger, environment_name, container_module_version)
398400

401+
if parameter:
402+
# Override existing parameters if any are provided via --parameter
403+
lean_config["parameters"] = lean_config_manager.get_parameters(parameter)
404+
399405
lean_runner = container.lean_runner
400406
lean_runner.run_lean(lean_config,
401407
environment_name,

lean/commands/cloud/backtest.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
# See the License for the specific language governing permissions and
1212
# limitations under the License.
1313

14-
from typing import Optional
14+
from typing import List, Optional, Tuple
1515
from click import command, argument, option
16-
from lean.click import LeanCommand
16+
from lean.click import LeanCommand, backtest_parameter_option
1717
from lean.container import container
1818

1919
@command(cls=LeanCommand)
@@ -27,7 +27,8 @@
2727
is_flag=True,
2828
default=False,
2929
help="Automatically open the results in the browser when the backtest is finished")
30-
def backtest(project: str, name: Optional[str], push: bool, open_browser: bool) -> None:
30+
@backtest_parameter_option
31+
def backtest(project: str, name: Optional[str], push: bool, open_browser: bool, parameter: List[Tuple[str, str]]) -> None:
3132
"""Backtest a project in the cloud.
3233
3334
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)
5455
if name is None:
5556
name = container.name_generator.generate_name()
5657

58+
parameters = container.lean_config_manager.get_parameters(parameter)
5759
cloud_runner = container.cloud_runner
58-
finished_backtest = cloud_runner.run_backtest(cloud_project, name)
60+
finished_backtest = cloud_runner.run_backtest(cloud_project, name, parameters)
5961

6062
if finished_backtest.error is None and finished_backtest.stacktrace is None:
6163
logger.info(finished_backtest.get_statistics_table())

lean/components/api/backtest_client.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,27 @@ def get(self, project_id: int, backtest_id: str) -> QCBacktest:
3939

4040
return QCBacktest(**data["backtest"])
4141

42-
def create(self, project_id: int, compile_id: str, name: str) -> QCBacktest:
42+
def create(self, project_id: int, compile_id: str, name: str, parameters: Dict[str, str] = None) -> QCBacktest:
4343
"""Creates a new backtest.
4444
4545
:param project_id: the id of the project to create a backtest for
4646
:param compile_id: the id of a compilation of the given project
4747
:param name: the name of the new backtest
48+
:param parameters: optional key-value parameters for the backtest
4849
:return: the created backtest
4950
"""
5051
from lean import __version__
51-
data = self._api.post("backtests/create", {
52+
payload = {
5253
"projectId": project_id,
5354
"compileId": compile_id,
5455
"backtestName": name,
5556
"requestSource": f"CLI {__version__}"
56-
})
57+
}
58+
59+
if parameters:
60+
payload["parameters"] = parameters
61+
62+
data = self._api.post("backtests/create", payload)
5763

5864
return QCBacktest(**data["backtest"])
5965

lean/components/cloud/cloud_runner.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
# See the License for the specific language governing permissions and
1212
# limitations under the License.
1313

14-
from typing import List
14+
from typing import List, Dict
1515

1616
from click import confirm
1717

@@ -75,15 +75,16 @@ def is_backtest_done(self, backtest_data: QCBacktest, delay: float = 10.0):
7575
self._logger.error(f"Error checking backtest completion status for ID {backtest_data.backtestId}: {e}")
7676
raise
7777

78-
def run_backtest(self, project: QCProject, name: str) -> QCBacktest:
78+
def run_backtest(self, project: QCProject, name: str, parameters: Dict[str, str] = None) -> QCBacktest:
7979
"""Runs a backtest in the cloud.
8080
8181
:param project: the project to backtest
8282
:param name: the name of the backtest
83+
:param parameters: optional key-value parameters for the backtest
8384
:return: the completed backtest
8485
"""
8586
finished_compile = self.compile_project(project)
86-
created_backtest = self._api_client.backtests.create(project.projectId, finished_compile.compileId, name)
87+
created_backtest = self._api_client.backtests.create(project.projectId, finished_compile.compileId, name, parameters)
8788

8889
self._logger.info(f"Started backtest named '{name}' for project '{project.name}'")
8990
self._logger.info(f"Backtest url: {created_backtest.get_url()}")

lean/components/config/lean_config_manager.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313

1414
from os.path import normcase, normpath
1515
from pathlib import Path
16-
from typing import Any, Dict, Optional, List
17-
16+
from typing import Any, Dict, Optional, List, Tuple
1817

1918
from lean.components.cloud.module_manager import ModuleManager
2019
from lean.components.config.cli_config_manager import CLIConfigManager
@@ -353,3 +352,10 @@ def parse_json(self, content) -> Dict[str, Any]:
353352
# just in case slower fallback
354353
from json5 import loads
355354
return loads(content)
355+
356+
def get_parameters(self, parameters: List[Tuple[str, str]]) -> Dict[str, str]:
357+
"""Convert a list of (key, value) pairs into a dictionary."""
358+
params_dict = dict(parameters)
359+
if parameters:
360+
self._logger.debug(f"Using parameters: {params_dict}")
361+
return params_dict

tests/commands/cloud/test_backtest.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def test_cloud_backtest_runs_project_by_id() -> None:
5353

5454
assert result.exit_code == 0
5555

56-
cloud_runner.run_backtest.assert_called_once_with(project, mock.ANY)
56+
cloud_runner.run_backtest.assert_called_once_with(project, mock.ANY, mock.ANY)
5757

5858

5959
def test_cloud_backtest_runs_project_by_name() -> None:
@@ -73,7 +73,7 @@ def test_cloud_backtest_runs_project_by_name() -> None:
7373

7474
assert result.exit_code == 0
7575

76-
cloud_runner.run_backtest.assert_called_once_with(project, mock.ANY)
76+
cloud_runner.run_backtest.assert_called_once_with(project, mock.ANY, mock.ANY)
7777

7878

7979
def test_cloud_backtest_uses_given_name() -> None:
@@ -240,3 +240,38 @@ def test_cloud_backtest_aborts_when_input_matches_no_cloud_project() -> None:
240240
assert result.exit_code != 0
241241

242242
cloud_runner.run_backtest.assert_not_called()
243+
244+
245+
def test_cloud_backtest_with_parameters() -> None:
246+
create_fake_lean_cli_directory()
247+
248+
project = create_api_project(1, "My Project")
249+
backtest = create_api_backtest()
250+
251+
api_client = mock.Mock()
252+
api_client.projects.get_all.return_value = [project]
253+
254+
cloud_runner = mock.Mock()
255+
cloud_runner.run_backtest.return_value = backtest
256+
initialize_container(api_client_to_use=api_client, cloud_runner_to_use=cloud_runner)
257+
258+
# Run cloud backtest with --parameter option
259+
result = CliRunner().invoke(lean, [
260+
"cloud", "backtest", "My Project",
261+
"--parameter", "integer", "123",
262+
"--parameter", "float", "456.789",
263+
"--parameter", "string", "hello world",
264+
"--parameter", "negative", "-42.5"
265+
])
266+
267+
assert result.exit_code == 0
268+
269+
cloud_runner.run_backtest.assert_called_once()
270+
args, _ = cloud_runner.run_backtest.call_args
271+
parameters = args[2]
272+
273+
# --parameter values should be parsed correctly
274+
assert parameters["integer"] == "123"
275+
assert parameters["float"] == "456.789"
276+
assert parameters["string"] == "hello world"
277+
assert parameters["negative"] == "-42.5"

tests/commands/test_backtest.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,3 +727,85 @@ def test_backtest_calls_lean_runner_with_paths_to_mount() -> None:
727727
False,
728728
{},
729729
{"some-config": "/path/to/file.json"})
730+
731+
732+
def test_backtest_with_parameters() -> None:
733+
create_fake_lean_cli_directory()
734+
735+
# Run backtest with --parameter option
736+
result = CliRunner().invoke(lean, [
737+
"backtest", "Python Project",
738+
"--parameter", "integer", "123",
739+
"--parameter", "float", "456.789",
740+
"--parameter", "string", "hello world",
741+
"--parameter", "negative", "-42.5"
742+
])
743+
744+
assert result.exit_code == 0
745+
746+
container.lean_runner.run_lean.assert_called_once()
747+
args, _ = container.lean_runner.run_lean.call_args
748+
749+
lean_config = args[0]
750+
parameters = lean_config["parameters"]
751+
752+
# --parameter values should be parsed correctly
753+
assert parameters["integer"] == "123"
754+
assert parameters["float"] == "456.789"
755+
assert parameters["string"] == "hello world"
756+
assert parameters["negative"] == "-42.5"
757+
758+
759+
def test_backtest_parameters_override_config_json() -> None:
760+
create_fake_lean_cli_directory()
761+
762+
# Add parameters in config.json
763+
project_config_path = Path.cwd() / "Python Project" / "config.json"
764+
current_content = project_config_path.read_text(encoding="utf-8")
765+
config_dict = json.loads(current_content)
766+
config_dict["parameters"] = {
767+
"param1": 789,
768+
"param2": 789.12
769+
}
770+
project_config_path.write_text(json.dumps(config_dict, indent=4))
771+
772+
# Run backtest without --parameter -> uses config.json parameters
773+
result = CliRunner().invoke(lean, [
774+
"backtest", "Python Project",
775+
])
776+
777+
assert result.exit_code == 0
778+
assert container.lean_runner.run_lean.call_count == 1
779+
780+
args, _ = container.lean_runner.run_lean.call_args
781+
782+
lean_config = args[0]
783+
parameters = lean_config["parameters"]
784+
785+
# parameters from config.json should be used
786+
assert parameters["param1"] == 789
787+
assert parameters["param2"] == 789.12
788+
789+
# Run backtest with --parameter -> should override config.json
790+
result = CliRunner().invoke(lean, [
791+
"backtest", "Python Project",
792+
"--parameter", "integer", "123",
793+
"--parameter", "float", "456.789",
794+
"--parameter", "string", "hello world",
795+
"--parameter", "negative", "-42.5"
796+
])
797+
798+
assert result.exit_code == 0
799+
assert container.lean_runner.run_lean.call_count == 2
800+
801+
args, _ = container.lean_runner.run_lean.call_args
802+
lean_config = args[0]
803+
parameters = lean_config["parameters"]
804+
805+
# Only CLI --parameter values should remain
806+
assert "param1" not in parameters
807+
assert "param2" not in parameters
808+
assert parameters["integer"] == "123"
809+
assert parameters["float"] == "456.789"
810+
assert parameters["string"] == "hello world"
811+
assert parameters["negative"] == "-42.5"

0 commit comments

Comments
 (0)