diff --git a/README.md b/README.md index 443c5f61..c013caf8 100644 --- a/README.md +++ b/README.md @@ -755,6 +755,7 @@ Options: --binance-api-key TEXT Your Binance API key --binance-api-secret TEXT Your Binance API secret --binance-use-testnet BOOLEAN Whether the testnet should be used + --zerodha-organization TEXT The name or id of the organization with the zerodha module subscription --zerodha-api-key TEXT Your Kite Connect API key --zerodha-access-token TEXT Your Kite Connect access token --zerodha-product-type [MIS|CNC|NRML] diff --git a/lean/commands/live.py b/lean/commands/live.py index 265ad676..90ab2a5f 100644 --- a/lean/commands/live.py +++ b/lean/commands/live.py @@ -65,7 +65,7 @@ "tt-order-routing-host", "tt-order-routing-port", "tt-log-fix-messages"], "KrakenBrokerage": ["kraken-api-key", "kraken-api-secret", "kraken-verification-tier"], - "FTXBrokerage": ["ftx-api-key", "ftx-api-secret", "ftx-account-tier"] + "FTXBrokerage": ["ftx-api-key", "ftx-api-secret", "ftx-account-tier", "ftx-exchange-name"] } # Data queue handler -> required configuration properties @@ -347,6 +347,10 @@ def _get_default_value(key: str) -> Optional[Any]: type=bool, default=lambda: _get_default_value("binance-use-testnet"), help="Whether the testnet should be used") +@click.option("--zerodha-organization", + type=str, + default=lambda: _get_default_value("job-organization-id"), + help="The name or id of the organization with the zerodha module subscription") @click.option("--zerodha-api-key", type=str, default=lambda: _get_default_value("zerodha-api-key"), @@ -632,6 +636,7 @@ def live(project: Path, binance_api_key: Optional[str], binance_api_secret: Optional[str], binance_use_testnet: Optional[bool], + zerodha_organization: Optional[str], zerodha_api_key: Optional[str], zerodha_access_token: Optional[str], zerodha_product_type: Optional[str], @@ -777,7 +782,8 @@ def live(project: Path, "zerodha_access_token", "zerodha_product_type", "zerodha_trading_segment"]) - brokerage_configurer = ZerodhaBrokerage(zerodha_api_key, + brokerage_configurer = ZerodhaBrokerage(_get_organization_id(zerodha_organization, "Zerodha"), + zerodha_api_key, zerodha_access_token, zerodha_product_type, zerodha_trading_segment) @@ -912,7 +918,8 @@ def live(project: Path, "zerodha_product_type", "zerodha_trading_segment", "zerodha_history_subscription"]) - data_feed_configurer = ZerodhaDataFeed(ZerodhaBrokerage(zerodha_api_key, + data_feed_configurer = ZerodhaDataFeed(ZerodhaBrokerage(_get_organization_id(zerodha_organization, "Zerodha"), + zerodha_api_key, zerodha_access_token, zerodha_product_type, zerodha_trading_segment), diff --git a/lean/constants.py b/lean/constants.py index 4d3253aa..7a1a6f7a 100644 --- a/lean/constants.py +++ b/lean/constants.py @@ -87,6 +87,9 @@ # The product id of the FTX module FTX_PRODUCT_ID = 138 +# The product id of the ZERODHA module +ZERODHA_PRODUCT_ID = 174 + # The product id of the SAMCO module SAMCO_PRODUCT_ID = 173 diff --git a/lean/models/brokerages/local/ftx.py b/lean/models/brokerages/local/ftx.py index 1f754a24..3f75a559 100644 --- a/lean/models/brokerages/local/ftx.py +++ b/lean/models/brokerages/local/ftx.py @@ -89,12 +89,13 @@ class FTXBrokerage(LocalBrokerage): _exchange: FTXExchange _is_module_installed = False - def __init__(self, organization_id: str, api_key: str, api_secret: str, account_tier: str, exchange: FTXExchange) -> None: + def __init__(self, organization_id: str, api_key: str, api_secret: str, account_tier: str, exchange_name: str) -> None: self._api_key = api_key self._api_secret = api_secret self._account_tier = account_tier self._organization_id = organization_id - self._exchange = exchange + self._exchange_name = exchange_name + self._exchange = FTXExchange() if exchange_name.casefold() == "FTX".casefold() else FTXUSExchange() @classmethod def get_name(cls) -> str: @@ -129,7 +130,7 @@ def _build(cls, lean_config: Dict[str, Any], logger: Logger) -> LocalBrokerage: cls._get_default(lean_config, f'{prefix}-account-tier') ) - return FTXBrokerage(organization_id, api_key, api_secret, account_tier, exchange) + return FTXBrokerage(organization_id, api_key, api_secret, account_tier, exchange_name) def _configure_environment(self, lean_config: Dict[str, Any], environment_name: str) -> None: self.ensure_module_installed() @@ -144,6 +145,7 @@ def configure_credentials(self, lean_config: Dict[str, Any]) -> None: lean_config[f'{prefix}-api-key'] = self._api_key lean_config[f'{prefix}-api-secret'] = self._api_secret lean_config[f'{prefix}-account-tier'] = self._account_tier + lean_config["ftx-exchange-name"] = self._exchange_name lean_config["job-organization-id"] = self._organization_id self._save_properties(lean_config, ["job-organization-id", f'{prefix}-api-key', f'{prefix}-api-secret', f'{prefix}-account-tier']) diff --git a/lean/models/brokerages/local/zerodha.py b/lean/models/brokerages/local/zerodha.py index f13f0bf2..60a38a53 100644 --- a/lean/models/brokerages/local/zerodha.py +++ b/lean/models/brokerages/local/zerodha.py @@ -18,12 +18,17 @@ from lean.components.util.logger import Logger from lean.models.brokerages.local.base import LocalBrokerage from lean.models.config import LeanConfigConfigurer - +from lean.container import container +from lean.constants import ZERODHA_PRODUCT_ID +from lean.models.logger import Option class ZerodhaBrokerage(LocalBrokerage): """A LocalBrokerage implementation for the Zerodha brokerage.""" - def __init__(self, api_key: str, access_token: str, product_type: str, trading_segment: str) -> None: + _is_module_installed = False + + def __init__(self, organization_id: str, api_key: str, access_token: str, product_type: str, trading_segment: str) -> None: + self._organization_id = organization_id self._api_key = api_key self._access_token = access_token self._product_type = product_type @@ -33,8 +38,23 @@ def __init__(self, api_key: str, access_token: str, product_type: str, trading_s def get_name(cls) -> str: return "Zerodha" + @classmethod + def get_module_id(cls) -> int: + return ZERODHA_PRODUCT_ID + @classmethod def _build(cls, lean_config: Dict[str, Any], logger: Logger) -> LocalBrokerage: + + api_client = container.api_client() + + organizations = api_client.organizations.get_all() + options = [Option(id=organization.id, label=organization.name) for organization in organizations] + + organization_id = logger.prompt_list( + "Select the organization with the Zerodha module subscription", + options + ) + logger.info("You need API credentials for Kite Connect (https://kite.trade/) to use the Zerodha brokerage.") api_key = click.prompt("API key", cls._get_default(lean_config, "zerodha-api-key")) @@ -60,24 +80,32 @@ def _build(cls, lean_config: Dict[str, Any], logger: Logger) -> LocalBrokerage: type=click.Choice(["EQUITY", "COMMODITY"], case_sensitive=False) ) - return ZerodhaBrokerage(api_key, access_token, product_type, trading_segment) + return ZerodhaBrokerage(organization_id, api_key, access_token, product_type, trading_segment) def _configure_environment(self, lean_config: Dict[str, Any], environment_name: str) -> None: + self.ensure_module_installed() lean_config["environments"][environment_name]["live-mode-brokerage"] = "ZerodhaBrokerage" lean_config["environments"][environment_name]["transaction-handler"] = \ "QuantConnect.Lean.Engine.TransactionHandlers.BrokerageTransactionHandler" def configure_credentials(self, lean_config: Dict[str, Any]) -> None: + lean_config["job-organization-id"] = self._organization_id lean_config["zerodha-api-key"] = self._api_key lean_config["zerodha-access-token"] = self._access_token lean_config["zerodha-product-type"] = self._product_type lean_config["zerodha-trading-segment"] = self._trading_segment - self._save_properties(lean_config, ["zerodha-api-key", + self._save_properties(lean_config, ["job-organization-id", + "zerodha-api-key", "zerodha-access-token", "zerodha-product-type", "zerodha-trading-segment"]) + def ensure_module_installed(self) -> None: + if not self._is_module_installed: + container.module_manager().install_module(self.__class__.get_module_id(), self._organization_id) + self._is_module_installed = True + class ZerodhaDataFeed(LeanConfigConfigurer): """A LeanConfigConfigurer implementation for the Zerodha data feed.""" @@ -102,6 +130,7 @@ def build(cls, lean_config: Dict[str, Any], logger: Logger) -> LeanConfigConfigu return ZerodhaDataFeed(brokerage, history_subscription) def configure(self, lean_config: Dict[str, Any], environment_name: str) -> None: + self._brokerage.ensure_module_installed() lean_config["environments"][environment_name]["data-queue-handler"] = "ZerodhaBrokerage" lean_config["environments"][environment_name]["history-provider"] = "BrokerageHistoryProvider" diff --git a/tests/commands/test_live.py b/tests/commands/test_live.py index 5ccef9cc..f6e260b8 100644 --- a/tests/commands/test_live.py +++ b/tests/commands/test_live.py @@ -25,6 +25,7 @@ from lean.constants import DEFAULT_ENGINE_IMAGE from lean.container import container from lean.models.docker import DockerImage +from lean.models.api import QCMinimalOrganization from tests.test_helpers import create_fake_lean_cli_directory ENGINE_IMAGE = DockerImage.parse(DEFAULT_ENGINE_IMAGE) @@ -305,7 +306,43 @@ def test_live_aborts_when_lean_config_is_missing_properties(target: str, replace "zerodha-access-token": "456", "zerodha-product-type": "MIS", "zerodha-trading-segment": "EQUITY" - } + }, + "Samco": { + "samco-client-id": "123", + "samco-client-password": "456", + "samco-year-of-birth": "2000", + "samco-product-type": "MIS", + "samco-trading-segment": "EQUITY" + }, + "Atreyu": { + "atreyu-host": "abc", + "atreyu-req-port": "123", + "atreyu-sub-port": "456", + "atreyu-username": "abc", + "atreyu-password": "abc", + "atreyu-client-id": "abc", + "atreyu-broker-mpid": "abc", + "atreyu-locate-rqd": "abc", + }, + "Terminal Link": { + "bloomberg-environment": "Beta", + "bloomberg-server-host": "abc", + "bloomberg-server-port": "123", + "bloomberg-emsx-broker": "abc", + "bloomberg-allow-modification": "no", + }, + "Kraken": { + "kraken-api-key": "abc", + "kraken-api-secret": "abc", + "kraken-verification-tier": "abc", + }, + "FTX": { + "ftx-api-key": "abc", + "ftx-api-secret": "abc", + "ftx-account-tier": "abc", + "ftx-exchange-name": "FTX" + }, + } data_feed_required_options = { @@ -321,7 +358,11 @@ def test_live_aborts_when_lean_config_is_missing_properties(target: str, replace "Zerodha": { **brokerage_required_options["Zerodha"], "zerodha-history-subscription": "yes" - } + }, + "Samco": brokerage_required_options["Samco"], + "Terminal Link": brokerage_required_options["Terminal Link"], + "Kraken": brokerage_required_options["Kraken"], + "FTX": brokerage_required_options["FTX"], } @@ -390,6 +431,7 @@ def test_live_non_interactive_aborts_when_missing_data_feed_options(data_feed: s lean_runner.run_lean.assert_not_called() + @pytest.mark.parametrize("brokerage,data_feed", 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: @@ -401,6 +443,12 @@ def test_live_non_interactive_calls_run_lean_when_all_options_given(brokerage: s 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)) + options = [] for key, value in brokerage_required_options[brokerage].items(): @@ -439,6 +487,12 @@ def test_live_non_interactive_falls_back_to_lean_config_for_brokerage_settings(b 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)) + options = [] for key, value in current_options: @@ -454,6 +508,12 @@ def test_live_non_interactive_falls_back_to_lean_config_for_brokerage_settings(b if brokerage == "Binance": data_feed = "Bitfinex" options.extend(["--bitfinex-api-key", "123", "--bitfinex-api-secret", "456"]) + elif brokerage == "FTX": + data_feed = "Binance" + options.extend(["--ftx-exchange-name", "abc", + "--binance-api-key", "123", + "--binance-api-secret", "456", + "--binance-use-testnet", "no"]) else: data_feed = "Binance" options.extend(["--binance-api-key", "123", @@ -490,6 +550,12 @@ def test_live_non_interactive_falls_back_to_lean_config_for_data_feed_settings(d 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)) + options = [] for key, value in current_options: @@ -502,6 +568,9 @@ def test_live_non_interactive_falls_back_to_lean_config_for_data_feed_settings(d "data-folder": "data" })) + if data_feed == "FTX": + options.extend(["--ftx-exchange-name", "abc"]) + result = CliRunner().invoke(lean, ["live", "Python Project", "--brokerage", "Paper Trading", "--data-feed", data_feed,