nexusquant-cli 0.1.1__tar.gz → 0.2.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.
Files changed (24) hide show
  1. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/.gitignore +3 -0
  2. nexusquant_cli-0.2.0/CLAUDE.md +66 -0
  3. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/PKG-INFO +18 -15
  4. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/README.md +15 -13
  5. nexusquant_cli-0.2.0/dist_archive/nexusquant_cli-0.1.1-py3-none-any.whl +0 -0
  6. nexusquant_cli-0.2.0/dist_archive/nexusquant_cli-0.1.1.tar.gz +0 -0
  7. nexusquant_cli-0.2.0/dist_archive/nexusquant_cli-0.1.2-py3-none-any.whl +0 -0
  8. nexusquant_cli-0.2.0/dist_archive/nexusquant_cli-0.1.2.tar.gz +0 -0
  9. nexusquant_cli-0.2.0/nexusquant_cli/__init__.py +1 -0
  10. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/auth_pkce.py +14 -5
  11. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/__init__.py +2 -0
  12. nexusquant_cli-0.2.0/nexusquant_cli/commands/policy/__init__.py +0 -0
  13. nexusquant_cli-0.2.0/nexusquant_cli/commands/policy/cmd.py +327 -0
  14. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/strategy/cmd.py +18 -39
  15. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/config.py +1 -1
  16. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/pyproject.toml +4 -1
  17. nexusquant_cli-0.1.1/nexusquant_cli/__init__.py +0 -1
  18. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/__main__.py +0 -0
  19. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/api_client.py +0 -0
  20. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/_util.py +0 -0
  21. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/auth/__init__.py +0 -0
  22. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/auth/cmd.py +0 -0
  23. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/strategy/__init__.py +0 -0
  24. {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/main.py +0 -0
@@ -4,3 +4,6 @@ __pycache__/
4
4
  *.egg-info/
5
5
  dist/
6
6
  build/
7
+ *.ipynb
8
+ .idea/
9
+ .DS_Store
@@ -0,0 +1,66 @@
1
+ # nexusquant-cli
2
+
3
+ Python CLI for Nexus strategy providers. Handles Cognito PKCE browser-based login, token storage/refresh, and wraps the Nexus provider API (strategies, signals, containers).
4
+
5
+ ## What this repo does
6
+
7
+ Command-line tool that strategy providers (and admins) use to manage their strategies and submit signals without going through the web UI. Authenticates via Cognito browser login, stores tokens locally, and calls the same REST API as the frontend.
8
+
9
+ ## Code structure
10
+
11
+ ```
12
+ nexusquant_cli/
13
+ main.py # CLI entry point (Click group)
14
+ api_client.py # HTTP client wrapping nexus-service REST API
15
+ auth_pkce.py # Cognito PKCE auth flow (opens browser, listens for callback)
16
+ config.py # Token storage path, API base URL
17
+ commands/ # One file per command group (auth, strategy, signal, ...)
18
+ __main__.py # Allows `python -m nexusquant_cli`
19
+ ```
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ # Development (editable)
25
+ pip install -e .
26
+
27
+ # From PyPI
28
+ pip install nexusquant-cli
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ # Authenticate (opens browser for Cognito login)
35
+ nexusquant auth
36
+ nexusquant auth --status
37
+ nexusquant auth --refresh
38
+ nexusquant auth --logout
39
+
40
+ # Strategy management
41
+ nexusquant strategy create --strategy-id my_alpha --name "My Alpha" --schema-file schema.json
42
+ nexusquant strategy list
43
+ nexusquant strategy update --strategy-id my_alpha ...
44
+ nexusquant strategy delete --strategy-id my_alpha
45
+
46
+ # Signal submission
47
+ nexusquant signal submit --strategy-id my_alpha --file signals.json
48
+ ```
49
+
50
+ Tokens are stored at `~/Library/Application Support/nexusquant-cli/credentials.json` on macOS (respects XDG on Linux). File is written with `0600` permissions.
51
+
52
+ ## Deploy / publish
53
+
54
+ ```bash
55
+ # Build distribution
56
+ python -m build
57
+
58
+ # Publish to PyPI
59
+ twine upload dist/*
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ API base URL is set in `nexusquant_cli/config.py`. Change `API_BASE_URL` to point at a different environment (staging vs production).
65
+
66
+ Required Cognito role: `custom:userType=strategyProvider` or `custom:userType=admin`. Standard users cannot use provider commands.
@@ -1,9 +1,10 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: nexusquant-cli
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: NexusQuant strategy provider CLI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: httpx>=0.27
7
+ Requires-Dist: nexusquant-sdk>=0.2
7
8
  Requires-Dist: platformdirs>=4.2
8
9
  Requires-Dist: rich>=13.7
9
10
  Requires-Dist: typer>=0.12
@@ -21,6 +22,10 @@ It supports browser login through Cognito, stores tokens locally, refreshes toke
21
22
  python3 -m pip install -e .
22
23
  ```
23
24
 
25
+ ```bash
26
+ pip install nexusquant-cli
27
+ ```
28
+
24
29
  ## Login
25
30
 
26
31
  ```bash
@@ -62,14 +67,13 @@ Send a single signal:
62
67
  nexusquant strategy signal my_alpha_001 \
63
68
  --strategy-name "My Alpha" \
64
69
  --ticker AAPL \
65
- --direction bull \
70
+ --direction buy \
66
71
  --price 150.25 \
67
- --level 0.8 \
68
72
  --quantity 100 \
69
73
  --order-type MARKET
70
74
  ```
71
75
 
72
- `--order-type` is a signal-level setting. Use `MARKET` for market orders or `NORMAL` for limit orders; `NORMAL` uses the signal `--price` as the limit reference. If omitted, the service defaults to `MARKET`.
76
+ `--order-type` is a signal-level setting: `MARKET` or `LIMIT` (limit uses `--price`). Legacy `NORMAL` is accepted by the CLI as an alias for `LIMIT`. If omitted, the API defaults to `MARKET`.
73
77
 
74
78
  Send a multi-route `signals` map:
75
79
 
@@ -112,9 +116,8 @@ nexusquant strategy sub config my_alpha_001
112
116
  "ticker": "AAPL",
113
117
  "time": "2026-04-26T19:30:00Z",
114
118
  "price": 150.25,
115
- "level": 0.8,
116
- "direction": "bull",
117
- "order_type": "NORMAL",
119
+ "direction": "buy",
120
+ "order_type": "LIMIT",
118
121
  "quantity": 100,
119
122
  "metadata": {}
120
123
  }
@@ -125,12 +128,12 @@ nexusquant strategy sub config my_alpha_001
125
128
 
126
129
  Normal use does not require configuration. For staging or local development:
127
130
 
128
- | Variable | Meaning |
129
- | --- | --- |
130
- | `NEXUSQUANT_API_ENDPOINT` | API endpoint host, default `https://api.nexusquant.co`; CLI calls `/api/...` under it |
131
- | `NEXUSQUANT_API_BASE_URL` | Optional full API base override, e.g. `https://api.nexusquant.co/api` |
132
- | `NEXUSQUANT_COGNITO_DOMAIN` | Cognito Hosted UI host, default `auth.lookatwallstreet.com` |
133
- | `NEXUSQUANT_COGNITO_CLIENT_ID` | Cognito app client id |
134
- | `NEXUSQUANT_REDIRECT_URI` | OAuth callback, default `http://127.0.0.1:8251/callback` |
131
+ | Variable | Meaning |
132
+ | ------------------------------ | ------------------------------------------------------------------------------------- |
133
+ | `NEXUSQUANT_API_ENDPOINT` | API endpoint host, default `https://api.nexusquant.co`; CLI calls `/api/...` under it |
134
+ | `NEXUSQUANT_API_BASE_URL` | Optional full API base override, e.g. `https://api.nexusquant.co/api` |
135
+ | `NEXUSQUANT_COGNITO_DOMAIN` | Cognito Hosted UI host, default `auth.lookatwallstreet.com` |
136
+ | `NEXUSQUANT_COGNITO_CLIENT_ID` | Cognito app client id |
137
+ | `NEXUSQUANT_REDIRECT_URI` | OAuth callback, default `http://127.0.0.1:8251/callback` |
135
138
 
136
139
  There is also a hidden typo-compatible alias: `nexusquant startegy ...` maps to `nexusquant strategy ...`.
@@ -10,6 +10,10 @@ It supports browser login through Cognito, stores tokens locally, refreshes toke
10
10
  python3 -m pip install -e .
11
11
  ```
12
12
 
13
+ ```bash
14
+ pip install nexusquant-cli
15
+ ```
16
+
13
17
  ## Login
14
18
 
15
19
  ```bash
@@ -51,14 +55,13 @@ Send a single signal:
51
55
  nexusquant strategy signal my_alpha_001 \
52
56
  --strategy-name "My Alpha" \
53
57
  --ticker AAPL \
54
- --direction bull \
58
+ --direction buy \
55
59
  --price 150.25 \
56
- --level 0.8 \
57
60
  --quantity 100 \
58
61
  --order-type MARKET
59
62
  ```
60
63
 
61
- `--order-type` is a signal-level setting. Use `MARKET` for market orders or `NORMAL` for limit orders; `NORMAL` uses the signal `--price` as the limit reference. If omitted, the service defaults to `MARKET`.
64
+ `--order-type` is a signal-level setting: `MARKET` or `LIMIT` (limit uses `--price`). Legacy `NORMAL` is accepted by the CLI as an alias for `LIMIT`. If omitted, the API defaults to `MARKET`.
62
65
 
63
66
  Send a multi-route `signals` map:
64
67
 
@@ -101,9 +104,8 @@ nexusquant strategy sub config my_alpha_001
101
104
  "ticker": "AAPL",
102
105
  "time": "2026-04-26T19:30:00Z",
103
106
  "price": 150.25,
104
- "level": 0.8,
105
- "direction": "bull",
106
- "order_type": "NORMAL",
107
+ "direction": "buy",
108
+ "order_type": "LIMIT",
107
109
  "quantity": 100,
108
110
  "metadata": {}
109
111
  }
@@ -114,12 +116,12 @@ nexusquant strategy sub config my_alpha_001
114
116
 
115
117
  Normal use does not require configuration. For staging or local development:
116
118
 
117
- | Variable | Meaning |
118
- | --- | --- |
119
- | `NEXUSQUANT_API_ENDPOINT` | API endpoint host, default `https://api.nexusquant.co`; CLI calls `/api/...` under it |
120
- | `NEXUSQUANT_API_BASE_URL` | Optional full API base override, e.g. `https://api.nexusquant.co/api` |
121
- | `NEXUSQUANT_COGNITO_DOMAIN` | Cognito Hosted UI host, default `auth.lookatwallstreet.com` |
122
- | `NEXUSQUANT_COGNITO_CLIENT_ID` | Cognito app client id |
123
- | `NEXUSQUANT_REDIRECT_URI` | OAuth callback, default `http://127.0.0.1:8251/callback` |
119
+ | Variable | Meaning |
120
+ | ------------------------------ | ------------------------------------------------------------------------------------- |
121
+ | `NEXUSQUANT_API_ENDPOINT` | API endpoint host, default `https://api.nexusquant.co`; CLI calls `/api/...` under it |
122
+ | `NEXUSQUANT_API_BASE_URL` | Optional full API base override, e.g. `https://api.nexusquant.co/api` |
123
+ | `NEXUSQUANT_COGNITO_DOMAIN` | Cognito Hosted UI host, default `auth.lookatwallstreet.com` |
124
+ | `NEXUSQUANT_COGNITO_CLIENT_ID` | Cognito app client id |
125
+ | `NEXUSQUANT_REDIRECT_URI` | OAuth callback, default `http://127.0.0.1:8251/callback` |
124
126
 
125
127
  There is also a hidden typo-compatible alias: `nexusquant startegy ...` maps to `nexusquant strategy ...`.
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -22,6 +22,7 @@ from typing import Any
22
22
  import httpx
23
23
 
24
24
  from nexusquant_cli.config import (
25
+ clear_credentials,
25
26
  cognito_client_id,
26
27
  cognito_domain_host,
27
28
  load_credentials,
@@ -214,11 +215,19 @@ def ensure_fresh_id_token(*, force_refresh: bool = False) -> str:
214
215
  if not refresh_token:
215
216
  return str(creds["id_token"])
216
217
 
217
- new_tokens = refresh_tokens(
218
- domain_host=cognito_domain_host(),
219
- client_id=cognito_client_id(),
220
- refresh_token=refresh_token,
221
- )
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 run: nexusquant auth"
229
+ ) from exc
230
+ raise
222
231
  merged = {
223
232
  "id_token": new_tokens.get("id_token") or creds["id_token"],
224
233
  "access_token": new_tokens.get("access_token") or creds.get("access_token"),
@@ -3,9 +3,11 @@ from __future__ import annotations
3
3
  import typer
4
4
 
5
5
  from nexusquant_cli.commands.auth.cmd import register as register_auth
6
+ from nexusquant_cli.commands.policy.cmd import register as register_policy
6
7
  from nexusquant_cli.commands.strategy.cmd import register as register_strategy
7
8
 
8
9
 
9
10
  def register_all(app: typer.Typer) -> None:
10
11
  register_auth(app)
12
+ register_policy(app)
11
13
  register_strategy(app)
@@ -0,0 +1,327 @@
1
+ """`nexusquant policy …` —— SizingPolicy: publish a formula instead of a number.
2
+
3
+ All validation lives in ``nexusquant_sdk._policy`` and is called from here, never
4
+ re-implemented. A second copy of the rules would eventually drift from the server,
5
+ and a client check that disagrees with the server is worse than no check at all —
6
+ it hands you false confidence. (The backend is always the authority; these commands
7
+ only move the obvious mistakes onto your machine.)
8
+
9
+ What you submit is two things::
10
+
11
+ {"expr": {"buy.quantity": "...", "buy.price": "..."}, "params": ["a", "b"]}
12
+
13
+ ``expr`` is the formula, ``params`` are the names it needs you to send with every
14
+ signal. Run ``nexusquant policy features`` first — that endpoint is the authority on
15
+ which names exist.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from pathlib import Path
22
+ from typing import Annotated, Any
23
+
24
+ import typer
25
+ from rich.console import Console
26
+ from rich.table import Table
27
+
28
+ from nexusquant_cli.commands._util import load_json_file
29
+ from nexusquant_sdk import (
30
+ SignalValidationError,
31
+ parse_param_args,
32
+ policy_features,
33
+ policy_list,
34
+ policy_publish,
35
+ policy_send_signal,
36
+ policy_set_mode,
37
+ policy_spec_for,
38
+ )
39
+
40
+ console = Console()
41
+
42
+ policy_app = typer.Typer(
43
+ help=(
44
+ "SizingPolicy: submit a formula that decides order quantity (and optionally "
45
+ "limit price) instead of sending a fixed number. Requires "
46
+ "custom:userType=strategyProvider or admin."
47
+ ),
48
+ no_args_is_help=True,
49
+ )
50
+
51
+
52
+ def _err(e: Exception) -> None:
53
+ console.print(f"[red]{e}[/red]")
54
+ raise typer.Exit(1) from e
55
+
56
+
57
+ def _print_gate(gate: dict[str, Any]) -> None:
58
+ """Print every gate step. When a publish is rejected, which step and why is the
59
+ only useful information, so it is never collapsed into a single line."""
60
+ table = Table(show_header=True, header_style="dim")
61
+ table.add_column("gate", style="bold")
62
+ table.add_column("")
63
+ table.add_column("detail", overflow="fold")
64
+ for step in gate.get("steps") or []:
65
+ table.add_row(
66
+ str(step.get("name")),
67
+ "[green]pass[/green]" if step.get("ok") else "[red]fail[/red]",
68
+ str(step.get("detail") or ""),
69
+ )
70
+ console.print(table)
71
+ for warning in gate.get("warnings") or []:
72
+ console.print(f"[yellow]⚠ {warning}[/yellow]")
73
+
74
+
75
+ @policy_app.command(
76
+ "features",
77
+ help=(
78
+ "List the names a formula may use. Run this before writing one — the server "
79
+ "registry is the authority, and names outside it cannot be published."
80
+ ),
81
+ )
82
+ def features_cmd(
83
+ scope: Annotated[
84
+ str | None,
85
+ typer.Option("--scope", help="Filter: 'shared' (you send them) or 'account' (bound in the user's container)"),
86
+ ] = None,
87
+ as_json: Annotated[bool, typer.Option("--json", help="Raw output for scripting")] = False,
88
+ ) -> None:
89
+ try:
90
+ resp = policy_features()
91
+ except Exception as e: # noqa: BLE001
92
+ _err(e)
93
+ data = (resp or {}).get("data") or {}
94
+ rows = data.get("features") or []
95
+ if not rows:
96
+ console.print("[dim]Server returned no features.[/dim]")
97
+ return
98
+ if as_json:
99
+ console.print_json(data=data)
100
+ return
101
+
102
+ want = (scope or "").strip().lower()
103
+ if want in ("account", "per_user"):
104
+ rows = [r for r in rows if r.get("scope") == "per_user"]
105
+ elif want == "shared":
106
+ rows = [r for r in rows if r.get("scope") == "shared"]
107
+ elif want:
108
+ _err(RuntimeError("--scope must be 'shared' or 'account'"))
109
+
110
+ table = Table(show_header=True, header_style="dim")
111
+ table.add_column("name", style="bold", overflow="fold")
112
+ table.add_column("label", overflow="fold")
113
+ table.add_column("supplied by")
114
+ table.add_column("what it is", overflow="fold")
115
+ for r in sorted(rows, key=lambda x: (x.get("scope") != "shared", x.get("name") or "")):
116
+ per_user = r.get("scope") == "per_user"
117
+ # A registered-but-unsuppliable name is flagged in red here: it can be written
118
+ # into a formula, but publishing will reject it — and that rejection lands
119
+ # after you have already written the whole thing.
120
+ if per_user and not r.get("suppliable", True):
121
+ who = "[red]unavailable[/red]"
122
+ elif per_user:
123
+ who = "[cyan]user's container[/cyan]"
124
+ else:
125
+ who = "you, per signal"
126
+ table.add_row(
127
+ str(r.get("name")), str(r.get("label") or ""), who,
128
+ str(r.get("definition") or ""),
129
+ )
130
+ console.print(table)
131
+ if data.get("guidance"):
132
+ console.print(f"\n[yellow]{data['guidance']}[/yellow]")
133
+ console.print(
134
+ "\n[dim]Names marked 'you, per signal' go into your artifact's params. "
135
+ "Names bound in the user's container must NOT — reference them directly.[/dim]"
136
+ )
137
+
138
+
139
+ @policy_app.command(
140
+ "publish",
141
+ help=(
142
+ "Publish a policy. This call IS the go-live action — there is no shadow or "
143
+ "canary step, and the previous version on the same slot is deactivated."
144
+ ),
145
+ )
146
+ def publish_cmd(
147
+ strategy_id: Annotated[str, typer.Option("--strategy-id", help="A strategy you own")],
148
+ ticker: Annotated[str, typer.Option("--ticker", help="Which symbol this formula is for")],
149
+ file: Annotated[Path, typer.Option("--file", help='JSON: {"expr": {...}, "params": [...]}')],
150
+ profile: Annotated[str | None, typer.Option("--profile", help="Tier; defaults to 'normal'")] = None,
151
+ skip_name_check: Annotated[
152
+ bool,
153
+ typer.Option("--skip-name-check", help="Do not fetch the registry before validating locally"),
154
+ ] = False,
155
+ ) -> None:
156
+ raw = load_json_file(file, label="--file")
157
+ if not isinstance(raw, dict):
158
+ _err(RuntimeError("--file must contain a JSON object"))
159
+ try:
160
+ resp = policy_publish(
161
+ strategy_id,
162
+ ticker=ticker,
163
+ expr=raw.get("expr") or {},
164
+ params=raw.get("params") or [],
165
+ profile=profile,
166
+ check_names=not skip_name_check,
167
+ )
168
+ except SignalValidationError as e:
169
+ _err(e)
170
+ except Exception as e: # noqa: BLE001
171
+ _err(e)
172
+ data = (resp or {}).get("data") or {}
173
+ if data.get("gate"):
174
+ _print_gate(data["gate"])
175
+ console.print(f"[green]Published and live[/green] {data.get('policy_id')}")
176
+ if data.get("slots"):
177
+ console.print(f"Output slots: {', '.join(data['slots'])}")
178
+ if data.get("params"):
179
+ console.print(
180
+ f"Send with every signal: {', '.join(data['params'])}"
181
+ " [dim]miss one and the signal is rejected[/dim]"
182
+ )
183
+ if data.get("account_features"):
184
+ console.print(
185
+ f"[cyan]Reads user holdings: {', '.join(data['account_features'])}[/cyan]"
186
+ " [dim]values are bound in the user's container; you never see them[/dim]"
187
+ )
188
+ if data.get("superseded"):
189
+ console.print(f"Deactivated on the same slot: {', '.join(data['superseded'])}")
190
+
191
+
192
+ @policy_app.command("list", help="List published policies for one strategy.")
193
+ def list_cmd(
194
+ strategy_id: Annotated[str, typer.Argument(help="Strategy ID")],
195
+ ticker: Annotated[str | None, typer.Option("--ticker")] = None,
196
+ mode: Annotated[str | None, typer.Option("--mode", help="active / off")] = None,
197
+ ) -> None:
198
+ try:
199
+ resp = policy_list(strategy_id, ticker=ticker, mode=mode)
200
+ except Exception as e: # noqa: BLE001
201
+ _err(e)
202
+ rows = ((resp or {}).get("data") or {}).get("policies") or []
203
+ if not rows:
204
+ console.print("[dim]No published policies.[/dim]")
205
+ return
206
+ table = Table(show_header=True, header_style="dim")
207
+ for col in ("policy_id", "ticker", "mode", "output slots", "params", "reads holdings"):
208
+ table.add_column(col, overflow="fold")
209
+ for r in rows:
210
+ art = r.get("artifact") or {}
211
+ slots = r.get("slots") or [k for k in (art.get("expr") or {}) if "." in k]
212
+ params = r.get("params") or art.get("params") or []
213
+ acct = r.get("account_features") or []
214
+ table.add_row(
215
+ str(r.get("policy_id")), str(r.get("ticker")),
216
+ ("[green]active[/green]" if r.get("mode") == "active" else str(r.get("mode"))),
217
+ ", ".join(sorted(slots)), ", ".join(sorted(params)),
218
+ ("[cyan]" + ", ".join(sorted(acct)) + "[/cyan]") if acct else "",
219
+ )
220
+ console.print(table)
221
+
222
+
223
+ @policy_app.command(
224
+ "set-mode",
225
+ help="Deactivate or restore a policy (active ↔ off). Publishing already goes live, so this is the emergency stop, not a promotion step.",
226
+ )
227
+ def set_mode_cmd(
228
+ policy_id: Annotated[str, typer.Option("--policy-id")],
229
+ mode: Annotated[str, typer.Option("--mode", help="active or off")],
230
+ ) -> None:
231
+ try:
232
+ resp = policy_set_mode(policy_id, mode)
233
+ except ValueError as e:
234
+ _err(e)
235
+ except Exception as e: # noqa: BLE001
236
+ _err(e)
237
+ data = (resp or {}).get("data") or {}
238
+ console.print(f"[green]{data.get('previous_mode')} → {data.get('mode')}[/green] {policy_id}")
239
+
240
+
241
+ @policy_app.command(
242
+ "send-signal",
243
+ help=(
244
+ "Send a signal carrying the policy's parameter values (--param name=value, "
245
+ "repeatable). Every declared parameter must be present or the signal is rejected."
246
+ ),
247
+ )
248
+ def send_signal_cmd(
249
+ strategy_id: Annotated[str, typer.Option("--strategy-id")],
250
+ ticker: Annotated[str, typer.Option("--ticker")],
251
+ price: Annotated[float, typer.Option("--price")],
252
+ direction: Annotated[str, typer.Option("--direction", help="buy or sell")],
253
+ strategy_name: Annotated[str | None, typer.Option("--strategy-name")] = None,
254
+ param: Annotated[
255
+ list[str] | None,
256
+ typer.Option("--param", "-p", help="Parameter value, e.g. depth=3; repeat per declared name"),
257
+ ] = None,
258
+ params_file: Annotated[
259
+ Path | None, typer.Option("--params-file", help="Or a JSON object with all parameter values")
260
+ ] = None,
261
+ quantity: Annotated[
262
+ int | None,
263
+ typer.Option("--quantity", help="Used when the formula has no slot for this direction"),
264
+ ] = None,
265
+ order_type: Annotated[str | None, typer.Option("--order-type", help="MARKET / LIMIT")] = None,
266
+ policy_id: Annotated[
267
+ str | None, typer.Option("--policy-id", help="Defaults to the active policy on this (strategy, ticker)")
268
+ ] = None,
269
+ no_fetch: Annotated[
270
+ bool,
271
+ typer.Option("--no-fetch", help="Skip fetching the policy; only the 'no account state' rule is then checked"),
272
+ ] = False,
273
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Validate and print the payload without sending")] = False,
274
+ ) -> None:
275
+ supplied: dict[str, Any] = {}
276
+ if params_file:
277
+ loaded = load_json_file(params_file, label="--params-file")
278
+ if not isinstance(loaded, dict):
279
+ _err(RuntimeError("--params-file must contain a JSON object"))
280
+ supplied.update(loaded)
281
+ try:
282
+ supplied.update(parse_param_args(param or []))
283
+ except SignalValidationError as e:
284
+ _err(e)
285
+
286
+ spec = None
287
+ if not no_fetch:
288
+ try:
289
+ spec = policy_spec_for(strategy_id, ticker=ticker, policy_id=policy_id)
290
+ except SignalValidationError as e:
291
+ _err(e)
292
+ except Exception as e: # noqa: BLE001
293
+ _err(e)
294
+
295
+ if dry_run:
296
+ # Build without sending, so the same validation runs and you see the exact body.
297
+ from nexusquant_sdk import _policy
298
+
299
+ try:
300
+ body = _policy.build_signal_payload(
301
+ strategy_id=strategy_id,
302
+ strategy_name=strategy_name or strategy_id,
303
+ ticker=ticker, price=price, direction=direction,
304
+ params=supplied, spec=spec, quantity=quantity, order_type=order_type,
305
+ )
306
+ except SignalValidationError as e:
307
+ _err(e)
308
+ console.print("[dim]--dry-run: validated, not sent[/dim]")
309
+ console.print_json(data=body)
310
+ return
311
+
312
+ try:
313
+ resp = policy_send_signal(
314
+ strategy_id, strategy_name=strategy_name, ticker=ticker, price=price,
315
+ direction=direction, params=supplied, quantity=quantity,
316
+ order_type=order_type, spec=spec, fetch_spec=False,
317
+ )
318
+ except SignalValidationError as e:
319
+ _err(e)
320
+ except Exception as e: # noqa: BLE001
321
+ _err(e)
322
+ console.print("[green]Sent[/green]")
323
+ console.print_json(data=resp)
324
+
325
+
326
+ def register(app: typer.Typer) -> None:
327
+ app.add_typer(policy_app, name="policy")
@@ -219,19 +219,15 @@ def strategy_signal(
219
219
  ] = None,
220
220
  direction: Annotated[
221
221
  str | None,
222
- typer.Option("--direction", help="Signal direction: bull or bear."),
222
+ typer.Option("--direction", help='Signal direction: "buy" or "sell".'),
223
223
  ] = None,
224
224
  price: Annotated[
225
225
  float | None,
226
226
  typer.Option(
227
227
  "--price",
228
- help="Reference price. Required for send and notional_usd sizing.",
228
+ help="Reference price. Required when sending; LIMIT orders use this as limit price.",
229
229
  ),
230
230
  ] = None,
231
- level: Annotated[
232
- float | None,
233
- typer.Option("--level", help="Signal strength/level, e.g. 0.8."),
234
- ] = None,
235
231
  time_value: Annotated[
236
232
  str | None,
237
233
  typer.Option(
@@ -239,33 +235,18 @@ def strategy_signal(
239
235
  help="Signal timestamp in ISO-8601. Defaults to current UTC time when sending.",
240
236
  ),
241
237
  ] = 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
238
  quantity: Annotated[
249
239
  int | None,
250
240
  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.",
241
+ "--quantity",
242
+ help="Share quantity (positive integer). Required when sending a single signal.",
259
243
  ),
260
244
  ] = None,
261
245
  order_type: Annotated[
262
246
  str | None,
263
247
  typer.Option(
264
248
  "--order-type",
265
- help=(
266
- "Signal-level broker order type: MARKET (default) or NORMAL "
267
- "(limit order; uses --price)."
268
- ),
249
+ help='Broker order type: MARKET (default at API) or LIMIT (limit at --price). "NORMAL" is accepted as an alias for LIMIT.',
269
250
  ),
270
251
  ] = None,
271
252
  metadata_json: Annotated[
@@ -296,10 +277,10 @@ def strategy_signal(
296
277
  ``nexusquant strategy signal my_alpha --history --limit 20``
297
278
 
298
279
  Single signal:
299
- ``nexusquant strategy signal my_alpha --ticker AAPL --direction bull --price 150 --level 0.8 --quantity 100 --order-type MARKET``
280
+ ``nexusquant strategy signal my_alpha --ticker AAPL --direction buy --price 150 --quantity 100 --order-type MARKET``
300
281
 
301
282
  Limit signal:
302
- ``nexusquant strategy signal my_alpha --ticker AAPL --direction bear --price 150 --level 0.8 --quantity 100 --order-type NORMAL``
283
+ ``nexusquant strategy signal my_alpha --ticker AAPL --direction sell --price 150 --quantity 100 --order-type LIMIT``
303
284
 
304
285
  Signals map:
305
286
  ``nexusquant strategy signal my_alpha --signals-file signals.json --strategy-name "My Alpha"``
@@ -310,9 +291,7 @@ def strategy_signal(
310
291
  ticker,
311
292
  direction,
312
293
  price,
313
- level,
314
294
  quantity,
315
- notional_usd,
316
295
  order_type,
317
296
  signals_file,
318
297
  )
@@ -348,7 +327,7 @@ def strategy_signal(
348
327
  "--ticker": ticker,
349
328
  "--direction": direction,
350
329
  "--price": price,
351
- "--level": level,
330
+ "--quantity": quantity,
352
331
  }.items()
353
332
  if value is None
354
333
  ]
@@ -357,23 +336,23 @@ def strategy_signal(
357
336
  f"Sending a single signal requires: {', '.join(missing)}"
358
337
  )
359
338
 
339
+ direction_norm = direction.strip().lower()
340
+ if direction_norm not in ("buy", "sell"):
341
+ raise typer.BadParameter('--direction must be "buy" or "sell".')
342
+
360
343
  signal: dict[str, Any] = {
361
344
  "ticker": ticker,
362
345
  "time": time_value or _now_iso(),
363
346
  "price": price,
364
- "level": level,
365
- "direction": direction,
347
+ "direction": direction_norm,
348
+ "quantity": int(quantity),
366
349
  }
367
- if sizing_kind is not None:
368
- signal["sizing_kind"] = sizing_kind
369
- if quantity is not None:
370
- signal["quantity"] = quantity
371
- if notional_usd is not None:
372
- signal["notional_usd"] = notional_usd
373
350
  if order_type is not None:
374
351
  normalized_order_type = order_type.strip().upper()
375
- if normalized_order_type not in {"MARKET", "NORMAL"}:
376
- raise typer.BadParameter("--order-type must be MARKET or NORMAL.")
352
+ if normalized_order_type == "NORMAL":
353
+ normalized_order_type = "LIMIT"
354
+ if normalized_order_type not in {"MARKET", "LIMIT"}:
355
+ raise typer.BadParameter('--order-type must be MARKET, LIMIT, or legacy alias NORMAL (=LIMIT).')
377
356
  signal["order_type"] = normalized_order_type
378
357
  metadata = load_json_option(metadata_json, label="metadata")
379
358
  if metadata is not None:
@@ -7,7 +7,7 @@ from typing import Any
7
7
 
8
8
  from platformdirs import user_config_dir
9
9
 
10
- CONFIG_DIR = Path(user_config_dir("nexusquant-cli", appauthor=False))
10
+ CONFIG_DIR = Path(user_config_dir("nexusquant-sdk", appauthor=False))
11
11
  CREDENTIALS_PATH = CONFIG_DIR / "credentials.json"
12
12
 
13
13
  DEFAULT_API_ENDPOINT = "https://api.nexusquant.co"
@@ -4,12 +4,15 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nexusquant-cli"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "NexusQuant strategy provider CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  dependencies = [
12
12
  "httpx>=0.27",
13
+ # policy 命令的全部校验都在 SDK 里,这里只调不抄 —— 抄一份就会与服务端分叉,
14
+ # 而分叉的客户端校验比没有更糟(它给的是假信心)。
15
+ "nexusquant-sdk>=0.2",
13
16
  "platformdirs>=4.2",
14
17
  "rich>=13.7",
15
18
  "typer>=0.12",
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"