BAT-035 (dcy-025): trex.snapshot.req → trex.slots.req 즉시 교체
- topology: SNAPSHOT_REQ_QUEUE 제거, SLOTS_REQ_QUEUE = "trex.slots.req" 추가
- portfolio.py: View/build_snapshot_envelope/SnapshotEnvelope/_asset_to_dict 제거;
slot_to_dict 시그니처 확장(snapshot_store/watchdog_store/pause_store/watchdog_timeout_sec)
— asset_quantity(hub)/cash_balance/cash_estimated/warned/watchdog{}/paused 병합 (REQ-074)
- messaging_handlers.py: make_snapshot_rpc_handler 제거, make_slots_rpc_handler 추가
— filter(all/inactive/pending/holding/error) + spy(bool) 처리; strategy 필드 제외 (REQ-073/074)
- daemon.py: _snapshot_rpc_loop → _slots_rpc_loop, make_slots_rpc_handler 연결
- cli.py: universe/slots → slots_queue + spy=True + server-side filter; buyable→inactive 매핑 (REQ-063)
- rabbitmq.py: snapshot_queue → slots_queue
- docs: interface-contract.md + runtime-flows.md slots.req 계약으로 업데이트 (REQ-075)
- REQ-028: Implemented → Deprecated (코드 삭제 완료)
- 테스트: test_snapshot_handler/selection/rpc_watchdog/messaging_snapshot_consumer 삭제;
test_slots_handler.py(신규, 13 cases), test_killswitch_masking_resume 재작성,
test_ledger_option_d._apply_cash_estimate → slots handler 경유, test_topology/cli_operator 갱신
BAT-036 (dcy-024): per-instance safety_sell_enabled 플래그
- config.py: DaemonInstanceConfig + DaemonConfig 에 safety_sell_enabled: bool = True 추가;
from_toml 파싱, from_config 전파
- daemon.py: start() workers 조건부 — safety_sell_enabled=false 시 trex-safety-sell 미기동 + WARNING
- tests: test_instance_config.py 에 safety_sell_enabled 파싱 3개 케이스 추가
REQ: 073/074/075/021/034/063/048/005 Approved → Implemented
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
331 lines
12 KiB
Python
331 lines
12 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"
|