Skip to content

Commit 550b853

Browse files
committed
Add QuantConnect connector framework
1 parent f176f5d commit 550b853

9 files changed

Lines changed: 813 additions & 2 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ It contains:
1313
- common domain models and runtime target helpers
1414
- narrow ports for market data, portfolio snapshots, order execution, notifications, and state
1515
- reusable broker adapter utilities
16+
- QuantConnect Cloud deployment helpers for hybrid hosted/self-hosted runtimes
1617
- strategy loading, strategy-plugin, and alert-message contracts
1718
- optional strategy-plugin alert channels for email, SMS, push, and Telegram providers
1819
- synthetic-data tests for public behavior
@@ -60,10 +61,13 @@ src/quant_platform_kit/
6061
binance/
6162
schwab/
6263
longbridge/
64+
quantconnect/
6365
notifications/
6466
tests/
6567
```
6668

69+
See [docs/quantconnect.md](./docs/quantconnect.md) for the public QuantConnect connector contract and placeholder-only examples.
70+
6771
## Development
6872

6973
Run the public test suite:

README.zh-CN.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- 通用领域模型和运行目标 helper
1414
- 市场数据、持仓快照、订单执行、通知、状态存储等窄接口
1515
- 可复用的券商适配工具
16+
- 面向混合托管/自托管运行时的 QuantConnect Cloud 部署 helper
1617
- 策略加载、策略插件、告警消息契约
1718
- 可选的策略插件 email、SMS、push 和 Telegram 告警通道
1819
- 使用合成数据的公开测试
@@ -60,10 +61,13 @@ src/quant_platform_kit/
6061
binance/
6162
schwab/
6263
longbridge/
64+
quantconnect/
6365
notifications/
6466
tests/
6567
```
6668

69+
公开的 QuantConnect 连接器契约和仅含占位符的示例见 [docs/quantconnect.md](./docs/quantconnect.md)
70+
6771
## 开发
6872

6973
运行公开测试:

docs/quantconnect.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# QuantConnect Connector
2+
3+
`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.
4+
5+
The connector supports:
6+
7+
- QuantConnect REST API authentication headers from a user id and API token
8+
- live algorithm management calls for authenticate, create, read, list, stop, and liquidate
9+
- QuantConnect live deployment payloads
10+
- Interactive Brokers brokerage payloads for QuantConnect Cloud
11+
- redacted payload helpers for logs and notifications
12+
13+
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.
14+
15+
## Example
16+
17+
```python
18+
from quant_platform_kit.quantconnect import (
19+
InteractiveBrokersBrokerageSettings,
20+
QuantConnectCredentials,
21+
QuantConnectLiveConnector,
22+
QuantConnectLiveDeployment,
23+
QuantConnectRestClient,
24+
)
25+
26+
credentials = QuantConnectCredentials.from_env(env)
27+
brokerage = InteractiveBrokersBrokerageSettings.from_env(env)
28+
29+
deployment = QuantConnectLiveDeployment(
30+
project_id=12345678,
31+
compile_id="compile-id-from-quantconnect",
32+
node_id="LN-node-id-from-quantconnect",
33+
brokerage=brokerage,
34+
data_providers={
35+
"InteractiveBrokersBrokerage": brokerage,
36+
},
37+
parameters={
38+
"strategy_profile": "example_strategy",
39+
"runtime_target": "quantconnect-cloud-slot-a",
40+
},
41+
)
42+
43+
client = QuantConnectRestClient(credentials=credentials)
44+
connector = QuantConnectLiveConnector(client)
45+
46+
# Use deployment.redacted_payload() for logs. Do not log deployment.to_payload().
47+
result = connector.deploy(deployment)
48+
```
49+
50+
The default environment variable names are:
51+
52+
```text
53+
QUANTCONNECT_USER_ID
54+
QUANTCONNECT_API_TOKEN
55+
QUANTCONNECT_ORGANIZATION_ID
56+
57+
QUANTCONNECT_IB_USER_NAME
58+
QUANTCONNECT_IB_ACCOUNT
59+
QUANTCONNECT_IB_PASSWORD
60+
QUANTCONNECT_IB_WEEKLY_RESTART_UTC_TIME
61+
QUANTCONNECT_IB_FINANCIAL_ADVISORS_GROUP_FILTER
62+
```
63+
64+
For a hybrid deployment, keep the target routing in a private platform repository or deployment secret:
65+
66+
```json
67+
{
68+
"self_hosted": [
69+
{
70+
"strategy_profile": "tqqq_growth_income",
71+
"platform": "interactive_brokers",
72+
"account_selector": ["U00000000"]
73+
}
74+
],
75+
"quantconnect_cloud": [
76+
{
77+
"strategy_profile": "example_strategy",
78+
"project_id": 12345678,
79+
"node_id": "LN-placeholder",
80+
"brokerage_secret": "qc-ibkr-slot-b"
81+
}
82+
]
83+
}
84+
```
85+
86+
The public example above uses placeholders only. Real account mappings and brokerage credentials must stay in private runtime configuration.
87+
88+
## References
89+
90+
- QuantConnect Cloud API live management: <https://www.quantconnect.com/docs/v2/cloud-platform/api-reference/live-management>
91+
- QuantConnect create live algorithm API: <https://www.quantconnect.com/docs/v2/cloud-platform/api-reference/live-management/create-live-algorithm>
92+
- Lean CLI cloud live deploy: <https://www.quantconnect.com/docs/v2/lean-cli/api-reference/lean-cloud-live-deploy>

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "quant-platform-kit"
7-
version = "0.7.32"
7+
version = "0.7.33"
88
description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies."
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/quant_platform_kit/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
used by older strategy repositories.
55
"""
66

7-
__version__ = "0.7.21"
7+
__version__ = "0.7.33"
88

99
from .common.models import (
1010
ExecutionReport,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""QuantConnect cloud deployment helpers."""
2+
3+
from .client import (
4+
DEFAULT_QUANTCONNECT_API_BASE_URL,
5+
QuantConnectApiError,
6+
QuantConnectLiveConnector,
7+
QuantConnectRestClient,
8+
)
9+
from .models import (
10+
BrokerageHolding,
11+
CashAmount,
12+
InteractiveBrokersBrokerageSettings,
13+
QuantConnectCredentials,
14+
QuantConnectLiveDeployment,
15+
QuantConnectPaperBrokerageSettings,
16+
redact_sensitive_payload,
17+
)
18+
19+
__all__ = [
20+
"DEFAULT_QUANTCONNECT_API_BASE_URL",
21+
"BrokerageHolding",
22+
"CashAmount",
23+
"InteractiveBrokersBrokerageSettings",
24+
"QuantConnectApiError",
25+
"QuantConnectCredentials",
26+
"QuantConnectLiveConnector",
27+
"QuantConnectLiveDeployment",
28+
"QuantConnectPaperBrokerageSettings",
29+
"QuantConnectRestClient",
30+
"redact_sensitive_payload",
31+
]
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import time
5+
import urllib.error
6+
import urllib.request
7+
from collections.abc import Mapping
8+
from dataclasses import dataclass, field
9+
from typing import Any, Callable
10+
11+
from .models import QuantConnectCredentials, QuantConnectLiveDeployment, redact_sensitive_payload
12+
13+
14+
DEFAULT_QUANTCONNECT_API_BASE_URL = "https://www.quantconnect.com/api/v2"
15+
16+
17+
class QuantConnectApiError(RuntimeError):
18+
def __init__(
19+
self,
20+
message: str,
21+
*,
22+
status_code: int | None = None,
23+
payload: Mapping[str, Any] | None = None,
24+
) -> None:
25+
super().__init__(message)
26+
self.status_code = status_code
27+
self.payload = dict(payload or {})
28+
29+
30+
@dataclass(frozen=True)
31+
class QuantConnectRestClient:
32+
credentials: QuantConnectCredentials
33+
api_base_url: str = DEFAULT_QUANTCONNECT_API_BASE_URL
34+
timeout: float = 15.0
35+
opener: Any = None
36+
clock: Callable[[], float] = field(default=time.time, repr=False)
37+
38+
def authenticate(self) -> dict[str, Any]:
39+
return self.post_json("/authenticate", {})
40+
41+
def create_live_algorithm(self, deployment: QuantConnectLiveDeployment | Mapping[str, Any]) -> dict[str, Any]:
42+
payload = deployment.to_payload() if hasattr(deployment, "to_payload") else dict(deployment)
43+
return self.post_json("/live/create", payload)
44+
45+
def read_live_algorithm(self, *, project_id: int, deploy_id: str) -> dict[str, Any]:
46+
return self.post_json(
47+
"/live/read",
48+
{
49+
"projectId": int(project_id),
50+
"deployId": str(deploy_id).strip(),
51+
},
52+
)
53+
54+
def list_live_algorithms(
55+
self,
56+
*,
57+
project_id: int | None = None,
58+
status: str | None = None,
59+
) -> dict[str, Any]:
60+
payload: dict[str, Any] = {}
61+
if project_id is not None:
62+
payload["projectId"] = int(project_id)
63+
text_status = str(status or "").strip()
64+
if text_status:
65+
payload["status"] = text_status
66+
return self.post_json("/live/list", payload)
67+
68+
def stop_live_algorithm(self, *, project_id: int) -> dict[str, Any]:
69+
return self.post_json("/live/update/stop", {"projectId": int(project_id)})
70+
71+
def liquidate_live_algorithm(self, *, project_id: int) -> dict[str, Any]:
72+
return self.post_json("/live/update/liquidate", {"projectId": int(project_id)})
73+
74+
def post_json(self, path: str, payload: Mapping[str, Any] | None = None) -> dict[str, Any]:
75+
request_payload = dict(payload or {})
76+
request = urllib.request.Request(
77+
self._endpoint(path),
78+
data=json.dumps(request_payload, ensure_ascii=False).encode("utf-8"),
79+
headers={
80+
**self.credentials.build_auth_headers(clock=self.clock),
81+
"Content-Type": "application/json",
82+
},
83+
method="POST",
84+
)
85+
86+
try:
87+
with self._opener()(request, timeout=self.timeout) as response:
88+
status_code = _response_status(response)
89+
raw_body = response.read()
90+
except urllib.error.HTTPError as exc:
91+
status_code = int(exc.code)
92+
raw_body = exc.read()
93+
parsed_error = _parse_response_body(raw_body)
94+
raise QuantConnectApiError(
95+
f"QuantConnect API request failed with HTTP {status_code}",
96+
status_code=status_code,
97+
payload=redact_sensitive_payload(parsed_error),
98+
) from exc
99+
100+
result = _parse_response_body(raw_body)
101+
if status_code < 200 or status_code >= 300:
102+
raise QuantConnectApiError(
103+
f"QuantConnect API request failed with HTTP {status_code}",
104+
status_code=status_code,
105+
payload=redact_sensitive_payload(result),
106+
)
107+
if result.get("success") is False:
108+
errors = result.get("errors")
109+
message = "QuantConnect API request failed"
110+
if errors:
111+
message = f"{message}: {errors}"
112+
raise QuantConnectApiError(
113+
message,
114+
status_code=status_code,
115+
payload=redact_sensitive_payload(result),
116+
)
117+
return result
118+
119+
def _endpoint(self, path: str) -> str:
120+
base_url = str(self.api_base_url or DEFAULT_QUANTCONNECT_API_BASE_URL).rstrip("/")
121+
endpoint_path = str(path or "").strip().lstrip("/")
122+
if not endpoint_path:
123+
raise ValueError("path must not be empty.")
124+
return f"{base_url}/{endpoint_path}"
125+
126+
def _opener(self) -> Any:
127+
return self.opener or urllib.request.urlopen
128+
129+
130+
@dataclass(frozen=True)
131+
class QuantConnectLiveConnector:
132+
client: QuantConnectRestClient
133+
134+
def deploy(self, deployment: QuantConnectLiveDeployment) -> dict[str, Any]:
135+
return self.client.create_live_algorithm(deployment)
136+
137+
def running_deployments(self, *, project_id: int | None = None) -> tuple[dict[str, Any], ...]:
138+
result = self.client.list_live_algorithms(project_id=project_id, status="Running")
139+
live = result.get("live") or ()
140+
if not isinstance(live, list):
141+
return ()
142+
return tuple(dict(item) for item in live if isinstance(item, Mapping))
143+
144+
def stop_project(self, *, project_id: int, liquidate: bool = False) -> dict[str, Any]:
145+
if liquidate:
146+
return self.client.liquidate_live_algorithm(project_id=project_id)
147+
return self.client.stop_live_algorithm(project_id=project_id)
148+
149+
150+
def _response_status(response: Any) -> int:
151+
status = getattr(response, "status", None)
152+
if status is None:
153+
status = response.getcode()
154+
return int(status)
155+
156+
157+
def _parse_response_body(raw_body: bytes | str | None) -> dict[str, Any]:
158+
if raw_body is None or raw_body == b"" or raw_body == "":
159+
return {}
160+
if isinstance(raw_body, bytes):
161+
body_text = raw_body.decode("utf-8")
162+
else:
163+
body_text = raw_body
164+
parsed = json.loads(body_text)
165+
if not isinstance(parsed, dict):
166+
raise QuantConnectApiError("QuantConnect API response must decode to an object.")
167+
return parsed

0 commit comments

Comments
 (0)