feat(rel-007): dcy-025/024 + BAT-035/036 — slots.req 즉시 교체 + safety_sell_enabled
BAT-035 (dcy-025): trex.snapshot.req → trex.slots.req 즉시 교체
- topology: SNAPSHOT_REQ_QUEUE 제거, SLOTS_REQ_QUEUE = "trex.slots.req" 추가
- portfolio.py: View/build_snapshot_envelope/SnapshotEnvelope/_asset_to_dict 제거;
slot_to_dict 시그니처 확장(snapshot_store/watchdog_store/pause_store/watchdog_timeout_sec)
— asset_quantity(hub)/cash_balance/cash_estimated/warned/watchdog{}/paused 병합 (REQ-074)
- messaging_handlers.py: make_snapshot_rpc_handler 제거, make_slots_rpc_handler 추가
— filter(all/inactive/pending/holding/error) + spy(bool) 처리; strategy 필드 제외 (REQ-073/074)
- daemon.py: _snapshot_rpc_loop → _slots_rpc_loop, make_slots_rpc_handler 연결
- cli.py: universe/slots → slots_queue + spy=True + server-side filter; buyable→inactive 매핑 (REQ-063)
- rabbitmq.py: snapshot_queue → slots_queue
- docs: interface-contract.md + runtime-flows.md slots.req 계약으로 업데이트 (REQ-075)
- REQ-028: Implemented → Deprecated (코드 삭제 완료)
- 테스트: test_snapshot_handler/selection/rpc_watchdog/messaging_snapshot_consumer 삭제;
test_slots_handler.py(신규, 13 cases), test_killswitch_masking_resume 재작성,
test_ledger_option_d._apply_cash_estimate → slots handler 경유, test_topology/cli_operator 갱신
BAT-036 (dcy-024): per-instance safety_sell_enabled 플래그
- config.py: DaemonInstanceConfig + DaemonConfig 에 safety_sell_enabled: bool = True 추가;
from_toml 파싱, from_config 전파
- daemon.py: start() workers 조건부 — safety_sell_enabled=false 시 trex-safety-sell 미기동 + WARNING
- tests: test_instance_config.py 에 safety_sell_enabled 파싱 3개 케이스 추가
REQ: 073/074/075/021/034/063/048/005 Approved → Implemented
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,8 +17,6 @@ from trex_core.application.messaging import RpcRequest, RpcResponse
|
||||
from trex_core.application.portfolio import (
|
||||
PauseStore,
|
||||
SnapshotStore,
|
||||
View,
|
||||
build_snapshot_envelope,
|
||||
slot_to_dict,
|
||||
)
|
||||
from trex_core.application.watchdog import WatchdogStore
|
||||
@@ -168,158 +166,98 @@ def _handle_update_thresholds(
|
||||
return RpcResponse(payload=payload)
|
||||
|
||||
|
||||
# ---- snapshot RPC handler ---------------------------------------------------
|
||||
# ---- slots RPC handler (REQ-073/074) ----------------------------------------
|
||||
|
||||
def make_snapshot_rpc_handler(
|
||||
def make_slots_rpc_handler(
|
||||
slot_repo: SlotRepository,
|
||||
slot_service: SlotService,
|
||||
*,
|
||||
instance_key: str = "default",
|
||||
snapshot_store: Optional[SnapshotStore] = None,
|
||||
pause_store: Optional[PauseStore] = None,
|
||||
max_active_slots: Optional[int] = None,
|
||||
watchdog_store: Optional[WatchdogStore] = None,
|
||||
watchdog_timeout_sec: int = 120,
|
||||
killswitch_store=None,
|
||||
ledger_outbox=None,
|
||||
):
|
||||
"""Build the RPC handler for `trex.snapshot.req` (REQ-028).
|
||||
"""Build the RPC handler for `trex.slots.req` (REQ-073/074).
|
||||
|
||||
When `snapshot_store` + `pause_store` are provided (production wiring),
|
||||
returns the BAT-008 envelope:
|
||||
{
|
||||
"assets": list of {symbol, asset_id, cash_balance,
|
||||
asset_quantity, active_strategy_id,
|
||||
strategy_params},
|
||||
"view": "all" | "buyable" | "held",
|
||||
"available_slots": int,
|
||||
"degraded": bool,
|
||||
"reason": DegradedReason value or null,
|
||||
"message": sanitized text or null
|
||||
}
|
||||
Request: {filter?: str, spy?: bool}
|
||||
filter: "all"|"inactive"|"pending"|"holding"|"error" (default "all")
|
||||
spy=true: watchdog not updated (operator view, REQ-034 CHG-01)
|
||||
|
||||
Without those, falls back to the BAT-007 slot+capacity stub shape so
|
||||
older callers and existing tests don't break.
|
||||
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).
|
||||
"""
|
||||
|
||||
def handler(req: RpcRequest) -> RpcResponse:
|
||||
body = req.payload or {}
|
||||
day = _parse_date(body.get("trading_date")) if body.get("trading_date") else _today()
|
||||
filter_val = str(body.get("filter") or "all").lower()
|
||||
spy = bool(body.get("spy", False))
|
||||
|
||||
# REQ-034: update watchdog for all holding slots visible in this RPC
|
||||
# (strategy polling == liveness signal). dcy-016/REQ-021: an operator
|
||||
# query (`touch_watchdog=false` / `actor="operator"`) must NOT count as
|
||||
# liveness — otherwise an operator looking at slots would suppress
|
||||
# safety-sell on a dead strategy (R-4). Default keeps legacy behavior.
|
||||
touch_watchdog = bool(body.get("touch_watchdog", True))
|
||||
if str(body.get("actor") or "").lower() == "operator":
|
||||
touch_watchdog = False
|
||||
if watchdog_store is not None and touch_watchdog:
|
||||
# Retrieve and filter slots by status
|
||||
slots = slot_repo.list_slots(instance_key)
|
||||
if filter_val == "inactive":
|
||||
slots = [s for s in slots if s.status.value == "inactive"]
|
||||
elif filter_val == "pending":
|
||||
slots = [s for s in slots if s.status.value in ("pending_buy", "pending_sell")]
|
||||
elif filter_val == "holding":
|
||||
slots = [s for s in slots if s.status.value == "holding"]
|
||||
elif filter_val == "error":
|
||||
slots = [s for s in slots if s.status.value == "error"]
|
||||
# "all" or unrecognized → no filter (REQ-073 AC-2)
|
||||
|
||||
# REQ-034 CHG-01: update watchdog for holding slots unless spy=True
|
||||
if not spy and watchdog_store is not None:
|
||||
for s in slot_repo.list_holding_slots(instance_key):
|
||||
watchdog_store.update(str(s.slot_id))
|
||||
|
||||
# --- BAT-008 envelope path (snapshot store wired) ---
|
||||
if snapshot_store is not None and pause_store is not None:
|
||||
requested_view = str(body.get("view") or "all").lower()
|
||||
try:
|
||||
view = View(requested_view)
|
||||
except ValueError:
|
||||
view = View.ALL
|
||||
strategy_key = _opt_str(body.get("strategy_key"))
|
||||
# dcy-020 / REQ-059: available = remaining admission headroom under
|
||||
# the M cap (max(0, M − active)); inactive count when no cap.
|
||||
cap = slot_service.compute_slot_capacity(max_active_slots)
|
||||
envelope = build_snapshot_envelope(
|
||||
store=snapshot_store,
|
||||
# Build merged slot rows (REQ-074)
|
||||
slot_rows = [
|
||||
slot_to_dict(
|
||||
s,
|
||||
snapshot_store=snapshot_store,
|
||||
watchdog_store=watchdog_store,
|
||||
pause_store=pause_store,
|
||||
slot_repo=slot_repo,
|
||||
instance_key=instance_key,
|
||||
available_slots=cap.available_count,
|
||||
view=view,
|
||||
strategy_key=strategy_key,
|
||||
trading_date=day,
|
||||
killswitch_store=killswitch_store,
|
||||
watchdog_timeout_sec=watchdog_timeout_sec,
|
||||
)
|
||||
payload = envelope.to_dict()
|
||||
_attach_watchdog(payload, slot_repo, instance_key, watchdog_store, watchdog_timeout_sec)
|
||||
_apply_cash_estimate(payload, ledger_outbox)
|
||||
return RpcResponse(payload=payload)
|
||||
for s in slots
|
||||
]
|
||||
|
||||
# --- BAT-007 legacy stub-shape path ---
|
||||
view_raw = str(body.get("view") or "all")
|
||||
strategy_filter = _opt_str(body.get("strategy_id"))
|
||||
slots = slot_repo.list_slots(instance_key)
|
||||
if view_raw == "open":
|
||||
slots = [s for s in slots if s.status.value in ("pending_buy", "holding", "pending_sell")]
|
||||
if strategy_filter is not None:
|
||||
slots = [s for s in slots if s.strategy_id == strategy_filter]
|
||||
cap = slot_service.compute_slot_capacity(max_active_slots)
|
||||
payload = {
|
||||
"view": view_raw,
|
||||
"strategy_id": strategy_filter,
|
||||
"slots": [_slot_to_dict(s) for s in slots],
|
||||
"capacity": {
|
||||
"inactive_count": cap.inactive_count,
|
||||
"holding_count": cap.holding_count,
|
||||
"pending_count": cap.pending_count,
|
||||
"active_count": cap.active_count,
|
||||
"available_count": cap.available_count,
|
||||
"total_slots": cap.total_slots,
|
||||
"scarcity_ratio": cap.scarcity_ratio,
|
||||
},
|
||||
}
|
||||
_attach_watchdog(payload, slot_repo, instance_key, watchdog_store, watchdog_timeout_sec)
|
||||
return RpcResponse(payload=payload)
|
||||
# dcy-018 / REQ-029: cash-estimated overlay (best-effort)
|
||||
if ledger_outbox is not None:
|
||||
try:
|
||||
deltas = ledger_outbox.pending_cash_delta()
|
||||
if deltas:
|
||||
for row in slot_rows:
|
||||
pid = row.get("portfolio_id")
|
||||
d = deltas.get(pid)
|
||||
if d:
|
||||
base = row.get("cash_balance") or 0.0
|
||||
row["cash_balance"] = base + d
|
||||
row["cash_estimated"] = True
|
||||
except Exception: # noqa: BLE001 - estimate must never break the response
|
||||
pass
|
||||
|
||||
# Degraded state from snapshot store
|
||||
degraded = False
|
||||
reason = None
|
||||
message = None
|
||||
if snapshot_store is not None:
|
||||
reason_code, msg = snapshot_store.degraded_state()
|
||||
degraded = reason_code is not None
|
||||
reason = reason_code.value if reason_code is not None else None
|
||||
message = msg
|
||||
|
||||
return RpcResponse(payload={
|
||||
"slots": slot_rows,
|
||||
"degraded": degraded,
|
||||
"reason": reason,
|
||||
"message": message,
|
||||
})
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def _apply_cash_estimate(payload, ledger_outbox) -> None:
|
||||
"""Overlay an estimated cash for assets with unsettled (pending) ledger
|
||||
writes (dcy-018 / REQ-029, OQ-4). cash_balance += pending cash delta and the
|
||||
asset is flagged `cash_estimated=true` until option-D write-back lands.
|
||||
Best-effort: any failure leaves the response unchanged (NFR-1)."""
|
||||
if ledger_outbox is None:
|
||||
return
|
||||
try:
|
||||
deltas = ledger_outbox.pending_cash_delta()
|
||||
if not deltas:
|
||||
return
|
||||
for a in payload.get("assets", []):
|
||||
d = deltas.get(a.get("asset_id"))
|
||||
if d:
|
||||
base = a.get("cash_balance") or 0.0
|
||||
a["cash_balance"] = base + d
|
||||
a["cash_estimated"] = True
|
||||
except Exception: # noqa: BLE001 - estimate must never break the response
|
||||
return
|
||||
|
||||
|
||||
def _attach_watchdog(payload, slot_repo, instance_key, watchdog_store, timeout_sec) -> None:
|
||||
"""Attach holding-slot watchdog state to a snapshot response (dcy-017/REQ-034).
|
||||
|
||||
`watchdog` = list of {slot_id, portfolio_id, symbol, expired, last_seen} for
|
||||
holding slots — lets `trex-core slots` surface watchdog ownership (strategy
|
||||
absence). Best-effort: any failure leaves the response unchanged (FR-4)."""
|
||||
if watchdog_store is None:
|
||||
return
|
||||
try:
|
||||
states = []
|
||||
for s in slot_repo.list_holding_slots(instance_key):
|
||||
sid = str(s.slot_id)
|
||||
last = watchdog_store.last_seen(sid)
|
||||
states.append({
|
||||
"slot_id": sid,
|
||||
"portfolio_id": s.portfolio_id,
|
||||
"symbol": s.symbol,
|
||||
"expired": watchdog_store.is_expired(sid, timeout_sec),
|
||||
"last_seen": last.isoformat() if last else None,
|
||||
})
|
||||
payload["watchdog"] = states
|
||||
except Exception: # noqa: BLE001 - visibility must never break the response
|
||||
return
|
||||
|
||||
|
||||
# ---- pause/resume RPC handler (REQ-032 AC-4) --------------------------------
|
||||
|
||||
def make_pause_rpc_handler(pause_store: PauseStore):
|
||||
@@ -528,7 +466,3 @@ def _today() -> date_cls:
|
||||
return datetime.now(tz=timezone.utc).date()
|
||||
|
||||
|
||||
# BAT-022: canonical slot dict shared with build_snapshot_envelope (single
|
||||
# source of truth so BAT-007 stub and BAT-008 envelope emit an identical
|
||||
# `slots[]` shape). Local alias kept for existing call sites.
|
||||
_slot_to_dict = slot_to_dict
|
||||
|
||||
Reference in New Issue
Block a user