Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <TEXT TEXT>... 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.
Expand Down Expand Up @@ -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 <TEXT TEXT>... 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)_
Expand Down
21 changes: 21 additions & 0 deletions lean/click.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion lean/commands/backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.

Expand Down Expand Up @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions lean/commands/cloud/backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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())
Expand Down
12 changes: 9 additions & 3 deletions lean/components/api/backtest_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
7 changes: 4 additions & 3 deletions lean/components/cloud/cloud_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()}")
Expand Down
10 changes: 8 additions & 2 deletions lean/components/config/lean_config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
39 changes: 37 additions & 2 deletions tests/commands/cloud/test_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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"
82 changes: 82 additions & 0 deletions tests/commands/test_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading