"""Operator CLI tests (dcy-016 / REQ-032) — RPC injected, no broker/DB needed.""" from __future__ import annotations import json import pytest from trex_core.runtime import cli @pytest.fixture(autouse=True) def _env(monkeypatch): monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y") monkeypatch.setenv("TREX_BROKER", "paper") monkeypatch.delenv("TREX_KIS_ALLOW_LIVE", raising=False) class _RpcRecorder: def __init__(self, response): self.response = response self.calls = [] def __call__(self, queue, payload, *, correlation_id, timeout): self.calls.append({"queue": queue, "payload": payload, "cid": correlation_id}) return self.response _SLOT_HOLDING = { "slot_id": "S-1", "status": "holding", "symbol": "005930", "qty": 10.0, "asset_quantity": 10.0, "warned": False, "avg_price": 28700.0, "entry_price": None, "portfolio_id": "PF-1", "forced_close_at": None, "cash_balance": 1_000_000.0, "cash_estimated": False, "watchdog": {"expired": False, "last_seen": None}, "paused": False, "universe_detached": False, } _SLOT_INACTIVE = { "slot_id": "S-2", "status": "inactive", "symbol": None, "qty": None, "asset_quantity": None, "warned": False, "avg_price": None, "entry_price": None, "portfolio_id": None, "forced_close_at": None, "cash_balance": None, "cash_estimated": False, "watchdog": None, "paused": False, "universe_detached": False, } _SLOTS_RESP = { "slots": [_SLOT_HOLDING, _SLOT_INACTIVE], "degraded": False, "reason": None, "message": None, } _ADMITTED = {"intent_id": "i", "status": "admitted", "slot_id": "S-1", "reason": None} _REJECTED = {"intent_id": "i", "status": "rejected", "slot_id": None, "reason": "unknown_portfolio"} # --- universe / slots (slots RPC + spy=True suppression) --------------------- def test_universe_uses_slots_queue_with_spy(capsys): rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False, "reason": None, "message": None}) rc = cli.main(["universe"], rpc=rpc) assert rc == 0 call = rpc.calls[0] assert call["queue"] == "trex.slots.req" # default instance assert call["payload"]["spy"] is True # operator view — no watchdog touch assert call["payload"]["filter"] == "inactive" def test_universe_json_emits_slots_and_degraded(capsys): rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False, "reason": None, "message": None}) cli.main(["universe", "--json"], rpc=rpc) out = json.loads(capsys.readouterr().out) assert "slots" in out and "degraded" in out and "filter" in out assert "assets" not in out and "available_slots" not in out def test_slots_shows_slot_state(capsys): rpc = _RpcRecorder(_SLOTS_RESP) cli.main(["slots"], rpc=rpc) out = capsys.readouterr().out # request uses slots.req + spy=True assert rpc.calls[0]["queue"] == "trex.slots.req" assert rpc.calls[0]["payload"]["spy"] is True assert "count=2" in out assert "S-1" in out and "S-2" in out def test_slots_status_sends_server_side_filter(capsys): rpc = _RpcRecorder({"slots": [_SLOT_HOLDING], "degraded": False, "reason": None, "message": None}) cli.main(["slots", "--status", "holding"], rpc=rpc) assert rpc.calls[0]["payload"]["filter"] == "holding" out = capsys.readouterr().out assert "count=1" in out and "S-1" in out def test_slots_buyable_maps_to_inactive_filter(): rpc = _RpcRecorder({"slots": [], "degraded": False, "reason": None, "message": None}) cli.main(["slots", "--status", "buyable"], rpc=rpc) assert rpc.calls[0]["payload"]["filter"] == "inactive" def test_slots_symbol_filters_to_one_symbol(capsys): rpc = _RpcRecorder(_SLOTS_RESP) cli.main(["slots", "--symbol", "005930"], rpc=rpc) out = capsys.readouterr().out assert "count=1" in out and "S-1" in out and "S-2" not in out def test_slots_symbol_combines_with_status(capsys): rpc = _RpcRecorder({"slots": [_SLOT_HOLDING], "degraded": False, "reason": None, "message": None}) cli.main(["slots", "--status", "holding", "--symbol", "999999"], rpc=rpc) out = capsys.readouterr().out assert "count=0" in out def test_slots_slot_id_filters_to_one_slot(capsys): rpc = _RpcRecorder(_SLOTS_RESP) cli.main(["slots", "--slot-id", "S-2"], rpc=rpc) out = capsys.readouterr().out assert "count=1" in out and "slot S-2" in out and "S-1" not in out assert "portfolio_id" in out and "universe_detached" in out def test_slots_symbol_detailed_output(capsys): rpc = _RpcRecorder(_SLOTS_RESP) cli.main(["slots", "--symbol", "005930"], rpc=rpc) out = capsys.readouterr().out assert "slot S-1" in out and "status" in out and "avg_price" in out def test_slots_marks_watchdog_expired(capsys): """REQ-034: slots flags watchdog-expired (strategy absent) positions.""" resp = { "slots": [{ **_SLOT_HOLDING, "watchdog": {"expired": True, "last_seen": None}, }], "degraded": False, "reason": None, "message": None, } cli.main(["slots", "--status", "holding"], rpc=_RpcRecorder(resp)) out = capsys.readouterr().out assert "watchdog_expired=1" in out and "WATCHDOG" in out def test_instance_shortcut_routes_to_instance_queue(): rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False, "reason": None, "message": None}) cli.main(["universe", "--instance", "inst-2"], rpc=rpc) assert rpc.calls[0]["queue"] == "trex.slots.req.inst-2" def test_json_output_is_valid_json(capsys): rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False, "reason": None, "message": None}) cli.main(["universe", "--json"], rpc=rpc) parsed = json.loads(capsys.readouterr().out) assert "slots" in parsed # --- enter (buy intent RPC) + safeguards ------------------------------------ def test_enter_aborts_without_confirmation_no_rpc(): rpc = _RpcRecorder(_ADMITTED) rc = cli.main(["enter", "PF-1", "--qty", "10"], rpc=rpc, confirm=lambda _p: False) assert rc == 1 and rpc.calls == [] # aborted -> no order def test_enter_without_qty_errors_before_rpc(capsys): # REQ-032 AC-7 (CHG-20260610-06, issue #23): missing --qty → rc=1, no RPC. rpc = _RpcRecorder(_ADMITTED) rc = cli.main(["enter", "PF-1", "--yes"], rpc=rpc) assert rc == 1 and rpc.calls == [] assert "--qty" in capsys.readouterr().err def test_enter_rejects_capital_option(capsys): # REQ-032 AC-8 (CHG-20260610-07, dcy-023/issue #22): --capital is gone — # argparse rejects it as an unknown argument before any RPC. rpc = _RpcRecorder(_ADMITTED) with pytest.raises(SystemExit) as exc: cli.main(["enter", "PF-1", "--qty", "10", "--capital", "100000", "--yes"], rpc=rpc) assert exc.value.code == 2 and rpc.calls == [] assert "--capital" in capsys.readouterr().err def test_enter_with_yes_submits_buy_intent(): rpc = _RpcRecorder(_ADMITTED) rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes"], rpc=rpc) assert rc == 0 p = rpc.calls[0]["payload"] assert rpc.calls[0]["queue"] == "trex.intent.req" assert p["intent_type"] == "buy" and p["portfolio_id"] == "PF-1" assert p["qty"] == 10 assert "allocated_capital" not in p # dcy-023 / REQ-032 AC-8 assert "symbol" not in p # server-resolved (dcy-011) def test_enter_with_forced_close_at_maps_to_payload(): # REQ-032 AC-5: --forced-close-at (absolute) → buy intent forced_close_at. rpc = _RpcRecorder(_ADMITTED) rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes", "--forced-close-at", "2026-06-09T15:20:00+09:00"], rpc=rpc) assert rc == 0 p = rpc.calls[0]["payload"] assert p["forced_close_at"] == "2026-06-09T15:20:00+09:00" def test_enter_without_forced_close_at_omits_field(): # REQ-032 AC-6: unspecified → field absent (backward compat). rpc = _RpcRecorder(_ADMITTED) rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes"], rpc=rpc) assert rc == 0 assert "forced_close_at" not in rpc.calls[0]["payload"] def test_enter_rejects_malformed_forced_close_at_before_submit(): # REQ-032 AC-6: invalid timestamp rejected at CLI, no RPC submitted. rpc = _RpcRecorder(_ADMITTED) rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes", "--forced-close-at", "notatime"], rpc=rpc) assert rc == 1 and rpc.calls == [] def test_enter_rejected_returns_nonzero(): rpc = _RpcRecorder(_REJECTED) rc = cli.main(["enter", "PF-x", "--qty", "10", "--yes"], rpc=rpc) assert rc == 1 def test_enter_dry_run_does_not_submit(capsys): rpc = _RpcRecorder(_ADMITTED) rc = cli.main(["enter", "PF-1", "--qty", "5", "--dry-run"], rpc=rpc) assert rc == 0 and rpc.calls == [] assert "dry-run" in capsys.readouterr().out def test_enter_json_requires_yes(): rpc = _RpcRecorder(_ADMITTED) rc = cli.main(["enter", "PF-1", "--qty", "10", "--json"], rpc=rpc) assert rc == 2 and rpc.calls == [] # no auto-submit (FR-7) # --- exit (sell intent RPC) ------------------------------------------------- def test_exit_with_yes_submits_sell_with_limit_price(): rpc = _RpcRecorder(_ADMITTED) cli.main(["exit", "S-1", "--price", "70000", "--yes"], rpc=rpc) p = rpc.calls[0]["payload"] assert p["intent_type"] == "sell" and p["slot_id"] == "S-1" assert p["limit_price"] == 70000 def test_exit_market_when_no_price(): rpc = _RpcRecorder(_ADMITTED) cli.main(["exit", "S-1", "--yes"], rpc=rpc) assert rpc.calls[0]["payload"]["limit_price"] is None # --- live guard (OQ-2 double ack) ------------------------------------------- def test_live_requires_live_flag(monkeypatch): monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True)) rpc = _RpcRecorder(_ADMITTED) rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes"], rpc=rpc) # --yes but no --live assert rc == 2 and rpc.calls == [] def test_live_proceeds_with_yes_live(monkeypatch): monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True)) rpc = _RpcRecorder(_ADMITTED) rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes", "--live"], rpc=rpc) assert rc == 0 and len(rpc.calls) == 1 # --- transport failure surfaces as a clear error ---------------------------- def test_rpc_failure_surfaces_error(capsys): def boom(*a, **k): raise RuntimeError("daemon unreachable") rc = cli.main(["universe"], rpc=boom) assert rc == 3 assert "daemon unreachable" in capsys.readouterr().err # --- cancel (control RPC operator backstop — dcy-022 / REQ-071) ------------- _CANCEL_OK = {"status": "ok", "slot_id": "S-pending"} _CANCEL_IGNORED = {"status": "ignored", "slot_id": "S-1", "reason": "not_pending"} _CANCEL_NOT_FOUND = {"status": "error", "slot_id": "S-x", "reason": "slot_not_found"} def test_cancel_aborts_without_confirmation_no_rpc(): rpc = _RpcRecorder(_CANCEL_OK) rc = cli.main(["cancel", "S-pending"], rpc=rpc, confirm=lambda _p: False) assert rc == 1 and rpc.calls == [] def test_cancel_with_yes_sends_control_rpc(capsys): rpc = _RpcRecorder(_CANCEL_OK) rc = cli.main(["cancel", "S-pending", "--yes"], rpc=rpc) assert rc == 0 call = rpc.calls[0] assert call["queue"] == "trex.control.req" assert call["payload"]["action"] == "cancel_pending" assert call["payload"]["slot_id"] == "S-pending" assert "cancel sent" in capsys.readouterr().out def test_cancel_ignored_not_pending_exits_zero(capsys): rpc = _RpcRecorder(_CANCEL_IGNORED) rc = cli.main(["cancel", "S-1", "--yes"], rpc=rpc) assert rc == 0 assert "ignored" in capsys.readouterr().out def test_cancel_slot_not_found_exits_nonzero(capsys): rpc = _RpcRecorder(_CANCEL_NOT_FOUND) rc = cli.main(["cancel", "S-x", "--yes"], rpc=rpc) assert rc == 1 def test_cancel_dry_run_does_not_submit(capsys): rpc = _RpcRecorder(_CANCEL_OK) rc = cli.main(["cancel", "S-pending", "--dry-run"], rpc=rpc) assert rc == 0 and rpc.calls == [] assert "dry-run" in capsys.readouterr().out def test_cancel_json_outputs_json(capsys): rpc = _RpcRecorder(_CANCEL_OK) rc = cli.main(["cancel", "S-pending", "--yes", "--json"], rpc=rpc) assert rc == 0 out = json.loads(capsys.readouterr().out) assert out["status"] == "ok" # --------------------------------------------------------------------------- # sync parser tests (REQ-105 / BAT-051) — parser-level only, no DB/broker # --------------------------------------------------------------------------- def test_sync_parser_portfolio_id_accepted(): """sync --portfolio-id is parseable.""" parser = cli._build_parser() args = parser.parse_args(["sync", "--portfolio-id", "PF-1", "--yes"]) assert args.cmd == "sync" assert args.portfolio_id == "PF-1" assert args.yes is True def test_sync_parser_all_accepted(): """sync --all is parseable.""" parser = cli._build_parser() args = parser.parse_args(["sync", "--all", "--yes"]) assert args.cmd == "sync" assert args.all is True def test_sync_parser_dry_run_accepted(): """sync --dry-run is parseable.""" parser = cli._build_parser() args = parser.parse_args(["sync", "--portfolio-id", "PF-1", "--dry-run"]) assert args.dry_run is True def test_sync_parser_live_accepted(): """sync --yes --live is parseable.""" parser = cli._build_parser() args = parser.parse_args(["sync", "--portfolio-id", "PF-1", "--yes", "--live"]) assert args.live is True def test_sync_no_target_fails(capsys, monkeypatch): """sync without --portfolio-id or --all returns rc=1.""" def _fake_sync(args, cfg, confirm): from trex_core.runtime.cli import _fail _fail("--portfolio-id or --all is required", args.json) return 1 monkeypatch.setattr(cli, "_cmd_sync", _fake_sync) rc = cli.main(["sync"]) assert rc == 1 def test_sync_requires_yes_when_not_dry_run(capsys, monkeypatch): """sync without --yes (and not dry-run) should abort.""" def _fake_sync(args, cfg, confirm): # Simulate the confirmation prompt returning False (no --yes, interactive aborts) if not getattr(args, "dry_run", False) and not getattr(args, "yes", False): if not confirm("Sync?"): print("aborted") return 1 return 0 monkeypatch.setattr(cli, "_cmd_sync", _fake_sync) rc = cli.main(["sync", "--portfolio-id", "PF-1"], confirm=lambda _: False) assert rc == 1 def test_sync_live_requires_live_flag(capsys, monkeypatch): """sync on live account without --live returns rc=2.""" monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True)) def _fake_sync(args, cfg, confirm): broker, is_live = cli._broker_mode(cfg) if is_live and not getattr(args, "live", False): cli._fail("live account requires --live (with --yes) to confirm a real sync", args.json) return 2 return 0 monkeypatch.setattr(cli, "_cmd_sync", _fake_sync) rc = cli.main(["sync", "--portfolio-id", "PF-1", "--yes"]) assert rc == 2 # --------------------------------------------------------------------------- # verify parser tests (REQ-106 / BAT-051) — parser-level only # --------------------------------------------------------------------------- def test_verify_parser_portfolio_id_accepted(): """verify --portfolio-id is parseable.""" parser = cli._build_parser() args = parser.parse_args(["verify", "--portfolio-id", "PF-1"]) assert args.cmd == "verify" assert args.portfolio_id == "PF-1" def test_verify_parser_no_portfolio_id_accepted(): """verify (no --portfolio-id) is parseable (all C-warned slots).""" parser = cli._build_parser() args = parser.parse_args(["verify"]) assert args.cmd == "verify" assert args.portfolio_id is None def test_verify_parser_json_accepted(): """verify --json is parseable.""" parser = cli._build_parser() args = parser.parse_args(["verify", "--json"]) assert args.json is True def test_verify_no_db_no_live_flag_needed(capsys, monkeypatch): """verify does not require --yes or --live (read-centric, REQ-106).""" def _fake_verify(args, cfg): # Simulate: read-centric, no live gate needed print("no C-warned slots found") return 0 monkeypatch.setattr(cli, "_cmd_verify", _fake_verify) rc = cli.main(["verify"]) assert rc == 0