Files
trex-core/tests/application/test_slots_handler.py
T
genichinandClaude Fable 5 690071afad feat(slots): additive strategy fields + filter (REQ-116, BAT-055)
trex.slots.req wire 계약 additive 확장 — 기존 소비자(orb/tsl) backward compatible.

- PortfolioItem: strategy_name(Optional[str]) + markets(list) 필드 추가
- hub_read.py: get_portfolios()에서 strategy_name/markets 파싱 추가 (safe default)
- slot_to_dict(): strategy_name/strategy_params/markets 3필드 row에 추가 (SnapshotStore→PortfolioItem 경유)
- make_slots_rpc_handler(): strategy 요청 파라미터 → Python 레벨 후처리 필터 (available_slots 계산 이후, REQ-073 AC-6 불변)
- 테스트: AC-1~8 전수 + hub_read 파싱 + 회귀 갱신 (777 passed, 47 skipped)
- docs/interface-contract.md: 요청 파라미터·응답 행 스키마·소스 테이블·REQ-074 AC-5 주석 갱신

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
2026-07-04 11:10:01 +09:00

249 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 (REQ-116 additive 재도입): slot-origin strategy fields absent from row.
# strategy_name / strategy_params / markets (PortfolioItem-origin) are now present
# (REQ-116 BAT-055 additive); slot-origin fields still excluded.
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 "active_strategy_id" not in s
# strategy_params / strategy_name / markets are now present (REQ-116 additive)
assert "strategy_params" in s
assert "strategy_name" in s
assert "markets" 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