feat(slots): additive strategy fields + filter (REQ-116, BAT-055)

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
This commit is contained in:
2026-07-04 11:10:01 +09:00
co-authored by Claude Fable 5
parent 5267e72efe
commit 690071afad
13 changed files with 714 additions and 12 deletions
+5
View File
@@ -160,6 +160,9 @@ class PortfolioHubReadAdapter:
pid = str(raw.get("portfolio_id") or raw.get("asset_id") or "")
if not pid:
continue
# REQ-116 (BAT-055): parse strategy_name and markets additively.
# Absent keys or wrong types → safe defaults (None / []).
raw_markets = raw.get("markets")
out.append(PortfolioItem(
portfolio_id=pid,
symbol=str(raw.get("symbol") or ""),
@@ -167,6 +170,8 @@ class PortfolioHubReadAdapter:
asset_quantity=float(raw.get("asset_quantity") or 0.0),
active_strategy_id=raw.get("active_strategy_id") or None,
strategy_params=dict(raw.get("strategy_params") or {}),
strategy_name=raw.get("strategy_name") or None,
markets=list(raw_markets) if isinstance(raw_markets, list) else [],
))
return out
+16 -2
View File
@@ -228,11 +228,14 @@ def make_slots_rpc_handler(
max_active_slots: Optional[int] = None,
strategy_state_store=None,
):
"""Build the RPC handler for `trex.slots.req` (REQ-073/074).
"""Build the RPC handler for `trex.slots.req` (REQ-073/074/116).
Request: {filter?: str, spy?: bool}
Request: {filter?: str, spy?: bool, strategy?: str}
filter: "all"|"inactive"|"pending"|"holding"|"error" (default "all")
spy=true: watchdog not updated (operator view, REQ-034 CHG-01)
strategy: exact-match on strategy_name (REQ-116, optional). When specified
only slots whose strategy_name == strategy are returned. AND-combined with
filter. available_slots is always computed before this filter (REQ-073 AC-6).
Response: {slots: [...], available_slots: int|null, degraded: bool,
reason: str|null, message: str|null}
@@ -244,6 +247,9 @@ def make_slots_rpc_handler(
body = req.payload or {}
filter_val = str(body.get("filter") or "all").lower()
spy = bool(body.get("spy", False))
# REQ-116 (BAT-055): optional strategy filter — exact-match on strategy_name.
# None means no filter (all slots returned regardless of strategy_name).
strategy_filter = _opt_str(body.get("strategy"))
# issue #25: filter by status at the DB (REQ-002/006 list_slots
# status_filter) instead of fetching every row and filtering in Python.
@@ -320,11 +326,19 @@ def make_slots_rpc_handler(
reason = reason_code.value if reason_code is not None else None
message = msg
# REQ-073 AC-6: available_slots computed from instance-wide active count —
# independent of strategy filter (REQ-116). Calculate before filtering.
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
# REQ-116 (BAT-055): strategy filter — Python-level post-filter after
# available_slots is already computed. AND-combined with status filter.
# Unmatched strategy (no slots) returns [] without error.
if strategy_filter is not None:
slot_rows = [r for r in slot_rows if r.get("strategy_name") == strategy_filter]
return RpcResponse(payload={
"slots": slot_rows,
"available_slots": available_slots,
+18
View File
@@ -30,6 +30,9 @@ class PortfolioItem:
Mirrors portfolio-hub's `list_portfolios_v1` PortfolioItem with **no price
field** — pricing is the strategy module's concern, not core's.
REQ-116 (BAT-055): strategy_name and markets added additively.
strategy_params already existed; now surfaced in slot_to_dict output.
"""
portfolio_id: str # == asset_id, dcy-004 OQ-1
symbol: str
@@ -37,6 +40,9 @@ class PortfolioItem:
asset_quantity: float # integer shares semantically; float for tolerance
active_strategy_id: Optional[str] = None
strategy_params: dict = field(default_factory=dict) # opaque, pass-through
# REQ-116 additive fields (BAT-055) — safe defaults for legacy hub responses
strategy_name: Optional[str] = None # portfolio-hub strategy_name; None if absent
markets: list = field(default_factory=list) # portfolio-hub markets list; [] if absent
class PortfolioHubReadError(Exception):
@@ -461,13 +467,21 @@ def slot_to_dict(
}
# hub authority: asset_quantity (hub primary) + cash_balance
# REQ-116: strategy_name / strategy_params / markets also from PortfolioItem
asset_qty: Optional[float] = None
cash_balance: Optional[float] = None
strategy_name_val: Optional[str] = None
strategy_params_val: dict = {}
markets_val: list = []
if snapshot_store is not None and s.portfolio_id:
snap = snapshot_store.get(s.portfolio_id)
if snap is not None:
asset_qty = float(snap.item.asset_quantity)
cash_balance = float(snap.item.cash_balance)
# REQ-116 (BAT-055): strategy fields from PortfolioItem (hub authority)
strategy_name_val = snap.item.strategy_name
strategy_params_val = dict(snap.item.strategy_params)
markets_val = list(snap.item.markets)
# qty (slot mirror) vs asset_quantity (hub): divergence → warned=True
slot_qty = float(s.qty) if s.qty is not None else None
@@ -477,6 +491,10 @@ def slot_to_dict(
d["cash_balance"] = cash_balance
d["cash_estimated"] = False # handler overlays ledger_outbox estimate if available
d["warned"] = warned
# REQ-116 (BAT-055): strategy fields — additive, safe default when hub absent
d["strategy_name"] = strategy_name_val
d["strategy_params"] = strategy_params_val
d["markets"] = markets_val
# watchdog nested object (WatchdogStore)
if watchdog_store is not None: