Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ It contains:
- common domain models and runtime target helpers
- narrow ports for market data, portfolio snapshots, order execution, notifications, and state
- reusable broker adapter utilities
- QuantConnect Cloud deployment helpers for hybrid hosted/self-hosted runtimes
- strategy loading, strategy-plugin, and alert-message contracts
- optional strategy-plugin alert channels for email, SMS, push, and Telegram providers
- synthetic-data tests for public behavior
Expand Down Expand Up @@ -60,10 +61,13 @@ src/quant_platform_kit/
binance/
schwab/
longbridge/
quantconnect/
notifications/
tests/
```

See [docs/quantconnect.md](./docs/quantconnect.md) for the public QuantConnect connector contract and placeholder-only examples.

## Development

Run the public test suite:
Expand Down
4 changes: 4 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- 通用领域模型和运行目标 helper
- 市场数据、持仓快照、订单执行、通知、状态存储等窄接口
- 可复用的券商适配工具
- 面向混合托管/自托管运行时的 QuantConnect Cloud 部署 helper
- 策略加载、策略插件、告警消息契约
- 可选的策略插件 email、SMS、push 和 Telegram 告警通道
- 使用合成数据的公开测试
Expand Down Expand Up @@ -60,10 +61,13 @@ src/quant_platform_kit/
binance/
schwab/
longbridge/
quantconnect/
notifications/
tests/
```

公开的 QuantConnect 连接器契约和仅含占位符的示例见 [docs/quantconnect.md](./docs/quantconnect.md)。

## 开发

运行公开测试:
Expand Down
92 changes: 92 additions & 0 deletions docs/quantconnect.md
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>
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "quant-platform-kit"
version = "0.7.32"
version = "0.7.33"
description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies."
readme = "README.md"
requires-python = ">=3.9"
Expand Down
2 changes: 1 addition & 1 deletion src/quant_platform_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
used by older strategy repositories.
"""

__version__ = "0.7.21"
__version__ = "0.7.33"

from .common.models import (
ExecutionReport,
Expand Down
31 changes: 31 additions & 0 deletions src/quant_platform_kit/quantconnect/__init__.py
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",
]
167 changes: 167 additions & 0 deletions src/quant_platform_kit/quantconnect/client.py
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle non-JSON HTTP error bodies without crashing

In the HTTPError path, the code unconditionally calls _parse_response_body(raw_body), but many gateways/proxies return HTML or empty bodies on 4xx/5xx responses; in those cases json.loads raises and callers receive a decode exception instead of QuantConnectApiError with 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 raise QuantConnectApiError.

Useful? React with 👍 / 👎.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject empty 2xx responses from QuantConnect API

Returning {} for an empty response body makes post_json treat a 2xx response as success unless success is explicitly False, so truncated/invalid responses can silently pass through (for example, deploy calls can return no deployId without raising). Since the API contract documents object responses with a success field, empty bodies should be treated as protocol errors and surfaced as QuantConnectApiError.

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
Loading