nexusquant-cli 0.3.0__tar.gz → 0.4.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 (33) hide show
  1. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/PKG-INFO +1 -1
  2. nexusquant_cli-0.4.0/nexusquant_cli/__init__.py +1 -0
  3. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/api_client.py +47 -0
  4. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/embedded.py +47 -9
  5. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/roles/investor.py +212 -0
  6. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/pyproject.toml +1 -1
  7. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/tests/test_nexus_cli.py +149 -0
  8. nexusquant_cli-0.3.0/nexusquant_cli/__init__.py +0 -1
  9. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/.gitignore +0 -0
  10. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/CLAUDE.md +0 -0
  11. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/README.md +0 -0
  12. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/docs/investor-operations.md +0 -0
  13. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/__main__.py +0 -0
  14. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/auth_pkce.py +0 -0
  15. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/commands/__init__.py +0 -0
  16. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/commands/_util.py +0 -0
  17. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/commands/auth/__init__.py +0 -0
  18. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/commands/auth/cmd.py +0 -0
  19. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/commands/policy/__init__.py +0 -0
  20. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/commands/policy/cmd.py +0 -0
  21. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/commands/strategy/__init__.py +0 -0
  22. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/commands/strategy/cmd.py +0 -0
  23. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/config.py +0 -0
  24. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/identity.py +0 -0
  25. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/main.py +0 -0
  26. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/nexus.py +0 -0
  27. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/roles/__init__.py +0 -0
  28. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/roles/_agent.py +0 -0
  29. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/roles/admin.py +0 -0
  30. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/roles/auth.py +0 -0
  31. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/nexusquant_cli/roles/provider.py +0 -0
  32. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/tests/__init__.py +0 -0
  33. {nexusquant_cli-0.3.0 → nexusquant_cli-0.4.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nexusquant-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: NexusQuant CLI for users, strategy providers and admins
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: httpx>=0.27
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -192,6 +192,23 @@ def list_my_strategies() -> Any:
192
192
  return get_json("/user/strategy/list/")
193
193
 
194
194
 
195
+ def update_strategy_config(strategy_id: str, body: dict[str, Any]) -> Any:
196
+ """PUT /user/strategy/config/{id}/ — {strategy_params} replaces that slice, {execution} merges.
197
+
198
+ After the first save, every save switches listening OFF (the server closes the window).
199
+ """
200
+ return put_json(f"/user/strategy/config/{strategy_id}/", body)
201
+
202
+
203
+ def set_listening(strategy_id: str, on: bool) -> Any:
204
+ """POST /user/strategy/subscribe|unsubscribe/{id}/ — arm / disarm auto-trading for one strategy.
205
+
206
+ ON means future signals from this strategy place REAL orders in the user's broker account.
207
+ """
208
+ action = "subscribe" if on else "unsubscribe"
209
+ return post_json(f"/user/strategy/{action}/{strategy_id}/", {})
210
+
211
+
195
212
  def list_signal_logs(
196
213
  *,
197
214
  strategy_id: str | None = None,
@@ -229,6 +246,36 @@ def get_container_status(container_id: str) -> Any:
229
246
  return get_json(f"/container/{container_id}/status/")
230
247
 
231
248
 
249
+ def get_broker_token_status(container_id: str) -> Any:
250
+ """GET /container/{cid}/broker_token_status/ — token / session time left and how to renew."""
251
+ return get_json(f"/container/{container_id}/broker_token_status/")
252
+
253
+
254
+ def get_broker_disconnect_notice(container_id: str) -> Any:
255
+ """GET /container/{cid}/broker-disconnect-notice/ — {notice: "competing_session"|null, broker_id}."""
256
+ return get_json(f"/container/{container_id}/broker-disconnect-notice/")
257
+
258
+
259
+ def ping_broker(container_id: str) -> Any:
260
+ """POST /container/{cid}/ping/ — the web app's「检查连接」.
261
+
262
+ Not a pure read: it asks the broker (e.g. get_funds), updates the stored connection
263
+ status, and may self-heal (Schwab status, a forced Webull reconnect) unless the user
264
+ disconnected on purpose. Writes a broker_ping activity row.
265
+ """
266
+ return post_json(f"/container/{container_id}/ping/", {})
267
+
268
+
269
+ def disconnect_broker(container_id: str) -> Any:
270
+ """POST /container/{cid}/disconnect/ — stop the broker session; no orders until reconnected."""
271
+ return post_json(f"/container/{container_id}/disconnect/", {})
272
+
273
+
274
+ def reconnect_ibkr(container_id: str) -> Any:
275
+ """POST /container/{cid}/connect/ step=gateway_reconnect — IBKR re-login; sends a 2FA push to the phone."""
276
+ return post_json(f"/container/{container_id}/connect/", {"step": "gateway_reconnect", "broker_id": "ibkr"})
277
+
278
+
232
279
  def list_brokers() -> Any:
233
280
  """GET /container/brokers/ — supported broker catalog (public)."""
234
281
  return get_json("/container/brokers/")
@@ -13,8 +13,12 @@ as the user who is chatting, without any credentials on disk:
13
13
  write the user's ID token to stdin ──▶ read one line, set as the only token
14
14
  run the command with --json, print JSON, exit
15
15
 
16
- The token never appears in argv, the environment, or a file. Only `investor list|read …`
17
- is accepted; anything that changes state is refused before a token is read.
16
+ The token never appears in argv, the environment, or a file.
17
+
18
+ Read commands (`investor list|read …`, plus `ping broker`) always run. Commands that change state run only when the
19
+ caller sets NEXUS_CLI_WRITE_CONFIRMED=1 — the bridge does so only after the user clicked "allow"
20
+ for that exact call — and only from the WRITE_COMMANDS allowlist; they get --yes because the
21
+ confirmation already happened. Everything else is refused before a token is read.
18
22
  """
19
23
 
20
24
  from __future__ import annotations
@@ -23,6 +27,18 @@ import sys
23
27
 
24
28
  READY = "NEXUS_CLI_READY"
25
29
  READ_VERBS = {"list", "read"}
30
+ # Not pure reads, but no confirmation (owner 2026-10-09): the bridge rate-limits them instead.
31
+ UNCONFIRMED_ACTIONS = {"ping": {"broker"}}
32
+ # verb → resources. No broker connect, no container lifecycle: credentials / destructive.
33
+ WRITE_COMMANDS = {
34
+ "subscribe": {"strategy"},
35
+ "unsubscribe": {"strategy"},
36
+ "update": {"config", "listening", "settings", "agent-access"},
37
+ "delete": {"agent-session"},
38
+ "disconnect": {"broker"},
39
+ "reconnect": {"broker"}, # IBKR only (2FA push); the command itself refuses other brokers
40
+ }
41
+ WRITE_CONFIRMED_ENV = "NEXUS_CLI_WRITE_CONFIRMED"
26
42
 
27
43
 
28
44
  def _harden() -> None:
@@ -35,18 +51,38 @@ def _harden() -> None:
35
51
  raise SystemExit(f"prctl(PR_SET_DUMPABLE, 0) failed: errno {ctypes.get_errno()}")
36
52
 
37
53
 
38
- def validate(argv: list[str]) -> str | None:
39
- """None if argv is an allowed read command; otherwise why not."""
54
+ def _options(app, verb: str, resource: str) -> set[str]:
55
+ """Option names of `investor <verb> <resource>` (empty if it does not exist)."""
56
+ import typer.main
57
+
58
+ try:
59
+ cmd = typer.main.get_command(app).commands[verb].commands[resource]
60
+ except (KeyError, AttributeError):
61
+ return set()
62
+ return {opt for param in cmd.params for opt in getattr(param, "opts", [])}
63
+
64
+
65
+ def is_write(argv: list[str]) -> bool:
66
+ return len(argv) >= 3 and argv[0] == "investor" and argv[2] in WRITE_COMMANDS.get(argv[1], set())
67
+
68
+
69
+ def validate(argv: list[str], *, write_confirmed: bool = False) -> str | None:
70
+ """None if argv may run; otherwise why not."""
40
71
  if len(argv) < 3 or argv[0] != "investor":
41
- return "only `investor <list|read> <resource> …` is available"
42
- if argv[1] not in READ_VERBS:
43
- return f"`{argv[1]}` is not allowed here — read-only commands only (list, read)"
44
- return None
72
+ return "only `investor <verb> <resource> …` is available"
73
+ if argv[1] in READ_VERBS or argv[2] in UNCONFIRMED_ACTIONS.get(argv[1], set()):
74
+ return None
75
+ if is_write(argv):
76
+ return None if write_confirmed else "this command changes your account and needs your confirmation first"
77
+ return f"`investor {argv[1]} {argv[2]}` is not available here"
45
78
 
46
79
 
47
80
  def main(argv: list[str] | None = None) -> int:
81
+ import os
82
+
48
83
  argv = list(sys.argv[1:] if argv is None else argv)
49
- why = validate(argv)
84
+ write = is_write(argv)
85
+ why = validate(argv, write_confirmed=os.environ.get(WRITE_CONFIRMED_ENV) == "1")
50
86
  if why:
51
87
  print(why, file=sys.stderr)
52
88
  return 2
@@ -66,6 +102,8 @@ def main(argv: list[str] | None = None) -> int:
66
102
 
67
103
  if "--json" not in argv:
68
104
  argv.append("--json")
105
+ if write and "--yes" not in argv and "--yes" in _options(investor_app, argv[1], argv[2]):
106
+ argv.append("--yes") # confirmed in the web UI already; a prompt here would block on stdin
69
107
  try:
70
108
  investor_app(argv[1:], prog_name="nexus-cli investor", standalone_mode=False)
71
109
  except SystemExit as e: # typer.Exit / click exceptions
@@ -213,6 +213,7 @@ def subscribe_strategy(
213
213
  def unsubscribe_strategy(
214
214
  strategy_id: Annotated[str, typer.Argument(help="Strategy id.")],
215
215
  yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
216
+ as_json: JsonOpt = False,
216
217
  ) -> None:
217
218
  if not yes:
218
219
  mine = (_call(api_client.list_my_strategies) or {}).get("strategies") or []
@@ -227,6 +228,9 @@ def unsubscribe_strategy(
227
228
  )
228
229
  typer.confirm(f"Remove {strategy_id} from My Strategies?{warn}", abort=True)
229
230
  resp = _call(lambda: api_client.market_unsubscribe(strategy_id)) or {}
231
+ if as_json:
232
+ console.print_json(data=resp)
233
+ return
230
234
  console.print(f"[green]{resp.get('message') or 'Removed'}[/green] {strategy_id}")
231
235
 
232
236
 
@@ -402,16 +406,109 @@ def read_settings(as_json: JsonOpt = False) -> None:
402
406
  console.print(f"daily report {'on' if d.get('receive_daily_report') else 'off'}")
403
407
 
404
408
 
409
+ def _kv_pairs(pairs: list[str] | None, label: str) -> dict[str, Any]:
410
+ """KEY=VALUE → {KEY: value}; value parsed as JSON when it is a number / bool / null, else kept as text."""
411
+ import json as _json
412
+
413
+ out: dict[str, Any] = {}
414
+ for raw in pairs or []:
415
+ key, sep, value = raw.partition("=")
416
+ if not sep or not key.strip():
417
+ raise typer.BadParameter(f"{label} must look like KEY=VALUE, got {raw!r}")
418
+ try:
419
+ parsed = _json.loads(value)
420
+ out[key.strip()] = parsed if not isinstance(parsed, (dict, list)) else value
421
+ except ValueError:
422
+ out[key.strip()] = value
423
+ return out
424
+
425
+
426
+ @update_app.command(
427
+ "config",
428
+ help="Change your personal config for a subscribed strategy. --param sets strategy parameters "
429
+ "(others keep their current value); --execution sets execution limits such as max_order_cash_usd. "
430
+ "Saving switches listening OFF if it was on — switch it back on with `update listening`.",
431
+ )
432
+ def update_config(
433
+ strategy_id: Annotated[str, typer.Argument(help="Strategy id.")],
434
+ param: Annotated[list[str] | None, typer.Option("--param", help="KEY=VALUE, repeatable.")] = None,
435
+ execution: Annotated[list[str] | None, typer.Option("--execution", help="KEY=VALUE, repeatable.")] = None,
436
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
437
+ as_json: JsonOpt = False,
438
+ ) -> None:
439
+ params, execs = _kv_pairs(param, "--param"), _kv_pairs(execution, "--execution")
440
+ if not params and not execs:
441
+ raise typer.BadParameter("Nothing to change. Use --param KEY=VALUE and/or --execution KEY=VALUE.")
442
+ row = next((x for x in _my_strategies() if str(x.get("id")) == strategy_id), None)
443
+ if row is None:
444
+ console.print(f"[red]{strategy_id} is not in My Strategies.[/red]")
445
+ raise typer.Exit(1)
446
+ body: dict[str, Any] = {}
447
+ if params:
448
+ current = dict(((row.get("personal_config") or {}).get("strategy_params")) or {})
449
+ body["strategy_params"] = {**current, **params} # the server replaces this slice: keep the others
450
+ if execs:
451
+ body["execution"] = execs # the server merges this slice
452
+ if not yes:
453
+ warn = " Listening is ON and will be switched OFF by saving." if row.get("signal_listening") else ""
454
+ typer.confirm(f"Save config for {strategy_id}: {params or ''} {execs or ''}?{warn}", abort=True)
455
+ d = _call(lambda: api_client.update_strategy_config(strategy_id, body)) or {}
456
+ out = {"strategy_id": strategy_id, "message": d.get("message"), "personal_config": d.get("personal_config"),
457
+ "effective_config": d.get("effective_config"),
458
+ "listening_switched_off": bool(row.get("signal_listening") and row.get("config_acknowledged_at"))}
459
+ if as_json:
460
+ console.print_json(data=out)
461
+ return
462
+ console.print(f"[green]Saved[/green] {strategy_id}")
463
+ if out["listening_switched_off"]:
464
+ console.print("[yellow]Listening was switched OFF by this save.[/yellow]")
465
+ console.print_json(data=out["effective_config"] or {})
466
+
467
+
468
+ @update_app.command(
469
+ "listening",
470
+ help="Switch auto-trading for a subscribed strategy ON or OFF. ON means this strategy's future signals "
471
+ "place REAL orders in your broker account, sized by your effective config.",
472
+ )
473
+ def update_listening(
474
+ strategy_id: Annotated[str, typer.Argument(help="Strategy id.")],
475
+ on: Annotated[bool, typer.Option("--on/--off", help="Arm or disarm auto-trading.")],
476
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
477
+ as_json: JsonOpt = False,
478
+ ) -> None:
479
+ if not yes:
480
+ row = next((x for x in _my_strategies() if str(x.get("id")) == strategy_id), None)
481
+ if row is None:
482
+ console.print(f"[red]{strategy_id} is not in My Strategies.[/red]")
483
+ raise typer.Exit(1)
484
+ what = ("REAL orders will be placed for every new signal from this strategy" if on
485
+ else "no new orders will be placed for this strategy")
486
+ typer.confirm(f"Turn listening {'ON' if on else 'OFF'} for {strategy_id}? {what}.", abort=True)
487
+ d = _call(lambda: api_client.set_listening(strategy_id, on)) or {}
488
+ out = {"strategy_id": strategy_id, "listening": on, "message": d.get("message"),
489
+ "warning": d.get("warning"), "failed_containers": d.get("failed_containers")}
490
+ if as_json:
491
+ console.print_json(data=out)
492
+ return
493
+ console.print(f"[green]Listening {'ON' if on else 'OFF'}[/green] {strategy_id} [dim]{d.get('message') or ''}[/dim]")
494
+ if d.get("warning"):
495
+ console.print(f"[yellow]{d['warning']}[/yellow]")
496
+
497
+
405
498
  @update_app.command("settings", help="Change notification settings.")
406
499
  def update_settings(
407
500
  daily_report: Annotated[
408
501
  bool | None,
409
502
  typer.Option("--daily-report/--no-daily-report", help="Receive the daily trading report email."),
410
503
  ] = None,
504
+ as_json: JsonOpt = False,
411
505
  ) -> None:
412
506
  if daily_report is None:
413
507
  raise typer.BadParameter("Nothing to change. Use --daily-report or --no-daily-report.")
414
508
  d = _call(lambda: api_client.update_user_settings({"receive_daily_report": daily_report})) or {}
509
+ if as_json:
510
+ console.print_json(data=d)
511
+ return
415
512
  console.print(f"[green]daily report {'on' if d.get('receive_daily_report') else 'off'}[/green]")
416
513
 
417
514
 
@@ -448,6 +545,114 @@ def read_container(as_json: JsonOpt = False) -> None:
448
545
  console.print(f"[dim]{status['opend_status_message']}[/dim]")
449
546
 
450
547
 
548
+ def _container() -> dict[str, Any]:
549
+ health = _call(api_client.get_container_health) or {}
550
+ cont = health.get("container") or {}
551
+ if not health.get("has_container") or not cont.get("id"):
552
+ console.print("[yellow]No trading environment yet.[/yellow] Connect a broker in the web app first.")
553
+ raise typer.Exit(1)
554
+ return {"health": health, "id": str(cont["id"])}
555
+
556
+
557
+ ping_app = typer.Typer(help="Check something live.", no_args_is_help=True)
558
+ disconnect_app = typer.Typer(help="Disconnect something.", no_args_is_help=True)
559
+ reconnect_app = typer.Typer(help="Re-authorize something.", no_args_is_help=True)
560
+
561
+
562
+ @read_app.command(
563
+ "broker",
564
+ help="Broker connection in one view: login state and selected account, token / session time left, "
565
+ "and whether another login knocked this one off.",
566
+ )
567
+ def read_broker(as_json: JsonOpt = False) -> None:
568
+ c = _container()
569
+ status = (_call(lambda: api_client.get_container_status(c["id"])) or {}).get("container") or {}
570
+ token = _call(lambda: api_client.get_broker_token_status(c["id"])) or {}
571
+ notice = _call(lambda: api_client.get_broker_disconnect_notice(c["id"])) or {}
572
+ acc = status.get("selected_account") or {}
573
+ out = {
574
+ "broker_id": status.get("broker_id"),
575
+ "running": c["health"].get("is_running"),
576
+ "login_status": status.get("opend_login_status"),
577
+ "needs_verification": status.get("opend_needs_verification"),
578
+ "status_message": status.get("opend_status_message"),
579
+ "account": {k: acc.get(k) for k in ("acc_id", "market", "trd_env", "is_simulation")} if acc else None,
580
+ "token": token,
581
+ "disconnect_notice": notice.get("notice"),
582
+ }
583
+ if as_json:
584
+ console.print_json(data=out)
585
+ return
586
+ console.print(f"broker {out['broker_id'] or '—'} · running {_yes(out['running'])} · login {out['login_status'] or '—'}")
587
+ if out["disconnect_notice"]:
588
+ console.print(f"[red]Knocked off: {out['disconnect_notice']}[/red] (another login used the same account)")
589
+ if out["status_message"]:
590
+ console.print(f"[dim]{out['status_message']}[/dim]")
591
+ console.print_json(data=token)
592
+
593
+
594
+ @ping_app.command(
595
+ "broker",
596
+ help="Check the broker connection now (the web app's 「检查连接」). Asks the broker live, updates the "
597
+ "stored status, and may repair a dropped connection unless you disconnected on purpose.",
598
+ )
599
+ def ping_broker(as_json: JsonOpt = False) -> None:
600
+ c = _container()
601
+ d = _call(lambda: api_client.ping_broker(c["id"])) or {}
602
+ if as_json:
603
+ console.print_json(data=d)
604
+ return
605
+ ok = d.get("connected")
606
+ console.print(("[green]connected[/green]" if ok else "[red]not connected[/red]") + f" {d.get('message') or ''}")
607
+
608
+
609
+ @disconnect_app.command(
610
+ "broker",
611
+ help="Disconnect your broker. No orders are placed for any strategy until you connect again in the web app.",
612
+ )
613
+ def disconnect_broker(
614
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
615
+ as_json: JsonOpt = False,
616
+ ) -> None:
617
+ c = _container()
618
+ if not yes:
619
+ typer.confirm("Disconnect your broker? No orders will be placed until you reconnect in the web app.", abort=True)
620
+ d = _call(lambda: api_client.disconnect_broker(c["id"])) or {}
621
+ if as_json:
622
+ console.print_json(data=d)
623
+ return
624
+ console.print(f"[green]Disconnected[/green] {d.get('message') or ''}")
625
+
626
+
627
+ @reconnect_app.command(
628
+ "broker",
629
+ help="Re-authorize an IBKR connection: sends a 2FA push to your phone — approve it there. Other brokers "
630
+ "re-authorize in the web app (Schwab needs the redirect URL pasted into the web form, never into a chat).",
631
+ )
632
+ def reconnect_broker(
633
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
634
+ as_json: JsonOpt = False,
635
+ ) -> None:
636
+ c = _container()
637
+ status = (_call(lambda: api_client.get_container_status(c["id"])) or {}).get("container") or {}
638
+ broker = str(status.get("broker_id") or "").lower()
639
+ if broker != "ibkr":
640
+ msg = (f"Re-authorizing {broker or 'this broker'} is done in the web app "
641
+ "(My Strategies → broker connection). It needs a browser login or a code that must not go through a chat.")
642
+ if as_json:
643
+ console.print_json(data={"supported": False, "broker_id": broker, "message": msg})
644
+ else:
645
+ console.print(f"[yellow]{msg}[/yellow]")
646
+ raise typer.Exit(1)
647
+ if not yes:
648
+ typer.confirm("Re-login IBKR now? A 2FA push will be sent to your phone.", abort=True)
649
+ d = _call(lambda: api_client.reconnect_ibkr(c["id"])) or {}
650
+ if as_json:
651
+ console.print_json(data=d)
652
+ return
653
+ console.print(f"[green]Requested[/green] — approve the IBKR push on your phone. {d.get('message') or ''}")
654
+
655
+
451
656
  @list_app.command("brokers", help="Brokers the platform supports and how each one logs in.")
452
657
  def list_brokers(as_json: JsonOpt = False) -> None:
453
658
  rows = (_call(api_client.list_brokers) or {}).get("brokers") or []
@@ -593,10 +798,14 @@ def read_agent_session(
593
798
  def delete_agent_session(
594
799
  session: Annotated[str, typer.Argument(help="Session id from `list agent-sessions`.")],
595
800
  yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
801
+ as_json: JsonOpt = False,
596
802
  ) -> None:
597
803
  if not yes:
598
804
  typer.confirm(f"Delete conversation {session} from your history?", abort=True)
599
805
  d = (_call(lambda: api_client.delete_agent_session(session)) or {}).get("data") or {}
806
+ if as_json:
807
+ console.print_json(data=d)
808
+ return
600
809
  console.print(f"[green]Deleted[/green] {session} [dim]({d.get('deleted_at')})[/dim]")
601
810
 
602
811
 
@@ -635,3 +844,6 @@ investor_app.add_typer(update_app, name="update")
635
844
  investor_app.add_typer(subscribe_app, name="subscribe")
636
845
  investor_app.add_typer(unsubscribe_app, name="unsubscribe")
637
846
  investor_app.add_typer(delete_app, name="delete")
847
+ investor_app.add_typer(ping_app, name="ping")
848
+ investor_app.add_typer(disconnect_app, name="disconnect")
849
+ investor_app.add_typer(reconnect_app, name="reconnect")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nexusquant-cli"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "NexusQuant CLI for users, strategy providers and admins"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -492,3 +492,152 @@ def test_investor_commands_import_without_the_sdk() -> None:
492
492
  r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
493
493
  assert r.returncode == 0, r.stderr
494
494
  assert r.stdout.strip() == "ok"
495
+
496
+
497
+ # ── confirmed writes (agent asks the user first) ────────────────────────────
498
+
499
+
500
+ def _stdin(token="user-token-abc"):
501
+ return type("S", (), {"readline": lambda self: token + "\n", "close": lambda self: None})()
502
+
503
+
504
+ @pytest.mark.parametrize(
505
+ "argv, confirmed, allowed",
506
+ [
507
+ (["investor", "update", "listening", "s1", "--on"], False, False),
508
+ (["investor", "update", "listening", "s1", "--on"], True, True),
509
+ (["investor", "update", "config", "s1", "--param", "a=1"], True, True),
510
+ (["investor", "unsubscribe", "strategy", "s1"], True, True),
511
+ (["investor", "update", "nope"], True, False), # not in the write allowlist
512
+ (["investor", "connect", "broker"], True, False),
513
+ (["admin", "review", "strategy", "s1"], True, False),
514
+ ],
515
+ )
516
+ def test_embedded_write_gate(argv, confirmed, allowed) -> None:
517
+ from nexusquant_cli import embedded
518
+
519
+ assert (embedded.validate(argv, write_confirmed=confirmed) is None) is allowed
520
+
521
+
522
+ def test_embedded_confirmed_write_runs_with_yes_only_where_supported(http: Recorder, monkeypatch, capsys) -> None:
523
+ from nexusquant_cli import config, embedded
524
+
525
+ monkeypatch.setenv(embedded.WRITE_CONFIRMED_ENV, "1")
526
+ monkeypatch.setattr(embedded.sys, "stdin", _stdin())
527
+ http.on("POST", "/strategy/unsubscribe/s1/", {"message": "ok"})
528
+ http.on("POST", "/strategy/market/checkout/", {"results": [{"strategy_id": "s2", "ok": True}]})
529
+ try:
530
+ assert embedded.main(["investor", "unsubscribe", "strategy", "s1"]) == 0 # has --yes → no prompt
531
+ assert embedded.main(["investor", "subscribe", "strategy", "s2"]) == 0 # no --yes option → none appended
532
+ finally:
533
+ config.set_token_override(None)
534
+ assert [c[:2] for c in http.calls] == [("POST", "/strategy/unsubscribe/s1/"), ("POST", "/strategy/market/checkout/")]
535
+
536
+
537
+ def test_embedded_unconfirmed_write_never_reads_token(monkeypatch) -> None:
538
+ from nexusquant_cli import embedded
539
+
540
+ monkeypatch.delenv(embedded.WRITE_CONFIRMED_ENV, raising=False)
541
+ read = []
542
+ monkeypatch.setattr(embedded.sys, "stdin", type("S", (), {"readline": lambda self: read.append(1) or "t\n",
543
+ "close": lambda self: None})())
544
+ assert embedded.main(["investor", "update", "listening", "s1", "--on"]) == 2
545
+ assert read == []
546
+
547
+
548
+ def test_update_config_keeps_other_params(http: Recorder) -> None:
549
+ http.on("GET", "/user/strategy/list/", {"strategies": [
550
+ {"id": "s1", "signal_listening": False, "config_acknowledged_at": "2026-10-01T00:00:00Z",
551
+ "personal_config": {"strategy_params": {"A": 1, "B": 2}}}]})
552
+ http.on("PUT", "/user/strategy/config/s1/", {"message": "ok", "effective_config": {}})
553
+ result = runner.invoke(app, ["investor", "update", "config", "s1", "--param", "B=5",
554
+ "--execution", "max_order_cash_usd=1000", "--yes", "--json"])
555
+ assert result.exit_code == 0, result.output
556
+ method, path, _, body = http.calls[-1]
557
+ assert (method, path) == ("PUT", "/user/strategy/config/s1/")
558
+ assert body == {"strategy_params": {"A": 1, "B": 5}, "execution": {"max_order_cash_usd": 1000}}
559
+
560
+
561
+ @pytest.mark.parametrize("flag, path", [("--on", "/user/strategy/subscribe/s1/"), ("--off", "/user/strategy/unsubscribe/s1/")])
562
+ def test_update_listening(http: Recorder, flag, path) -> None:
563
+ result = runner.invoke(app, ["investor", "update", "listening", "s1", flag, "--yes", "--json"])
564
+ assert result.exit_code == 0, result.output
565
+ assert http.calls[-1][:2] == ("POST", path)
566
+
567
+
568
+ def test_update_listening_on_asks_first(http: Recorder) -> None:
569
+ http.on("GET", "/user/strategy/list/", {"strategies": [{"id": "s1"}]})
570
+ result = runner.invoke(app, ["investor", "update", "listening", "s1", "--on"], input="n\n")
571
+ assert result.exit_code != 0
572
+ assert "REAL orders" in result.output
573
+ assert [c[0] for c in http.calls] == ["GET"]
574
+
575
+
576
+ # ── broker: read / ping (no confirmation) / disconnect & IBKR reconnect (confirmed) ──
577
+
578
+
579
+ def _with_container(http: Recorder, broker="schwab") -> None:
580
+ http.on("GET", "/user/container/health/", {"has_container": True, "is_running": True, "container": {"id": "c1"}})
581
+ http.on("GET", "/container/c1/status/", {"container": {"broker_id": broker, "opend_login_status": "logged_in"}})
582
+
583
+
584
+ def test_read_broker_combines_three_reads(http: Recorder) -> None:
585
+ _with_container(http)
586
+ http.on("GET", "/container/c1/broker_token_status/", {"status": {"seconds_left": 100}})
587
+ http.on("GET", "/container/c1/broker-disconnect-notice/", {"notice": "competing_session", "broker_id": "schwab"})
588
+ result = runner.invoke(app, ["investor", "read", "broker", "--json"])
589
+ assert result.exit_code == 0, result.output
590
+ out = json.loads(result.output)
591
+ assert out["broker_id"] == "schwab" and out["disconnect_notice"] == "competing_session"
592
+ assert all(c[0] == "GET" for c in http.calls)
593
+
594
+
595
+ def test_ping_disconnect_reconnect_routes(http: Recorder) -> None:
596
+ _with_container(http, broker="ibkr")
597
+ for argv, path, body in (
598
+ (["investor", "ping", "broker", "--json"], "/container/c1/ping/", {}),
599
+ (["investor", "disconnect", "broker", "--yes", "--json"], "/container/c1/disconnect/", {}),
600
+ (["investor", "reconnect", "broker", "--yes", "--json"], "/container/c1/connect/",
601
+ {"step": "gateway_reconnect", "broker_id": "ibkr"}),
602
+ ):
603
+ result = runner.invoke(app, argv)
604
+ assert result.exit_code == 0, result.output
605
+ assert http.calls[-1][0] == "POST" and http.calls[-1][1] == path and http.calls[-1][3] == body
606
+
607
+
608
+ def test_reconnect_refuses_non_ibkr_without_calling_connect(http: Recorder) -> None:
609
+ _with_container(http, broker="schwab")
610
+ result = runner.invoke(app, ["investor", "reconnect", "broker", "--yes"])
611
+ assert result.exit_code == 1
612
+ assert "web app" in result.output
613
+ assert not [c for c in http.calls if c[0] == "POST"]
614
+
615
+
616
+ def test_disconnect_asks_first(http: Recorder) -> None:
617
+ _with_container(http)
618
+ result = runner.invoke(app, ["investor", "disconnect", "broker"], input="n\n")
619
+ assert result.exit_code != 0
620
+ assert not [c for c in http.calls if c[0] == "POST"]
621
+
622
+
623
+ def test_broker_commands_without_container(http: Recorder) -> None:
624
+ http.on("GET", "/user/container/health/", {"has_container": False})
625
+ result = runner.invoke(app, ["investor", "ping", "broker"])
626
+ assert result.exit_code == 1
627
+ assert [c[1] for c in http.calls] == ["/user/container/health/"]
628
+
629
+
630
+ @pytest.mark.parametrize(
631
+ "argv, confirmed, allowed",
632
+ [
633
+ (["investor", "ping", "broker"], False, True), # no confirmation (bridge rate-limits)
634
+ (["investor", "disconnect", "broker"], False, False),
635
+ (["investor", "disconnect", "broker"], True, True),
636
+ (["investor", "reconnect", "broker"], True, True),
637
+ (["investor", "connect", "broker"], True, False), # first-time connect: never
638
+ ],
639
+ )
640
+ def test_embedded_broker_gate(argv, confirmed, allowed) -> None:
641
+ from nexusquant_cli import embedded
642
+
643
+ assert (embedded.validate(argv, write_confirmed=confirmed) is None) is allowed
@@ -1 +0,0 @@
1
- __version__ = "0.3.0"
File without changes
File without changes
File without changes