dcy-034 마지막 조각. sync(슬롯 qty←브로커+warning해제+OQ-A 전이엣지+FR-7 평단seed)/verify(C 재대조·강제해제없음). core-A QC minor 2건 정리 동봉. QC(opus) PASS, 683 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
444 lines
16 KiB
Python
444 lines
16 KiB
Python
"""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 <pid> 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 <pid> 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 <pid> 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
|