feat(rel-007): dcy-025/024 + BAT-035/036 — slots.req 즉시 교체 + safety_sell_enabled

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>
This commit is contained in:
2026-06-11 14:45:45 +09:00
co-authored by Claude Opus 4.8
parent fb05a73130
commit f0ad37747b
44 changed files with 1963 additions and 1189 deletions
+70 -71
View File
@@ -25,137 +25,136 @@ class _RpcRecorder:
return self.response
_SNAPSHOT = {
"assets": [{"asset_id": "PF-1", "symbol": "005930", "asset_quantity": 0,
"active_strategy_id": "orb"}],
"view": "all", "available_slots": 9, "degraded": False, "reason": None,
# BAT-022: envelope per-slot state (operator-facing). `slots` queries read
# this, not the universe `assets[]`.
"slots": [
{"slot_id": "S-1", "status": "holding",
"symbol": "005930", "qty": 10.0, "avg_price": 28700.0, "entry_price": None,
"strategy_id": "orb", "sell_strategy_id": None,
"portfolio_id": "PF-1", "forced_close_at": None},
{"slot_id": "S-2", "status": "inactive",
"symbol": None, "qty": None, "avg_price": None, "entry_price": None,
"strategy_id": None, "sell_strategy_id": None,
"portfolio_id": None, "forced_close_at": None},
],
_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 (snapshot RPC + watchdog suppression) ------------------
def test_universe_uses_snapshot_queue_with_operator_suppression(capsys):
rpc = _RpcRecorder(_SNAPSHOT)
# --- 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.snapshot.req" # default instance
assert call["payload"]["actor"] == "operator"
assert call["payload"]["touch_watchdog"] is False # FR-2a / R-4
assert "PF-1" in capsys.readouterr().out
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_is_scoped_to_universe(capsys):
"""BAT-022: `universe --json` emits universe data only — `slots[]`/`watchdog[]`
(operator slot table) are NOT leaked into the universe view."""
rpc = _RpcRecorder(_SNAPSHOT)
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 set(out.keys()) == {"instance", "view", "assets", "available_slots",
"degraded", "reason"}
assert "slots" not in out and "watchdog" not in 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_real_slot_state_not_universe(capsys):
"""BAT-022: `slots` shows envelope `slots[]` (slot table) with status,
not the universe `assets[]`."""
rpc = _RpcRecorder(_SNAPSHOT)
def test_slots_shows_slot_state(capsys):
rpc = _RpcRecorder(_SLOTS_RESP)
cli.main(["slots"], rpc=rpc)
out = capsys.readouterr().out
# request stays non-invasive (operator suppression)
assert rpc.calls[0]["payload"]["actor"] == "operator"
assert rpc.calls[0]["payload"]["touch_watchdog"] is False
# both slots shown, with status column (slot state, not universe assets)
# 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\tholding" in out and "S-2\tinactive" in out
assert "S-1" in out and "S-2" in out
def test_slots_status_filters_by_slot_status(capsys):
"""BAT-022: `--status` filters by slot status client-side (no view mapping)."""
rpc = _RpcRecorder(_SNAPSHOT)
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\tholding" in out and "S-2" not in 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):
"""`--symbol` shows only the slot(s) bound to that symbol (client-side filter)."""
rpc = _RpcRecorder(_SNAPSHOT)
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):
"""`--symbol` composes with `--status` (both must match)."""
rpc = _RpcRecorder(_SNAPSHOT)
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 # S-1 is holding but symbol != 999999
assert "count=0" in out
def test_slots_slot_id_filters_to_one_slot(capsys):
"""`--slot-id` shows only the matching slot, in the detailed block."""
rpc = _RpcRecorder(_SNAPSHOT)
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
# detailed block fields present
assert "portfolio_id" in out and "universe_detached" in out
def test_slots_symbol_detailed_output(capsys):
"""`--symbol` switches to the detailed per-slot block (not the terse table)."""
rpc = _RpcRecorder(_SNAPSHOT)
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_owned(capsys):
"""dcy-017: slots flags strategy-absent (watchdog-owned) positions."""
def test_slots_marks_watchdog_expired(capsys):
"""REQ-034: slots flags watchdog-expired (strategy absent) positions."""
resp = {
"assets": [], "view": "all", "available_slots": 9, "degraded": False,
"reason": None,
"slots": [{"slot_id": "S-1",
"status": "holding", "symbol": "005930", "qty": 10.0,
"avg_price": 28700.0, "entry_price": None,
"strategy_id": "orb",
"sell_strategy_id": None, "portfolio_id": "PF-1",
"forced_close_at": None}],
"watchdog": [{"slot_id": "S-1", "portfolio_id": "PF-1", "symbol": "005930",
"expired": True, "last_seen": None}],
"slots": [{
**_SLOT_HOLDING,
"watchdog": {"expired": True, "last_seen": None},
}],
"degraded": False, "reason": None, "message": None,
}
cli.main(["slots", "--status", "held"], rpc=_RpcRecorder(resp))
cli.main(["slots", "--status", "holding"], rpc=_RpcRecorder(resp))
out = capsys.readouterr().out
assert "watchdog_owned=1" in out and "WATCHDOG-OWNED" in out
assert "watchdog_expired=1" in out and "WATCHDOG" in out
def test_instance_shortcut_routes_to_instance_queue():
rpc = _RpcRecorder(_SNAPSHOT)
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.snapshot.req.inst-2"
assert rpc.calls[0]["queue"] == "trex.slots.req.inst-2"
def test_json_output_is_valid_json(capsys):
rpc = _RpcRecorder(_SNAPSHOT)
rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False,
"reason": None, "message": None})
cli.main(["universe", "--json"], rpc=rpc)
parsed = json.loads(capsys.readouterr().out)
assert parsed["assets"][0]["asset_id"] == "PF-1"
assert "slots" in parsed
# --- enter (buy intent RPC) + safeguards ------------------------------------