fix(cli): BAT-022 slots 명령이 universe 대신 실제 슬롯 상태 표시 (dcy-016 후속)
`trex-core slots` 가 snapshot RPC 의 universe `assets`(portfolio)를 슬롯 상태로 오인 표시하던 갭 정정. snapshot envelope 이 슬롯 상태를 운반하지 않아 발생 (10개 슬롯인데 universe 1개·status 없음 — BAT-007 HTTP 슬롯 read 제거 후 슬롯 상태 read 표면 부재가 근인). - REQ-028 (CHG-20260608-01): snapshot envelope 에 read-only per-slot `slots[]` additive 추가(AC-4). strategy `assets[]`(universe, no-price) 불변. - REQ-032 (CHG-20260608-03): `_cmd_slots` 가 envelope `slots[]`(실제 슬롯 상태) 표시 + `--status` 를 슬롯 status 기준 client-side 필터 + watchdog 결합. `universe` 는 `assets[]` 유지. `universe --json` 도 universe 데이터로 scope. - canonical `portfolio.slot_to_dict` 단일화 — BAT-007 stub / BAT-008 envelope 두 경로가 동일 슬롯 스키마(외부명 `entry_price` 유지, dcy-012 호환). - interface-contract 에 `slots[]` 계약 + `assets[]` vs `slots[]` 구분 반영. 검증: 단위 338 passed + ruff clean + 신규 코드 데몬으로 실 PostgreSQL/RabbitMQ 운영 재현 PASS(slots=10·status·DB 일치·universe 분리·비침습). REQ-028/032 Implemented, BAT-022 release-candidate. pytest 인프라 게이트는 Human Approval Memo 수용(운영 재현이 실 경로 커버). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ from trex_core.application.portfolio import (
|
||||
SnapshotStore,
|
||||
View,
|
||||
build_snapshot_envelope,
|
||||
slot_to_dict,
|
||||
)
|
||||
from trex_core.application.watchdog import WatchdogStore
|
||||
from trex_core.domain.slot import (
|
||||
@@ -460,21 +461,7 @@ def _today() -> date_cls:
|
||||
return datetime.now(tz=timezone.utc).date()
|
||||
|
||||
|
||||
def _slot_to_dict(s) -> dict:
|
||||
return {
|
||||
"slot_id": str(s.slot_id),
|
||||
"trading_date": s.trading_date.isoformat(),
|
||||
"status": s.status.value,
|
||||
"symbol": s.symbol,
|
||||
"qty": float(s.qty) if s.qty is not None else None,
|
||||
"avg_price": float(s.avg_price) if s.avg_price is not None else None,
|
||||
# dcy-012 / NFR-3: external read field name kept as "entry_price" for
|
||||
# contract compatibility; internally backed by the renamed limit_price
|
||||
# (the pending order limit, cleared once holding).
|
||||
"entry_price": float(s.limit_price) if s.limit_price is not None else None,
|
||||
"allocated_capital": float(s.allocated_capital) if s.allocated_capital is not None else None,
|
||||
"strategy_id": s.strategy_id,
|
||||
"sell_strategy_id": s.sell_strategy_id,
|
||||
"portfolio_id": s.portfolio_id,
|
||||
"forced_close_at": s.forced_close_at.isoformat() if s.forced_close_at else None,
|
||||
}
|
||||
# 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
|
||||
|
||||
@@ -266,6 +266,11 @@ class SnapshotEnvelope:
|
||||
degraded: bool
|
||||
reason: Optional[str]
|
||||
message: Optional[str] = None
|
||||
# REQ-028 CHG-20260608-01 (BAT-022): operator-facing per-slot state list,
|
||||
# additive and independent of strategy-facing `assets[]` (universe). Lets
|
||||
# `trex-core slots` show the daemon's real slot table (idle/holding/pending)
|
||||
# instead of universe portfolios. Always a list ([] when no slots).
|
||||
slots: list[dict] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -275,6 +280,7 @@ class SnapshotEnvelope:
|
||||
"degraded": self.degraded,
|
||||
"reason": self.reason,
|
||||
"message": self.message,
|
||||
"slots": list(self.slots),
|
||||
}
|
||||
|
||||
|
||||
@@ -290,6 +296,32 @@ def _asset_to_dict(item: PortfolioItem) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def slot_to_dict(s) -> dict:
|
||||
"""Canonical operator-facing per-slot state shape (REQ-028 AC-4, BAT-022).
|
||||
|
||||
Shared by the BAT-008 envelope (`build_snapshot_envelope`) and the BAT-007
|
||||
stub-shape handler so `trex-core slots` sees an identical slot dict on either
|
||||
path. Unlike `_asset_to_dict` (strategy universe, no-price), this is
|
||||
operator-only and exposes prices. External read field name `entry_price`
|
||||
is kept for contract compatibility (dcy-012 / NFR-3 — internally backed by
|
||||
the renamed `limit_price`, the pending order limit cleared once holding).
|
||||
"""
|
||||
return {
|
||||
"slot_id": str(s.slot_id),
|
||||
"trading_date": s.trading_date.isoformat(),
|
||||
"status": s.status.value,
|
||||
"symbol": s.symbol,
|
||||
"qty": float(s.qty) if s.qty is not None else None,
|
||||
"avg_price": float(s.avg_price) if s.avg_price is not None else None,
|
||||
"entry_price": float(s.limit_price) if s.limit_price is not None else None,
|
||||
"allocated_capital": float(s.allocated_capital) if s.allocated_capital is not None else None,
|
||||
"strategy_id": s.strategy_id,
|
||||
"sell_strategy_id": s.sell_strategy_id,
|
||||
"portfolio_id": s.portfolio_id,
|
||||
"forced_close_at": s.forced_close_at.isoformat() if s.forced_close_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _sanitize(message: str) -> str:
|
||||
"""Strip credentials/URLs/tokens — REQ-028 AC-2 sanitization."""
|
||||
if not message:
|
||||
@@ -395,6 +427,10 @@ def build_snapshot_envelope(
|
||||
d["asset_quantity"] = holding_qty[pid]
|
||||
out.append(d)
|
||||
|
||||
# REQ-028 CHG-20260608-01 (BAT-022): operator-facing per-slot state — the
|
||||
# full daemon slot table for `day`, independent of the asset view filter.
|
||||
slot_states = [slot_to_dict(s) for s in slots]
|
||||
|
||||
reason_code, message = store.degraded_state()
|
||||
return SnapshotEnvelope(
|
||||
assets=out,
|
||||
@@ -403,4 +439,5 @@ def build_snapshot_envelope(
|
||||
degraded=reason_code is not None,
|
||||
reason=reason_code.value if reason_code is not None else None,
|
||||
message=message,
|
||||
slots=slot_states,
|
||||
)
|
||||
|
||||
+28
-15
@@ -102,7 +102,11 @@ def _cmd_universe(args, cfg, rpc) -> int:
|
||||
correlation_id=str(uuid.uuid4()), timeout=args.timeout)
|
||||
assets = resp.get("assets", [])
|
||||
if args.json:
|
||||
_emit(resp, as_json=True)
|
||||
# Scope to universe data — `slots[]`/`watchdog[]` belong to `slots`.
|
||||
_emit({"instance": instance, "view": resp.get("view"), "assets": assets,
|
||||
"available_slots": resp.get("available_slots"),
|
||||
"degraded": resp.get("degraded"), "reason": resp.get("reason")},
|
||||
as_json=True)
|
||||
else:
|
||||
print(f"instance={instance} view=all assets={len(assets)} "
|
||||
f"degraded={resp.get('degraded')}")
|
||||
@@ -113,26 +117,34 @@ def _cmd_universe(args, cfg, rpc) -> int:
|
||||
|
||||
def _cmd_slots(args, cfg, rpc) -> int:
|
||||
instance = _resolve_instance(args)
|
||||
view = {"holding": "held", "held": "held", "buyable": "buyable"}.get(
|
||||
args.status or "all", "all"
|
||||
)
|
||||
payload = {"view": view, "actor": "operator", "touch_watchdog": False,
|
||||
payload = {"view": "all", "actor": "operator", "touch_watchdog": False,
|
||||
"trading_date": _today_iso(cfg)}
|
||||
resp = rpc(topology.snapshot_queue(instance), payload,
|
||||
correlation_id=str(uuid.uuid4()), timeout=args.timeout)
|
||||
assets = resp.get("assets", [])
|
||||
# REQ-032 CHG-20260608-03 (BAT-022): show the real daemon slot table
|
||||
# (envelope `slots[]`), not the universe `assets[]`. `--status` filters by
|
||||
# slot status; `universe` keeps showing `assets[]`.
|
||||
slots = resp.get("slots", [])
|
||||
status = (args.status or "all").lower()
|
||||
if status in ("holding", "held"):
|
||||
slots = [s for s in slots if s.get("status") == "holding"]
|
||||
elif status == "buyable":
|
||||
slots = [s for s in slots if s.get("status") == "idle"]
|
||||
elif status == "pending":
|
||||
slots = [s for s in slots if s.get("status") in ("pending_buy", "pending_sell")]
|
||||
# dcy-017: holding-slot watchdog state (strategy-absent ownership).
|
||||
owned = {w.get("portfolio_id"): w for w in resp.get("watchdog", []) if w.get("expired")}
|
||||
owned = {w.get("portfolio_id") for w in resp.get("watchdog", []) if w.get("expired")}
|
||||
if args.json:
|
||||
_emit(resp, as_json=True)
|
||||
_emit({"instance": instance, "status": status, "slots": slots}, as_json=True)
|
||||
else:
|
||||
print(f"instance={instance} view={view} count={len(assets)} "
|
||||
f"watchdog_owned={len(owned)}")
|
||||
for a in assets:
|
||||
pid = a.get("asset_id")
|
||||
print(f"instance={instance} status={status} count={len(slots)} "
|
||||
f"watchdog_owned={len([s for s in slots if s.get('portfolio_id') in owned])}")
|
||||
for s in slots:
|
||||
pid = s.get("portfolio_id")
|
||||
tag = " [WATCHDOG-OWNED: strategy absent]" if pid in owned else ""
|
||||
print(f" {pid}\t{a.get('symbol')}\tqty={a.get('asset_quantity')}\t"
|
||||
f"strategy={a.get('active_strategy_id')}{tag}")
|
||||
print(f" {s.get('slot_id')}\t{s.get('status')}\t{s.get('symbol')}\t"
|
||||
f"qty={s.get('qty')}\tlimit={s.get('entry_price')}\t"
|
||||
f"avg={s.get('avg_price')}\tpid={s.get('portfolio_id')}{tag}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -250,7 +262,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
_add_common(p_uni)
|
||||
|
||||
p_slots = sub.add_parser("slots", help="List positions (snapshot RPC)")
|
||||
p_slots.add_argument("--status", choices=["all", "held", "holding", "buyable"], default="all")
|
||||
p_slots.add_argument("--status", choices=["all", "held", "holding", "buyable", "pending"],
|
||||
default="all")
|
||||
_add_common(p_slots)
|
||||
|
||||
p_enter = sub.add_parser("enter", help="Submit a buy intent (intent RPC)")
|
||||
|
||||
Reference in New Issue
Block a user