feat(cli): dcy-016 운영자 CLI 확장 (universe/slots/enter/exit) + 워치독 억제 (BAT-020)
운영자가 조회·매수·매도를 CLI 로 수행하되 데몬 RabbitMQ RPC 로 통일해 단일 writer admission·불변식을 보존(DB 직접 쓰기 없음)하고, 운영자 조회가 전략 생존 신호로 오인되지 않게 워치독을 억제한다. 거버넌스: 전부 change-req (신규 REQ 없음, dcy-016 OQ-3). - REQ-032: CLI universe/slots(snapshot RPC) + enter(buy)/exit(sell) intent RPC + 인스턴스 라우팅(--config/--instance/default) + enter/exit 안전장치 (확인 프롬프트 기본 ON / --yes(paper) / --yes --live(이중 ack) / --dry-run) + 전 명령 --json. pause/resume 는 direct-DB 유지 - REQ-022: CLI request-reply RPC 클라이언트(call_rpc) 재사용 - REQ-021/034: snapshot 요청 워치독 억제 플래그(touch_watchdog/actor=operator) → 핸들러가 last_seen_at 갱신 스킵. 기본값 현행 유지(하위호환). 운영자 조회가 죽은 전략의 safety-sell 을 무력화하던 결함(R-4) 제거 - REQ-026: interface-contract/runtime-flows 정합 CLI 는 rpc/confirm 주입 가능(테스트 broker/DB-free). snapshot envelope 은 asset-centric 이라 exit 은 운영자 제공 slot_id 사용(slot_id 노출은 본 주기 밖). 검증: 단위 327 passed/34 skipped, 인프라 게이트(PG+RabbitMQ) 361 passed, ruff clean. 신규 test_cli_operator(14) + snapshot 워치독 억제(2). BAT-020 release-candidate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -193,8 +193,15 @@ def make_snapshot_rpc_handler(
|
||||
body = req.payload or {}
|
||||
day = _parse_date(body.get("trading_date")) if body.get("trading_date") else _today()
|
||||
|
||||
# REQ-034: update watchdog for all holding slots visible in this RPC.
|
||||
if watchdog_store is not None:
|
||||
# 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:
|
||||
for s in slot_repo.list_slots(day):
|
||||
if s.status is SlotState.HOLDING:
|
||||
watchdog_store.update(str(s.slot_id))
|
||||
|
||||
+286
-20
@@ -1,45 +1,311 @@
|
||||
"""trex-core CLI entrypoints (REQ-032).
|
||||
"""trex-core operator CLI (REQ-032, dcy-016).
|
||||
|
||||
Subcommands:
|
||||
pause <portfolio_id> — durably pause a portfolio (Postgres)
|
||||
resume <portfolio_id> — clear the pause
|
||||
paused — list paused portfolio_ids
|
||||
pause <portfolio_id> — durably pause a portfolio (Postgres, direct-DB)
|
||||
resume <portfolio_id> — clear the pause (direct-DB)
|
||||
paused — list paused portfolio_ids (direct-DB)
|
||||
universe — list today's universe assets (snapshot RPC)
|
||||
slots [--status] — list positions/slots (snapshot RPC)
|
||||
enter <portfolio_id> — submit a buy intent (intent RPC; safeguarded)
|
||||
exit <slot_id> — submit a sell intent (intent RPC; safeguarded)
|
||||
|
||||
universe/slots/enter/exit go through the daemon's RabbitMQ RPC surface so the
|
||||
single-writer admission + invariants are preserved (no direct slot writes,
|
||||
dcy-016 NFR-1). Operator queries carry `actor="operator"` so they do NOT count
|
||||
as a strategy liveness signal (REQ-021/034 watchdog suppression, R-4).
|
||||
|
||||
enter/exit safeguards (dcy-016 OQ-2): confirmation prompt ON by default;
|
||||
`--yes` skips it (paper); live accounts require `--yes --live` (double ack);
|
||||
`--dry-run` previews the resolved intent without submitting. `--json` is a
|
||||
non-interactive machine mode, so enter/exit with `--json` require `--yes`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json as _json
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from trex_core.adapters.messaging import topology
|
||||
from trex_core.adapters.portfolio.pause_store import PostgreSQLPauseStore
|
||||
from trex_core.runtime.config import DaemonConfig
|
||||
from trex_core.runtime.config import DaemonConfig, DaemonInstanceConfig
|
||||
|
||||
_DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
# --------------------------------------------------------------------------- #
|
||||
# instance routing (OQ-1: --config canonical, --instance shortcut/override)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _resolve_instance(args) -> str:
|
||||
if getattr(args, "instance", None):
|
||||
return args.instance
|
||||
if getattr(args, "config", None):
|
||||
return DaemonInstanceConfig.from_toml(args.config).instance_key
|
||||
return topology.DEFAULT_INSTANCE_KEY
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# default transport-backed RPC (overridable in tests)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _default_rpc(cfg: DaemonConfig):
|
||||
"""Return a callable(queue, payload, *, correlation_id, timeout) -> dict
|
||||
backed by a real RabbitMQ transport built from config."""
|
||||
from trex_core.adapters.messaging import InMemoryTransport, RabbitMQTransport
|
||||
|
||||
mq = cfg.rabbitmq
|
||||
transport = RabbitMQTransport(mq.url, timeout=mq.timeout) if mq else InMemoryTransport()
|
||||
|
||||
def _rpc(queue: str, payload: dict, *, correlation_id: str, timeout: float) -> dict:
|
||||
try:
|
||||
return transport.call_rpc(
|
||||
queue, payload, correlation_id=correlation_id, timeout=timeout
|
||||
)
|
||||
finally:
|
||||
transport.close()
|
||||
|
||||
return _rpc
|
||||
|
||||
|
||||
def _default_confirm(prompt: str) -> bool:
|
||||
try:
|
||||
return input(f"{prompt} [y/N] ").strip().lower() in ("y", "yes")
|
||||
except EOFError:
|
||||
return False
|
||||
|
||||
|
||||
def _broker_mode(cfg: DaemonConfig) -> tuple[str, bool]:
|
||||
"""(broker, is_live). live = kis broker with allow_live enabled."""
|
||||
broker = cfg.broker
|
||||
is_live = broker == "kis" and bool(cfg.kis and cfg.kis.allow_live)
|
||||
return broker, is_live
|
||||
|
||||
|
||||
def _emit(data: dict, *, as_json: bool) -> None:
|
||||
if as_json:
|
||||
print(_json.dumps(data, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# RPC subcommand handlers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _today_iso(cfg: DaemonConfig) -> str:
|
||||
return datetime.now(tz=ZoneInfo(cfg.trading_timezone)).date().isoformat()
|
||||
|
||||
|
||||
def _cmd_universe(args, cfg, rpc) -> int:
|
||||
instance = _resolve_instance(args)
|
||||
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", [])
|
||||
if args.json:
|
||||
_emit(resp, as_json=True)
|
||||
else:
|
||||
print(f"instance={instance} view=all assets={len(assets)} "
|
||||
f"degraded={resp.get('degraded')}")
|
||||
for a in assets:
|
||||
print(f" {a.get('asset_id')}\t{a.get('symbol')}\tqty={a.get('asset_quantity')}")
|
||||
return 0
|
||||
|
||||
|
||||
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,
|
||||
"trading_date": _today_iso(cfg)}
|
||||
resp = rpc(topology.snapshot_queue(instance), payload,
|
||||
correlation_id=str(uuid.uuid4()), timeout=args.timeout)
|
||||
assets = resp.get("assets", [])
|
||||
if args.json:
|
||||
_emit(resp, as_json=True)
|
||||
else:
|
||||
print(f"instance={instance} view={view} count={len(assets)}")
|
||||
for a in assets:
|
||||
print(f" {a.get('asset_id')}\t{a.get('symbol')}\tqty={a.get('asset_quantity')}\t"
|
||||
f"strategy={a.get('active_strategy_id')}")
|
||||
return 0
|
||||
|
||||
|
||||
def _intent_guard(args, cfg, summary: dict, action: str, confirm) -> int | None:
|
||||
"""Shared enter/exit safeguard. Returns an exit code to short-circuit, or
|
||||
None to proceed with submission."""
|
||||
broker, is_live = _broker_mode(cfg)
|
||||
summary = {**summary, "instance": _resolve_instance(args), "broker": broker,
|
||||
"live": is_live, "action": action}
|
||||
if args.dry_run:
|
||||
if args.json:
|
||||
_emit({**summary, "dry_run": True}, as_json=True)
|
||||
else:
|
||||
print(f"[dry-run] {action} {summary}")
|
||||
return 0
|
||||
# live double-ack gate (OQ-2)
|
||||
if is_live and not args.live:
|
||||
msg = "live account requires --live (with --yes) to confirm a real order"
|
||||
_fail(msg, args.json)
|
||||
return 2
|
||||
# confirmation: --json is non-interactive → --yes required
|
||||
if not args.yes:
|
||||
if args.json:
|
||||
_fail("--json enter/exit requires --yes (no auto-submit)", True)
|
||||
return 2
|
||||
prompt = (f"Submit {action} on instance={summary['instance']} "
|
||||
f"broker={broker}{' LIVE' if is_live else ''}: {summary}?")
|
||||
if not confirm(prompt):
|
||||
print("aborted")
|
||||
return 1
|
||||
return None
|
||||
|
||||
|
||||
def _cmd_enter(args, cfg, rpc, confirm) -> int:
|
||||
summary = {"portfolio_id": args.portfolio_id, "qty": args.qty,
|
||||
"allocated_capital": args.capital, "entry_price": args.entry_price}
|
||||
short = _intent_guard(args, cfg, summary, "enter(buy)", confirm)
|
||||
if short is not None:
|
||||
return short
|
||||
payload = {
|
||||
"intent_type": "buy", "intent_id": str(uuid.uuid4()),
|
||||
"trading_date": _today_iso(cfg), "portfolio_id": args.portfolio_id,
|
||||
"qty": args.qty, "allocated_capital": args.capital,
|
||||
"entry_price": args.entry_price, "strategy_id": args.strategy_id,
|
||||
"sell_strategy_id": args.sell_strategy_id,
|
||||
"stop_loss_price": args.stop_loss, "take_profit_price": args.take_profit,
|
||||
}
|
||||
resp = rpc(topology.intent_queue(_resolve_instance(args)), payload,
|
||||
correlation_id=payload["intent_id"], timeout=args.timeout)
|
||||
return _report_intent(resp, args.json)
|
||||
|
||||
|
||||
def _cmd_exit(args, cfg, rpc, confirm) -> int:
|
||||
summary = {"slot_id": args.slot_id, "price": args.price or "market",
|
||||
"reason": args.reason}
|
||||
short = _intent_guard(args, cfg, summary, "exit(sell)", confirm)
|
||||
if short is not None:
|
||||
return short
|
||||
payload = {
|
||||
"intent_type": "sell", "intent_id": str(uuid.uuid4()),
|
||||
"slot_id": args.slot_id, "reason": args.reason,
|
||||
"sell_strategy_id": args.sell_strategy_id, "limit_price": args.price,
|
||||
}
|
||||
resp = rpc(topology.intent_queue(_resolve_instance(args)), payload,
|
||||
correlation_id=payload["intent_id"], timeout=args.timeout)
|
||||
return _report_intent(resp, args.json)
|
||||
|
||||
|
||||
def _report_intent(resp: dict, as_json: bool) -> int:
|
||||
if as_json:
|
||||
_emit(resp, as_json=True)
|
||||
else:
|
||||
print(f"status={resp.get('status')} slot_id={resp.get('slot_id')} "
|
||||
f"reason={resp.get('reason')}")
|
||||
# admission rejection is a non-zero exit so scripts can detect it.
|
||||
return 0 if resp.get("status") == "admitted" else 1
|
||||
|
||||
|
||||
def _fail(message: str, as_json: bool) -> None:
|
||||
if as_json:
|
||||
print(_json.dumps({"error": message}), file=sys.stderr)
|
||||
else:
|
||||
print(f"error: {message}", file=sys.stderr)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# arg parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _add_common(p) -> None:
|
||||
p.add_argument("--config", help="per-instance TOML (canonical instance routing)")
|
||||
p.add_argument("--instance", help="instance key (shortcut; overrides --config)")
|
||||
p.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
||||
p.add_argument("--timeout", type=float, default=_DEFAULT_TIMEOUT)
|
||||
|
||||
|
||||
def _add_intent_guards(p) -> None:
|
||||
p.add_argument("--yes", action="store_true", help="skip confirmation (paper)")
|
||||
p.add_argument("--live", action="store_true", help="confirm a LIVE order (with --yes)")
|
||||
p.add_argument("--dry-run", action="store_true", help="preview intent, do not submit")
|
||||
p.add_argument("--sell-strategy-id", dest="sell_strategy_id", default=None)
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="trex-core", description="trex-core admin CLI")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_pause = sub.add_parser("pause", help="Pause a portfolio_id")
|
||||
p_pause.add_argument("portfolio_id")
|
||||
p_pause.add_argument("--actor", default="cli")
|
||||
|
||||
p_resume = sub.add_parser("resume", help="Resume a portfolio_id")
|
||||
p_resume.add_argument("portfolio_id")
|
||||
|
||||
sub.add_parser("paused", help="List paused portfolio_ids")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
cfg = DaemonConfig.from_env()
|
||||
store = PostgreSQLPauseStore(cfg.dsn)
|
||||
p_uni = sub.add_parser("universe", help="List today's universe (snapshot RPC)")
|
||||
_add_common(p_uni)
|
||||
|
||||
if args.cmd == "pause":
|
||||
store.pause(args.portfolio_id, actor=args.actor)
|
||||
print(f"paused {args.portfolio_id}")
|
||||
elif args.cmd == "resume":
|
||||
store.resume(args.portfolio_id)
|
||||
print(f"resumed {args.portfolio_id}")
|
||||
elif args.cmd == "paused":
|
||||
for pid in sorted(store.list_paused()):
|
||||
print(pid)
|
||||
p_slots = sub.add_parser("slots", help="List positions (snapshot RPC)")
|
||||
p_slots.add_argument("--status", choices=["all", "held", "holding", "buyable"], default="all")
|
||||
_add_common(p_slots)
|
||||
|
||||
p_enter = sub.add_parser("enter", help="Submit a buy intent (intent RPC)")
|
||||
p_enter.add_argument("portfolio_id")
|
||||
p_enter.add_argument("--qty", type=float, default=None)
|
||||
p_enter.add_argument("--capital", type=float, default=None)
|
||||
p_enter.add_argument("--entry-price", dest="entry_price", type=float, default=None)
|
||||
p_enter.add_argument("--strategy-id", dest="strategy_id", default=None)
|
||||
p_enter.add_argument("--stop-loss", dest="stop_loss", type=float, default=None)
|
||||
p_enter.add_argument("--take-profit", dest="take_profit", type=float, default=None)
|
||||
_add_intent_guards(p_enter)
|
||||
_add_common(p_enter)
|
||||
|
||||
p_exit = sub.add_parser("exit", help="Submit a sell intent (intent RPC)")
|
||||
p_exit.add_argument("slot_id")
|
||||
p_exit.add_argument("--price", type=float, default=None)
|
||||
p_exit.add_argument("--reason", default="operator_exit")
|
||||
_add_intent_guards(p_exit)
|
||||
_add_common(p_exit)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int:
|
||||
"""CLI entrypoint.
|
||||
|
||||
`rpc` and `confirm` are injectable for tests: `rpc(queue, payload, *,
|
||||
correlation_id, timeout) -> dict`, `confirm(prompt) -> bool`.
|
||||
"""
|
||||
args = _build_parser().parse_args(argv)
|
||||
cfg = DaemonConfig.from_env()
|
||||
confirm = confirm or _default_confirm
|
||||
|
||||
# direct-DB control commands (pause/resume stay direct-DB, OQ-3).
|
||||
if args.cmd in ("pause", "resume", "paused"):
|
||||
store = PostgreSQLPauseStore(cfg.dsn)
|
||||
if args.cmd == "pause":
|
||||
store.pause(args.portfolio_id, actor=args.actor)
|
||||
print(f"paused {args.portfolio_id}")
|
||||
elif args.cmd == "resume":
|
||||
store.resume(args.portfolio_id)
|
||||
print(f"resumed {args.portfolio_id}")
|
||||
else:
|
||||
for pid in sorted(store.list_paused()):
|
||||
print(pid)
|
||||
return 0
|
||||
|
||||
rpc = rpc or _default_rpc(cfg)
|
||||
try:
|
||||
if args.cmd == "universe":
|
||||
return _cmd_universe(args, cfg, rpc)
|
||||
if args.cmd == "slots":
|
||||
return _cmd_slots(args, cfg, rpc)
|
||||
if args.cmd == "enter":
|
||||
return _cmd_enter(args, cfg, rpc, confirm)
|
||||
if args.cmd == "exit":
|
||||
return _cmd_exit(args, cfg, rpc, confirm)
|
||||
except Exception as exc: # noqa: BLE001 - surface RPC/transport failure clearly
|
||||
_fail(f"{type(exc).__name__}: {exc}", getattr(args, "json", False))
|
||||
return 3
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user