Files
trex-core/tests/application/test_slots_handler.py
T
genichinandClaude Opus 4.8 f0ad37747b 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>
2026-06-11 14:45:45 +09:00

213 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""slots RPC handler tests (REQ-073 AC-1~5, REQ-074 AC-1~5)."""
from __future__ import annotations
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import make_slots_rpc_handler
from trex_core.application.portfolio import (
DegradedReason,
InMemoryPauseStore,
PortfolioItem,
SnapshotStore,
)
from trex_core.application.watchdog import WatchdogStore
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
def _wire(items=None, *, max_active_slots=None):
repo = InMemorySlotRepository()
svc = SlotService(repo)
repo.reconcile_universe("default", {"pf-A", "pf-B"})
store = SnapshotStore()
store.swap(items or [
PortfolioItem(portfolio_id="pf-A", symbol="005930",
cash_balance=1_000_000, asset_quantity=0),
PortfolioItem(portfolio_id="pf-B", symbol="000660",
cash_balance=500_000, asset_quantity=10),
])
pause = InMemoryPauseStore()
watchdog = WatchdogStore()
handler = make_slots_rpc_handler(
repo,
snapshot_store=store,
pause_store=pause,
watchdog_store=watchdog,
)
return repo, svc, store, pause, watchdog, handler
def _req(filter=None, spy=False):
payload = {}
if filter is not None:
payload["filter"] = filter
if spy:
payload["spy"] = True
return RpcRequest(payload=payload, correlation_id="test-corr")
# REQ-073 AC-3: response keys only {slots/degraded/reason/message}
def test_response_keys_only_slots_health():
_, _, _, _, _, handler = _wire()
resp = handler(_req())
assert set(resp.payload.keys()) == {"slots", "degraded", "reason", "message"}
assert "assets" not in resp.payload
assert "available_slots" not in resp.payload
assert "watchdog" not in resp.payload
# REQ-073 AC-2: filter=all returns all slots
def test_filter_all_returns_all_slots():
_, _, _, _, _, handler = _wire()
resp = handler(_req(filter="all"))
assert len(resp.payload["slots"]) == 2
# REQ-073 AC-2: filter=inactive returns only inactive slots
def test_filter_inactive_returns_inactive_slots():
repo, svc, store, _, _, handler = _wire()
# Buy pf-A into holding
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930")
svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=5.0)
resp = handler(_req(filter="inactive"))
slots = resp.payload["slots"]
assert all(s["status"] == "inactive" for s in slots)
assert len(slots) == 1 # pf-B remains inactive
# REQ-073 AC-2: filter=holding returns holding slots
def test_filter_holding_returns_holding_slots():
repo, svc, _, _, _, handler = _wire()
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930")
svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=5.0)
resp = handler(_req(filter="holding"))
slots = resp.payload["slots"]
assert all(s["status"] == "holding" for s in slots)
assert len(slots) == 1
# REQ-073 AC-2: filter=pending returns pending_buy ∪ pending_sell
def test_filter_pending_returns_pending_buy_and_sell():
repo, svc, _, _, _, handler = _wire()
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930")
resp = handler(_req(filter="pending"))
slots = resp.payload["slots"]
assert all(s["status"] in ("pending_buy", "pending_sell") for s in slots)
assert len(slots) == 1
# REQ-073 AC-2: unrecognized filter → treated as all
def test_invalid_filter_treated_as_all():
_, _, _, _, _, handler = _wire()
resp = handler(_req(filter="buyable")) # old View value
assert len(resp.payload["slots"]) == 2
# REQ-073 AC-5: degraded=True + reason propagated
def test_degraded_signal_propagated():
_, _, store, _, _, handler = _wire()
store.set_degraded(DegradedReason.PORTFOLIO_HUB_UNAVAILABLE,
"GET hub failed: Bearer secret-token")
resp = handler(_req())
body = resp.payload
assert body["degraded"] is True
assert body["reason"] == "portfolio_hub_unavailable"
assert body["message"] is not None
assert "secret-token" not in body["message"]
# REQ-074 AC-1: slot row contains merged fields
def test_slot_row_contains_merged_fields():
_, _, _, _, _, handler = _wire()
resp = handler(_req())
for s in resp.payload["slots"]:
assert "slot_id" in s
assert "status" in s
assert "symbol" in s
assert "qty" in s
assert "asset_quantity" in s
assert "cash_balance" in s
assert "cash_estimated" in s
assert "warned" in s
assert "watchdog" in s
assert "paused" in s
# REQ-074 AC-2: qty ≠ asset_quantity → warned=True
def test_warned_true_when_qty_diverges():
repo, svc, store, _, _, handler = _wire([
PortfolioItem(portfolio_id="pf-A", symbol="005930",
cash_balance=1_000_000, asset_quantity=3),
])
repo.reconcile_universe("default", {"pf-A"})
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930")
svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=5.0)
resp = handler(_req(filter="holding"))
s = resp.payload["slots"][0]
# slot.qty=5 vs hub asset_quantity=3 → divergence
assert s["qty"] == 5.0
assert s["asset_quantity"] == 3.0
assert s["warned"] is True
# REQ-074 AC-4: paused holding slot → paused=True, filter=holding still returns it
def test_paused_holding_orthogonal_to_filter():
repo, svc, store, pause, _, handler = _wire()
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930")
svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=5.0)
pause.pause("pf-A")
resp = handler(_req(filter="holding"))
assert len(resp.payload["slots"]) == 1
assert resp.payload["slots"][0]["paused"] is True
# REQ-074 AC-5: strategy fields absent from row
def test_strategy_fields_absent():
_, _, _, _, _, handler = _wire()
resp = handler(_req())
for s in resp.payload["slots"]:
assert "strategy_id" not in s
assert "sell_strategy_id" not in s
assert "strategy_params" not in s
assert "active_strategy_id" not in s
# REQ-073 AC-4: spy=True → watchdog NOT updated
def test_spy_true_no_watchdog_update():
repo, svc, _, _, watchdog, handler = _wire()
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930")
holding = svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=5.0)
sid = str(holding.slot_id)
assert watchdog.last_seen(sid) is None # not yet seen
handler(_req(filter="holding", spy=True))
assert watchdog.last_seen(sid) is None # still not updated (spy=True)
# REQ-073 AC-4: spy=False (default) → holding watchdog updated
def test_spy_false_updates_watchdog():
repo, svc, _, _, watchdog, handler = _wire()
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930")
holding = svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=5.0)
sid = str(holding.slot_id)
handler(_req(filter="holding", spy=False))
assert watchdog.last_seen(sid) is not None # updated