nexusquant-sdk 0.1.0__py3-none-any.whl
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.
- nexusquant_sdk/__init__.py +34 -0
- nexusquant_sdk/_api_client.py +83 -0
- nexusquant_sdk/_auth_pkce.py +243 -0
- nexusquant_sdk/_config.py +77 -0
- nexusquant_sdk/_core.py +282 -0
- nexusquant_sdk-0.1.0.dist-info/METADATA +103 -0
- nexusquant_sdk-0.1.0.dist-info/RECORD +8 -0
- nexusquant_sdk-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|
nexusquant_sdk/_core.py
ADDED
|
@@ -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,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,8 @@
|
|
|
1
|
+
nexusquant_sdk/__init__.py,sha256=7P_cVjb0GpOQ4HQHTN5tGxpB1W67tDLmcesiCd-5h08,750
|
|
2
|
+
nexusquant_sdk/_api_client.py,sha256=wtaRMZc7ZKBoOJOwgychR-HpUJaDmrcQGrXNACpnR90,2616
|
|
3
|
+
nexusquant_sdk/_auth_pkce.py,sha256=BGujZzSbINT6TT5ubDEAY_Tp0edt1W7lViSdfGP9AMw,7649
|
|
4
|
+
nexusquant_sdk/_config.py,sha256=bbRdansv_OTCmQy3KP_bxx7OuQNXy2VDK1WtGCGTcr0,2133
|
|
5
|
+
nexusquant_sdk/_core.py,sha256=OOMFcrsFLtUX64llzxP-R74-Z9DF9EUnCbUgdcQRvzI,9140
|
|
6
|
+
nexusquant_sdk-0.1.0.dist-info/METADATA,sha256=YVcRmmwIe0El-tjYwjEbHoyFM4a6a13jXBCwdc1laH8,2549
|
|
7
|
+
nexusquant_sdk-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
nexusquant_sdk-0.1.0.dist-info/RECORD,,
|