"""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