diff --git a/README.md b/README.md index 035dd2eb..a1cf986b 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ Usage: lean cloud live deploy [OPTIONS] PROJECT --notify-insights. Options: - --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Pro|Binance|Zerodha|Samco|Kraken|FTX] + --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Pro|Binance|Zerodha|Samco|Trading Technologies|Kraken|FTX] The brokerage to use --ib-user-name TEXT Your Interactive Brokers username --ib-account TEXT Your Interactive Brokers account id @@ -288,6 +288,25 @@ Options: --samco-trading-segment [equity|commodity] EQUITY if you are trading equities on NSE or BSE, COMMODITY if you are trading commodities on MCX + --tt-user-name TEXT Your Trading Technologies username + --tt-session-password TEXT Your Trading Technologies session password + --tt-account-name TEXT Your Trading Technologies account name + --tt-rest-app-key TEXT Your Trading Technologies REST app key + --tt-rest-app-secret TEXT Your Trading Technologies REST app secret + --tt-rest-environment TEXT The REST environment to run in + --tt-market-data-sender-comp-id TEXT + The market data sender comp id to use + --tt-market-data-target-comp-id TEXT + The market data target comp id to use + --tt-market-data-host TEXT The host of the market data server + --tt-market-data-port TEXT The port of the market data server + --tt-order-routing-sender-comp-id TEXT + The order routing sender comp id to use + --tt-order-routing-target-comp-id TEXT + The order routing target comp id to use + --tt-order-routing-host TEXT The host of the order routing server + --tt-order-routing-port TEXT The port of the order routing server + --tt-log-fix-messages BOOLEAN Whether FIX messages should be logged --kraken-api-key TEXT Your Kraken API key --kraken-api-secret TEXT Your Kraken API secret --kraken-verification-tier [Starter|Intermediate|Pro] @@ -312,6 +331,7 @@ Options: --notify-sms TEXT A comma-separated list of phone numbers configuring SMS-notifications --notify-telegram TEXT A comma-separated list of 'user/group Id:token(optional)' pairs configuring telegram- notifications + --live-cash-balance TEXT A comma-separated list of currency:amount pairs of initial cash balance --push Push local modifications to the cloud before starting live trading --open Automatically open the live results in the browser once the deployment starts --verbose Enable debug logging @@ -979,6 +999,7 @@ Options: --release Compile C# projects in release configuration instead of debug --image TEXT The LEAN engine image to use (defaults to quantconnect/lean:latest) --python-venv TEXT The path of the python virtual environment to be used + --live-cash-balance TEXT A comma-separated list of currency:amount pairs of initial cash balance --update Pull the LEAN engine image before starting live trading --lean-config FILE The Lean configuration file that should be used (defaults to the nearest lean.json) --verbose Enable debug logging diff --git a/lean/commands/cloud/live/deploy.py b/lean/commands/cloud/live/deploy.py index 7a2b0f9e..551eceaa 100644 --- a/lean/commands/cloud/live/deploy.py +++ b/lean/commands/cloud/live/deploy.py @@ -12,7 +12,7 @@ # limitations under the License. import webbrowser -from typing import Dict, List, Tuple, Optional +from typing import List, Tuple, Optional import click from lean.click import LeanCommand, ensure_options from lean.components.api.api_client import APIClient @@ -20,12 +20,14 @@ from lean.container import container from lean.models.api import (QCEmailNotificationMethod, QCNode, QCNotificationMethod, QCSMSNotificationMethod, QCWebhookNotificationMethod, QCTelegramNotificationMethod, QCProject) +from lean.models.json_module import LiveCashBalanceInput from lean.models.logger import Option from lean.models.brokerages.cloud.cloud_brokerage import CloudBrokerage -from lean.models.configuration import Configuration, InfoConfiguration, InternalInputUserInput, OrganzationIdConfiguration +from lean.models.configuration import InternalInputUserInput, OrganzationIdConfiguration from lean.models.click_options import options_from_json from lean.models.brokerages.cloud import all_cloud_brokerages from lean.commands.cloud.live.live import live +from lean.components.util.live_utils import _get_configs_for_options, get_latest_cash_state, configure_initial_cash_balance def _log_notification_methods(methods: List[QCNotificationMethod]) -> None: """Logs a list of notification methods.""" @@ -160,22 +162,13 @@ def _configure_auto_restart(logger: Logger) -> bool: logger.info("This can help improve its resilience to temporary errors such as a brokerage API disconnection") return click.confirm("Do you want to enable automatic algorithm restarting?", default=True) -#TODO: same duplication present in commands\live.py -def _get_configs_for_options() -> Dict[Configuration, str]: - run_options: Dict[str, Configuration] = {} - for module in all_cloud_brokerages: - for config in module.get_all_input_configs([InternalInputUserInput, InfoConfiguration]): - if config._id in run_options: - raise ValueError(f'Options names should be unique. Duplicate key present: {config._id}') - run_options[config._id] = config - return list(run_options.values()) @live.command(cls=LeanCommand, default_command=True, name="deploy") @click.argument("project", type=str) @click.option("--brokerage", type=click.Choice([b.get_name() for b in all_cloud_brokerages], case_sensitive=False), help="The brokerage to use") -@options_from_json(_get_configs_for_options()) +@options_from_json(_get_configs_for_options("cloud")) @click.option("--node", type=str, help="The name or id of the live node to run on") @click.option("--auto-restart", type=bool, help="Whether automatic algorithm restarting must be enabled") @click.option("--notify-order-events", type=bool, help="Whether notifications must be sent for order events") @@ -188,6 +181,9 @@ def _get_configs_for_options() -> Dict[Configuration, str]: help="A comma-separated list of 'url:HEADER_1=VALUE_1:HEADER_2=VALUE_2:etc' pairs configuring webhook-notifications") @click.option("--notify-sms", type=str, help="A comma-separated list of phone numbers configuring SMS-notifications") @click.option("--notify-telegram", type=str, help="A comma-separated list of 'user/group Id:token(optional)' pairs configuring telegram-notifications") +@click.option("--live-cash-balance", + type=str, + help=f"A comma-separated list of currency:amount pairs of initial cash balance") @click.option("--push", is_flag=True, default=False, @@ -206,6 +202,7 @@ def deploy(project: str, notify_webhooks: Optional[str], notify_sms: Optional[str], notify_telegram: Optional[str], + live_cash_balance: Optional[str], push: bool, open_browser: bool, **kwargs) -> None: @@ -289,6 +286,13 @@ def deploy(project: str, brokerage_settings = brokerage_instance.get_settings() price_data_handler = brokerage_instance.get_price_data_handler() + cash_balance_option = brokerage_instance._initial_cash_balance + if cash_balance_option != LiveCashBalanceInput.NotSupported: + previous_cash_state = get_latest_cash_state(api_client, cloud_project.projectId, project) + live_cash_balance = configure_initial_cash_balance(logger, cash_balance_option, live_cash_balance, previous_cash_state) + elif live_cash_balance is not None and live_cash_balance != "": + raise RuntimeError(f"Custom cash balance setting is not available for {brokerage_instance.get_name()}") + logger.info(f"Brokerage: {brokerage_instance.get_name()}") logger.info(f"Project id: {cloud_project.projectId}") logger.info(f"Environment: {brokerage_settings['environment'].title()}") @@ -300,6 +304,8 @@ def deploy(project: str, logger.info(f"Insight notifications: {'Yes' if notify_insights else 'No'}") if notify_order_events or notify_insights: _log_notification_methods(notify_methods) + if live_cash_balance: + logger.info(f"Initial live cash balance: {live_cash_balance}") logger.info(f"Automatic algorithm restarting: {'Yes' if auto_restart else 'No'}") if brokerage is None: @@ -316,7 +322,8 @@ def deploy(project: str, cloud_project.leanVersionId, notify_order_events, notify_insights, - notify_methods) + notify_methods, + live_cash_balance) logger.info(f"Live url: {live_algorithm.get_url()}") diff --git a/lean/commands/live/deploy.py b/lean/commands/live/deploy.py index fdcb7c91..e66948c5 100644 --- a/lean/commands/live/deploy.py +++ b/lean/commands/live/deploy.py @@ -15,6 +15,7 @@ import subprocess import time from datetime import datetime +import json from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import click @@ -25,10 +26,11 @@ from lean.models.errors import MoreInfoError from lean.models.lean_config_configurer import LeanConfigConfigurer from lean.models.logger import Option -from lean.models.configuration import Configuration, InfoConfiguration, InternalInputUserInput, OrganzationIdConfiguration +from lean.models.configuration import InternalInputUserInput, OrganzationIdConfiguration from lean.models.click_options import options_from_json -from lean.models.json_module import JsonModule +from lean.models.json_module import JsonModule, LiveCashBalanceInput from lean.commands.live.live import live +from lean.components.util.live_utils import _get_configs_for_options, get_latest_cash_state, configure_initial_cash_balance from lean.models.data_providers import all_data_providers _environment_skeleton = { @@ -172,7 +174,6 @@ def _configure_lean_config_interactively(lean_config: Dict[str, Any], environmen setattr(data_feed, '_is_installed_and_build', True) data_feed.build(lean_config, logger).configure(lean_config, environment_name) - _cached_organizations = None @@ -262,21 +263,6 @@ def _get_default_value(key: str) -> Optional[Any]: return value -def _get_configs_for_options() -> List[Configuration]: - run_options: Dict[str, Configuration] = {} - config_with_module_id: Dict[str, str] = {} - for module in all_local_brokerages + all_local_data_feeds + all_data_providers: - for config in module.get_all_input_configs([InternalInputUserInput, InfoConfiguration]): - if config._id in run_options: - if (config._id in config_with_module_id - and config_with_module_id[config._id] == module._id): - # config of same module - continue - else: - raise ValueError(f'Options names should be unique. Duplicate key present: {config._id}') - run_options[config._id] = config - config_with_module_id[config._id] = module._id - return list(run_options.values()) @live.command(cls=LeanCommand, requires_lean_config=True, requires_docker=True, default_command=True, name="deploy") @click.argument("project", type=PathParameter(exists=True, file_okay=True, dir_okay=True)) @@ -300,7 +286,7 @@ def _get_configs_for_options() -> List[Configuration]: @click.option("--data-provider", type=click.Choice([dp.get_name() for dp in all_data_providers], case_sensitive=False), help="Update the Lean configuration file to retrieve data from the given provider") -@options_from_json(_get_configs_for_options()) +@options_from_json(_get_configs_for_options("local")) @click.option("--release", is_flag=True, default=False, @@ -311,6 +297,9 @@ def _get_configs_for_options() -> List[Configuration]: @click.option("--python-venv", type=str, help=f"The path of the python virtual environment to be used") +@click.option("--live-cash-balance", + type=str, + help=f"A comma-separated list of currency:amount pairs of initial cash balance") @click.option("--update", is_flag=True, default=False, @@ -325,6 +314,7 @@ def deploy(project: Path, release: bool, image: Optional[str], python_venv: Optional[str], + live_cash_balance: Optional[str], update: bool, **kwargs) -> None: """Start live trading a project locally using Docker. @@ -422,9 +412,19 @@ def deploy(project: Path, output_config_manager = container.output_config_manager() lean_config["algorithm-id"] = f"L-{output_config_manager.get_live_deployment_id(output)}" - + if python_venv is not None and python_venv != "": lean_config["python-venv"] = f'{"/" if python_venv[0] != "/" else ""}{python_venv}' + cash_balance_option = env_brokerage._initial_cash_balance + logger = container.logger() + if cash_balance_option != LiveCashBalanceInput.NotSupported: + previous_cash_state = get_latest_cash_state(container.api_client(), project_config.get("cloud-id", None), project) + live_cash_balance = configure_initial_cash_balance(logger, cash_balance_option, live_cash_balance, previous_cash_state) + if live_cash_balance: + lean_config["live-cash-balance"] = live_cash_balance + elif live_cash_balance is not None and live_cash_balance != "": + raise RuntimeError(f"Custom cash balance setting is not available for {brokerage}") + lean_runner = container.lean_runner() lean_runner.run_lean(lean_config, environment_name, algorithm_file, output, engine_image, None, release, detach) diff --git a/lean/commands/report.py b/lean/commands/report.py index ab2f4c60..6a9cd3ad 100644 --- a/lean/commands/report.py +++ b/lean/commands/report.py @@ -22,6 +22,7 @@ from lean.constants import DEFAULT_ENGINE_IMAGE, PROJECT_CONFIG_FILE_NAME from lean.container import container from lean.models.errors import MoreInfoError +from lean.components.util.live_utils import get_state_json def _find_project_directory(backtest_file: Path) -> Optional[Path]: @@ -107,18 +108,13 @@ def report(backtest_results: Optional[Path], raise RuntimeError(f"{report_destination} already exists, use --overwrite to overwrite it") if backtest_results is None: - backtest_json_files = list(Path.cwd().rglob("backtests/*/*.json")) - result_json_files = [f for f in backtest_json_files if - not f.name.endswith("-order-events.json") and not f.name.endswith("alpha-results.json")] - - if len(result_json_files) == 0: + backtest_results = get_state_json("backtests") + if not backtest_results: raise MoreInfoError( - "Could not find a recent backtest result file, please use the --backtest-results option", - "https://www.lean.io/docs/v2/lean-cli/reports#02-Generate-Reports" + "Could not find a recent backtest result file, please use the --backtest-results option", + "https://www.lean.io/docs/v2/lean-cli/reports#02-Generate-Reports" ) - backtest_results = sorted(result_json_files, key=lambda f: f.stat().st_mtime, reverse=True)[0] - logger = container.logger() if live_results is None: diff --git a/lean/components/api/live_client.py b/lean/components/api/live_client.py index df6689fd..21ee5899 100644 --- a/lean/components/api/live_client.py +++ b/lean/components/api/live_client.py @@ -56,13 +56,14 @@ def start(self, project_id: int, compile_id: str, node_id: str, - brokerage_settings: Dict[str, str], + brokerage_settings: Dict[str, Any], price_data_handler: str, automatic_redeploy: bool, version_id: int, notify_order_events: bool, notify_insights: bool, - notify_methods: List[QCNotificationMethod]) -> QCMinimalLiveAlgorithm: + notify_methods: List[QCNotificationMethod], + live_cash_balance: Optional[List[Dict[str, float]]] = None) -> QCMinimalLiveAlgorithm: """Starts live trading for a project. :param project_id: the id of the project to start live trading for @@ -75,9 +76,13 @@ def start(self, :param notify_order_events: whether notifications should be sent on order events :param notify_insights: whether notifications should be sent on insights :param notify_methods: the places to send notifications to + :param live_cash_balance: the list of initial cash balance :return: the created live algorithm """ + if live_cash_balance: + brokerage_settings["cash"] = live_cash_balance + parameters = { "projectId": project_id, "compileId": compile_id, diff --git a/lean/components/util/live_utils.py b/lean/components/util/live_utils.py new file mode 100644 index 00000000..c0d3eae2 --- /dev/null +++ b/lean/components/util/live_utils.py @@ -0,0 +1,142 @@ +# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. +# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import click +from datetime import datetime +import json +from pathlib import Path +import pytz +import os +from typing import Any, Dict, List +from lean.components.api.api_client import APIClient +from lean.components.util.logger import Logger +from lean.models.brokerages.cloud import all_cloud_brokerages +from lean.models.brokerages.local import all_local_brokerages, all_local_data_feeds +from lean.models.data_providers import all_data_providers +from lean.models.json_module import LiveCashBalanceInput +from lean.models.configuration import Configuration, InfoConfiguration, InternalInputUserInput +from lean.models.lean_config_configurer import LeanConfigConfigurer + +def _get_configs_for_options(env: str) -> List[Configuration]: + if env == "cloud": + brokerage = all_cloud_brokerages + elif env == "local": + brokerage = all_local_brokerages + all_local_data_feeds + all_data_providers + else: + raise ValueError("Only 'cloud' and 'local' are accepted for the argument 'env'") + + run_options: Dict[str, Configuration] = {} + config_with_module_id: Dict[str, str] = {} + for module in brokerage: + for config in module.get_all_input_configs([InternalInputUserInput, InfoConfiguration]): + if config._id in run_options: + if (config._id in config_with_module_id + and config_with_module_id[config._id] == module._id): + # config of same module + continue + else: + raise ValueError(f'Options names should be unique. Duplicate key present: {config._id}') + run_options[config._id] = config + config_with_module_id[config._id] = module._id + return list(run_options.values()) + + +def get_latest_cash_state(api_client: APIClient, project_id: str, project_name: Path) -> List[Dict[str, Any]]: + cloud_deployment_list = api_client.get("live/read") + cloud_deployment_time = [datetime.strptime(instance["launched"], "%Y-%m-%d %H:%M:%S").astimezone(pytz.UTC) for instance in cloud_deployment_list["live"] + if instance["projectId"] == project_id] + cloud_last_time = sorted(cloud_deployment_time, reverse = True)[0] if cloud_deployment_time else pytz.utc.localize(datetime.min) + + local_last_time = pytz.utc.localize(datetime.min) + live_deployment_path = f"{project_name}/live" + if os.path.isdir(live_deployment_path): + local_deployment_time = [datetime.strptime(subdir, "%Y-%m-%d_%H-%M-%S").astimezone().astimezone(pytz.UTC) for subdir in os.listdir(live_deployment_path)] + if local_deployment_time: + local_last_time = sorted(local_deployment_time, reverse = True)[0] + + if cloud_last_time > local_last_time: + last_state = api_client.get("live/read/portfolio", {"projectId": project_id}) + previous_cash_state = last_state["portfolio"]["cash"] if last_state and "cash" in last_state["portfolio"] else None + elif cloud_last_time < local_last_time: + previous_state_file = get_state_json("live") + if not previous_state_file: + return None + previous_portfolio_state = json.loads(open(previous_state_file).read()) + previous_cash_state = previous_portfolio_state["Cash"] if previous_portfolio_state else None + else: + return None + + return previous_cash_state + + +def configure_initial_cash_balance(logger: Logger, cash_input_option: LiveCashBalanceInput, live_cash_balance: str, previous_cash_state: List[Dict[str, Any]])\ + -> List[Dict[str, float]]: + """Interactively configures the intial cash balance. + + :param logger: the logger to use + :param cash_input_option: if the initial cash balance setting is optional/required + :param live_cash_balance: the initial cash balance option input + :param previous_cash_state: the dictionary containing cash balance in previous portfolio state + :return: the list of dictionary containing intial currency and amount information + """ + cash_list = [] + previous_cash_balance = [] + if previous_cash_state: + for cash_state in previous_cash_state.values(): + currency = cash_state["Symbol"] + amount = cash_state["Amount"] + previous_cash_balance.append({"currency": currency, "amount": amount}) + + if live_cash_balance is not None and live_cash_balance != "": + for cash_pair in live_cash_balance.split(","): + currency, amount = cash_pair.split(":") + cash_list.append({"currency": currency, "amount": float(amount)}) + + elif (cash_input_option == LiveCashBalanceInput.Required and not previous_cash_balance)\ + or click.confirm(f"""Previous cash balance: {previous_cash_balance} +Do you want to set a different initial cash balance?""", default=False): + continue_adding = True + + while continue_adding: + logger.info("Setting initial cash balance...") + currency = click.prompt("Currency") + amount = click.prompt("Amount", type=float) + cash_list.append({"currency": currency, "amount": amount}) + logger.info(f"Cash balance: {cash_list}") + + if not click.confirm("Do you want to add more currency?", default=False): + continue_adding = False + + else: + cash_list = previous_cash_balance + + return cash_list + + +def _filter_json_name_backtest(file: Path) -> bool: + return not file.name.endswith("-order-events.json") and not file.name.endswith("alpha-results.json") + + +def _filter_json_name_live(file: Path) -> bool: + return file.name.replace("L-", "", 1).replace(".json", "").isdigit() # The json should have name like "L-1234567890.json" + + +def get_state_json(environment: str) -> str: + json_files = list(Path.cwd().rglob(f"{environment}/*/*.json")) + name_filter = _filter_json_name_backtest if environment == "backtests" else _filter_json_name_live + filtered_json_files = [f for f in json_files if name_filter(f)] + + if len(filtered_json_files) == 0: + return None + + return sorted(filtered_json_files, key=lambda f: f.stat().st_mtime, reverse=True)[0] \ No newline at end of file diff --git a/lean/models/__init__.py b/lean/models/__init__.py index 91ff7619..6ec59451 100644 --- a/lean/models/__init__.py +++ b/lean/models/__init__.py @@ -18,7 +18,7 @@ from pathlib import Path json_modules = {} -file_name = "modules-1.3.json" +file_name = "modules-1.4.json" dirname = os.path.dirname(__file__) file_path = os.path.join(dirname, f'../{file_name}') diff --git a/lean/models/configuration.py b/lean/models/configuration.py index ba1ab62d..b96b77e9 100644 --- a/lean/models/configuration.py +++ b/lean/models/configuration.py @@ -93,6 +93,7 @@ def __init__(self, config_json_object): self._id: str = config_json_object["id"] self._config_type: str = config_json_object["type"] self._value: str = config_json_object["value"] + self._is_cloud_property: bool = "cloud-id" in config_json_object self._is_required_from_user = False self._is_type_configurations_env: bool = type( self) is ConfigurationsEnvConfiguration diff --git a/lean/models/json_module.py b/lean/models/json_module.py index c146a3a1..18c28b39 100644 --- a/lean/models/json_module.py +++ b/lean/models/json_module.py @@ -11,6 +11,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from enum import Enum from typing import Any, Dict, List, Type from lean.components.util.logger import Logger from lean.container import container @@ -35,6 +36,9 @@ def __init__(self, json_module_data: Dict[str, Any]) -> None: self._lean_configs = self.sort_configs() self._is_module_installed: bool = False self._is_installed_and_build: bool = False + self._initial_cash_balance: LiveCashBalanceInput = LiveCashBalanceInput(json_module_data["live-cash-balance-state"]) \ + if "live-cash-balance-state" in json_module_data \ + else None def sort_configs(self) -> List[Configuration]: sorted_configs = [] @@ -163,6 +167,8 @@ def build(self, lean_config: Dict[str, Any], logger: Logger) -> 'JsonModule': continue if type(configuration) is InternalInputUserInput: continue + if self.__class__.__name__ == 'CloudBrokerage' and not configuration._is_cloud_property: + continue if configuration._log_message is not None: logger.info(configuration._log_message.strip()) if configuration.is_type_organization_id: @@ -186,3 +192,9 @@ def build(self, lean_config: Dict[str, Any], logger: Logger) -> 'JsonModule': configuration._id, user_choice) return self + + +class LiveCashBalanceInput(str, Enum): + Required = "required" + Optional = "optional" + NotSupported = "not-supported" \ No newline at end of file diff --git a/tests/commands/cloud/live/test_cloud_live_commands.py b/tests/commands/cloud/live/test_cloud_live_commands.py index 79f09a6a..9fe2509c 100644 --- a/tests/commands/cloud/live/test_cloud_live_commands.py +++ b/tests/commands/cloud/live/test_cloud_live_commands.py @@ -11,7 +11,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - from unittest import mock from click.testing import CliRunner from dependency_injector import providers @@ -21,6 +20,7 @@ from lean.container import container from lean.models.api import QCEmailNotificationMethod, QCWebhookNotificationMethod, QCSMSNotificationMethod, QCTelegramNotificationMethod from tests.test_helpers import create_fake_lean_cli_directory, create_qc_nodes +from tests.commands.test_live import brokerage_required_options def test_cloud_live_stop() -> None: create_fake_lean_cli_directory() @@ -53,6 +53,7 @@ def test_cloud_live_deploy() -> None: api_client = mock.Mock() api_client.nodes.get_all.return_value = create_qc_nodes() + api_client.get.return_value = {'portfolio': {"cash": {}}, 'live': []} container.api_client.override(providers.Object(api_client)) cloud_project_manager = mock.Mock() @@ -62,7 +63,8 @@ def test_cloud_live_deploy() -> None: container.cloud_runner.override(providers.Object(cloud_runner)) result = CliRunner().invoke(lean, ["cloud", "live", "Python Project", "--brokerage", "Paper Trading", "--node", "live", - "--auto-restart", "yes", "--notify-order-events", "no", "--notify-insights", "no"]) + "--auto-restart", "yes", "--notify-order-events", "no", "--notify-insights", "no", + "--live-cash-balance", "USD:100"]) assert result.exit_code == 0 @@ -75,7 +77,8 @@ def test_cloud_live_deploy() -> None: mock.ANY, False, False, - []) + [], + mock.ANY) @pytest.mark.parametrize("notice_method,configs", [("emails", "customAddress:customSubject"), ("emails", "customAddress1:customSubject1,customAddress2:customSubject2"), @@ -96,6 +99,7 @@ def test_cloud_live_deploy_with_notifications(notice_method: str, configs: str) api_client = mock.Mock() api_client.nodes.get_all.return_value = create_qc_nodes() + api_client.get.return_value = {'portfolio': {"cash": {}}, 'live': []} container.api_client.override(providers.Object(api_client)) cloud_project_manager = mock.Mock() @@ -106,7 +110,7 @@ def test_cloud_live_deploy_with_notifications(notice_method: str, configs: str) result = CliRunner().invoke(lean, ["cloud", "live", "Python Project", "--brokerage", "Paper Trading", "--node", "live", "--auto-restart", "yes", "--notify-order-events", "yes", "--notify-insights", "yes", - f"--notify-{notice_method}", configs]) + "--live-cash-balance", "USD:100", f"--notify-{notice_method}", configs]) assert result.exit_code == 0 @@ -151,4 +155,69 @@ def test_cloud_live_deploy_with_notifications(notice_method: str, configs: str) mock.ANY, True, True, - notification) + notification, + mock.ANY) + + +@pytest.mark.parametrize("brokerage,cash", [("Paper Trading", "USD:100"), + ("Paper Trading", "USD:100,EUR:200"), + ("Atreyu", "USD:100"), + ("Trading Technologies", "USD:100"), + ("Binance", "USD:100"), + ("Bitfinex", "USD:100"), + ("FTX", "USD:100"), + ("Coinbase Pro", "USD:100"), + ("Interactive Brokers", "USD:100"), + ("Kraken", "USD:100"), + ("OANDA", "USD:100"), + ("Samco", "USD:100"), + ("Terminal Link", "USD:100"), + ("Tradier", "USD:100"), + ("Zerodha", "USD:100")]) +def test_cloud_live_deploy_with_live_cash_balance(brokerage: str, cash: str) -> None: + create_fake_lean_cli_directory() + + cloud_project_manager = mock.Mock() + container.cloud_project_manager.override(providers.Object(cloud_project_manager)) + + api_client = mock.Mock() + api_client.nodes.get_all.return_value = create_qc_nodes() + api_client.get.return_value = {'live': [], 'portfolio': {}} + container.api_client.override(providers.Object(api_client)) + + cloud_runner = mock.Mock() + container.cloud_runner.override(providers.Object(cloud_runner)) + + options = [] + for key, value in brokerage_required_options[brokerage].items(): + if "organization" not in key: + options.extend([f"--{key}", value]) + + result = CliRunner().invoke(lean, ["cloud", "live", "Python Project", "--brokerage", brokerage, "--live-cash-balance", cash, + "--node", "live", "--auto-restart", "yes", "--notify-order-events", "no", + "--notify-insights", "no", *options]) + + if brokerage not in ["Paper Trading", "Trading Technologies"]: + assert result.exit_code != 0 + api_client.live.start.assert_not_called() + return + + assert result.exit_code == 0 + + cash_pairs = cash.split(",") + if len(cash_pairs) == 2: + cash_list = [{"currency": "USD", "amount": 100}, {"currency": "EUR", "amount": 200}] + else: + cash_list = [{"currency": "USD", "amount": 100}] + + api_client.live.start.assert_called_once_with(mock.ANY, + mock.ANY, + "3", + mock.ANY, + mock.ANY, + True, + mock.ANY, + False, + False, + [], + cash_list) diff --git a/tests/commands/test_live.py b/tests/commands/test_live.py index 93923f4d..757dff57 100644 --- a/tests/commands/test_live.py +++ b/tests/commands/test_live.py @@ -64,22 +64,29 @@ def create_fake_environment(name: str, live_mode: bool) -> None: path.write_text(config, encoding="utf-8") -def test_live_calls_lean_runner_with_correct_algorithm_file() -> None: - # TODO: currently it is not using the live-paper envrionment - create_fake_lean_cli_directory() - create_fake_environment("live-paper", True) - +def _mock_docker_lean_runner(): docker_manager = mock.Mock() container.docker_manager.override(providers.Object(docker_manager)) - lean_runner = mock.Mock() container.lean_runner.override(providers.Object(lean_runner)) + return lean_runner, docker_manager + +def _mock_docker_lean_runner_api(): + lean_runner, docker_manager = _mock_docker_lean_runner() api_client = mock.MagicMock() api_client.organizations.get_all.return_value = [ QCMinimalOrganization(id="abc", name="abc", type="type", ownerName="You", members=1, preferred=True) ] container.api_client.override(providers.Object(api_client)) + return lean_runner, api_client, docker_manager + + +def test_live_calls_lean_runner_with_correct_algorithm_file() -> None: + # TODO: currently it is not using the live-paper envrionment + create_fake_lean_cli_directory() + create_fake_environment("live-paper", True) + lean_runner, _, _ = _mock_docker_lean_runner_api() result = CliRunner().invoke(lean, ["live", "Python Project", "--environment", "live-paper"]) @@ -99,12 +106,7 @@ def test_live_calls_lean_runner_with_correct_algorithm_file() -> None: def test_live_aborts_when_environment_does_not_exist() -> None: create_fake_lean_cli_directory() - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) + lean_runner, _ = _mock_docker_lean_runner() result = CliRunner().invoke(lean, ["live", "Python Project", "--environment", "fake-environment"]) @@ -116,12 +118,7 @@ def test_live_aborts_when_environment_does_not_exist() -> None: def test_live_aborts_when_environment_has_live_mode_set_to_false() -> None: create_fake_lean_cli_directory() create_fake_environment("backtesting", False) - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) + lean_runner, _ = _mock_docker_lean_runner() result = CliRunner().invoke(lean, ["live", "Python Project", "--environment", "backtesting"]) @@ -133,18 +130,7 @@ def test_live_aborts_when_environment_has_live_mode_set_to_false() -> None: def test_live_calls_lean_runner_with_default_output_directory() -> None: create_fake_lean_cli_directory() create_fake_environment("live-paper", True) - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) - - api_client = mock.MagicMock() - api_client.organizations.get_all.return_value = [ - QCMinimalOrganization(id="abc", name="abc", type="type", ownerName="You", members=1, preferred=True) - ] - container.api_client.override(providers.Object(api_client)) + lean_runner, _, _ = _mock_docker_lean_runner_api() result = CliRunner().invoke(lean, ["live", "Python Project", "--environment", "live-paper"]) @@ -160,18 +146,7 @@ def test_live_calls_lean_runner_with_default_output_directory() -> None: def test_live_calls_lean_runner_with_custom_output_directory() -> None: create_fake_lean_cli_directory() create_fake_environment("live-paper", True) - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) - - api_client = mock.MagicMock() - api_client.organizations.get_all.return_value = [ - QCMinimalOrganization(id="abc", name="abc", type="type", ownerName="You", members=1, preferred=True) - ] - container.api_client.override(providers.Object(api_client)) + lean_runner, _, _ = _mock_docker_lean_runner_api() result = CliRunner().invoke(lean, ["live", "Python Project", @@ -190,18 +165,7 @@ def test_live_calls_lean_runner_with_custom_output_directory() -> None: def test_live_calls_lean_runner_with_release_mode() -> None: create_fake_lean_cli_directory() create_fake_environment("live-paper", True) - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) - - api_client = mock.MagicMock() - api_client.organizations.get_all.return_value = [ - QCMinimalOrganization(id="abc", name="abc", type="type", ownerName="You", members=1, preferred=True) - ] - container.api_client.override(providers.Object(api_client)) + lean_runner, _, _ = _mock_docker_lean_runner_api() result = CliRunner().invoke(lean, ["live", "CSharp Project", "--environment", "live-paper", "--release"]) @@ -220,18 +184,7 @@ def test_live_calls_lean_runner_with_release_mode() -> None: def test_live_calls_lean_runner_with_detach() -> None: create_fake_lean_cli_directory() create_fake_environment("live-paper", True) - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) - - api_client = mock.MagicMock() - api_client.organizations.get_all.return_value = [ - QCMinimalOrganization(id="abc", name="abc", type="type", ownerName="You", members=1, preferred=True) - ] - container.api_client.override(providers.Object(api_client)) + lean_runner, _, _ = _mock_docker_lean_runner_api() result = CliRunner().invoke(lean, ["live", "Python Project", "--environment", "live-paper", "--detach"]) @@ -250,12 +203,7 @@ def test_live_calls_lean_runner_with_detach() -> None: def test_live_aborts_when_project_does_not_exist() -> None: create_fake_lean_cli_directory() create_fake_environment("live-paper", True) - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) + lean_runner, _ = _mock_docker_lean_runner() result = CliRunner().invoke(lean, ["live", "This Project Does Not Exist"]) @@ -267,12 +215,7 @@ def test_live_aborts_when_project_does_not_exist() -> None: def test_live_aborts_when_project_does_not_contain_algorithm_file() -> None: create_fake_lean_cli_directory() create_fake_environment("live-paper", True) - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) + lean_runner, _ = _mock_docker_lean_runner() result = CliRunner().invoke(lean, ["live", "data"]) @@ -285,17 +228,12 @@ def test_live_aborts_when_project_does_not_contain_algorithm_file() -> None: def test_live_aborts_when_lean_config_is_missing_properties(target: str, replacement: str) -> None: create_fake_lean_cli_directory() create_fake_environment("live-paper", True) + lean_runner, _ = _mock_docker_lean_runner() config_path = Path.cwd() / "lean.json" config = config_path.read_text(encoding="utf-8") config_path.write_text(config.replace(target, replacement), encoding="utf-8") - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) - result = CliRunner().invoke(lean, ["live", "Python Project", "--environment", "live-paper"]) assert result.exit_code != 0 @@ -395,7 +333,24 @@ def test_live_aborts_when_lean_config_is_missing_properties(target: str, replace "ftx-exchange-name": "FTX", "ftx-organization": "abc", }, - + "Trading Technologies": { + "tt-organization": "abc", + "tt-user-name": "abc", + "tt-session-password": "abc", + "tt-account-name": "abc", + "tt-rest-app-key": "abc", + "tt-rest-app-secret": "abc", + "tt-rest-environment": "abc", + "tt-market-data-sender-comp-id": "abc", + "tt-market-data-target-comp-id": "abc", + "tt-market-data-host": "abc", + "tt-market-data-port": "abc", + "tt-order-routing-sender-comp-id": "abc", + "tt-order-routing-target-comp-id": "abc", + "tt-order-routing-host": "abc", + "tt-order-routing-port": "abc", + "tt-log-fix-messages": "no" + } } data_feed_required_options = { @@ -424,18 +379,7 @@ def test_live_aborts_when_lean_config_is_missing_properties(target: str, replace def test_live_calls_lean_runner_with_data_provider(data_provider: str) -> None: create_fake_lean_cli_directory() create_fake_environment("live-paper", True) - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) - - api_client = mock.MagicMock() - api_client.organizations.get_all.return_value = [ - QCMinimalOrganization(id="abc", name="abc", type="type", ownerName="You", members=1, preferred=True) - ] - container.api_client.override(providers.Object(api_client)) + lean_runner, _, _ = _mock_docker_lean_runner_api() options = [] for key, value in data_providers_required_options[data_provider].items(): @@ -463,13 +407,13 @@ def test_live_non_interactive_aborts_when_missing_brokerage_options(brokerage: s required_options = brokerage_required_options[brokerage].items() for length in range(len(required_options)): - for current_options in itertools.combinations(required_options, length): - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) - + comb = itertools.combinations(required_options, length) + # TODO: investigate the reason of slow iterations + if len(list(comb)) > 1000: + continue + for current_options in comb: + lean_runner, _ = _mock_docker_lean_runner() + options = [] for key, value in current_options: @@ -484,11 +428,13 @@ def test_live_non_interactive_aborts_when_missing_brokerage_options(brokerage: s "--binance-api-secret", "456", "--binance-use-testnet", "live"]) - result = CliRunner().invoke(lean, ["live", "Python Project", - "--brokerage", brokerage, - "--data-feed", data_feed, - *options]) + if brokerage == "Trading Technologies" or brokerage == "Atreyu": + options.extend(["--live-cash-balance", "USD:100"]) + result = CliRunner().invoke(lean, ["live", "Python Project", + "--brokerage", brokerage, + "--data-feed", data_feed, + *options]) assert result.exit_code != 0 lean_runner.run_lean.assert_not_called() @@ -501,11 +447,7 @@ def test_live_non_interactive_aborts_when_missing_data_feed_options(data_feed: s required_options = data_feed_required_options[data_feed].items() for length in range(len(required_options)): for current_options in itertools.combinations(required_options, length): - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) + lean_runner, _ = _mock_docker_lean_runner() options = [] @@ -515,6 +457,7 @@ def test_live_non_interactive_aborts_when_missing_data_feed_options(data_feed: s result = CliRunner().invoke(lean, ["live", "Python Project", "--brokerage", "Paper Trading", "--data-feed", data_feed, + "--live-cash-balance", "USD:100", *options]) traceback.print_exception(*result.exc_info) @@ -529,18 +472,7 @@ def test_live_non_interactive_aborts_when_missing_data_feed_options(data_feed: s itertools.product(brokerage_required_options.keys(), data_feed_required_options.keys())) def test_live_non_interactive_calls_run_lean_when_all_options_given(brokerage: str, data_feed: str) -> None: create_fake_lean_cli_directory() - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) - - api_client = mock.MagicMock() - api_client.organizations.get_all.return_value = [ - QCMinimalOrganization(id="abc", name="abc", type="type", ownerName="You", members=1, preferred=True) - ] - container.api_client.override(providers.Object(api_client)) + lean_runner, _, _ = _mock_docker_lean_runner_api() options = [] @@ -550,6 +482,9 @@ def test_live_non_interactive_calls_run_lean_when_all_options_given(brokerage: s for key, value in data_feed_required_options[data_feed].items(): options.extend([f"--{key}", value]) + if brokerage == "Trading Technologies" or brokerage == "Paper Trading": + options.extend(["--live-cash-balance", "USD:100"]) + result = CliRunner().invoke(lean, ["live", "Python Project", "--brokerage", brokerage, "--data-feed", data_feed, @@ -567,22 +502,12 @@ def test_live_non_interactive_calls_run_lean_when_all_options_given(brokerage: s None, False, False) + @pytest.mark.parametrize("brokerage,data_feed1,data_feed2",[(brokerage, *data_feeds) for brokerage, data_feeds in itertools.product(brokerage_required_options.keys(), itertools.combinations(data_feed_required_options.keys(), 2))]) def test_live_non_interactive_calls_run_lean_when_all_options_given_with_multiple_data_feeds(brokerage: str, data_feed1: str, data_feed2: str) -> None: create_fake_lean_cli_directory() - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) - - api_client = mock.MagicMock() - api_client.organizations.get_all.return_value = [ - QCMinimalOrganization(id="abc", name="abc", type="type", ownerName="You", members=1, preferred=True) - ] - container.api_client.override(providers.Object(api_client)) + lean_runner, _, _ = _mock_docker_lean_runner_api() options = [] @@ -595,6 +520,9 @@ def test_live_non_interactive_calls_run_lean_when_all_options_given_with_multipl for key, value in data_feed_required_options[data_feed2].items(): options.extend([f"--{key}", value]) + if brokerage == "Trading Technologies" or brokerage == "Paper Trading": + options.extend(["--live-cash-balance", "USD:100"]) + result = CliRunner().invoke(lean, ["live", "Python Project", "--brokerage", brokerage, "--data-feed", data_feed1, @@ -621,18 +549,12 @@ def test_live_non_interactive_falls_back_to_lean_config_for_brokerage_settings(b required_options = brokerage_required_options[brokerage].items() for length in range(len(required_options)): - for current_options in itertools.combinations(required_options, length): - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) - - api_client = mock.MagicMock() - api_client.organizations.get_all.return_value = [ - QCMinimalOrganization(id="abc", name="abc", type="type", ownerName="You", members=1, preferred=True) - ] - container.api_client.override(providers.Object(api_client)) + comb = itertools.combinations(required_options, length) + # TODO: investigate the reason of slow iterations + if len(list(comb)) > 1000: + continue + for current_options in comb: + lean_runner, _, _ = _mock_docker_lean_runner_api() options = [] @@ -664,6 +586,9 @@ def test_live_non_interactive_falls_back_to_lean_config_for_brokerage_settings(b "--binance-api-secret", "456", "--binance-use-testnet", "live"]) + if brokerage == "Trading Technologies" or brokerage == "Atreyu": + options.extend(["--live-cash-balance", "USD:100"]) + result = CliRunner().invoke(lean, ["live", "Python Project", "--brokerage", brokerage, "--data-feed", data_feed, @@ -689,18 +614,12 @@ def test_live_non_interactive_falls_back_to_lean_config_for_data_feed_settings(d required_options = data_feed_required_options[data_feed].items() for length in range(len(required_options)): - for current_options in itertools.combinations(required_options, length): - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) - - api_client = mock.MagicMock() - api_client.organizations.get_all.return_value = [ - QCMinimalOrganization(id="abc", name="abc", type="type", ownerName="You", members=1, preferred=True) - ] - container.api_client.override(providers.Object(api_client)) + comb = itertools.combinations(required_options, length) + # TODO: investigate the reason of slow iterations + if len(list(comb)) > 1000: + continue + for current_options in comb: + lean_runner, _, _ = _mock_docker_lean_runner_api() options = [] @@ -723,6 +642,7 @@ def test_live_non_interactive_falls_back_to_lean_config_for_data_feed_settings(d result = CliRunner().invoke(lean, ["live", "Python Project", "--brokerage", "Paper Trading", "--data-feed", data_feed, + "--live-cash-balance", "USD:100", *options]) assert result.exit_code == 0 @@ -747,17 +667,7 @@ def test_live_non_interactive_falls_back_to_lean_config_for_multiple_data_feed_s pytest.skip('computationally expensive test') for length in range(len(required_options)): for current_options in itertools.combinations(required_options, length): - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) - - api_client = mock.MagicMock() - api_client.organizations.get_all.return_value = [ - QCMinimalOrganization(id="abc", name="abc", type="type", ownerName="You", members=1, preferred=True) - ] - container.api_client.override(providers.Object(api_client)) + lean_runner, _, _ = _mock_docker_lean_runner_api() options = [] @@ -781,6 +691,7 @@ def test_live_non_interactive_falls_back_to_lean_config_for_multiple_data_feed_s "--brokerage", "Paper Trading", "--data-feed", data_feed1, "--data-feed", data_feed2, + "--live-cash-balance", "USD:100", *options]) assert result.exit_code == 0 @@ -798,12 +709,7 @@ def test_live_non_interactive_falls_back_to_lean_config_for_multiple_data_feed_s def test_live_forces_update_when_update_option_given() -> None: create_fake_lean_cli_directory() create_fake_environment("live-paper", True) - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) + lean_runner, _, docker_manager = _mock_docker_lean_runner_api() result = CliRunner().invoke(lean, ["live", "Python Project", "--environment", "live-paper", "--update"]) @@ -823,12 +729,7 @@ def test_live_forces_update_when_update_option_given() -> None: def test_live_passes_custom_image_to_lean_runner_when_set_in_config() -> None: create_fake_lean_cli_directory() create_fake_environment("live-paper", True) - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) + lean_runner, _, _ = _mock_docker_lean_runner_api() container.cli_config_manager().engine_image.set_value("custom/lean:123") @@ -849,12 +750,7 @@ def test_live_passes_custom_image_to_lean_runner_when_set_in_config() -> None: def test_live_passes_custom_image_to_lean_runner_when_given_as_option() -> None: create_fake_lean_cli_directory() create_fake_environment("live-paper", True) - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) + lean_runner, _, _ = _mock_docker_lean_runner_api() container.cli_config_manager().engine_image.set_value("custom/lean:123") @@ -879,18 +775,7 @@ def test_live_passes_custom_image_to_lean_runner_when_given_as_option() -> None: def test_live_passes_custom_python_venv_to_lean_runner_when_given_as_option(python_venv: str) -> None: create_fake_lean_cli_directory() create_fake_environment("live-paper", True) - - docker_manager = mock.Mock() - container.docker_manager.override(providers.Object(docker_manager)) - - lean_runner = mock.Mock() - container.lean_runner.override(providers.Object(lean_runner)) - - api_client = mock.MagicMock() - api_client.organizations.get_all.return_value = [ - QCMinimalOrganization(id="abc", name="abc", type="type", ownerName="You", members=1, preferred=True) - ] - container.api_client.override(providers.Object(api_client)) + lean_runner, _, _ = _mock_docker_lean_runner_api() result = CliRunner().invoke(lean, ["live", "Python Project", "--environment", "live-paper", "--python-venv", python_venv]) @@ -904,3 +789,50 @@ def test_live_passes_custom_python_venv_to_lean_runner_when_given_as_option(pyth assert args[0]["python-venv"] == "/Custom-venv" else: assert "python-venv" not in args[0] + + +@pytest.mark.parametrize("brokerage,cash", [("Paper Trading", "USD:100"), + ("Paper Trading", "USD:100,EUR:200"), + ("Atreyu", "USD:100"), + ("Trading Technologies", "USD:100"), + ("Binance", "USD:100"), + ("Bitfinex", "USD:100"), + ("FTX", "USD:100"), + ("Coinbase Pro", "USD:100"), + ("Interactive Brokers", "USD:100"), + ("Kraken", "USD:100"), + ("OANDA", "USD:100"), + ("Samco", "USD:100"), + ("Terminal Link", "USD:100"), + ("Tradier", "USD:100"), + ("Zerodha", "USD:100")]) +def test_live_passes_live_cash_balance_to_lean_runner_when_given_as_option(brokerage: str, cash: str) -> None: + create_fake_lean_cli_directory() + lean_runner, _, _ = _mock_docker_lean_runner_api() + + options = [] + required_options = brokerage_required_options[brokerage].items() + for key, value in required_options: + options.extend([f"--{key}", value]) + + result = CliRunner().invoke(lean, ["live", "Python Project", "--brokerage", brokerage, "--live-cash-balance", cash, + "--data-feed", "Custom data only", *options]) + + # TODO: remove Atreyu after the discontinuation of the brokerage support (when removed from module-*.json) + if brokerage not in ["Paper Trading", "Atreyu", "Trading Technologies"]: + assert result.exit_code != 0 + lean_runner.run_lean.start.assert_not_called() + return + + assert result.exit_code == 0 + + cash_pairs = cash.split(",") + if len(cash_pairs) == 2: + cash_list = [{"currency": "USD", "amount": 100}, {"currency": "EUR", "amount": 200}] + else: + cash_list = [{"currency": "USD", "amount": 100}] + + lean_runner.run_lean.assert_called_once() + args, _ = lean_runner.run_lean.call_args + + assert args[0]["live-cash-balance"] == cash_list