Files
trex-core/trex_core/runtime/cli.py
T
genichinandClaude Opus 4.8 8b2c26c04c feat(config): config 디렉터리 기반 설정 관리 (--config-path/--env-file, trex-orb-v1 정합, BAT-026)
REQ-048/010 change-req. `--config` 를 이름으로 해석 + `--config-path <dir>`
(기본 ./config) + resolve_config_toml(슬래시 포함 값은 하위호환 명시 경로).
`.env` 를 config-dir 결정적 해석(--env-file 우선, 없으면 <config_dir>/.env,
CWD 무관) + config/.env.example + .gitignore config/* 허용목록. 운영자 CLI 도
--config-path/--env-file 동일 적용. from_env() legacy 루트 .env 폴백 유지로
완전 하위호환.

단위 23 + 전체 375 passed, ruff clean, 라이브 스모크(name·path 동일).
REQ-048/010 → Implemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:45:08 +09:00

370 lines
16 KiB
Python

"""trex-core operator CLI (REQ-032, dcy-016).
Subcommands:
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,
DaemonInstanceConfig,
_load_dotenv,
resolve_config_toml,
)
_DEFAULT_TIMEOUT = 10.0
# --------------------------------------------------------------------------- #
# 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):
toml = resolve_config_toml(args.config, getattr(args, "config_path", None))
return DaemonInstanceConfig.from_toml(toml).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:
# 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')}")
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)
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)
# 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") for w in resp.get("watchdog", []) if w.get("expired")}
if args.json:
_emit({"instance": instance, "status": status, "slots": slots}, as_json=True)
else:
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" {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
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:
# REQ-032 AC-6: validate the absolute auto-close timestamp BEFORE submitting,
# so a malformed value is rejected at the CLI instead of failing admission.
forced_close_at = getattr(args, "forced_close_at", None)
if forced_close_at is not None:
try:
forced_close_at = datetime.fromisoformat(forced_close_at).isoformat()
except ValueError:
_fail(f"invalid --forced-close-at (expected ISO8601): {args.forced_close_at!r}",
args.json)
return 1
summary = {"portfolio_id": args.portfolio_id, "qty": args.qty,
"allocated_capital": args.capital, "entry_price": args.entry_price,
"forced_close_at": forced_close_at}
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,
}
# REQ-032 AC-5/6: only carry forced_close_at when specified (backward compat).
if forced_close_at is not None:
payload["forced_close_at"] = forced_close_at
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 name (resolved within --config-path) or path")
p.add_argument("--config-path", dest="config_path", default=None,
help="config directory (default ./config) for --config + .env")
p.add_argument("--instance", help="instance key (shortcut; overrides --config)")
p.add_argument("--env-file", dest="env_file", default=None,
help="explicit .env path; overrides <config-dir>/.env")
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")
p_uni = sub.add_parser("universe", help="List today's universe (snapshot RPC)")
_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", "pending"],
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)
p_enter.add_argument(
"--forced-close-at", dest="forced_close_at", default=None,
help="absolute auto-close timestamp (ISO8601, e.g. 2026-06-09T15:20:00+09:00) "
"→ buy intent forced_close_at",
)
_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)
# REQ-010 CHG-20260609-01: load the config-dir .env (or --env-file) before
# reading env. from_env() also runs the legacy root-.env fallback; the
# config-dir load wins (loaded first, override=False).
_toml = (
resolve_config_toml(args.config, getattr(args, "config_path", None))
if getattr(args, "config", None)
else None
)
_load_dotenv(_toml, getattr(args, "env_file", None))
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
if __name__ == "__main__": # pragma: no cover
sys.exit(main())