diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 18dbad1c..248edc3b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: - name: Install dependencies run: | - pip install wheel + pip install setuptools==69.5.1 wheel pip install -r requirements.txt - name: Run tests diff --git a/README.md b/README.md index 000469e2..2e4ca255 100644 --- a/README.md +++ b/README.md @@ -320,9 +320,9 @@ Usage: lean cloud live deploy [OPTIONS] PROJECT --notify-insights. Options: - --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Terminal Link|Trading Technologies|Kraken|TDAmeritrade|Bybit] + --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Terminal Link|Trading Technologies|Kraken|TDAmeritrade|Bybit|TradeStation|Alpaca] The brokerage to use - --data-provider-live [QuantConnect|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Terminal Link|Trading Technologies|Kraken|TDAmeritrade|Polygon|IEX|CoinApi|Bybit] + --data-provider-live [QuantConnect|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Terminal Link|Trading Technologies|Kraken|TDAmeritrade|Polygon|IEX|CoinApi|Bybit|TradeStation|Alpaca] The live data provider to use --ib-user-name TEXT Your Interactive Brokers username --ib-account TEXT Your Interactive Brokers account id @@ -406,6 +406,12 @@ Options: --bybit-api-secret TEXT Your Bybit API secret --bybit-vip-level [VIP0|VIP1|VIP2|VIP3|VIP4|VIP5|SupremeVIP|Pro1|Pro2|Pro3|Pro4|Pro5] Your Bybit VIP Level + --trade-station-environment [live|paper] + Whether Live or Paper environment should be used + --trade-station-account-type [Cash|Margin|Futures|DVP] + Specifies the type of account on TradeStation + --alpaca-environment [live|paper] + Whether Live or Paper environment should be used --polygon-api-key TEXT Your Polygon.io API Key --iex-cloud-api-key TEXT Your iexcloud.io API token publishable key --iex-price-plan [Launch|Grow|Enterprise] @@ -413,6 +419,8 @@ Options: --coinapi-api-key TEXT Your coinapi.io Api Key --coinapi-product [Free|Startup|Streamer|Professional|Enterprise] CoinApi pricing plan (https://www.coinapi.io/market-data-api/pricing) + --alpaca-api-key TEXT Your Alpaca Api Key + --alpaca-api-secret TEXT Your Alpaca Api Secret --node TEXT The name or id of the live node to run on --auto-restart BOOLEAN Whether automatic algorithm restarting must be enabled --notify-order-events BOOLEAN Whether notifications must be sent for order events @@ -1225,9 +1233,9 @@ Options: --environment TEXT The environment to use --output DIRECTORY Directory to store results in (defaults to PROJECT/live/TIMESTAMP) -d, --detach Run the live deployment in a detached Docker container and return immediately - --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Terminal Link|Trading Technologies|Kraken|TDAmeritrade|Bybit] + --brokerage [Paper Trading|Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Terminal Link|Trading Technologies|Kraken|TDAmeritrade|Bybit|TradeStation|Alpaca] The brokerage to use - --data-provider-live [Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Terminal Link|Trading Technologies|Kraken|TDAmeritrade|IQFeed|Polygon|IEX|CoinApi|ThetaData|Custom data only|Bybit] + --data-provider-live [Interactive Brokers|Tradier|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Zerodha|Samco|Terminal Link|Trading Technologies|Kraken|TDAmeritrade|IQFeed|Polygon|IEX|CoinApi|ThetaData|Custom data only|Bybit|TradeStation|Alpaca] The live data provider to use --data-provider-historical [Interactive Brokers|Oanda|Bitfinex|Coinbase Advanced Trade|Binance|Kraken|IQFeed|Polygon|FactSet|IEX|AlphaVantage|CoinApi|ThetaData|QuantConnect|Local|Bybit] Update the Lean configuration file to retrieve data from the given historical provider @@ -1328,6 +1336,12 @@ Options: Your Bybit VIP Level --bybit-use-testnet [live|paper] Whether the testnet should be used + --trade-station-environment [live|paper] + Whether Live or Paper environment should be used + --trade-station-account-type [Cash|Margin|Futures|DVP] + Specifies the type of account on TradeStation + --alpaca-environment [live|paper] + Whether Live or Paper environment should be used --ib-enable-delayed-streaming-data BOOLEAN Whether delayed data may be used when your algorithm subscribes to a security you don't have a market data subscription for (Optional). @@ -1347,6 +1361,8 @@ Options: --thetadata-rest-url TEXT The ThetaData host address (Optional). --thetadata-subscription-plan [Free|Value|Standard|Pro] Your ThetaData subscription price plan + --alpaca-api-key TEXT Your Alpaca Api Key + --alpaca-api-secret TEXT Your Alpaca Api Secret --factset-auth-config-file FILE The path to the FactSet authentication configuration file --alpha-vantage-api-key TEXT Your Alpha Vantage Api Key diff --git a/lean/components/api/api_client.py b/lean/components/api/api_client.py index c6787c75..731a6fed 100644 --- a/lean/components/api/api_client.py +++ b/lean/components/api/api_client.py @@ -13,6 +13,7 @@ from typing import Any, Dict +from lean.components.api.auth0_client import Auth0Client from lean.components.api.account_client import AccountClient from lean.components.api.backtest_client import BacktestClient from lean.components.api.compile_client import CompileClient @@ -52,6 +53,7 @@ def __init__(self, logger: Logger, http_client: HTTPClient, user_id: str, api_to self.set_user_token(user_id, api_token) # Create the clients containing the methods to send requests to the various API endpoints + self.auth0 = Auth0Client(self) self.accounts = AccountClient(self) self.backtests = BacktestClient(self) self.compiles = CompileClient(self) diff --git a/lean/components/api/auth0_client.py b/lean/components/api/auth0_client.py new file mode 100644 index 00000000..ec9d0046 --- /dev/null +++ b/lean/components/api/auth0_client.py @@ -0,0 +1,61 @@ +# 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. + +from lean.components.api.api_client import * +from lean.components.util.logger import Logger +from lean.constants import API_BASE_URL +from lean.models.api import QCAuth0Authorization +from lean.models.errors import RequestFailedError + + +class Auth0Client: + """The Auth0Client class contains methods to interact with live/auth0/* API endpoints.""" + + def __init__(self, api_client: 'APIClient') -> None: + """Creates a new Auth0Client instance. + + :param api_client: the APIClient instance to use when making requests + """ + self._api = api_client + + def read(self, brokerage_id: str) -> QCAuth0Authorization: + """Reads the authorization data for a brokerage. + + :param brokerage_id: the id of the brokerage to read the authorization data for + :return: the authorization data for the specified brokerage + """ + try: + payload = { + "brokerage": brokerage_id + } + + data = self._api.post("live/auth0/read", payload) + return QCAuth0Authorization(**data) + except RequestFailedError as e: + return QCAuth0Authorization(authorization=None) + + @staticmethod + def authorize(brokerage_id: str, logger: Logger) -> None: + """Starts the authorization process for a brokerage. + + :param brokerage_id: the id of the brokerage to start the authorization process for + :param logger: the logger instance to use + """ + from webbrowser import open + + full_url = f"{API_BASE_URL}live/auth0/authorize?brokerage={brokerage_id}" + logger.info(f"Please open the following URL in your browser to authorize the LEAN CLI.") + logger.info(full_url) + open(full_url) + + diff --git a/lean/components/util/auth0_helper.py b/lean/components/util/auth0_helper.py new file mode 100644 index 00000000..b5c453d4 --- /dev/null +++ b/lean/components/util/auth0_helper.py @@ -0,0 +1,45 @@ +# 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. + +from lean.models.api import QCAuth0Authorization +from lean.components.api.api_client import Auth0Client +from lean.components.util.logger import Logger + + +def get_authorization(auth0_client: Auth0Client, brokerage_id: str, logger: Logger) -> QCAuth0Authorization: + """Gets the authorization data for a brokerage, authorizing if necessary. + + :param auth0_client: An instance of Auth0Client, containing methods to interact with live/auth0/* API endpoints. + :param brokerage_id: The ID of the brokerage to get the authorization data for. + :param logger: An instance of Logger, handling all output printing. + :return: The authorization data for the specified brokerage. + """ + from time import time, sleep + + data = auth0_client.read(brokerage_id) + if data.authorization is not None: + return data + + start_time = time() + auth0_client.authorize(brokerage_id, logger) + + # keep checking for new data every 5 seconds for 7 minutes + while time() - start_time < 420: + logger.info("Will sleep 5 seconds and retry fetching authorization...") + sleep(5) + data = auth0_client.read(brokerage_id) + if data.authorization is None: + continue + return data + + raise Exception("Authorization failed") diff --git a/lean/models/__init__.py b/lean/models/__init__.py index 952d30e5..4e0e8bca 100644 --- a/lean/models/__init__.py +++ b/lean/models/__init__.py @@ -17,7 +17,7 @@ from time import time json_modules = {} -file_name = "modules-1.13.json" +file_name = "modules-1.14.json" directory = Path(__file__).parent file_path = directory.parent / file_name diff --git a/lean/models/api.py b/lean/models/api.py index 4df959c4..14851de3 100644 --- a/lean/models/api.py +++ b/lean/models/api.py @@ -21,6 +21,8 @@ # The models in this module are all parts of responses from the QuantConnect API # The keys of properties are not changed, so they don't obey the rest of the project's naming conventions +class QCAuth0Authorization(WrappedBaseModel): + authorization: Optional[Dict[str, str]] class ProjectEncryptionKey(WrappedBaseModel): id: str diff --git a/lean/models/configuration.py b/lean/models/configuration.py index c1e1601e..867abb29 100644 --- a/lean/models/configuration.py +++ b/lean/models/configuration.py @@ -119,6 +119,8 @@ def factory(config_json_object) -> 'Configuration': return UserInputConfiguration.factory(config_json_object) elif config_json_object["type"] == "filter-env": return BrokerageEnvConfiguration.factory(config_json_object) + elif config_json_object["type"] == "oauth-token": + return AuthConfiguration.factory(config_json_object) else: raise ValueError( f'Undefined input method type {config_json_object["type"]}') @@ -388,6 +390,35 @@ def ask_user_for_input(self, default_value, logger: Logger, hide_input: bool = F raise ValueError(f"Undefined input method type {self._input_method}") +class AuthConfiguration(InternalInputUserInput): + + def __init__(self, config_json_object): + super().__init__(config_json_object) + + def factory(config_json_object) -> 'AuthConfiguration': + """Creates an instance of the child classes. + + :param config_json_object: the json object dict with configuration info + :return: An instance of AuthConfiguration. + """ + if config_json_object["type"] == "oauth-token": + return AuthConfiguration(config_json_object) + else: + raise ValueError( + f'Undefined input method type {config_json_object["type"]}') + + def ask_user_for_input(self, default_value, logger: Logger, hide_input: bool = False): + """Prompts user to provide input while validating the type of input + against the expected type + + :param default_value: The default to prompt to the user. + :param logger: The instance of logger class. + :param hide_input: Whether to hide the input (not used for this type of input, which is never hidden). + :return: The value provided by the user. + """ + raise ValueError(f'user input not allowed with {self.__class__.__name__}') + + class FilterEnvConfiguration(BrokerageEnvConfiguration): """This class adds extra filters to user filters.""" diff --git a/lean/models/json_module.py b/lean/models/json_module.py index 14bde708..b95173fe 100644 --- a/lean/models/json_module.py +++ b/lean/models/json_module.py @@ -17,11 +17,12 @@ from click import get_current_context from click.core import ParameterSource +from lean.components.util.auth0_helper import get_authorization from lean.components.util.logger import Logger from lean.constants import MODULE_TYPE, MODULE_PLATFORM, MODULE_CLI_PLATFORM from lean.container import container from lean.models.configuration import BrokerageEnvConfiguration, Configuration, InternalInputUserInput, \ - PathParameterUserInput + PathParameterUserInput, AuthConfiguration from copy import copy from abc import ABC @@ -132,13 +133,17 @@ def get_settings(self) -> Dict[str, str]: for configuration in self._lean_configs: if not self._check_if_config_passes_filters(configuration, all_for_platform_type=False): continue - settings[configuration._id] = str(configuration._value).replace("\\", "/") + if isinstance(configuration, AuthConfiguration) and isinstance(configuration._value, dict): + for key, value in configuration._value.items(): + settings[key] = str(value) + else: + settings[configuration._id] = str(configuration._value).replace("\\", "/") return settings def get_all_input_configs(self, filters: List[Type[Configuration]] = []) -> List[Configuration]: return [copy(config) for config in self._lean_configs if config._is_required_from_user - if type(config) not in filters + if not isinstance(config, tuple(filters)) and self._check_if_config_passes_filters(config, all_for_platform_type=True)] def convert_lean_key_to_variable(self, lean_key: str) -> str: @@ -195,6 +200,11 @@ def config_build(self, _logged_messages.add(log_message) if type(configuration) is InternalInputUserInput: continue + elif isinstance(configuration, AuthConfiguration): + auth_authorizations = get_authorization(container.api_client.auth0, self._display_name.lower(), logger) + logger.debug(f'auth: {auth_authorizations}') + configuration._value = auth_authorizations.authorization + continue property_name = self.convert_lean_key_to_variable(configuration._id) # Only ask for user input if the config wasn't given as an option