From 6c24bd4c2c099b4fb572db83ec9e68f6d39ff148 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Fri, 12 Jun 2026 10:28:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(slots-rpc):=20available=5Fslots=20capacity?= =?UTF-8?q?=20hint=20=EC=B6=94=EA=B0=80=20(BAT-037=20/=20REQ-073=20AC-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slots RPC 응답에 available_slots: int|null 포함. max_active_slots 미설정 시 null, 설정 시 max(0, M-active). 16 tests passed (AC-6 신규 3개 포함), 423 전체 통과. Co-Authored-By: Claude Sonnet 4.6 --- .../implementation.md | 17 ++++++--- .../index.md | 2 +- docs/batches/index.md | 2 +- tests/application/test_slots_handler.py | 37 +++++++++++++++++-- trex_core/application/messaging_handlers.py | 14 +++++-- trex_core/runtime/daemon.py | 1 + 6 files changed, 59 insertions(+), 14 deletions(-) diff --git a/docs/batches/bat-037_20260612_slots-rpc-available-slots/implementation.md b/docs/batches/bat-037_20260612_slots-rpc-available-slots/implementation.md index 94d396f..b089984 100644 --- a/docs/batches/bat-037_20260612_slots-rpc-available-slots/implementation.md +++ b/docs/batches/bat-037_20260612_slots-rpc-available-slots/implementation.md @@ -1,12 +1,12 @@ # BAT-037 Implementation -- Status: draft +- Status: completed - Batch: BAT-037 ## Plan Summary -- `make_slots_rpc_handler` 에 `max_active_slots` 파라미터 추가 및 응답에 `available_slots` 포함 -- `daemon.py` wiring 에 `max_active_slots` 전달 -- `test_slots_handler.py` AC-3/AC-6 갱신 +- `make_slots_rpc_handler` 에 `max_active_slots: Optional[int] = None` 파라미터 추가, 응답에 `available_slots` 포함 +- `daemon.py` 핸들러 wiring 에 `max_active_slots=self.config.max_active_slots` 전달 +- `test_slots_handler.py` AC-3 반전 + AC-6 테스트 3개 추가 ## Changed Files - `trex_core/application/messaging_handlers.py` @@ -14,10 +14,15 @@ - `tests/application/test_slots_handler.py` ## Execution Log -1. 구현 예정 +1. `make_slots_rpc_handler` 시그니처에 `max_active_slots: Optional[int] = None` 추가 +2. 응답 구성 직전 `available_slots` 계산: None → null, 설정 시 `max(0, M − count_active)` +3. `daemon.py` `_slots_rpc_loop` 에 `max_active_slots=self.config.max_active_slots` wiring +4. `test_slots_handler.py` AC-3 키 집합 갱신(`available_slots` 포함), `_wire` 에 `max_active_slots` 파라미터 전달 +5. AC-6 테스트 3개 추가: uncapped(null), remaining(M-N), full(0) ## Validation -- 구현 후 기록 +- `pytest tests/application/test_slots_handler.py` — 16 passed (신규 3개 포함) +- `pytest tests/` — 423 passed, 45 skipped, 회귀 없음 ## Remaining Risks - 없음 diff --git a/docs/batches/bat-037_20260612_slots-rpc-available-slots/index.md b/docs/batches/bat-037_20260612_slots-rpc-available-slots/index.md index e39eab6..5d73d9a 100644 --- a/docs/batches/bat-037_20260612_slots-rpc-available-slots/index.md +++ b/docs/batches/bat-037_20260612_slots-rpc-available-slots/index.md @@ -1,6 +1,6 @@ # BAT-037: slots RPC 응답에 available_slots capacity hint 추가 (CHG-20260612-01) -- Status: draft +- Status: in-delivery - Profile: standard - Source Discovery: dcy-025 (REQ-073 change-req) - Included REQ: REQ-073 (CHG-20260612-01) diff --git a/docs/batches/index.md b/docs/batches/index.md index d208926..b22044f 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -44,4 +44,4 @@ | BAT-034 | standard | release-candidate | REQ-002 (CHG-20260610-02), REQ-006 (CHG-20260610-02), REQ-007 (CHG-20260610-02), REQ-028 (CHG-20260610-01), REQ-032 (CHG-20260610-07) | dcy-023 (issue #22) | [bat-034](./bat-034_20260610_drop-allocated-capital/) | | BAT-035 | standard | confirmed | REQ-073, REQ-074, REQ-075, REQ-021 (CHG-20260611-01), REQ-034 (CHG-20260611-01), REQ-063 (CHG-20260611-01) | dcy-025 | [bat-035](./bat-035_20260611_strategy-read-surface-slots-req/) | | BAT-036 | standard | confirmed | REQ-048 (CHG-20260611-01), REQ-005 (CHG-20260611-01) | dcy-024 | [bat-036](./bat-036_20260611_safety-sell-disable-mode/) | -| BAT-037 | standard | draft | REQ-073 (CHG-20260612-01) | dcy-025 (REQ-073 change-req) | [bat-037](./bat-037_20260612_slots-rpc-available-slots/) | +| BAT-037 | standard | in-delivery | REQ-073 (CHG-20260612-01) | dcy-025 (REQ-073 change-req) | [bat-037](./bat-037_20260612_slots-rpc-available-slots/) | diff --git a/tests/application/test_slots_handler.py b/tests/application/test_slots_handler.py index 7291417..cba8589 100644 --- a/tests/application/test_slots_handler.py +++ b/tests/application/test_slots_handler.py @@ -32,6 +32,7 @@ def _wire(items=None, *, max_active_slots=None): snapshot_store=store, pause_store=pause, watchdog_store=watchdog, + max_active_slots=max_active_slots, ) return repo, svc, store, pause, watchdog, handler @@ -45,13 +46,12 @@ def _req(filter=None, spy=False): return RpcRequest(payload=payload, correlation_id="test-corr") -# REQ-073 AC-3: response keys only {slots/degraded/reason/message} +# 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", "degraded", "reason", "message"} + assert set(resp.payload.keys()) == {"slots", "available_slots", "degraded", "reason", "message"} assert "assets" not in resp.payload - assert "available_slots" not in resp.payload assert "watchdog" not in resp.payload @@ -210,3 +210,34 @@ def test_spy_false_updates_watchdog(): 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 diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index c9d3f67..f630d6a 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -177,6 +177,7 @@ def make_slots_rpc_handler( watchdog_store: Optional[WatchdogStore] = None, watchdog_timeout_sec: int = 120, ledger_outbox=None, + max_active_slots: Optional[int] = None, ): """Build the RPC handler for `trex.slots.req` (REQ-073/074). @@ -184,9 +185,10 @@ def make_slots_rpc_handler( filter: "all"|"inactive"|"pending"|"holding"|"error" (default "all") spy=true: watchdog not updated (operator view, REQ-034 CHG-01) - Response: {slots: [...], degraded: bool, reason: str|null, message: str|null} - Each slot row carries slot/core state + hub asset data + watchdog + overlay - flags (strategy fields excluded — REQ-074 AC-5). + Response: {slots: [...], available_slots: int|null, degraded: bool, + reason: str|null, message: str|null} + available_slots: capacity hint — null when uncapped, max(0, M-active) when capped + (REQ-073 AC-6). Does not replace the admission gate. """ def handler(req: RpcRequest) -> RpcResponse: @@ -248,8 +250,14 @@ def make_slots_rpc_handler( reason = reason_code.value if reason_code is not None else None message = msg + if max_active_slots is not None and max_active_slots > 0: + available_slots = max(0, max_active_slots - slot_repo.count_active(instance_key)) + else: + available_slots = None + return RpcResponse(payload={ "slots": slot_rows, + "available_slots": available_slots, "degraded": degraded, "reason": reason, "message": message, diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index 9dc373c..369fb24 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -274,6 +274,7 @@ class Daemon: watchdog_store=self.watchdog, watchdog_timeout_sec=self.config.safety_sell.watchdog_timeout_sec, ledger_outbox=self.outbox, + max_active_slots=self.config.max_active_slots, ) transport.consume_rpc_forever( topology.slots_queue(self.config.instance_key), handler, self.stop)