Skip to content
Open
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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ Options:
--ib-user-name TEXT Your Interactive Brokers username
--ib-account TEXT Your Interactive Brokers account id
--ib-password TEXT Your Interactive Brokers password
--ib-weekly-restart-utc-time TEXT
--ib-weekly-restart-utc-time [hh:mm:ss]
Weekly restart UTC time (hh:mm:ss). Each week on Sunday your algorithm is restarted at
this time, and will require 2FA verification. This is required by Interactive Brokers.
Use this option explicitly to override the default value. (Optional).
Expand Down Expand Up @@ -424,7 +424,7 @@ Options:
--ib-user-name TEXT Your Interactive Brokers username
--ib-account TEXT Your Interactive Brokers account id
--ib-password TEXT Your Interactive Brokers password
--ib-weekly-restart-utc-time TEXT
--ib-weekly-restart-utc-time [hh:mm:ss]
Weekly restart UTC time (hh:mm:ss). Each week on Sunday your algorithm is restarted at
this time, and will require 2FA verification. This is required by Interactive Brokers.
Use this option explicitly to override the default value. (Optional).
Expand Down Expand Up @@ -952,7 +952,7 @@ Options:
--ib-user-name TEXT Your Interactive Brokers username
--ib-account TEXT Your Interactive Brokers account id
--ib-password TEXT Your Interactive Brokers password
--ib-weekly-restart-utc-time TEXT
--ib-weekly-restart-utc-time [hh:mm:ss]
Weekly restart UTC time (hh:mm:ss). Each week on Sunday your algorithm is restarted at
this time, and will require 2FA verification. This is required by Interactive Brokers.
Use this option explicitly to override the default value. (Optional).
Expand Down Expand Up @@ -1417,7 +1417,7 @@ Options:
--ib-user-name TEXT Your Interactive Brokers username
--ib-account TEXT Your Interactive Brokers account id
--ib-password TEXT Your Interactive Brokers password
--ib-weekly-restart-utc-time TEXT
--ib-weekly-restart-utc-time [hh:mm:ss]
Weekly restart UTC time (hh:mm:ss). Each week on Sunday your algorithm is restarted at
this time, and will require 2FA verification. This is required by Interactive Brokers.
Use this option explicitly to override the default value. (Optional).
Expand Down Expand Up @@ -1898,7 +1898,7 @@ Options:
--ib-user-name TEXT Your Interactive Brokers username
--ib-account TEXT Your Interactive Brokers account id
--ib-password TEXT Your Interactive Brokers password
--ib-weekly-restart-utc-time TEXT
--ib-weekly-restart-utc-time [hh:mm:ss]
Weekly restart UTC time (hh:mm:ss). Each week on Sunday your algorithm is restarted at
this time, and will require 2FA verification. This is required by Interactive Brokers.
Use this option explicitly to override the default value. (Optional).
Expand Down Expand Up @@ -2167,7 +2167,7 @@ Options:
--ib-user-name TEXT Your Interactive Brokers username
--ib-account TEXT Your Interactive Brokers account id
--ib-password TEXT Your Interactive Brokers password
--ib-weekly-restart-utc-time TEXT
--ib-weekly-restart-utc-time [hh:mm:ss]
Weekly restart UTC time (hh:mm:ss). Each week on Sunday your algorithm is restarted at
this time, and will require 2FA verification. This is required by Interactive Brokers.
Use this option explicitly to override the default value. (Optional).
Expand Down
45 changes: 45 additions & 0 deletions lean/click.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,51 @@ def convert(self, value: str, param: Parameter, ctx: Context):
self.fail(f"'{value}' does not match the yyyyMMdd format.", param, ctx)


class TimeParameter(ParamType):
"""A click parameter which returns hh:mm:ss strings, the format expected by the API.

Input without seconds is accepted as well, in which case they default to zero.
"""

name = "time"

def __init__(self, maximum: str = None):
"""Creates a new TimeParameter instance.

:param maximum: the latest time that is accepted in hh:mm:ss format, or None if there is no upper bound
"""
self._maximum = maximum

def get_metavar(self, param: Parameter, ctx=None) -> str:
return "[hh:mm:ss]"

def convert(self, value: str, param: Parameter, ctx: Context) -> str:
parsed_value = self._parse(value)

if parsed_value is None:
self.fail(f"'{value}' does not match the hh:mm:ss format.", param, ctx)

if self._maximum is not None and parsed_value > self._parse(self._maximum):
self.fail(f"'{value}' is later than the latest supported time of {self._maximum}.", param, ctx)

return parsed_value.strftime("%H:%M:%S")

@staticmethod
def _parse(value: str):
"""Parses a time, returning None when the given value is not formatted as hh:mm:ss or hh:mm.

:param value: the value to parse
:return: the parsed datetime.time, or None when the value cannot be parsed
"""
from datetime import datetime
for time_format in ["%H:%M:%S", "%H:%M"]:
try:
return datetime.strptime(str(value), time_format).time()
except ValueError:
pass
return None


def ensure_options(options: List[str]) -> None:
"""Ensures certain options have values, raises an error if not.

Expand Down
35 changes: 33 additions & 2 deletions lean/models/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@

from pathlib import Path
from typing import Any, Dict, List
from click import prompt
from click import prompt, ClickException, ParamType
from lean.click import CaseInsensitiveChoice
from abc import ABC, abstractmethod
from lean.components.util.logger import Logger
from lean.click import PathParameter
from lean.click import PathParameter, TimeParameter


class BaseCondition(ABC):
Expand Down Expand Up @@ -128,6 +128,14 @@ def factory(config_json_object) -> 'Configuration':
raise ValueError(
f'Undefined input method type {config_json_object["type"]}')

def validate(self, value):
"""Validates a value which didn't go through a click prompt or option, like the ones read from the Lean config.

:param value: the value to validate
:return: the validated value
"""
return value

def __repr__(self):
return f'{self._id}: {self._value}'

Expand Down Expand Up @@ -255,6 +263,13 @@ class PromptUserInput(UserInputConfiguration):
"integer": int
}

# Configurations that need stricter validation than the modules json describes.
# The modules json is fetched from the CDN, so these overrides live here instead of in the json itself.
map_id_to_types = {
# Interactive Brokers doesn't support weekly restart times later than 23:30 UTC
"ib-weekly-restart-utc-time": TimeParameter(maximum="23:30:00")
}

def __init__(self, config_json_object):
super().__init__(config_json_object)
self._input_type: str = "string"
Expand All @@ -273,8 +288,24 @@ def ask_user_for_input(self, default_value, logger: Logger, hide_input: bool = F
return prompt(self._prompt_info, default_value, type=self.get_input_type())

def get_input_type(self):
if self._id in self.map_id_to_types:
return self.map_id_to_types[self._id]
return self.map_to_types.get(self._input_type, self._input_type)

def validate(self, value):
"""Validates a value which didn't go through a click prompt or option, like the ones read from the Lean config.

:param value: the value to validate
:return: the validated value
"""
input_type = self.get_input_type()
if value is None or not isinstance(input_type, ParamType):
return value
try:
return input_type.convert(value, None, None)
except ClickException as e:
raise RuntimeError(f"Invalid value for '{self._id}': {e.message}")


class ChoiceUserInput(UserInputConfiguration):
def __init__(self, config_json_object):
Expand Down
3 changes: 2 additions & 1 deletion lean/models/json_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,8 @@ def config_build(self,
else:
missing_options.append(f"--{configuration._id}")

configuration._value = user_choice
# the values that come from the Lean config didn't go through click, so they are validated here
configuration._value = configuration.validate(user_choice)

if len(missing_options) > 0:
raise RuntimeError(f"""You are missing the following option{"s" if len(missing_options) > 1 else ""}: {', '
Expand Down
51 changes: 51 additions & 0 deletions tests/commands/cloud/live/test_cloud_live_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,57 @@ def test_cloud_live_deploy_with_ib_using_hybrid_datafeed() -> None:
assert result.exit_code == 0
assert "Live data providers: QuantConnectBrokerage, InteractiveBrokersBrokerage" in result.output.replace("\n", "")

@pytest.mark.skipif(
sys.platform =="darwin", reason="MacOS does not support IB tests."
)
@pytest.mark.parametrize("weekly_restart_time", ["23:30:01", "23:50:00", "23:50", "invalid"])
def test_cloud_live_deploy_with_ib_fails_when_weekly_restart_time_not_supported(weekly_restart_time: str) -> None:
create_fake_lean_cli_directory()

result = CliRunner().invoke(lean, ["cloud", "live", "Python Project", "--brokerage", "Interactive Brokers", "--node", "live",
"--auto-restart", "yes", "--notify-order-events", "no", "--notify-insights", "no",
"--ib-user-name", "test_user", "--ib-account", "DU2366417", "--ib-password", "test_password",
"--ib-weekly-restart-utc-time", weekly_restart_time,
"--data-provider-live", "Interactive Brokers"])

assert result.exit_code != 0
assert weekly_restart_time in result.output


@pytest.mark.skipif(
sys.platform =="darwin", reason="MacOS does not support IB tests."
)
@pytest.mark.parametrize("weekly_restart_time", ["21:00", "21:00:00"])
def test_cloud_live_deploy_with_ib_sends_weekly_restart_time_with_seconds(weekly_restart_time: str) -> None:
create_fake_lean_cli_directory()

api_client = mock.Mock()
api_client.nodes.get_all.return_value = create_qc_nodes()
api_client.get.return_value = {
"status": "stopped",
"stopped": "2024-07-10 19:12:20",
"success": True,
"portfolio": {"holdings": {}, "cash": {}, "success": True}}
container.api_client = api_client

cloud_project_manager = mock.Mock()
container.cloud_project_manager = cloud_project_manager

cloud_runner = mock.Mock()
container.cloud_runner = cloud_runner

result = CliRunner().invoke(lean, ["cloud", "live", "Python Project", "--brokerage", "Interactive Brokers", "--node", "live",
"--auto-restart", "yes", "--notify-order-events", "no", "--notify-insights", "no",
"--ib-user-name", "test_user", "--ib-account", "DU2366417", "--ib-password", "test_password",
"--ib-weekly-restart-utc-time", weekly_restart_time,
"--data-provider-live", "Interactive Brokers"])

assert result.exit_code == 0

args, _ = api_client.live.start.call_args
assert args[3]["ib-weekly-restart-utc-time"] == "21:00:00"


def test_cloud_live_deploy_with_tradier_using_tradier_datafeed() -> None:
create_fake_lean_cli_directory()

Expand Down
22 changes: 22 additions & 0 deletions tests/commands/test_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,28 @@ def test_live_aborts_when_lean_config_is_missing_properties(target: str, replace

lean_runner.run_lean.assert_not_called()

@pytest.mark.skipif(
sys.platform == "darwin", reason="MacOS does not support IB tests."
)
@pytest.mark.parametrize("weekly_restart_time", ["23:50:00", "invalid"])
def test_live_aborts_when_lean_config_has_unsupported_ib_weekly_restart_time(weekly_restart_time: str) -> None:
create_fake_lean_cli_directory()
create_fake_environment("live-paper", True)
lean_runner = container.lean_runner

config_path = Path.cwd() / "lean.json"
config = config_path.read_text(encoding="utf-8")
config_path.write_text(config.replace("21:00:00", weekly_restart_time), encoding="utf-8")

result = CliRunner().invoke(lean, ["live", "Python Project", "--environment", "live-paper"])

assert result.exit_code != 0
assert "Invalid value for 'ib-weekly-restart-utc-time'" in str(result.exception)
assert weekly_restart_time in str(result.exception)

lean_runner.run_lean.assert_not_called()


def test_live_sets_dependent_configurations_from_modules_json_based_on_environment() -> None:
create_fake_lean_cli_directory()
create_fake_binance_environment("live-binance", True)
Expand Down
46 changes: 45 additions & 1 deletion tests/test_click.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import pytest
from click.testing import CliRunner

from lean.click import DateParameter, LeanCommand, PathParameter
from lean.click import DateParameter, LeanCommand, PathParameter, TimeParameter
from lean.container import container
from tests.test_helpers import create_fake_lean_cli_directory

Expand Down Expand Up @@ -209,3 +209,47 @@ def command(arg: datetime) -> None:
result = CliRunner().invoke(command, [input])

assert result.exit_code != 0


@pytest.mark.parametrize("input,expected", [("21:00:00", "21:00:00"), ("21:00", "21:00:00"), ("9:30", "09:30:00"),
("23:30", "23:30:00")])
def test_time_parameter_returns_time_with_seconds(input: str, expected: str) -> None:
given_arg: Optional[str] = None

@click.command()
@click.argument("arg", type=TimeParameter(maximum="23:30:00"))
def command(arg: str) -> None:
nonlocal given_arg
given_arg = arg

result = CliRunner().invoke(command, [input])

assert result.exit_code == 0

assert given_arg == expected


@pytest.mark.parametrize("input", ["25:00:00", "21:60", "hh:mm:ss", "210000", "this is invalid input"])
def test_time_parameter_fails_when_input_not_formatted_as_hhmmss(input: str) -> None:
@click.command()
@click.argument("arg", type=TimeParameter())
def command(arg: str) -> None:
pass

result = CliRunner().invoke(command, [input])

assert result.exit_code != 0
assert "does not match the hh:mm:ss format" in result.output


@pytest.mark.parametrize("input", ["23:30:01", "23:31", "23:59:59"])
def test_time_parameter_fails_when_input_later_than_maximum(input: str) -> None:
@click.command()
@click.argument("arg", type=TimeParameter(maximum="23:30:00"))
def command(arg: str) -> None:
pass

result = CliRunner().invoke(command, [input])

assert result.exit_code != 0
assert "later than the latest supported time of 23:30:00" in result.output
Loading