-
Notifications
You must be signed in to change notification settings - Fork 0
Add QuantConnect connector framework #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| # QuantConnect Connector | ||
|
|
||
| `quant_platform_kit.quantconnect` contains small, dependency-free helpers for platform repositories that deploy a strategy to QuantConnect Cloud while keeping account wiring and secrets outside this public repository. | ||
|
|
||
| The connector supports: | ||
|
|
||
| - QuantConnect REST API authentication headers from a user id and API token | ||
| - live algorithm management calls for authenticate, create, read, list, stop, and liquidate | ||
| - QuantConnect live deployment payloads | ||
| - Interactive Brokers brokerage payloads for QuantConnect Cloud | ||
| - redacted payload helpers for logs and notifications | ||
|
|
||
| It intentionally does not contain production account ids, usernames, passwords, node ids, or project ids. Private platform configuration should provide those values from Secret Manager, GitHub Actions secrets, or another deployment secret store. | ||
|
|
||
| ## Example | ||
|
|
||
| ```python | ||
| from quant_platform_kit.quantconnect import ( | ||
| InteractiveBrokersBrokerageSettings, | ||
| QuantConnectCredentials, | ||
| QuantConnectLiveConnector, | ||
| QuantConnectLiveDeployment, | ||
| QuantConnectRestClient, | ||
| ) | ||
|
|
||
| credentials = QuantConnectCredentials.from_env(env) | ||
| brokerage = InteractiveBrokersBrokerageSettings.from_env(env) | ||
|
|
||
| deployment = QuantConnectLiveDeployment( | ||
| project_id=12345678, | ||
| compile_id="compile-id-from-quantconnect", | ||
| node_id="LN-node-id-from-quantconnect", | ||
| brokerage=brokerage, | ||
| data_providers={ | ||
| "InteractiveBrokersBrokerage": brokerage, | ||
| }, | ||
| parameters={ | ||
| "strategy_profile": "example_strategy", | ||
| "runtime_target": "quantconnect-cloud-slot-a", | ||
| }, | ||
| ) | ||
|
|
||
| client = QuantConnectRestClient(credentials=credentials) | ||
| connector = QuantConnectLiveConnector(client) | ||
|
|
||
| # Use deployment.redacted_payload() for logs. Do not log deployment.to_payload(). | ||
| result = connector.deploy(deployment) | ||
| ``` | ||
|
|
||
| The default environment variable names are: | ||
|
|
||
| ```text | ||
| QUANTCONNECT_USER_ID | ||
| QUANTCONNECT_API_TOKEN | ||
| QUANTCONNECT_ORGANIZATION_ID | ||
|
|
||
| QUANTCONNECT_IB_USER_NAME | ||
| QUANTCONNECT_IB_ACCOUNT | ||
| QUANTCONNECT_IB_PASSWORD | ||
| QUANTCONNECT_IB_WEEKLY_RESTART_UTC_TIME | ||
| QUANTCONNECT_IB_FINANCIAL_ADVISORS_GROUP_FILTER | ||
| ``` | ||
|
|
||
| For a hybrid deployment, keep the target routing in a private platform repository or deployment secret: | ||
|
|
||
| ```json | ||
| { | ||
| "self_hosted": [ | ||
| { | ||
| "strategy_profile": "tqqq_growth_income", | ||
| "platform": "interactive_brokers", | ||
| "account_selector": ["U00000000"] | ||
| } | ||
| ], | ||
| "quantconnect_cloud": [ | ||
| { | ||
| "strategy_profile": "example_strategy", | ||
| "project_id": 12345678, | ||
| "node_id": "LN-placeholder", | ||
| "brokerage_secret": "qc-ibkr-slot-b" | ||
| } | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| The public example above uses placeholders only. Real account mappings and brokerage credentials must stay in private runtime configuration. | ||
|
|
||
| ## References | ||
|
|
||
| - QuantConnect Cloud API live management: <https://www.quantconnect.com/docs/v2/cloud-platform/api-reference/live-management> | ||
| - QuantConnect create live algorithm API: <https://www.quantconnect.com/docs/v2/cloud-platform/api-reference/live-management/create-live-algorithm> | ||
| - Lean CLI cloud live deploy: <https://www.quantconnect.com/docs/v2/lean-cli/api-reference/lean-cloud-live-deploy> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| """QuantConnect cloud deployment helpers.""" | ||
|
|
||
| from .client import ( | ||
| DEFAULT_QUANTCONNECT_API_BASE_URL, | ||
| QuantConnectApiError, | ||
| QuantConnectLiveConnector, | ||
| QuantConnectRestClient, | ||
| ) | ||
| from .models import ( | ||
| BrokerageHolding, | ||
| CashAmount, | ||
| InteractiveBrokersBrokerageSettings, | ||
| QuantConnectCredentials, | ||
| QuantConnectLiveDeployment, | ||
| QuantConnectPaperBrokerageSettings, | ||
| redact_sensitive_payload, | ||
| ) | ||
|
|
||
| __all__ = [ | ||
| "DEFAULT_QUANTCONNECT_API_BASE_URL", | ||
| "BrokerageHolding", | ||
| "CashAmount", | ||
| "InteractiveBrokersBrokerageSettings", | ||
| "QuantConnectApiError", | ||
| "QuantConnectCredentials", | ||
| "QuantConnectLiveConnector", | ||
| "QuantConnectLiveDeployment", | ||
| "QuantConnectPaperBrokerageSettings", | ||
| "QuantConnectRestClient", | ||
| "redact_sensitive_payload", | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import time | ||
| import urllib.error | ||
| import urllib.request | ||
| from collections.abc import Mapping | ||
| from dataclasses import dataclass, field | ||
| from typing import Any, Callable | ||
|
|
||
| from .models import QuantConnectCredentials, QuantConnectLiveDeployment, redact_sensitive_payload | ||
|
|
||
|
|
||
| DEFAULT_QUANTCONNECT_API_BASE_URL = "https://www.quantconnect.com/api/v2" | ||
|
|
||
|
|
||
| class QuantConnectApiError(RuntimeError): | ||
| def __init__( | ||
| self, | ||
| message: str, | ||
| *, | ||
| status_code: int | None = None, | ||
| payload: Mapping[str, Any] | None = None, | ||
| ) -> None: | ||
| super().__init__(message) | ||
| self.status_code = status_code | ||
| self.payload = dict(payload or {}) | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class QuantConnectRestClient: | ||
| credentials: QuantConnectCredentials | ||
| api_base_url: str = DEFAULT_QUANTCONNECT_API_BASE_URL | ||
| timeout: float = 15.0 | ||
| opener: Any = None | ||
| clock: Callable[[], float] = field(default=time.time, repr=False) | ||
|
|
||
| def authenticate(self) -> dict[str, Any]: | ||
| return self.post_json("/authenticate", {}) | ||
|
|
||
| def create_live_algorithm(self, deployment: QuantConnectLiveDeployment | Mapping[str, Any]) -> dict[str, Any]: | ||
| payload = deployment.to_payload() if hasattr(deployment, "to_payload") else dict(deployment) | ||
| return self.post_json("/live/create", payload) | ||
|
|
||
| def read_live_algorithm(self, *, project_id: int, deploy_id: str) -> dict[str, Any]: | ||
| return self.post_json( | ||
| "/live/read", | ||
| { | ||
| "projectId": int(project_id), | ||
| "deployId": str(deploy_id).strip(), | ||
| }, | ||
| ) | ||
|
|
||
| def list_live_algorithms( | ||
| self, | ||
| *, | ||
| project_id: int | None = None, | ||
| status: str | None = None, | ||
| ) -> dict[str, Any]: | ||
| payload: dict[str, Any] = {} | ||
| if project_id is not None: | ||
| payload["projectId"] = int(project_id) | ||
| text_status = str(status or "").strip() | ||
| if text_status: | ||
| payload["status"] = text_status | ||
| return self.post_json("/live/list", payload) | ||
|
|
||
| def stop_live_algorithm(self, *, project_id: int) -> dict[str, Any]: | ||
| return self.post_json("/live/update/stop", {"projectId": int(project_id)}) | ||
|
|
||
| def liquidate_live_algorithm(self, *, project_id: int) -> dict[str, Any]: | ||
| return self.post_json("/live/update/liquidate", {"projectId": int(project_id)}) | ||
|
|
||
| def post_json(self, path: str, payload: Mapping[str, Any] | None = None) -> dict[str, Any]: | ||
| request_payload = dict(payload or {}) | ||
| request = urllib.request.Request( | ||
| self._endpoint(path), | ||
| data=json.dumps(request_payload, ensure_ascii=False).encode("utf-8"), | ||
| headers={ | ||
| **self.credentials.build_auth_headers(clock=self.clock), | ||
| "Content-Type": "application/json", | ||
| }, | ||
| method="POST", | ||
| ) | ||
|
|
||
| try: | ||
| with self._opener()(request, timeout=self.timeout) as response: | ||
| status_code = _response_status(response) | ||
| raw_body = response.read() | ||
| except urllib.error.HTTPError as exc: | ||
| status_code = int(exc.code) | ||
| raw_body = exc.read() | ||
| parsed_error = _parse_response_body(raw_body) | ||
| raise QuantConnectApiError( | ||
| f"QuantConnect API request failed with HTTP {status_code}", | ||
| status_code=status_code, | ||
| payload=redact_sensitive_payload(parsed_error), | ||
| ) from exc | ||
|
|
||
| result = _parse_response_body(raw_body) | ||
| if status_code < 200 or status_code >= 300: | ||
| raise QuantConnectApiError( | ||
| f"QuantConnect API request failed with HTTP {status_code}", | ||
| status_code=status_code, | ||
| payload=redact_sensitive_payload(result), | ||
| ) | ||
| if result.get("success") is False: | ||
| errors = result.get("errors") | ||
| message = "QuantConnect API request failed" | ||
| if errors: | ||
| message = f"{message}: {errors}" | ||
| raise QuantConnectApiError( | ||
| message, | ||
| status_code=status_code, | ||
| payload=redact_sensitive_payload(result), | ||
| ) | ||
| return result | ||
|
|
||
| def _endpoint(self, path: str) -> str: | ||
| base_url = str(self.api_base_url or DEFAULT_QUANTCONNECT_API_BASE_URL).rstrip("/") | ||
| endpoint_path = str(path or "").strip().lstrip("/") | ||
| if not endpoint_path: | ||
| raise ValueError("path must not be empty.") | ||
| return f"{base_url}/{endpoint_path}" | ||
|
|
||
| def _opener(self) -> Any: | ||
| return self.opener or urllib.request.urlopen | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class QuantConnectLiveConnector: | ||
| client: QuantConnectRestClient | ||
|
|
||
| def deploy(self, deployment: QuantConnectLiveDeployment) -> dict[str, Any]: | ||
| return self.client.create_live_algorithm(deployment) | ||
|
|
||
| def running_deployments(self, *, project_id: int | None = None) -> tuple[dict[str, Any], ...]: | ||
| result = self.client.list_live_algorithms(project_id=project_id, status="Running") | ||
| live = result.get("live") or () | ||
| if not isinstance(live, list): | ||
| return () | ||
| return tuple(dict(item) for item in live if isinstance(item, Mapping)) | ||
|
|
||
| def stop_project(self, *, project_id: int, liquidate: bool = False) -> dict[str, Any]: | ||
| if liquidate: | ||
| return self.client.liquidate_live_algorithm(project_id=project_id) | ||
| return self.client.stop_live_algorithm(project_id=project_id) | ||
|
|
||
|
|
||
| def _response_status(response: Any) -> int: | ||
| status = getattr(response, "status", None) | ||
| if status is None: | ||
| status = response.getcode() | ||
| return int(status) | ||
|
|
||
|
|
||
| def _parse_response_body(raw_body: bytes | str | None) -> dict[str, Any]: | ||
| if raw_body is None or raw_body == b"" or raw_body == "": | ||
| return {} | ||
|
Comment on lines
+158
to
+159
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Returning Useful? React with 👍 / 👎. |
||
| if isinstance(raw_body, bytes): | ||
| body_text = raw_body.decode("utf-8") | ||
| else: | ||
| body_text = raw_body | ||
| parsed = json.loads(body_text) | ||
| if not isinstance(parsed, dict): | ||
| raise QuantConnectApiError("QuantConnect API response must decode to an object.") | ||
| return parsed | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the
HTTPErrorpath, the code unconditionally calls_parse_response_body(raw_body), but many gateways/proxies return HTML or empty bodies on 4xx/5xx responses; in those casesjson.loadsraises and callers receive a decode exception instead ofQuantConnectApiErrorwith the HTTP status. That breaks normal API error handling/retry logic exactly when the service is degraded, so this branch should tolerate parse failure and still raiseQuantConnectApiError.Useful? React with 👍 / 👎.