312 lines
13 KiB
Python
312 lines
13 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
|
||
else:
|
||
payload["strategy_instance_id"] = "test-instance"
|
||
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)
|
||
|
||
|
||
def test_spy_response_exposes_ledger_outbox_metrics():
|
||
class _MetricsReader:
|
||
def metrics_snapshot(self):
|
||
return {
|
||
"available": True,
|
||
"scope": "process_lifetime",
|
||
"instance_key": "default",
|
||
"claims": {
|
||
"empty_polls_total": 7,
|
||
"claimed_batches_total": 1,
|
||
"unavailable_total": 0,
|
||
"lost_total": 0,
|
||
"foreign_instance_total": 0,
|
||
},
|
||
"outcomes": {
|
||
"completed_total": 1,
|
||
"duplicate_completed_total": 0,
|
||
"retryable_failure_total": 0,
|
||
"conflict_blocked_total": 0,
|
||
"permanent_failure_total": 0,
|
||
"dead_lettered_total": 0,
|
||
},
|
||
}
|
||
|
||
repo, *_ = _wire()
|
||
handler = make_slots_rpc_handler(
|
||
repo,
|
||
ledger_outbox_metrics_reader=_MetricsReader(),
|
||
)
|
||
|
||
spy_payload = handler(_req(spy=True)).payload
|
||
strategy_payload = handler(_req(spy=False)).payload
|
||
|
||
assert spy_payload["ledger_outbox_metrics"]["claims"]["empty_polls_total"] == 7
|
||
assert "ledger_outbox_metrics" not in strategy_payload
|
||
|
||
|
||
def test_spy_response_reports_metrics_unavailable_without_failing_slots():
|
||
class _FailingReader:
|
||
def metrics_snapshot(self):
|
||
raise RuntimeError("SECRET_METRICS_READER_FAILURE")
|
||
|
||
repo, *_ = _wire()
|
||
for reader in (None, _FailingReader()):
|
||
handler = make_slots_rpc_handler(
|
||
repo,
|
||
instance_key="default",
|
||
ledger_outbox_metrics_reader=reader,
|
||
)
|
||
payload = handler(_req(spy=True)).payload
|
||
|
||
assert len(payload["slots"]) == 2
|
||
assert payload["ledger_outbox_metrics"] == {
|
||
"available": False,
|
||
"scope": "process_lifetime",
|
||
"instance_key": "default",
|
||
"reason": "ledger_outbox_metrics_unavailable",
|
||
}
|
||
|
||
|
||
# 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
|