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
249 lines
10 KiB
Python
249 lines
10 KiB
Python
"""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
|