nexusquant-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .env
8
+ .DS_Store
9
+ /.idea
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: nexusquant-sdk
3
+ Version: 0.1.0
4
+ Summary: NexusQuant strategy provider Python SDK
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: platformdirs>=4.2
8
+ Description-Content-Type: text/markdown
9
+
10
+ # nexusquant-sdk
11
+
12
+ NexusQuant 量化策略提供者 Python SDK,用于在 NexusQuant 平台上注册、管理量化策略并发送交易信号。
13
+
14
+ ## 安装
15
+
16
+ ```bash
17
+ pip install nexusquant-sdk
18
+ ```
19
+
20
+ 依赖 Python 3.10 及以上版本。
21
+
22
+ ## 快速开始
23
+
24
+ ```python
25
+ import nexusquant_sdk as nq
26
+
27
+ # 登录(首次使用,会打开浏览器完成授权)
28
+ nq.auth_login()
29
+
30
+ # 查看已注册的策略列表
31
+ strategies = nq.strategy_list()
32
+
33
+ # 发送单笔交易信号
34
+ nq.strategy_send_signal_single(
35
+ "my_alpha",
36
+ ticker="AAPL",
37
+ direction="buy", # "buy" 或 "sell"
38
+ price=150.0,
39
+ quantity=100, # 股数
40
+ order_type="LIMIT", # "MARKET" 或 "LIMIT"
41
+ )
42
+ ```
43
+
44
+ ## 认证
45
+
46
+ SDK 使用 OAuth 2.0 PKCE 流程完成登录,令牌存储在本地,无需每次重新登录。
47
+
48
+ ```python
49
+ nq.auth_login() # 打开浏览器完成登录,保存令牌到本地
50
+ nq.auth_logged_in() # 检查是否已登录,返回 True/False
51
+ nq.auth_refresh() # 使用 refresh token 刷新 ID token
52
+ nq.auth_logout() # 删除本地令牌,退出登录
53
+ nq.auth_credentials_path() # 查看令牌文件的本地路径
54
+ ```
55
+
56
+ ## 策略管理
57
+
58
+ ```python
59
+ # 注册或更新策略
60
+ nq.strategy_register("my_alpha", "我的 Alpha 策略", output_unit="SHARE_COUNT")
61
+
62
+ # 查询策略列表
63
+ nq.strategy_list()
64
+
65
+ # 查询信号历史
66
+ nq.strategy_signal_history("my_alpha", limit=20)
67
+
68
+ # 发送单笔信号
69
+ nq.strategy_send_signal_single(
70
+ "my_alpha",
71
+ ticker="AAPL",
72
+ direction="buy",
73
+ price=150.0,
74
+ quantity=100,
75
+ order_type="LIMIT",
76
+ )
77
+
78
+ # 批量发送信号(多路由映射)
79
+ nq.strategy_send_signals("my_alpha", {"default": { ... }})
80
+
81
+ # 从本地 JSON 文件发送信号
82
+ nq.strategy_send_signals_from_path("my_alpha", "signals.json")
83
+
84
+ # 查询订阅者配置快照
85
+ nq.strategy_subscriber_config("my_alpha")
86
+ ```
87
+
88
+ ## 环境变量配置
89
+
90
+ 可通过以下环境变量覆盖默认配置:
91
+
92
+ | 变量名 | 说明 |
93
+ |---|---|
94
+ | `NEXUSQUANT_API_ENDPOINT` | API 服务地址(默认:`https://api.nexusquant.co`) |
95
+ | `NEXUSQUANT_COGNITO_DOMAIN` | 认证服务域名 |
96
+ | `NEXUSQUANT_COGNITO_CLIENT_ID` | OAuth 客户端 ID |
97
+ | `NEXUSQUANT_REDIRECT_URI` | OAuth 回调地址(默认:`http://127.0.0.1:8251/callback`) |
98
+
99
+ ## 依赖
100
+
101
+ - Python 3.10+
102
+ - `httpx >= 0.27`
103
+ - `platformdirs >= 4.2`
@@ -0,0 +1,94 @@
1
+ # nexusquant-sdk
2
+
3
+ NexusQuant 量化策略提供者 Python SDK,用于在 NexusQuant 平台上注册、管理量化策略并发送交易信号。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ pip install nexusquant-sdk
9
+ ```
10
+
11
+ 依赖 Python 3.10 及以上版本。
12
+
13
+ ## 快速开始
14
+
15
+ ```python
16
+ import nexusquant_sdk as nq
17
+
18
+ # 登录(首次使用,会打开浏览器完成授权)
19
+ nq.auth_login()
20
+
21
+ # 查看已注册的策略列表
22
+ strategies = nq.strategy_list()
23
+
24
+ # 发送单笔交易信号
25
+ nq.strategy_send_signal_single(
26
+ "my_alpha",
27
+ ticker="AAPL",
28
+ direction="buy", # "buy" 或 "sell"
29
+ price=150.0,
30
+ quantity=100, # 股数
31
+ order_type="LIMIT", # "MARKET" 或 "LIMIT"
32
+ )
33
+ ```
34
+
35
+ ## 认证
36
+
37
+ SDK 使用 OAuth 2.0 PKCE 流程完成登录,令牌存储在本地,无需每次重新登录。
38
+
39
+ ```python
40
+ nq.auth_login() # 打开浏览器完成登录,保存令牌到本地
41
+ nq.auth_logged_in() # 检查是否已登录,返回 True/False
42
+ nq.auth_refresh() # 使用 refresh token 刷新 ID token
43
+ nq.auth_logout() # 删除本地令牌,退出登录
44
+ nq.auth_credentials_path() # 查看令牌文件的本地路径
45
+ ```
46
+
47
+ ## 策略管理
48
+
49
+ ```python
50
+ # 注册或更新策略
51
+ nq.strategy_register("my_alpha", "我的 Alpha 策略", output_unit="SHARE_COUNT")
52
+
53
+ # 查询策略列表
54
+ nq.strategy_list()
55
+
56
+ # 查询信号历史
57
+ nq.strategy_signal_history("my_alpha", limit=20)
58
+
59
+ # 发送单笔信号
60
+ nq.strategy_send_signal_single(
61
+ "my_alpha",
62
+ ticker="AAPL",
63
+ direction="buy",
64
+ price=150.0,
65
+ quantity=100,
66
+ order_type="LIMIT",
67
+ )
68
+
69
+ # 批量发送信号(多路由映射)
70
+ nq.strategy_send_signals("my_alpha", {"default": { ... }})
71
+
72
+ # 从本地 JSON 文件发送信号
73
+ nq.strategy_send_signals_from_path("my_alpha", "signals.json")
74
+
75
+ # 查询订阅者配置快照
76
+ nq.strategy_subscriber_config("my_alpha")
77
+ ```
78
+
79
+ ## 环境变量配置
80
+
81
+ 可通过以下环境变量覆盖默认配置:
82
+
83
+ | 变量名 | 说明 |
84
+ |---|---|
85
+ | `NEXUSQUANT_API_ENDPOINT` | API 服务地址(默认:`https://api.nexusquant.co`) |
86
+ | `NEXUSQUANT_COGNITO_DOMAIN` | 认证服务域名 |
87
+ | `NEXUSQUANT_COGNITO_CLIENT_ID` | OAuth 客户端 ID |
88
+ | `NEXUSQUANT_REDIRECT_URI` | OAuth 回调地址(默认:`http://127.0.0.1:8251/callback`) |
89
+
90
+ ## 依赖
91
+
92
+ - Python 3.10+
93
+ - `httpx >= 0.27`
94
+ - `platformdirs >= 4.2`
@@ -0,0 +1,34 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from nexusquant_sdk._core import (
4
+ auth_credentials_path,
5
+ auth_logged_in,
6
+ auth_login,
7
+ auth_logout,
8
+ auth_refresh,
9
+ get_version,
10
+ strategy_list,
11
+ strategy_register,
12
+ strategy_send_signal_single,
13
+ strategy_send_signals,
14
+ strategy_send_signals_from_path,
15
+ strategy_signal_history,
16
+ strategy_subscriber_config,
17
+ )
18
+
19
+ __all__ = [
20
+ "__version__",
21
+ "auth_credentials_path",
22
+ "auth_logged_in",
23
+ "auth_login",
24
+ "auth_logout",
25
+ "auth_refresh",
26
+ "get_version",
27
+ "strategy_list",
28
+ "strategy_register",
29
+ "strategy_send_signal_single",
30
+ "strategy_send_signals",
31
+ "strategy_send_signals_from_path",
32
+ "strategy_signal_history",
33
+ "strategy_subscriber_config",
34
+ ]
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from nexusquant_sdk._auth_pkce import ensure_fresh_id_token
9
+ from nexusquant_sdk._config import api_base_url
10
+
11
+
12
+ def _headers() -> dict[str, str]:
13
+ token = ensure_fresh_id_token()
14
+ return {
15
+ "Authorization": f"Bearer {token}",
16
+ "Accept": "application/json",
17
+ }
18
+
19
+
20
+ def _raise_for_error(response: httpx.Response) -> None:
21
+ if response.status_code < 400:
22
+ return
23
+ try:
24
+ detail: Any = response.json()
25
+ except json.JSONDecodeError:
26
+ detail = response.text
27
+ raise RuntimeError(f"HTTP {response.status_code}: {detail}")
28
+
29
+
30
+ def get_json(
31
+ path: str, *, params: dict[str, Any] | None = None, timeout: float = 60.0
32
+ ) -> Any:
33
+ with httpx.Client(timeout=timeout) as client:
34
+ response = client.get(
35
+ f"{api_base_url()}{path}", headers=_headers(), params=params
36
+ )
37
+ _raise_for_error(response)
38
+ return response.json()
39
+
40
+
41
+ def post_json(path: str, body: Any, *, timeout: float = 60.0) -> Any:
42
+ headers = {**_headers(), "Content-Type": "application/json"}
43
+ with httpx.Client(timeout=timeout) as client:
44
+ response = client.post(f"{api_base_url()}{path}", headers=headers, json=body)
45
+ _raise_for_error(response)
46
+ return response.json()
47
+
48
+
49
+ def create_strategy(body: dict[str, Any]) -> Any:
50
+ """POST /strategy-signal/register/ — create/update an external strategy."""
51
+ return post_json("/strategy-signal/register/", body)
52
+
53
+
54
+ def list_provider_strategies() -> Any:
55
+ """GET /strategy-signal/provider-strategies/ — owned strategies, or all for admin."""
56
+ return get_json("/strategy-signal/provider-strategies/")
57
+
58
+
59
+ def get_provider_signal_history(
60
+ strategy_id: str,
61
+ *,
62
+ limit: int,
63
+ offset: int,
64
+ direction: str | None = None,
65
+ status: str | None = None,
66
+ ) -> Any:
67
+ """GET /strategy-signal/provider-signals/{strategy_id}/ — scoped signal history."""
68
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
69
+ if direction:
70
+ params["direction"] = direction
71
+ if status:
72
+ params["status"] = status
73
+ return get_json(f"/strategy-signal/provider-signals/{strategy_id}/", params=params)
74
+
75
+
76
+ def send_strategy_signal(body: dict[str, Any]) -> Any:
77
+ """POST /strategy-signal/ — send one signal or a signals map."""
78
+ return post_json("/strategy-signal/", body)
79
+
80
+
81
+ def get_subscriber_configs(strategy_id: str) -> Any:
82
+ """GET /strategy-signal/subscriber-configs/{strategy_id}/ — non-PII subscriber config."""
83
+ return get_json(f"/strategy-signal/subscriber-configs/{strategy_id}/")
@@ -0,0 +1,243 @@
1
+ """
2
+ Cognito Hosted UI login via OAuth 2.0 Authorization Code + PKCE.
3
+
4
+ Nexus service reads ``custom:userType`` from JWT claims for provider/admin
5
+ authorization, so the SDK stores and sends the ID token as ``Authorization:
6
+ Bearer``. Refresh uses the Cognito refresh token when the ID token is near expiry.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import secrets
15
+ import threading
16
+ import time
17
+ import urllib.parse
18
+ import webbrowser
19
+ from http.server import BaseHTTPRequestHandler, HTTPServer
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from nexusquant_sdk._config import (
25
+ clear_credentials,
26
+ cognito_client_id,
27
+ cognito_domain_host,
28
+ load_credentials,
29
+ redirect_uri,
30
+ save_credentials,
31
+ )
32
+
33
+
34
+ def _b64url(data: bytes) -> str:
35
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
36
+
37
+
38
+ def _pkce_pair() -> tuple[str, str]:
39
+ verifier = secrets.token_urlsafe(48)
40
+ challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
41
+ return verifier, challenge
42
+
43
+
44
+ def _parse_query(path: str) -> dict[str, str]:
45
+ query = urllib.parse.urlparse(path).query
46
+ return dict(urllib.parse.parse_qsl(query))
47
+
48
+
49
+ def _token_url(domain_host: str) -> str:
50
+ return f"https://{domain_host}/oauth2/token"
51
+
52
+
53
+ def _post_token(domain_host: str, body: dict[str, Any]) -> dict[str, Any]:
54
+ with httpx.Client(timeout=30.0) as client:
55
+ response = client.post(
56
+ _token_url(domain_host),
57
+ data=body,
58
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
59
+ )
60
+ if response.status_code >= 400:
61
+ try:
62
+ detail: Any = response.json()
63
+ except json.JSONDecodeError:
64
+ detail = response.text
65
+ raise RuntimeError(
66
+ f"Cognito token endpoint error ({response.status_code}): {detail}"
67
+ )
68
+ return response.json()
69
+
70
+
71
+ def refresh_tokens(
72
+ *,
73
+ domain_host: str,
74
+ client_id: str,
75
+ refresh_token: str,
76
+ ) -> dict[str, Any]:
77
+ return _post_token(
78
+ domain_host,
79
+ {
80
+ "grant_type": "refresh_token",
81
+ "client_id": client_id,
82
+ "refresh_token": refresh_token,
83
+ },
84
+ )
85
+
86
+
87
+ def exchange_code_for_tokens(
88
+ *,
89
+ domain_host: str,
90
+ client_id: str,
91
+ code: str,
92
+ redirect_uri_value: str,
93
+ code_verifier: str,
94
+ ) -> dict[str, Any]:
95
+ return _post_token(
96
+ domain_host,
97
+ {
98
+ "grant_type": "authorization_code",
99
+ "client_id": client_id,
100
+ "code": code,
101
+ "redirect_uri": redirect_uri_value,
102
+ "code_verifier": code_verifier,
103
+ },
104
+ )
105
+
106
+
107
+ def run_browser_login() -> dict[str, Any]:
108
+ domain_host = cognito_domain_host()
109
+ client_id = cognito_client_id()
110
+ redirect_uri_value = redirect_uri()
111
+ verifier, challenge = _pkce_pair()
112
+
113
+ auth_params = {
114
+ "response_type": "code",
115
+ "client_id": client_id,
116
+ "redirect_uri": redirect_uri_value,
117
+ "scope": "openid email phone",
118
+ "code_challenge_method": "S256",
119
+ "code_challenge": challenge,
120
+ }
121
+ auth_url = (
122
+ f"https://{domain_host}/oauth2/authorize?{urllib.parse.urlencode(auth_params)}"
123
+ )
124
+
125
+ code_holder: dict[str, str | None] = {"code": None, "error": None}
126
+ done = threading.Event()
127
+
128
+ class Handler(BaseHTTPRequestHandler):
129
+ def log_message(self, _format: str, *_args: object) -> None:
130
+ return
131
+
132
+ def do_GET(self) -> None: # noqa: N802
133
+ params = _parse_query(self.path)
134
+ if "code" in params:
135
+ code_holder["code"] = params["code"]
136
+ self.send_response(200)
137
+ self.send_header("Content-Type", "text/html; charset=utf-8")
138
+ self.end_headers()
139
+ self.wfile.write(
140
+ b"<html><body><p>NexusQuant login successful. You can close this tab.</p></body></html>"
141
+ )
142
+ else:
143
+ error = (
144
+ params.get("error_description")
145
+ or params.get("error")
146
+ or "unknown_error"
147
+ )
148
+ code_holder["error"] = error
149
+ self.send_response(400)
150
+ self.send_header("Content-Type", "text/html; charset=utf-8")
151
+ self.end_headers()
152
+ self.wfile.write(
153
+ f"<html><body><p>NexusQuant login failed: {error}</p></body></html>".encode(
154
+ "utf-8"
155
+ )
156
+ )
157
+ done.set()
158
+
159
+ parsed = urllib.parse.urlparse(redirect_uri_value)
160
+ if parsed.hostname not in ("127.0.0.1", "localhost") or not parsed.port:
161
+ raise RuntimeError("NEXUSQUANT_REDIRECT_URI must be localhost with a port.")
162
+
163
+ bind_host = "127.0.0.1" if parsed.hostname == "localhost" else parsed.hostname
164
+ server = HTTPServer((bind_host or "127.0.0.1", parsed.port), Handler)
165
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
166
+ thread.start()
167
+ webbrowser.open(auth_url)
168
+
169
+ if not done.wait(timeout=300):
170
+ server.shutdown()
171
+ raise TimeoutError("No OAuth callback received within 5 minutes.")
172
+ server.shutdown()
173
+
174
+ if code_holder["error"]:
175
+ raise RuntimeError(f"Cognito error: {code_holder['error']}")
176
+ code = code_holder["code"]
177
+ if not code:
178
+ raise RuntimeError("No authorization code in callback.")
179
+
180
+ tokens = exchange_code_for_tokens(
181
+ domain_host=domain_host,
182
+ client_id=client_id,
183
+ code=code,
184
+ redirect_uri_value=redirect_uri_value,
185
+ code_verifier=verifier,
186
+ )
187
+ if "id_token" not in tokens:
188
+ raise RuntimeError("Cognito token response missing id_token.")
189
+ return tokens
190
+
191
+
192
+ def persist_tokens(tokens: dict[str, Any]) -> None:
193
+ save_credentials(
194
+ {
195
+ "id_token": tokens["id_token"],
196
+ "access_token": tokens.get("access_token"),
197
+ "refresh_token": tokens.get("refresh_token"),
198
+ "expires_in": int(tokens.get("expires_in") or 0),
199
+ "saved_at": time.time(),
200
+ }
201
+ )
202
+
203
+
204
+ def ensure_fresh_id_token(*, force_refresh: bool = False) -> str:
205
+ creds = load_credentials()
206
+ if not creds or not creds.get("id_token"):
207
+ raise RuntimeError("Not logged in. Please call auth_login() first.")
208
+
209
+ expires_in = int(creds.get("expires_in") or 0)
210
+ saved_at = float(creds.get("saved_at") or 0)
211
+ remaining = saved_at + expires_in - time.time() if expires_in > 0 else 999999
212
+ refresh_token = creds.get("refresh_token")
213
+ if not force_refresh and (remaining > 120 or not refresh_token):
214
+ return str(creds["id_token"])
215
+ if not refresh_token:
216
+ return str(creds["id_token"])
217
+
218
+ try:
219
+ new_tokens = refresh_tokens(
220
+ domain_host=cognito_domain_host(),
221
+ client_id=cognito_client_id(),
222
+ refresh_token=refresh_token,
223
+ )
224
+ except RuntimeError as exc:
225
+ if "invalid_grant" in str(exc):
226
+ clear_credentials()
227
+ raise RuntimeError(
228
+ "Refresh token expired or revoked. Please call auth_login() again."
229
+ ) from exc
230
+ raise
231
+ merged = {
232
+ "id_token": new_tokens.get("id_token") or creds["id_token"],
233
+ "access_token": new_tokens.get("access_token") or creds.get("access_token"),
234
+ "refresh_token": new_tokens.get("refresh_token") or refresh_token,
235
+ "expires_in": int(new_tokens.get("expires_in") or expires_in),
236
+ "saved_at": time.time(),
237
+ }
238
+ save_credentials(merged)
239
+ return str(merged["id_token"])
240
+
241
+
242
+ def login_and_save() -> None:
243
+ persist_tokens(run_browser_login())
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from platformdirs import user_config_dir
9
+
10
+ CONFIG_DIR = Path(user_config_dir("nexusquant-sdk", appauthor=False))
11
+ CREDENTIALS_PATH = CONFIG_DIR / "credentials.json"
12
+
13
+ DEFAULT_API_ENDPOINT = "https://api.nexusquant.co"
14
+ DEFAULT_COGNITO_DOMAIN = "auth.lookatwallstreet.com"
15
+ DEFAULT_COGNITO_CLIENT_ID = "5o7hhd4hn4birr8c29vndehiat"
16
+ DEFAULT_REDIRECT_URI = "http://127.0.0.1:8251/callback"
17
+
18
+
19
+ def _env(name: str, default: str | None = None) -> str | None:
20
+ value = os.environ.get(name)
21
+ if value is not None and value.strip():
22
+ return value.strip()
23
+ return default
24
+
25
+
26
+ def api_base_url() -> str:
27
+ endpoint = (
28
+ _env("NEXUSQUANT_API_ENDPOINT", DEFAULT_API_ENDPOINT) or DEFAULT_API_ENDPOINT
29
+ ).rstrip("/")
30
+ base_override = _env("NEXUSQUANT_API_BASE_URL")
31
+ if base_override:
32
+ return base_override.rstrip("/")
33
+ return f"{endpoint}/api"
34
+
35
+
36
+ def cognito_domain_host() -> str:
37
+ domain = (
38
+ _env("NEXUSQUANT_COGNITO_DOMAIN", DEFAULT_COGNITO_DOMAIN)
39
+ or DEFAULT_COGNITO_DOMAIN
40
+ )
41
+ return domain.removeprefix("https://").removeprefix("http://").split("/")[0]
42
+
43
+
44
+ def cognito_client_id() -> str:
45
+ return (
46
+ _env("NEXUSQUANT_COGNITO_CLIENT_ID", DEFAULT_COGNITO_CLIENT_ID)
47
+ or DEFAULT_COGNITO_CLIENT_ID
48
+ )
49
+
50
+
51
+ def redirect_uri() -> str:
52
+ return _env("NEXUSQUANT_REDIRECT_URI", DEFAULT_REDIRECT_URI) or DEFAULT_REDIRECT_URI
53
+
54
+
55
+ def load_credentials() -> dict[str, Any] | None:
56
+ if not CREDENTIALS_PATH.is_file():
57
+ return None
58
+ try:
59
+ return json.loads(CREDENTIALS_PATH.read_text(encoding="utf-8"))
60
+ except (json.JSONDecodeError, OSError):
61
+ return None
62
+
63
+
64
+ def save_credentials(data: dict[str, Any]) -> None:
65
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
66
+ CREDENTIALS_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
67
+ try:
68
+ CREDENTIALS_PATH.chmod(0o600)
69
+ except OSError:
70
+ pass
71
+
72
+
73
+ def clear_credentials() -> None:
74
+ try:
75
+ CREDENTIALS_PATH.unlink(missing_ok=True)
76
+ except OSError:
77
+ pass
@@ -0,0 +1,282 @@
1
+ """
2
+ Public SDK functions for the NexusQuant provider API.
3
+
4
+ Each function maps to an endpoint (and mirrors a CLI command).
5
+ Successful calls return the parsed JSON response (``dict`` / ``list`` / etc.).
6
+ Errors from HTTP or Cognito surface as ``RuntimeError``; invalid parameters
7
+ raise ``ValueError``.
8
+
9
+ Example::
10
+
11
+ import nexusquant_sdk as nq
12
+
13
+ nq.auth_login() # opens browser once
14
+
15
+ strategies = nq.strategy_list()
16
+ nq.strategy_send_signal_single(
17
+ "my_alpha",
18
+ ticker="AAPL",
19
+ direction="buy",
20
+ price=150.0,
21
+ quantity=100,
22
+ )
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from nexusquant_sdk import _api_client
33
+ from nexusquant_sdk._auth_pkce import ensure_fresh_id_token, login_and_save
34
+ from nexusquant_sdk._config import CREDENTIALS_PATH, clear_credentials, load_credentials
35
+
36
+
37
+ def get_version() -> str:
38
+ """Return the installed ``nexusquant-sdk`` package version string."""
39
+ import nexusquant_sdk as pkg
40
+
41
+ return pkg.__version__
42
+
43
+
44
+ def auth_login() -> None:
45
+ """Open the Cognito Hosted UI and persist tokens locally."""
46
+ login_and_save()
47
+
48
+
49
+ def auth_refresh(*, force_refresh: bool = True) -> None:
50
+ """Refresh the stored ID token using the refresh token."""
51
+ ensure_fresh_id_token(force_refresh=force_refresh)
52
+
53
+
54
+ def auth_logout() -> None:
55
+ """Remove locally saved Cognito tokens."""
56
+ clear_credentials()
57
+
58
+
59
+ def auth_credentials_path() -> str:
60
+ """Absolute path to the local credentials file."""
61
+ return str(CREDENTIALS_PATH)
62
+
63
+
64
+ def auth_logged_in() -> bool:
65
+ """Return ``True`` if an ID token is present locally."""
66
+ creds = load_credentials()
67
+ return bool(creds and creds.get("id_token"))
68
+
69
+
70
+ def _read_json_path(path: Path | str, *, label: str) -> Any:
71
+ p = Path(path)
72
+ try:
73
+ return json.loads(p.read_text(encoding="utf-8"))
74
+ except OSError as e:
75
+ raise ValueError(f"Cannot read {label}: {e}") from e
76
+ except json.JSONDecodeError as e:
77
+ raise ValueError(f"{label} must contain valid JSON: {e}") from e
78
+
79
+
80
+ def _loads_json(value: str | None, *, label: str) -> Any:
81
+ if value is None:
82
+ return None
83
+ try:
84
+ return json.loads(value)
85
+ except json.JSONDecodeError as e:
86
+ raise ValueError(f"{label} must be valid JSON: {e}") from e
87
+
88
+
89
+ def _now_iso() -> str:
90
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
91
+
92
+
93
+ def _strategy_name_or_id(strategy_name: str | None, strategy_id: str) -> str:
94
+ return strategy_name or strategy_id
95
+
96
+
97
+ def strategy_register(
98
+ strategy_id: str,
99
+ strategy_name: str,
100
+ *,
101
+ strategy_schema: dict[str, Any] | None = None,
102
+ strategy_schema_path: Path | str | None = None,
103
+ strategy_schema_json: str | None = None,
104
+ output_unit: str = "SHARE_COUNT",
105
+ description: str | None = None,
106
+ config: dict[str, Any] | None = None,
107
+ config_path: Path | str | None = None,
108
+ config_json: str | None = None,
109
+ enabled_execution_keys: list[str] | None = None,
110
+ ) -> Any:
111
+ """
112
+ Create or update an external strategy.
113
+
114
+ Supply at most one of ``strategy_schema``, ``strategy_schema_path``, or
115
+ ``strategy_schema_json``. If none are given, an empty object schema is used.
116
+ Same constraint applies to ``config`` / ``config_path`` / ``config_json``.
117
+ """
118
+ schema_sources = sum(
119
+ 1
120
+ for x in (strategy_schema, strategy_schema_path, strategy_schema_json)
121
+ if x is not None
122
+ )
123
+ if schema_sources > 1:
124
+ raise ValueError(
125
+ "Use only one of strategy_schema, strategy_schema_path, strategy_schema_json."
126
+ )
127
+ config_sources = sum(1 for x in (config, config_path, config_json) if x is not None)
128
+ if config_sources > 1:
129
+ raise ValueError("Use only one of config, config_path, config_json.")
130
+
131
+ if strategy_schema is not None:
132
+ resolved_schema: dict[str, Any] = strategy_schema
133
+ elif strategy_schema_path is not None:
134
+ loaded = _read_json_path(strategy_schema_path, label="strategy schema")
135
+ if not isinstance(loaded, dict):
136
+ raise ValueError("strategy schema file must contain a JSON object.")
137
+ resolved_schema = loaded
138
+ elif strategy_schema_json is not None:
139
+ loaded = _loads_json(strategy_schema_json, label="strategy schema")
140
+ if not isinstance(loaded, dict):
141
+ raise ValueError("strategy_schema_json must decode to a JSON object.")
142
+ resolved_schema = loaded
143
+ else:
144
+ resolved_schema = {"type": "object", "properties": {}, "required": []}
145
+
146
+ resolved_config: dict[str, Any] | None = None
147
+ if config is not None:
148
+ resolved_config = config
149
+ elif config_path is not None:
150
+ loaded = _read_json_path(config_path, label="strategy config")
151
+ if not isinstance(loaded, dict):
152
+ raise ValueError("strategy config file must contain a JSON object.")
153
+ resolved_config = loaded
154
+ elif config_json is not None:
155
+ loaded = _loads_json(config_json, label="strategy config")
156
+ if not isinstance(loaded, dict):
157
+ raise ValueError("config_json must decode to a JSON object.")
158
+ resolved_config = loaded
159
+
160
+ body: dict[str, Any] = {
161
+ "strategy_id": strategy_id,
162
+ "strategy_name": strategy_name,
163
+ "strategy_schema": resolved_schema,
164
+ "output_unit": output_unit,
165
+ }
166
+ if description is not None:
167
+ body["description"] = description
168
+ if resolved_config is not None:
169
+ body["config"] = resolved_config
170
+ if enabled_execution_keys is not None:
171
+ body["enabled_execution_keys"] = enabled_execution_keys
172
+ return _api_client.create_strategy(body)
173
+
174
+
175
+ def strategy_list() -> Any:
176
+ """List provider strategies."""
177
+ return _api_client.list_provider_strategies()
178
+
179
+
180
+ def strategy_signal_history(
181
+ strategy_id: str,
182
+ *,
183
+ limit: int = 50,
184
+ offset: int = 0,
185
+ direction: str | None = None,
186
+ status: str | None = None,
187
+ ) -> Any:
188
+ """Fetch signal history for a strategy."""
189
+ return _api_client.get_provider_signal_history(
190
+ strategy_id,
191
+ limit=limit,
192
+ offset=offset,
193
+ direction=direction,
194
+ status=status,
195
+ )
196
+
197
+
198
+ def strategy_send_signals(
199
+ strategy_id: str,
200
+ signals: dict[str, Any],
201
+ *,
202
+ strategy_name: str | None = None,
203
+ ) -> Any:
204
+ """
205
+ Send a multi-route ``signals`` map.
206
+
207
+ ``signals`` keys are ``"default"`` or user ids; values are per-route signal objects.
208
+ """
209
+ if not isinstance(signals, dict):
210
+ raise ValueError("signals must be a dict (JSON object).")
211
+ body: dict[str, Any] = {
212
+ "strategy_id": strategy_id,
213
+ "strategy_name": _strategy_name_or_id(strategy_name, strategy_id),
214
+ "signals": signals,
215
+ }
216
+ return _api_client.send_strategy_signal(body)
217
+
218
+
219
+ def strategy_send_signals_from_path(
220
+ strategy_id: str,
221
+ signals_path: Path | str,
222
+ *,
223
+ strategy_name: str | None = None,
224
+ ) -> Any:
225
+ """Load a UTF-8 JSON object from disk and send it as ``signals``."""
226
+ data = _read_json_path(signals_path, label="signals map")
227
+ if not isinstance(data, dict):
228
+ raise ValueError("signals file must contain a JSON object.")
229
+ return strategy_send_signals(strategy_id, data, strategy_name=strategy_name)
230
+
231
+
232
+ def strategy_send_signal_single(
233
+ strategy_id: str,
234
+ *,
235
+ ticker: str,
236
+ direction: str,
237
+ price: float,
238
+ quantity: int,
239
+ strategy_name: str | None = None,
240
+ time_iso: str | None = None,
241
+ order_type: str | None = None,
242
+ metadata: dict[str, Any] | None = None,
243
+ ) -> Any:
244
+ """
245
+ Send a single signal for a strategy.
246
+
247
+ ``direction`` must be ``"buy"`` or ``"sell"``.
248
+ ``quantity`` is share count (positive integer).
249
+ ``order_type`` may be ``"MARKET"``, ``"LIMIT"``, or legacy ``"NORMAL"`` (treated as LIMIT).
250
+ """
251
+ direction_norm = direction.strip().lower()
252
+ if direction_norm not in ("buy", "sell"):
253
+ raise ValueError('direction must be "buy" or "sell".')
254
+ if int(quantity) <= 0:
255
+ raise ValueError("quantity must be a positive integer.")
256
+ signal: dict[str, Any] = {
257
+ "ticker": ticker,
258
+ "time": time_iso or _now_iso(),
259
+ "price": price,
260
+ "direction": direction_norm,
261
+ "quantity": int(quantity),
262
+ }
263
+ if order_type is not None:
264
+ normalized = order_type.strip().upper()
265
+ if normalized == "NORMAL":
266
+ normalized = "LIMIT"
267
+ if normalized not in {"MARKET", "LIMIT"}:
268
+ raise ValueError('order_type must be "MARKET", "LIMIT", or legacy "NORMAL".')
269
+ signal["order_type"] = normalized
270
+ if metadata is not None:
271
+ signal["metadata"] = metadata
272
+ body: dict[str, Any] = {
273
+ "strategy_id": strategy_id,
274
+ "strategy_name": _strategy_name_or_id(strategy_name, strategy_id),
275
+ "signal": signal,
276
+ }
277
+ return _api_client.send_strategy_signal(body)
278
+
279
+
280
+ def strategy_subscriber_config(strategy_id: str) -> Any:
281
+ """Return non-PII subscriber configuration snapshot for a strategy."""
282
+ return _api_client.get_subscriber_configs(strategy_id)
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "nexusquant-sdk"
7
+ version = "0.1.0"
8
+ description = "NexusQuant strategy provider Python SDK"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "httpx>=0.27",
13
+ "platformdirs>=4.2",
14
+ ]
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["nexusquant_sdk"]
18
+
19
+ [tool.ruff]
20
+ line-length = 100
21
+ target-version = "py310"