nexusquant-cli 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,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: nexusquant-cli
3
+ Version: 0.1.0
4
+ Summary: NexusQuant strategy provider CLI
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: platformdirs>=4.2
8
+ Requires-Dist: rich>=13.7
9
+ Requires-Dist: typer>=0.12
10
+ Description-Content-Type: text/markdown
11
+
12
+ # nexusquant-cli
13
+
14
+ Command line tool for NexusQuant strategy providers.
15
+
16
+ It supports browser login through Cognito, stores tokens locally, refreshes tokens, and calls the Nexus strategy provider APIs. Provider commands require a Cognito user with `custom:userType=strategyProvider` or `custom:userType=admin`.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ python3 -m pip install -e .
22
+ ```
23
+
24
+ ## Login
25
+
26
+ ```bash
27
+ nexusquant auth
28
+ nexusquant auth --status
29
+ nexusquant auth --refresh
30
+ nexusquant auth --logout
31
+ ```
32
+
33
+ Tokens are stored in the current OS user's config directory, for example `~/Library/Application Support/nexusquant-cli/credentials.json` on macOS. The file is written with `0600` permissions when supported.
34
+
35
+ ## Strategy Commands
36
+
37
+ Create or update a strategy:
38
+
39
+ ```bash
40
+ nexusquant strategy create \
41
+ --strategy-id my_alpha_001 \
42
+ --name "My Alpha" \
43
+ --schema-file schema.json \
44
+ --output-unit SHARE_COUNT
45
+ ```
46
+
47
+ List strategies registered by the current provider. Admin users see all strategies:
48
+
49
+ ```bash
50
+ nexusquant strategy list
51
+ ```
52
+
53
+ List signal history for one strategy:
54
+
55
+ ```bash
56
+ nexusquant strategy signal my_alpha_001 --history --limit 20
57
+ ```
58
+
59
+ Send a single signal:
60
+
61
+ ```bash
62
+ nexusquant strategy signal my_alpha_001 \
63
+ --strategy-name "My Alpha" \
64
+ --ticker AAPL \
65
+ --direction bull \
66
+ --price 150.25 \
67
+ --level 0.8 \
68
+ --quantity 100
69
+ ```
70
+
71
+ Send a multi-route `signals` map:
72
+
73
+ ```bash
74
+ nexusquant strategy signal my_alpha_001 \
75
+ --strategy-name "My Alpha" \
76
+ --signals-file signals.json
77
+ ```
78
+
79
+ Get non-PII subscriber config for a strategy:
80
+
81
+ ```bash
82
+ nexusquant strategy sub config my_alpha_001
83
+ ```
84
+
85
+ ## JSON Inputs
86
+
87
+ `--schema-file` must contain the `strategy_schema` object accepted by `nexus-service`, for example:
88
+
89
+ ```json
90
+ {
91
+ "type": "object",
92
+ "properties": {
93
+ "window": {
94
+ "type": "integer",
95
+ "default": 14,
96
+ "title": "Window",
97
+ "source": "user"
98
+ }
99
+ },
100
+ "required": []
101
+ }
102
+ ```
103
+
104
+ `--signals-file` must contain a JSON object whose keys are `default` or user ids:
105
+
106
+ ```json
107
+ {
108
+ "default": {
109
+ "ticker": "AAPL",
110
+ "time": "2026-04-26T19:30:00Z",
111
+ "price": 150.25,
112
+ "level": 0.8,
113
+ "direction": "bull",
114
+ "quantity": 100,
115
+ "metadata": {}
116
+ }
117
+ }
118
+ ```
119
+
120
+ ## Environment Overrides
121
+
122
+ Normal use does not require configuration. For staging or local development:
123
+
124
+ | Variable | Meaning |
125
+ | --- | --- |
126
+ | `NEXUSQUANT_API_ENDPOINT` | API endpoint host, default `https://api.nexusquant.co`; CLI calls `/api/...` under it |
127
+ | `NEXUSQUANT_API_BASE_URL` | Optional full API base override, e.g. `https://api.nexusquant.co/api` |
128
+ | `NEXUSQUANT_COGNITO_DOMAIN` | Cognito Hosted UI host, default `auth.lookatwallstreet.com` |
129
+ | `NEXUSQUANT_COGNITO_CLIENT_ID` | Cognito app client id |
130
+ | `NEXUSQUANT_REDIRECT_URI` | OAuth callback, default `http://127.0.0.1:8251/callback` |
131
+
132
+ There is also a hidden typo-compatible alias: `nexusquant startegy ...` maps to `nexusquant strategy ...`.
@@ -0,0 +1,121 @@
1
+ # nexusquant-cli
2
+
3
+ Command line tool for NexusQuant strategy providers.
4
+
5
+ It supports browser login through Cognito, stores tokens locally, refreshes tokens, and calls the Nexus strategy provider APIs. Provider commands require a Cognito user with `custom:userType=strategyProvider` or `custom:userType=admin`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ python3 -m pip install -e .
11
+ ```
12
+
13
+ ## Login
14
+
15
+ ```bash
16
+ nexusquant auth
17
+ nexusquant auth --status
18
+ nexusquant auth --refresh
19
+ nexusquant auth --logout
20
+ ```
21
+
22
+ Tokens are stored in the current OS user's config directory, for example `~/Library/Application Support/nexusquant-cli/credentials.json` on macOS. The file is written with `0600` permissions when supported.
23
+
24
+ ## Strategy Commands
25
+
26
+ Create or update a strategy:
27
+
28
+ ```bash
29
+ nexusquant strategy create \
30
+ --strategy-id my_alpha_001 \
31
+ --name "My Alpha" \
32
+ --schema-file schema.json \
33
+ --output-unit SHARE_COUNT
34
+ ```
35
+
36
+ List strategies registered by the current provider. Admin users see all strategies:
37
+
38
+ ```bash
39
+ nexusquant strategy list
40
+ ```
41
+
42
+ List signal history for one strategy:
43
+
44
+ ```bash
45
+ nexusquant strategy signal my_alpha_001 --history --limit 20
46
+ ```
47
+
48
+ Send a single signal:
49
+
50
+ ```bash
51
+ nexusquant strategy signal my_alpha_001 \
52
+ --strategy-name "My Alpha" \
53
+ --ticker AAPL \
54
+ --direction bull \
55
+ --price 150.25 \
56
+ --level 0.8 \
57
+ --quantity 100
58
+ ```
59
+
60
+ Send a multi-route `signals` map:
61
+
62
+ ```bash
63
+ nexusquant strategy signal my_alpha_001 \
64
+ --strategy-name "My Alpha" \
65
+ --signals-file signals.json
66
+ ```
67
+
68
+ Get non-PII subscriber config for a strategy:
69
+
70
+ ```bash
71
+ nexusquant strategy sub config my_alpha_001
72
+ ```
73
+
74
+ ## JSON Inputs
75
+
76
+ `--schema-file` must contain the `strategy_schema` object accepted by `nexus-service`, for example:
77
+
78
+ ```json
79
+ {
80
+ "type": "object",
81
+ "properties": {
82
+ "window": {
83
+ "type": "integer",
84
+ "default": 14,
85
+ "title": "Window",
86
+ "source": "user"
87
+ }
88
+ },
89
+ "required": []
90
+ }
91
+ ```
92
+
93
+ `--signals-file` must contain a JSON object whose keys are `default` or user ids:
94
+
95
+ ```json
96
+ {
97
+ "default": {
98
+ "ticker": "AAPL",
99
+ "time": "2026-04-26T19:30:00Z",
100
+ "price": 150.25,
101
+ "level": 0.8,
102
+ "direction": "bull",
103
+ "quantity": 100,
104
+ "metadata": {}
105
+ }
106
+ }
107
+ ```
108
+
109
+ ## Environment Overrides
110
+
111
+ Normal use does not require configuration. For staging or local development:
112
+
113
+ | Variable | Meaning |
114
+ | --- | --- |
115
+ | `NEXUSQUANT_API_ENDPOINT` | API endpoint host, default `https://api.nexusquant.co`; CLI calls `/api/...` under it |
116
+ | `NEXUSQUANT_API_BASE_URL` | Optional full API base override, e.g. `https://api.nexusquant.co/api` |
117
+ | `NEXUSQUANT_COGNITO_DOMAIN` | Cognito Hosted UI host, default `auth.lookatwallstreet.com` |
118
+ | `NEXUSQUANT_COGNITO_CLIENT_ID` | Cognito app client id |
119
+ | `NEXUSQUANT_REDIRECT_URI` | OAuth callback, default `http://127.0.0.1:8251/callback` |
120
+
121
+ There is also a hidden typo-compatible alias: `nexusquant startegy ...` maps to `nexusquant strategy ...`.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from nexusquant_cli.main import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -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_cli.auth_pkce import ensure_fresh_id_token
9
+ from nexusquant_cli.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,234 @@
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 CLI 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_cli.config import (
25
+ cognito_client_id,
26
+ cognito_domain_host,
27
+ load_credentials,
28
+ redirect_uri,
29
+ save_credentials,
30
+ )
31
+
32
+
33
+ def _b64url(data: bytes) -> str:
34
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
35
+
36
+
37
+ def _pkce_pair() -> tuple[str, str]:
38
+ verifier = secrets.token_urlsafe(48)
39
+ challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
40
+ return verifier, challenge
41
+
42
+
43
+ def _parse_query(path: str) -> dict[str, str]:
44
+ query = urllib.parse.urlparse(path).query
45
+ return dict(urllib.parse.parse_qsl(query))
46
+
47
+
48
+ def _token_url(domain_host: str) -> str:
49
+ return f"https://{domain_host}/oauth2/token"
50
+
51
+
52
+ def _post_token(domain_host: str, body: dict[str, Any]) -> dict[str, Any]:
53
+ with httpx.Client(timeout=30.0) as client:
54
+ response = client.post(
55
+ _token_url(domain_host),
56
+ data=body,
57
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
58
+ )
59
+ if response.status_code >= 400:
60
+ try:
61
+ detail: Any = response.json()
62
+ except json.JSONDecodeError:
63
+ detail = response.text
64
+ raise RuntimeError(
65
+ f"Cognito token endpoint error ({response.status_code}): {detail}"
66
+ )
67
+ return response.json()
68
+
69
+
70
+ def refresh_tokens(
71
+ *,
72
+ domain_host: str,
73
+ client_id: str,
74
+ refresh_token: str,
75
+ ) -> dict[str, Any]:
76
+ return _post_token(
77
+ domain_host,
78
+ {
79
+ "grant_type": "refresh_token",
80
+ "client_id": client_id,
81
+ "refresh_token": refresh_token,
82
+ },
83
+ )
84
+
85
+
86
+ def exchange_code_for_tokens(
87
+ *,
88
+ domain_host: str,
89
+ client_id: str,
90
+ code: str,
91
+ redirect_uri_value: str,
92
+ code_verifier: str,
93
+ ) -> dict[str, Any]:
94
+ return _post_token(
95
+ domain_host,
96
+ {
97
+ "grant_type": "authorization_code",
98
+ "client_id": client_id,
99
+ "code": code,
100
+ "redirect_uri": redirect_uri_value,
101
+ "code_verifier": code_verifier,
102
+ },
103
+ )
104
+
105
+
106
+ def run_browser_login() -> dict[str, Any]:
107
+ domain_host = cognito_domain_host()
108
+ client_id = cognito_client_id()
109
+ redirect_uri_value = redirect_uri()
110
+ verifier, challenge = _pkce_pair()
111
+
112
+ auth_params = {
113
+ "response_type": "code",
114
+ "client_id": client_id,
115
+ "redirect_uri": redirect_uri_value,
116
+ "scope": "openid email phone",
117
+ "code_challenge_method": "S256",
118
+ "code_challenge": challenge,
119
+ }
120
+ auth_url = (
121
+ f"https://{domain_host}/oauth2/authorize?{urllib.parse.urlencode(auth_params)}"
122
+ )
123
+
124
+ code_holder: dict[str, str | None] = {"code": None, "error": None}
125
+ done = threading.Event()
126
+
127
+ class Handler(BaseHTTPRequestHandler):
128
+ def log_message(self, _format: str, *_args: object) -> None:
129
+ return
130
+
131
+ def do_GET(self) -> None: # noqa: N802
132
+ params = _parse_query(self.path)
133
+ if "code" in params:
134
+ code_holder["code"] = params["code"]
135
+ self.send_response(200)
136
+ self.send_header("Content-Type", "text/html; charset=utf-8")
137
+ self.end_headers()
138
+ self.wfile.write(
139
+ b"<html><body><p>NexusQuant login successful. You can close this tab.</p></body></html>"
140
+ )
141
+ else:
142
+ error = (
143
+ params.get("error_description")
144
+ or params.get("error")
145
+ or "unknown_error"
146
+ )
147
+ code_holder["error"] = error
148
+ self.send_response(400)
149
+ self.send_header("Content-Type", "text/html; charset=utf-8")
150
+ self.end_headers()
151
+ self.wfile.write(
152
+ f"<html><body><p>NexusQuant login failed: {error}</p></body></html>".encode(
153
+ "utf-8"
154
+ )
155
+ )
156
+ done.set()
157
+
158
+ parsed = urllib.parse.urlparse(redirect_uri_value)
159
+ if parsed.hostname not in ("127.0.0.1", "localhost") or not parsed.port:
160
+ raise RuntimeError("NEXUSQUANT_REDIRECT_URI must be localhost with a port.")
161
+
162
+ bind_host = "127.0.0.1" if parsed.hostname == "localhost" else parsed.hostname
163
+ server = HTTPServer((bind_host or "127.0.0.1", parsed.port), Handler)
164
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
165
+ thread.start()
166
+ webbrowser.open(auth_url)
167
+
168
+ if not done.wait(timeout=300):
169
+ server.shutdown()
170
+ raise TimeoutError("No OAuth callback received within 5 minutes.")
171
+ server.shutdown()
172
+
173
+ if code_holder["error"]:
174
+ raise RuntimeError(f"Cognito error: {code_holder['error']}")
175
+ code = code_holder["code"]
176
+ if not code:
177
+ raise RuntimeError("No authorization code in callback.")
178
+
179
+ tokens = exchange_code_for_tokens(
180
+ domain_host=domain_host,
181
+ client_id=client_id,
182
+ code=code,
183
+ redirect_uri_value=redirect_uri_value,
184
+ code_verifier=verifier,
185
+ )
186
+ if "id_token" not in tokens:
187
+ raise RuntimeError("Cognito token response missing id_token.")
188
+ return tokens
189
+
190
+
191
+ def persist_tokens(tokens: dict[str, Any]) -> None:
192
+ save_credentials(
193
+ {
194
+ "id_token": tokens["id_token"],
195
+ "access_token": tokens.get("access_token"),
196
+ "refresh_token": tokens.get("refresh_token"),
197
+ "expires_in": int(tokens.get("expires_in") or 0),
198
+ "saved_at": time.time(),
199
+ }
200
+ )
201
+
202
+
203
+ def ensure_fresh_id_token(*, force_refresh: bool = False) -> str:
204
+ creds = load_credentials()
205
+ if not creds or not creds.get("id_token"):
206
+ raise RuntimeError("尚未登录。请先执行:nexusquant auth")
207
+
208
+ expires_in = int(creds.get("expires_in") or 0)
209
+ saved_at = float(creds.get("saved_at") or 0)
210
+ remaining = saved_at + expires_in - time.time() if expires_in > 0 else 999999
211
+ refresh_token = creds.get("refresh_token")
212
+ if not force_refresh and (remaining > 120 or not refresh_token):
213
+ return str(creds["id_token"])
214
+ if not refresh_token:
215
+ return str(creds["id_token"])
216
+
217
+ new_tokens = refresh_tokens(
218
+ domain_host=cognito_domain_host(),
219
+ client_id=cognito_client_id(),
220
+ refresh_token=refresh_token,
221
+ )
222
+ merged = {
223
+ "id_token": new_tokens.get("id_token") or creds["id_token"],
224
+ "access_token": new_tokens.get("access_token") or creds.get("access_token"),
225
+ "refresh_token": new_tokens.get("refresh_token") or refresh_token,
226
+ "expires_in": int(new_tokens.get("expires_in") or expires_in),
227
+ "saved_at": time.time(),
228
+ }
229
+ save_credentials(merged)
230
+ return str(merged["id_token"])
231
+
232
+
233
+ def login_and_save() -> None:
234
+ persist_tokens(run_browser_login())
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from nexusquant_cli.commands.auth.cmd import register as register_auth
6
+ from nexusquant_cli.commands.strategy.cmd import register as register_strategy
7
+
8
+
9
+ def register_all(app: typer.Typer) -> None:
10
+ register_auth(app)
11
+ register_strategy(app)
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+
12
+ def print_json_or_exit(console: Console, fetch: Callable[[], Any]) -> None:
13
+ try:
14
+ data = fetch()
15
+ except Exception as e:
16
+ console.print(f"[red]{e}[/red]")
17
+ raise typer.Exit(1) from e
18
+ console.print_json(data=data)
19
+
20
+
21
+ def load_json_option(value: str | None, *, label: str) -> Any:
22
+ if value is None:
23
+ return None
24
+ try:
25
+ return json.loads(value)
26
+ except json.JSONDecodeError as e:
27
+ raise typer.BadParameter(f"{label} must be valid JSON: {e}") from e
28
+
29
+
30
+ def load_json_file(path: Path, *, label: str) -> Any:
31
+ try:
32
+ return json.loads(path.read_text(encoding="utf-8"))
33
+ except OSError as e:
34
+ raise typer.BadParameter(f"Cannot read {label}: {e}") from e
35
+ except json.JSONDecodeError as e:
36
+ raise typer.BadParameter(f"{label} must contain valid JSON: {e}") from e
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from nexusquant_cli.auth_pkce import ensure_fresh_id_token, login_and_save
7
+ from nexusquant_cli.config import CREDENTIALS_PATH, clear_credentials, load_credentials
8
+
9
+ console = Console()
10
+
11
+
12
+ def register(app: typer.Typer) -> None:
13
+ @app.command(
14
+ "auth",
15
+ help=(
16
+ "Login with Cognito Hosted UI and save local tokens. Use --refresh to force "
17
+ "refresh, --status to inspect local login state, or --logout to clear tokens."
18
+ ),
19
+ )
20
+ def auth(
21
+ logout: bool = typer.Option(
22
+ False,
23
+ "--logout",
24
+ help="Remove locally saved Cognito tokens and exit.",
25
+ ),
26
+ refresh: bool = typer.Option(
27
+ False,
28
+ "--refresh",
29
+ help="Refresh the saved ID token using the saved refresh token.",
30
+ ),
31
+ status: bool = typer.Option(
32
+ False,
33
+ "--status",
34
+ help="Print whether credentials are saved locally without showing token values.",
35
+ ),
36
+ ) -> None:
37
+ """
38
+ Authenticate this machine for NexusQuant strategy provider API calls.
39
+
40
+ Output: tokens are stored in the current OS user's config directory with 0600
41
+ permissions when supported. Commands send the ID token as Authorization: Bearer
42
+ because the backend checks ``custom:userType`` for strategyProvider/admin access.
43
+ """
44
+ if logout:
45
+ clear_credentials()
46
+ console.print(
47
+ "[green]已退出登录(本机保存的 NexusQuant token 已清除)。[/green]"
48
+ )
49
+ raise typer.Exit(0)
50
+
51
+ if status:
52
+ creds = load_credentials()
53
+ if creds and creds.get("id_token"):
54
+ console.print(f"[green]已登录。本地凭据: {CREDENTIALS_PATH}[/green]")
55
+ else:
56
+ console.print("[yellow]尚未登录。请执行:nexusquant auth[/yellow]")
57
+ raise typer.Exit(0)
58
+
59
+ try:
60
+ if refresh:
61
+ ensure_fresh_id_token(force_refresh=True)
62
+ console.print("[green]token 已刷新。[/green]")
63
+ else:
64
+ login_and_save()
65
+ console.print("[green]登录成功,token 已保存在本机。[/green]")
66
+ except Exception as e:
67
+ console.print(f"[red]{e}[/red]")
68
+ raise typer.Exit(1) from e
@@ -0,0 +1,388 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+ from typing import Annotated, Any
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from nexusquant_cli import api_client
11
+ from nexusquant_cli.commands._util import (
12
+ load_json_file,
13
+ load_json_option,
14
+ print_json_or_exit,
15
+ )
16
+
17
+ console = Console()
18
+
19
+ strategy_app = typer.Typer(
20
+ help=(
21
+ "Strategy provider commands. Security model: strategyProvider users can only "
22
+ "create, list, signal, and inspect subscriber configs for strategies they own; "
23
+ "admin users can operate across strategies."
24
+ ),
25
+ no_args_is_help=True,
26
+ )
27
+ sub_app = typer.Typer(
28
+ help="Subscriber-related provider commands for one strategy. Returns non-PII config only.",
29
+ no_args_is_help=True,
30
+ )
31
+
32
+
33
+ def _now_iso() -> str:
34
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
35
+
36
+
37
+ def _strategy_name_arg(strategy_name: str | None, strategy_id: str) -> str:
38
+ return strategy_name or strategy_id
39
+
40
+
41
+ @strategy_app.command(
42
+ "create",
43
+ help=(
44
+ "Create or update an external strategy via POST /strategy-signal/register/. "
45
+ "Requires custom:userType=strategyProvider or admin."
46
+ ),
47
+ )
48
+ def create_strategy(
49
+ strategy_id: Annotated[
50
+ str,
51
+ typer.Option(
52
+ "--strategy-id",
53
+ "-i",
54
+ help="Stable strategy id used in future signal calls, e.g. my_alpha_001.",
55
+ ),
56
+ ],
57
+ strategy_name: Annotated[
58
+ str,
59
+ typer.Option("--name", "-n", help="Human-readable strategy name shown in UI."),
60
+ ],
61
+ schema_file: Annotated[
62
+ Path | None,
63
+ typer.Option(
64
+ "--schema-file",
65
+ exists=True,
66
+ dir_okay=False,
67
+ readable=True,
68
+ help=(
69
+ "UTF-8 JSON file containing strategy_schema. Shape: JSON Schema object "
70
+ "with type/properties/required as accepted by nexus-service."
71
+ ),
72
+ ),
73
+ ] = None,
74
+ schema_json: Annotated[
75
+ str | None,
76
+ typer.Option(
77
+ "--schema-json",
78
+ help="Inline JSON object for strategy_schema. Use this or --schema-file.",
79
+ ),
80
+ ] = None,
81
+ output_unit: Annotated[
82
+ str,
83
+ typer.Option(
84
+ "--output-unit",
85
+ help=(
86
+ "Signal output unit. One of CASH_AMOUNT, SHARE_COUNT, "
87
+ "PERCENTAGE_BALANCE, PERCENTAGE_EQUITY."
88
+ ),
89
+ ),
90
+ ] = "SHARE_COUNT",
91
+ description: Annotated[
92
+ str | None,
93
+ typer.Option("--description", "-d", help="Optional strategy description."),
94
+ ] = None,
95
+ config_file: Annotated[
96
+ Path | None,
97
+ typer.Option(
98
+ "--config-file",
99
+ exists=True,
100
+ dir_okay=False,
101
+ readable=True,
102
+ help="Optional UTF-8 JSON file for provider metadata stored as Strategy.config.",
103
+ ),
104
+ ] = None,
105
+ config_json: Annotated[
106
+ str | None,
107
+ typer.Option("--config-json", help="Inline JSON object for Strategy.config."),
108
+ ] = None,
109
+ enabled_execution_key: Annotated[
110
+ list[str] | None,
111
+ typer.Option(
112
+ "--enabled-execution-key",
113
+ help=(
114
+ "Execution key exposed to users. Repeatable. Supported examples: "
115
+ "max_order_cash_usd, symbols_allowlist. Omit for platform default."
116
+ ),
117
+ ),
118
+ ] = None,
119
+ ) -> None:
120
+ """
121
+ Register an external strategy owned by the logged-in provider/admin.
122
+
123
+ AI usage example:
124
+ ``nexusquant strategy create -i demo -n Demo --schema-file schema.json --output-unit SHARE_COUNT``
125
+ """
126
+ if schema_file and schema_json:
127
+ raise typer.BadParameter("Use only one of --schema-file or --schema-json.")
128
+ if config_file and config_json:
129
+ raise typer.BadParameter("Use only one of --config-file or --config-json.")
130
+
131
+ strategy_schema = (
132
+ load_json_file(schema_file, label="strategy schema")
133
+ if schema_file
134
+ else load_json_option(schema_json, label="strategy schema")
135
+ )
136
+ if strategy_schema is None:
137
+ strategy_schema = {"type": "object", "properties": {}, "required": []}
138
+
139
+ config = (
140
+ load_json_file(config_file, label="strategy config")
141
+ if config_file
142
+ else load_json_option(config_json, label="strategy config")
143
+ )
144
+ body: dict[str, Any] = {
145
+ "strategy_id": strategy_id,
146
+ "strategy_name": strategy_name,
147
+ "strategy_schema": strategy_schema,
148
+ "output_unit": output_unit,
149
+ }
150
+ if description is not None:
151
+ body["description"] = description
152
+ if config is not None:
153
+ body["config"] = config
154
+ if enabled_execution_key is not None:
155
+ body["enabled_execution_keys"] = enabled_execution_key
156
+
157
+ print_json_or_exit(console, lambda: api_client.create_strategy(body))
158
+
159
+
160
+ @strategy_app.command(
161
+ "list",
162
+ help=(
163
+ "List strategies registered by the current provider. Admin users see all strategies. "
164
+ "Calls GET /strategy-signal/provider-strategies/."
165
+ ),
166
+ )
167
+ def list_strategies() -> None:
168
+ """
169
+ Output: JSON with strategies, total_count, ownership metadata, signal counts, and schema.
170
+ """
171
+ print_json_or_exit(console, api_client.list_provider_strategies)
172
+
173
+
174
+ @strategy_app.command(
175
+ "signal",
176
+ help=(
177
+ "Send a strategy signal, send a signals map, or list signal history for one strategy. "
178
+ "Without send fields, this lists history."
179
+ ),
180
+ )
181
+ def strategy_signal(
182
+ strategy_id: Annotated[
183
+ str,
184
+ typer.Argument(help="Strategy id in the URL/path and request body."),
185
+ ],
186
+ strategy_name: Annotated[
187
+ str | None,
188
+ typer.Option(
189
+ "--strategy-name",
190
+ "--name",
191
+ help="Strategy display name sent with a signal. Defaults to strategy_id.",
192
+ ),
193
+ ] = None,
194
+ history: Annotated[
195
+ bool,
196
+ typer.Option(
197
+ "--history",
198
+ help="List signal history instead of sending. This is the default when no send fields are provided.",
199
+ ),
200
+ ] = False,
201
+ limit: Annotated[
202
+ int,
203
+ typer.Option("--limit", help="History page size.", min=1, max=500),
204
+ ] = 50,
205
+ offset: Annotated[
206
+ int,
207
+ typer.Option("--offset", help="History offset for pagination.", min=0),
208
+ ] = 0,
209
+ status_filter: Annotated[
210
+ str | None,
211
+ typer.Option(
212
+ "--status",
213
+ help="History filter: received, sent_to_broker, completed, failed.",
214
+ ),
215
+ ] = None,
216
+ ticker: Annotated[
217
+ str | None,
218
+ typer.Option("--ticker", help="Ticker symbol for a single signal, e.g. AAPL."),
219
+ ] = None,
220
+ direction: Annotated[
221
+ str | None,
222
+ typer.Option("--direction", help="Signal direction: bull or bear."),
223
+ ] = None,
224
+ price: Annotated[
225
+ float | None,
226
+ typer.Option(
227
+ "--price",
228
+ help="Reference price. Required for send and notional_usd sizing.",
229
+ ),
230
+ ] = None,
231
+ level: Annotated[
232
+ float | None,
233
+ typer.Option("--level", help="Signal strength/level, e.g. 0.8."),
234
+ ] = None,
235
+ time_value: Annotated[
236
+ str | None,
237
+ typer.Option(
238
+ "--time",
239
+ help="Signal timestamp in ISO-8601. Defaults to current UTC time when sending.",
240
+ ),
241
+ ] = None,
242
+ sizing_kind: Annotated[
243
+ str | None,
244
+ typer.Option(
245
+ "--sizing-kind", help="Optional explicit sizing: shares or notional_usd."
246
+ ),
247
+ ] = None,
248
+ quantity: Annotated[
249
+ int | None,
250
+ typer.Option(
251
+ "--quantity", help="Share quantity for shares sizing. Positive integer."
252
+ ),
253
+ ] = None,
254
+ notional_usd: Annotated[
255
+ float | None,
256
+ typer.Option(
257
+ "--notional-usd",
258
+ help="USD notional for notional_usd sizing. Positive number.",
259
+ ),
260
+ ] = None,
261
+ metadata_json: Annotated[
262
+ str | None,
263
+ typer.Option(
264
+ "--metadata-json", help="Inline JSON object attached to signal.metadata."
265
+ ),
266
+ ] = None,
267
+ signals_file: Annotated[
268
+ Path | None,
269
+ typer.Option(
270
+ "--signals-file",
271
+ exists=True,
272
+ dir_okay=False,
273
+ readable=True,
274
+ help=(
275
+ "UTF-8 JSON object for multi-route signals map. Keys are default or user_id; "
276
+ "values are signal objects. When provided, single-signal options are ignored."
277
+ ),
278
+ ),
279
+ ] = None,
280
+ ) -> None:
281
+ """
282
+ Send or inspect signal history.
283
+
284
+ History:
285
+ ``nexusquant strategy signal my_alpha --history --limit 20``
286
+
287
+ Single signal:
288
+ ``nexusquant strategy signal my_alpha --ticker AAPL --direction bull --price 150 --level 0.8 --quantity 100``
289
+
290
+ Signals map:
291
+ ``nexusquant strategy signal my_alpha --signals-file signals.json --strategy-name "My Alpha"``
292
+ """
293
+ should_send_single = any(
294
+ value is not None
295
+ for value in (
296
+ ticker,
297
+ direction,
298
+ price,
299
+ level,
300
+ quantity,
301
+ notional_usd,
302
+ signals_file,
303
+ )
304
+ )
305
+ if history or not should_send_single:
306
+ print_json_or_exit(
307
+ console,
308
+ lambda: api_client.get_provider_signal_history(
309
+ strategy_id,
310
+ limit=limit,
311
+ offset=offset,
312
+ direction=direction if history else None,
313
+ status=status_filter,
314
+ ),
315
+ )
316
+ return
317
+
318
+ body: dict[str, Any] = {
319
+ "strategy_id": strategy_id,
320
+ "strategy_name": _strategy_name_arg(strategy_name, strategy_id),
321
+ }
322
+ if signals_file is not None:
323
+ signals = load_json_file(signals_file, label="signals map")
324
+ if not isinstance(signals, dict):
325
+ raise typer.BadParameter("--signals-file must contain a JSON object.")
326
+ body["signals"] = signals
327
+ print_json_or_exit(console, lambda: api_client.send_strategy_signal(body))
328
+ return
329
+
330
+ missing = [
331
+ name
332
+ for name, value in {
333
+ "--ticker": ticker,
334
+ "--direction": direction,
335
+ "--price": price,
336
+ "--level": level,
337
+ }.items()
338
+ if value is None
339
+ ]
340
+ if missing:
341
+ raise typer.BadParameter(
342
+ f"Sending a single signal requires: {', '.join(missing)}"
343
+ )
344
+
345
+ signal: dict[str, Any] = {
346
+ "ticker": ticker,
347
+ "time": time_value or _now_iso(),
348
+ "price": price,
349
+ "level": level,
350
+ "direction": direction,
351
+ }
352
+ if sizing_kind is not None:
353
+ signal["sizing_kind"] = sizing_kind
354
+ if quantity is not None:
355
+ signal["quantity"] = quantity
356
+ if notional_usd is not None:
357
+ signal["notional_usd"] = notional_usd
358
+ metadata = load_json_option(metadata_json, label="metadata")
359
+ if metadata is not None:
360
+ signal["metadata"] = metadata
361
+ body["signal"] = signal
362
+ print_json_or_exit(console, lambda: api_client.send_strategy_signal(body))
363
+
364
+
365
+ @sub_app.command(
366
+ "config",
367
+ help=(
368
+ "Get all active subscribers' non-PII config snapshot for one strategy. "
369
+ "Provider must own the strategy unless logged in as admin."
370
+ ),
371
+ )
372
+ def subscriber_config(
373
+ strategy_id: Annotated[str, typer.Argument(help="Strategy id to inspect.")],
374
+ ) -> None:
375
+ """
376
+ Output: user_id and uses_default_config for each active subscriber. For non-default
377
+ subscribers, includes normalized personal_config and effective_config. No email,
378
+ username, Cognito ID, container ID, or subscription time is returned.
379
+ """
380
+ print_json_or_exit(console, lambda: api_client.get_subscriber_configs(strategy_id))
381
+
382
+
383
+ strategy_app.add_typer(sub_app, name="sub")
384
+
385
+
386
+ def register(app: typer.Typer) -> None:
387
+ app.add_typer(strategy_app, name="strategy")
388
+ app.add_typer(strategy_app, name="startegy", hidden=True)
@@ -0,0 +1,78 @@
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-cli", 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
+ # Backwards-compatible override: allow either host-only endpoint or full /api base.
31
+ base_override = _env("NEXUSQUANT_API_BASE_URL")
32
+ if base_override:
33
+ return base_override.rstrip("/")
34
+ return f"{endpoint}/api"
35
+
36
+
37
+ def cognito_domain_host() -> str:
38
+ domain = (
39
+ _env("NEXUSQUANT_COGNITO_DOMAIN", DEFAULT_COGNITO_DOMAIN)
40
+ or DEFAULT_COGNITO_DOMAIN
41
+ )
42
+ return domain.removeprefix("https://").removeprefix("http://").split("/")[0]
43
+
44
+
45
+ def cognito_client_id() -> str:
46
+ return (
47
+ _env("NEXUSQUANT_COGNITO_CLIENT_ID", DEFAULT_COGNITO_CLIENT_ID)
48
+ or DEFAULT_COGNITO_CLIENT_ID
49
+ )
50
+
51
+
52
+ def redirect_uri() -> str:
53
+ return _env("NEXUSQUANT_REDIRECT_URI", DEFAULT_REDIRECT_URI) or DEFAULT_REDIRECT_URI
54
+
55
+
56
+ def load_credentials() -> dict[str, Any] | None:
57
+ if not CREDENTIALS_PATH.is_file():
58
+ return None
59
+ try:
60
+ return json.loads(CREDENTIALS_PATH.read_text(encoding="utf-8"))
61
+ except (json.JSONDecodeError, OSError):
62
+ return None
63
+
64
+
65
+ def save_credentials(data: dict[str, Any]) -> None:
66
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
67
+ CREDENTIALS_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
68
+ try:
69
+ CREDENTIALS_PATH.chmod(0o600)
70
+ except OSError:
71
+ pass
72
+
73
+
74
+ def clear_credentials() -> None:
75
+ try:
76
+ CREDENTIALS_PATH.unlink(missing_ok=True)
77
+ except OSError:
78
+ pass
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from nexusquant_cli import __version__
7
+ from nexusquant_cli.commands import register_all
8
+
9
+ console = Console()
10
+ app = typer.Typer(
11
+ name="nexusquant",
12
+ help=(
13
+ "NexusQuant strategy provider CLI. Login once with Cognito, then create "
14
+ "strategies, send strategy signals, list signal history, and fetch non-PII "
15
+ "subscriber configs. Requires custom:userType=strategyProvider or admin for "
16
+ "provider commands."
17
+ ),
18
+ no_args_is_help=True,
19
+ )
20
+
21
+ register_all(app)
22
+
23
+
24
+ @app.command("version", help="Print this CLI package version string.")
25
+ def version_cmd() -> None:
26
+ """Print CLI version."""
27
+ console.print(__version__)
28
+
29
+
30
+ if __name__ == "__main__":
31
+ app()
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "nexusquant-cli"
7
+ version = "0.1.0"
8
+ description = "NexusQuant strategy provider CLI"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "httpx>=0.27",
13
+ "platformdirs>=4.2",
14
+ "rich>=13.7",
15
+ "typer>=0.12",
16
+ ]
17
+
18
+ [project.scripts]
19
+ nexusquant = "nexusquant_cli.main:app"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["nexusquant_cli"]
23
+
24
+ [tool.ruff]
25
+ line-length = 100
26
+ target-version = "py310"