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.
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/.gitignore +3 -0
- nexusquant_cli-0.2.0/CLAUDE.md +66 -0
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/PKG-INFO +18 -15
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/README.md +15 -13
- nexusquant_cli-0.2.0/dist_archive/nexusquant_cli-0.1.1-py3-none-any.whl +0 -0
- nexusquant_cli-0.2.0/dist_archive/nexusquant_cli-0.1.1.tar.gz +0 -0
- nexusquant_cli-0.2.0/dist_archive/nexusquant_cli-0.1.2-py3-none-any.whl +0 -0
- nexusquant_cli-0.2.0/dist_archive/nexusquant_cli-0.1.2.tar.gz +0 -0
- nexusquant_cli-0.2.0/nexusquant_cli/__init__.py +1 -0
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/auth_pkce.py +14 -5
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/__init__.py +2 -0
- nexusquant_cli-0.2.0/nexusquant_cli/commands/policy/__init__.py +0 -0
- nexusquant_cli-0.2.0/nexusquant_cli/commands/policy/cmd.py +327 -0
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/strategy/cmd.py +18 -39
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/config.py +1 -1
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/pyproject.toml +4 -1
- nexusquant_cli-0.1.1/nexusquant_cli/__init__.py +0 -1
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/__main__.py +0 -0
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/api_client.py +0 -0
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/_util.py +0 -0
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/auth/__init__.py +0 -0
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/auth/cmd.py +0 -0
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/commands/strategy/__init__.py +0 -0
- {nexusquant_cli-0.1.1 → nexusquant_cli-0.2.0}/nexusquant_cli/main.py +0 -0
|
@@ -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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: nexusquant-cli
|
|
3
|
-
Version: 0.
|
|
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
|
|
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
|
|
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
|
-
"
|
|
116
|
-
"
|
|
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
|
|
129
|
-
|
|
|
130
|
-
| `NEXUSQUANT_API_ENDPOINT`
|
|
131
|
-
| `NEXUSQUANT_API_BASE_URL`
|
|
132
|
-
| `NEXUSQUANT_COGNITO_DOMAIN`
|
|
133
|
-
| `NEXUSQUANT_COGNITO_CLIENT_ID` | Cognito app client id
|
|
134
|
-
| `NEXUSQUANT_REDIRECT_URI`
|
|
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
|
|
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
|
|
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
|
-
"
|
|
105
|
-
"
|
|
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
|
|
118
|
-
|
|
|
119
|
-
| `NEXUSQUANT_API_ENDPOINT`
|
|
120
|
-
| `NEXUSQUANT_API_BASE_URL`
|
|
121
|
-
| `NEXUSQUANT_COGNITO_DOMAIN`
|
|
122
|
-
| `NEXUSQUANT_COGNITO_CLIENT_ID` | Cognito app client id
|
|
123
|
-
| `NEXUSQUANT_REDIRECT_URI`
|
|
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 ...`.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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)
|
|
File without changes
|
|
@@ -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=
|
|
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
|
|
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",
|
|
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
|
|
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
|
|
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
|
-
"--
|
|
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
|
-
"
|
|
365
|
-
"
|
|
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
|
|
376
|
-
|
|
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-
|
|
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.
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|