"""trex-core operator CLI (REQ-032, dcy-016). Subcommands: pause — durably pause a portfolio (Postgres, direct-DB) resume — clear the pause (direct-DB) paused — list paused portfolio_ids (direct-DB) universe — list today's universe assets (snapshot RPC) slots [--status] — list positions/slots (snapshot RPC) enter — submit a buy intent (intent RPC; safeguarded) exit — submit a sell intent (intent RPC; safeguarded) universe/slots/enter/exit go through the daemon's RabbitMQ RPC surface so the single-writer admission + invariants are preserved (no direct slot writes, dcy-016 NFR-1). Operator queries carry `actor="operator"` so they do NOT count as a strategy liveness signal (REQ-021/034 watchdog suppression, R-4). enter/exit safeguards (dcy-016 OQ-2): confirmation prompt ON by default; `--yes` skips it (paper); live accounts require `--yes --live` (double ack); `--dry-run` previews the resolved intent without submitting. `--json` is a non-interactive machine mode, so enter/exit with `--json` require `--yes`. """ from __future__ import annotations import argparse import json as _json import sys import uuid from datetime import datetime from zoneinfo import ZoneInfo from trex_core.adapters.messaging import topology from trex_core.adapters.portfolio.pause_store import PostgreSQLPauseStore from trex_core.runtime.config import DaemonConfig, DaemonInstanceConfig _DEFAULT_TIMEOUT = 10.0 # --------------------------------------------------------------------------- # # instance routing (OQ-1: --config canonical, --instance shortcut/override) # --------------------------------------------------------------------------- # def _resolve_instance(args) -> str: if getattr(args, "instance", None): return args.instance if getattr(args, "config", None): return DaemonInstanceConfig.from_toml(args.config).instance_key return topology.DEFAULT_INSTANCE_KEY # --------------------------------------------------------------------------- # # default transport-backed RPC (overridable in tests) # --------------------------------------------------------------------------- # def _default_rpc(cfg: DaemonConfig): """Return a callable(queue, payload, *, correlation_id, timeout) -> dict backed by a real RabbitMQ transport built from config.""" from trex_core.adapters.messaging import InMemoryTransport, RabbitMQTransport mq = cfg.rabbitmq transport = RabbitMQTransport(mq.url, timeout=mq.timeout) if mq else InMemoryTransport() def _rpc(queue: str, payload: dict, *, correlation_id: str, timeout: float) -> dict: try: return transport.call_rpc( queue, payload, correlation_id=correlation_id, timeout=timeout ) finally: transport.close() return _rpc def _default_confirm(prompt: str) -> bool: try: return input(f"{prompt} [y/N] ").strip().lower() in ("y", "yes") except EOFError: return False def _broker_mode(cfg: DaemonConfig) -> tuple[str, bool]: """(broker, is_live). live = kis broker with allow_live enabled.""" broker = cfg.broker is_live = broker == "kis" and bool(cfg.kis and cfg.kis.allow_live) return broker, is_live def _emit(data: dict, *, as_json: bool) -> None: if as_json: print(_json.dumps(data, ensure_ascii=False, default=str)) # --------------------------------------------------------------------------- # # RPC subcommand handlers # --------------------------------------------------------------------------- # def _today_iso(cfg: DaemonConfig) -> str: return datetime.now(tz=ZoneInfo(cfg.trading_timezone)).date().isoformat() def _cmd_universe(args, cfg, rpc) -> int: instance = _resolve_instance(args) payload = {"view": "all", "actor": "operator", "touch_watchdog": False, "trading_date": _today_iso(cfg)} resp = rpc(topology.snapshot_queue(instance), payload, correlation_id=str(uuid.uuid4()), timeout=args.timeout) assets = resp.get("assets", []) if args.json: _emit(resp, as_json=True) else: print(f"instance={instance} view=all assets={len(assets)} " f"degraded={resp.get('degraded')}") for a in assets: print(f" {a.get('asset_id')}\t{a.get('symbol')}\tqty={a.get('asset_quantity')}") return 0 def _cmd_slots(args, cfg, rpc) -> int: instance = _resolve_instance(args) view = {"holding": "held", "held": "held", "buyable": "buyable"}.get( args.status or "all", "all" ) payload = {"view": view, "actor": "operator", "touch_watchdog": False, "trading_date": _today_iso(cfg)} resp = rpc(topology.snapshot_queue(instance), payload, correlation_id=str(uuid.uuid4()), timeout=args.timeout) assets = resp.get("assets", []) # dcy-017: holding-slot watchdog state (strategy-absent ownership). owned = {w.get("portfolio_id"): w for w in resp.get("watchdog", []) if w.get("expired")} if args.json: _emit(resp, as_json=True) else: print(f"instance={instance} view={view} count={len(assets)} " f"watchdog_owned={len(owned)}") for a in assets: pid = a.get("asset_id") tag = " [WATCHDOG-OWNED: strategy absent]" if pid in owned else "" print(f" {pid}\t{a.get('symbol')}\tqty={a.get('asset_quantity')}\t" f"strategy={a.get('active_strategy_id')}{tag}") return 0 def _intent_guard(args, cfg, summary: dict, action: str, confirm) -> int | None: """Shared enter/exit safeguard. Returns an exit code to short-circuit, or None to proceed with submission.""" broker, is_live = _broker_mode(cfg) summary = {**summary, "instance": _resolve_instance(args), "broker": broker, "live": is_live, "action": action} if args.dry_run: if args.json: _emit({**summary, "dry_run": True}, as_json=True) else: print(f"[dry-run] {action} {summary}") return 0 # live double-ack gate (OQ-2) if is_live and not args.live: msg = "live account requires --live (with --yes) to confirm a real order" _fail(msg, args.json) return 2 # confirmation: --json is non-interactive → --yes required if not args.yes: if args.json: _fail("--json enter/exit requires --yes (no auto-submit)", True) return 2 prompt = (f"Submit {action} on instance={summary['instance']} " f"broker={broker}{' LIVE' if is_live else ''}: {summary}?") if not confirm(prompt): print("aborted") return 1 return None def _cmd_enter(args, cfg, rpc, confirm) -> int: summary = {"portfolio_id": args.portfolio_id, "qty": args.qty, "allocated_capital": args.capital, "entry_price": args.entry_price} short = _intent_guard(args, cfg, summary, "enter(buy)", confirm) if short is not None: return short payload = { "intent_type": "buy", "intent_id": str(uuid.uuid4()), "trading_date": _today_iso(cfg), "portfolio_id": args.portfolio_id, "qty": args.qty, "allocated_capital": args.capital, "entry_price": args.entry_price, "strategy_id": args.strategy_id, "sell_strategy_id": args.sell_strategy_id, "stop_loss_price": args.stop_loss, "take_profit_price": args.take_profit, } resp = rpc(topology.intent_queue(_resolve_instance(args)), payload, correlation_id=payload["intent_id"], timeout=args.timeout) return _report_intent(resp, args.json) def _cmd_exit(args, cfg, rpc, confirm) -> int: summary = {"slot_id": args.slot_id, "price": args.price or "market", "reason": args.reason} short = _intent_guard(args, cfg, summary, "exit(sell)", confirm) if short is not None: return short payload = { "intent_type": "sell", "intent_id": str(uuid.uuid4()), "slot_id": args.slot_id, "reason": args.reason, "sell_strategy_id": args.sell_strategy_id, "limit_price": args.price, } resp = rpc(topology.intent_queue(_resolve_instance(args)), payload, correlation_id=payload["intent_id"], timeout=args.timeout) return _report_intent(resp, args.json) def _report_intent(resp: dict, as_json: bool) -> int: if as_json: _emit(resp, as_json=True) else: print(f"status={resp.get('status')} slot_id={resp.get('slot_id')} " f"reason={resp.get('reason')}") # admission rejection is a non-zero exit so scripts can detect it. return 0 if resp.get("status") == "admitted" else 1 def _fail(message: str, as_json: bool) -> None: if as_json: print(_json.dumps({"error": message}), file=sys.stderr) else: print(f"error: {message}", file=sys.stderr) # --------------------------------------------------------------------------- # # arg parsing # --------------------------------------------------------------------------- # def _add_common(p) -> None: p.add_argument("--config", help="per-instance TOML (canonical instance routing)") p.add_argument("--instance", help="instance key (shortcut; overrides --config)") p.add_argument("--json", action="store_true", help="machine-readable JSON output") p.add_argument("--timeout", type=float, default=_DEFAULT_TIMEOUT) def _add_intent_guards(p) -> None: p.add_argument("--yes", action="store_true", help="skip confirmation (paper)") p.add_argument("--live", action="store_true", help="confirm a LIVE order (with --yes)") p.add_argument("--dry-run", action="store_true", help="preview intent, do not submit") p.add_argument("--sell-strategy-id", dest="sell_strategy_id", default=None) def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="trex-core", description="trex-core admin CLI") sub = parser.add_subparsers(dest="cmd", required=True) p_pause = sub.add_parser("pause", help="Pause a portfolio_id") p_pause.add_argument("portfolio_id") p_pause.add_argument("--actor", default="cli") p_resume = sub.add_parser("resume", help="Resume a portfolio_id") p_resume.add_argument("portfolio_id") sub.add_parser("paused", help="List paused portfolio_ids") p_uni = sub.add_parser("universe", help="List today's universe (snapshot RPC)") _add_common(p_uni) p_slots = sub.add_parser("slots", help="List positions (snapshot RPC)") p_slots.add_argument("--status", choices=["all", "held", "holding", "buyable"], default="all") _add_common(p_slots) p_enter = sub.add_parser("enter", help="Submit a buy intent (intent RPC)") p_enter.add_argument("portfolio_id") p_enter.add_argument("--qty", type=float, default=None) p_enter.add_argument("--capital", type=float, default=None) p_enter.add_argument("--entry-price", dest="entry_price", type=float, default=None) p_enter.add_argument("--strategy-id", dest="strategy_id", default=None) p_enter.add_argument("--stop-loss", dest="stop_loss", type=float, default=None) p_enter.add_argument("--take-profit", dest="take_profit", type=float, default=None) _add_intent_guards(p_enter) _add_common(p_enter) p_exit = sub.add_parser("exit", help="Submit a sell intent (intent RPC)") p_exit.add_argument("slot_id") p_exit.add_argument("--price", type=float, default=None) p_exit.add_argument("--reason", default="operator_exit") _add_intent_guards(p_exit) _add_common(p_exit) return parser def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int: """CLI entrypoint. `rpc` and `confirm` are injectable for tests: `rpc(queue, payload, *, correlation_id, timeout) -> dict`, `confirm(prompt) -> bool`. """ args = _build_parser().parse_args(argv) cfg = DaemonConfig.from_env() confirm = confirm or _default_confirm # direct-DB control commands (pause/resume stay direct-DB, OQ-3). if args.cmd in ("pause", "resume", "paused"): store = PostgreSQLPauseStore(cfg.dsn) if args.cmd == "pause": store.pause(args.portfolio_id, actor=args.actor) print(f"paused {args.portfolio_id}") elif args.cmd == "resume": store.resume(args.portfolio_id) print(f"resumed {args.portfolio_id}") else: for pid in sorted(store.list_paused()): print(pid) return 0 rpc = rpc or _default_rpc(cfg) try: if args.cmd == "universe": return _cmd_universe(args, cfg, rpc) if args.cmd == "slots": return _cmd_slots(args, cfg, rpc) if args.cmd == "enter": return _cmd_enter(args, cfg, rpc, confirm) if args.cmd == "exit": return _cmd_exit(args, cfg, rpc, confirm) except Exception as exc: # noqa: BLE001 - surface RPC/transport failure clearly _fail(f"{type(exc).__name__}: {exc}", getattr(args, "json", False)) return 3 return 0 if __name__ == "__main__": # pragma: no cover sys.exit(main())