nexusquant-cli 0.1.2__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.2 → nexusquant_cli-0.2.0}/PKG-INFO +3 -2
  2. nexusquant_cli-0.2.0/dist_archive/nexusquant_cli-0.1.1-py3-none-any.whl +0 -0
  3. nexusquant_cli-0.2.0/dist_archive/nexusquant_cli-0.1.1.tar.gz +0 -0
  4. nexusquant_cli-0.2.0/dist_archive/nexusquant_cli-0.1.2-py3-none-any.whl +0 -0
  5. nexusquant_cli-0.2.0/dist_archive/nexusquant_cli-0.1.2.tar.gz +0 -0
  6. nexusquant_cli-0.2.0/nexusquant_cli/__init__.py +1 -0
  7. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/__init__.py +2 -0
  8. nexusquant_cli-0.2.0/nexusquant_cli/commands/policy/__init__.py +0 -0
  9. nexusquant_cli-0.2.0/nexusquant_cli/commands/policy/cmd.py +327 -0
  10. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/pyproject.toml +4 -1
  11. nexusquant_cli-0.1.2/nexusquant_cli/__init__.py +0 -1
  12. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/.gitignore +0 -0
  13. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/CLAUDE.md +0 -0
  14. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/README.md +0 -0
  15. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/nexusquant_cli/__main__.py +0 -0
  16. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/nexusquant_cli/api_client.py +0 -0
  17. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/nexusquant_cli/auth_pkce.py +0 -0
  18. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/_util.py +0 -0
  19. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/auth/__init__.py +0 -0
  20. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/auth/cmd.py +0 -0
  21. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/strategy/__init__.py +0 -0
  22. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/strategy/cmd.py +0 -0
  23. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/nexusquant_cli/config.py +0 -0
  24. {nexusquant_cli-0.1.2 → nexusquant_cli-0.2.0}/nexusquant_cli/main.py +0 -0
@@ -1,9 +1,10 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: nexusquant-cli
3
- Version: 0.1.2
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
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -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")
@@ -4,12 +4,15 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nexusquant-cli"
7
- version = "0.1.2"
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.1"
File without changes
File without changes