Files
trex-core/tests/application/test_slots_handler.py
T
genichinandClaude Sonnet 4.6 6c24bd4c2c feat(slots-rpc): available_slots capacity hint 추가 (BAT-037 / REQ-073 AC-6)
slots RPC 응답에 available_slots: int|null 포함.
max_active_slots 미설정 시 null, 설정 시 max(0, M-active).
16 tests passed (AC-6 신규 3개 포함), 423 전체 통과.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 10:28:29 +09:00

244 lines
10 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": "SYM", "pf-B": "SYM"})
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,
max_active_slots=max_active_slots,
)
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 include available_slots; assets/watchdog/strategy absent
def test_response_keys_only_slots_health():
_, _, _, _, _, handler = _wire()
resp = handler(_req())
assert set(resp.payload.keys()) == {"slots", "available_slots", "degraded", "reason", "message"}
assert "assets" 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": "SYM"})
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
# REQ-073 AC-6: available_slots capacity hint
def test_available_slots_null_when_uncapped():
_, _, _, _, _, handler = _wire(max_active_slots=None)
resp = handler(_req())
assert resp.payload["available_slots"] is None
def test_available_slots_remaining_capacity():
repo, svc, _, _, _, handler = _wire(max_active_slots=2)
# 0 active → available = 2
assert handler(_req()).payload["available_slots"] == 2
# buy pf-A into holding → 1 active, available = 1
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)
assert handler(_req()).payload["available_slots"] == 1
def test_available_slots_zero_when_full():
repo, svc, _, _, _, handler = _wire(max_active_slots=1)
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)
# M=1, active=1 → available=0
assert handler(_req()).payload["available_slots"] == 0