feat(rel-006): dcy-022/023 + BAT-032~034 — 실행 정책·qty 검증·allocated_capital 제거 통합
BAT-032 (dcy-022): timeout_sec/slippage_pct 실행 정책, 미체결 자동 취소, 보호 지정가, `trex-core cancel` 백스톱. 마이그레이션 012. BAT-033: `trex-core enter --qty` 필수 검증 — 미지정 시 RPC 전 즉시 오류. BAT-034 (dcy-023): allocated_capital 죽은 필드 전 경로 제거 (domain·intent· persistence·snapshot·CLI). 마이그레이션 013(비가역 DROP). 외부 발신자 unknown key 무시 수신 의미론 적용. REL-006 confirmed: BAT-026~034 통합 릴리즈, pre-release smoke 증거 포함. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ Subcommands:
|
||||
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)
|
||||
cancel <slot_id> — cancel a pending order (control 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,
|
||||
@@ -182,7 +183,6 @@ def _print_slot_detail(s: dict, wd: dict | None) -> None:
|
||||
("qty", s.get("qty")),
|
||||
("avg_price", s.get("avg_price")),
|
||||
("entry_price (limit)", s.get("entry_price")),
|
||||
("allocated_capital", s.get("allocated_capital")),
|
||||
("strategy_id", s.get("strategy_id")),
|
||||
("sell_strategy_id", s.get("sell_strategy_id")),
|
||||
("forced_close_at", s.get("forced_close_at")),
|
||||
@@ -230,6 +230,12 @@ def _intent_guard(args, cfg, summary: dict, action: str, confirm) -> int | None:
|
||||
|
||||
|
||||
def _cmd_enter(args, cfg, rpc, confirm) -> int:
|
||||
# REQ-032 AC-7 (CHG-20260610-06, issue #23): qty is required — reject before
|
||||
# RPC so a missing --qty fails immediately instead of admitting a zero-qty order
|
||||
# that the broker rejects silently after the fact.
|
||||
if not args.qty:
|
||||
_fail("--qty is required for enter (no quantity → broker would reject)", args.json)
|
||||
return 1
|
||||
# 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)
|
||||
@@ -241,7 +247,7 @@ def _cmd_enter(args, cfg, rpc, confirm) -> int:
|
||||
args.json)
|
||||
return 1
|
||||
summary = {"portfolio_id": args.portfolio_id, "qty": args.qty,
|
||||
"allocated_capital": args.capital, "entry_price": args.entry_price,
|
||||
"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:
|
||||
@@ -249,7 +255,7 @@ def _cmd_enter(args, cfg, rpc, confirm) -> int:
|
||||
payload = {
|
||||
"intent_type": "buy", "intent_id": str(uuid.uuid4()),
|
||||
"portfolio_id": args.portfolio_id,
|
||||
"qty": args.qty, "allocated_capital": args.capital,
|
||||
"qty": args.qty,
|
||||
"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,
|
||||
@@ -278,6 +284,38 @@ def _cmd_exit(args, cfg, rpc, confirm) -> int:
|
||||
return _report_intent(resp, args.json)
|
||||
|
||||
|
||||
def _cmd_cancel(args, cfg, rpc, confirm) -> int:
|
||||
"""dcy-022 / REQ-071: cancel a pending order (operator backstop)."""
|
||||
summary = {"slot_id": args.slot_id}
|
||||
short = _intent_guard(args, cfg, summary, "cancel(pending)", confirm)
|
||||
if short is not None:
|
||||
return short
|
||||
payload = {
|
||||
"action": "cancel_pending",
|
||||
"slot_id": args.slot_id,
|
||||
"actor": "operator",
|
||||
}
|
||||
resp = rpc(
|
||||
topology.control_queue(_resolve_instance(args)),
|
||||
payload,
|
||||
correlation_id=str(uuid.uuid4()),
|
||||
timeout=args.timeout,
|
||||
)
|
||||
if args.json:
|
||||
_emit(resp, as_json=True)
|
||||
return 0 if resp.get("status") == "ok" else 1
|
||||
status = resp.get("status", "unknown")
|
||||
reason = resp.get("reason", "")
|
||||
if status == "ok":
|
||||
print(f"cancel sent: slot_id={resp.get('slot_id')} — execution loop will confirm on next poll")
|
||||
return 0
|
||||
if status == "ignored":
|
||||
print(f"ignored: {reason} — slot is not in a pending state")
|
||||
return 0
|
||||
_fail(f"cancel failed: {reason or status}", False)
|
||||
return 1
|
||||
|
||||
|
||||
def _report_intent(resp: dict, as_json: bool) -> int:
|
||||
if as_json:
|
||||
_emit(resp, as_json=True)
|
||||
@@ -342,7 +380,6 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
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)
|
||||
@@ -362,6 +399,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
_add_intent_guards(p_exit)
|
||||
_add_common(p_exit)
|
||||
|
||||
p_cancel = sub.add_parser(
|
||||
"cancel", help="Cancel a pending order — operator backstop (control RPC)"
|
||||
)
|
||||
p_cancel.add_argument("slot_id", help="slot_id of the pending_buy or pending_sell slot")
|
||||
_add_intent_guards(p_cancel)
|
||||
_add_common(p_cancel)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@@ -408,6 +452,8 @@ def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int:
|
||||
return _cmd_enter(args, cfg, rpc, confirm)
|
||||
if args.cmd == "exit":
|
||||
return _cmd_exit(args, cfg, rpc, confirm)
|
||||
if args.cmd == "cancel":
|
||||
return _cmd_cancel(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
|
||||
|
||||
@@ -145,6 +145,9 @@ class DaemonInstanceConfig:
|
||||
# admission capacity cap. BAT-030: asset-centric is the only model; absent
|
||||
# means no cap (operators are expected to set it in production).
|
||||
max_active_slots: Optional[int] = None
|
||||
# dcy-022 / REQ-068: default order timeout in seconds (opt-in per instance).
|
||||
# None = OFF = indefinite wait (legacy behavior). Per-intent timeout_sec overrides.
|
||||
default_order_timeout_sec: Optional[int] = None
|
||||
|
||||
@staticmethod
|
||||
def from_toml(path: str) -> "DaemonInstanceConfig":
|
||||
@@ -163,11 +166,18 @@ class DaemonInstanceConfig:
|
||||
raise RuntimeError(
|
||||
f"instance TOML 'max_active_slots' must be > 0, got {raw_max}: {path}"
|
||||
)
|
||||
raw_timeout = data.get("default_order_timeout_sec")
|
||||
default_timeout = int(raw_timeout) if raw_timeout is not None else None
|
||||
if default_timeout is not None and default_timeout <= 0:
|
||||
raise RuntimeError(
|
||||
f"instance TOML 'default_order_timeout_sec' must be > 0, got {raw_timeout}: {path}"
|
||||
)
|
||||
return DaemonInstanceConfig(
|
||||
instance_key=str(instance_key),
|
||||
portfolio_hub_token=str(data.get("portfolio_hub_token") or ""),
|
||||
trading_timezone=str(data.get("trading_timezone") or "Asia/Seoul"),
|
||||
max_active_slots=max_active,
|
||||
default_order_timeout_sec=default_timeout,
|
||||
)
|
||||
|
||||
|
||||
@@ -200,6 +210,9 @@ class DaemonConfig:
|
||||
# threshold in seconds. 0 disables (default). When >0, dispatched-pending
|
||||
# slots older than this are surfaced (WARN + counter) without state change.
|
||||
pending_stale_threshold_sec: int = 0
|
||||
# dcy-022 / REQ-068: default order timeout (seconds). None = OFF = indefinite.
|
||||
# Per-intent timeout_sec overrides. Passed to IntentService at wiring time.
|
||||
default_order_timeout_sec: Optional[int] = None
|
||||
# dcy-014 / REQ-048: operator-local timezone (CLI date display etc.). The
|
||||
# daily preallocation trigger was removed with the pool model (BAT-030).
|
||||
trading_timezone: str = "Asia/Seoul"
|
||||
|
||||
@@ -144,6 +144,7 @@ class Daemon:
|
||||
resolve_symbol=self._resolve_symbol,
|
||||
instance_key=config.instance_key,
|
||||
max_active_slots=config.max_active_slots, # dcy-020: asset-centric gate
|
||||
default_order_timeout_sec=config.default_order_timeout_sec, # dcy-022
|
||||
)
|
||||
self.broker = _build_broker(config)
|
||||
self.outbox = PostgreSQLLedgerOutbox(config.dsn)
|
||||
@@ -288,7 +289,9 @@ class Daemon:
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
handler = make_control_rpc_handler(
|
||||
self.pause_store, kill_switch_service=self.kill_switch,
|
||||
self.pause_store,
|
||||
kill_switch_service=self.kill_switch,
|
||||
cancel_pending_fn=self.execution.cancel_pending_slot, # dcy-022/REQ-071
|
||||
)
|
||||
transport.consume_rpc_forever(
|
||||
topology.control_queue(self.config.instance_key), handler, self.stop)
|
||||
|
||||
Reference in New Issue
Block a user