"""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"] is None assert s["slot_quantity"] == 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