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:
@@ -23,6 +23,7 @@ single producer for ledger work items (NFR-3, single record subject).
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
@@ -52,6 +53,7 @@ class ExecutionResult:
|
||||
ledger_skipped: int = 0 # REQ-018 AC-4: fills without portfolio_id
|
||||
stale_pending: int = 0 # dcy-013 / REQ-014 FR-3: pending orders over threshold
|
||||
sell_skipped_no_balance: int = 0 # dcy-020 / REQ-062: broker flat -> sell skipped
|
||||
timeout_cancelled: int = 0 # dcy-022 / REQ-069: cancel_order sent on timeout
|
||||
|
||||
|
||||
class ExecutionService:
|
||||
@@ -134,7 +136,25 @@ class ExecutionService:
|
||||
# limit_price only. A sell never reuses a buy limit (issue #10):
|
||||
# limit_price is cleared on entering HOLDING, so a market sell
|
||||
# carries None -> order_type "01".
|
||||
# dcy-022 / REQ-070: slippage_pct adjusts the effective limit price
|
||||
# when entry_price is set. buy: ceil(limit*(1+pct/100)); sell: floor.
|
||||
# No slippage when limit is None (market order — no reference price).
|
||||
limit = int(slot.limit_price) if slot.limit_price else None
|
||||
if limit is not None and slot.slippage_pct:
|
||||
factor = (
|
||||
1.0 + slot.slippage_pct / 100.0
|
||||
if side == "buy"
|
||||
else 1.0 - slot.slippage_pct / 100.0
|
||||
)
|
||||
limit = (
|
||||
int(math.ceil(limit * factor))
|
||||
if side == "buy"
|
||||
else int(math.floor(limit * factor))
|
||||
)
|
||||
_log.debug(
|
||||
"slippage applied: slot=%s side=%s base_limit=%s pct=%.4f effective=%s",
|
||||
slot.slot_id, side, slot.limit_price, slot.slippage_pct, limit,
|
||||
)
|
||||
ack = self._broker.submit_order(
|
||||
OrderRequest(
|
||||
slot_id=str(slot.slot_id),
|
||||
@@ -170,8 +190,10 @@ class ExecutionService:
|
||||
r.rolled_back += 1
|
||||
else:
|
||||
# accepted / partially_filled (not cancelled) -> leave for next
|
||||
# tick, but surface if it has been stuck too long (FR-3).
|
||||
# tick, but surface if it has been stuck too long (FR-3), and
|
||||
# send a cancel if the slot-level timeout is exceeded (REQ-069).
|
||||
self._check_stale_pending(slot, now, r)
|
||||
self._try_timeout_cancel(slot, now, side, r)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
def _balance_map(self) -> dict:
|
||||
@@ -294,6 +316,44 @@ class ExecutionService:
|
||||
age, self._stale_threshold_sec,
|
||||
)
|
||||
|
||||
def _try_timeout_cancel(
|
||||
self, slot, now: datetime, side: str, r: ExecutionResult
|
||||
) -> None:
|
||||
"""Send cancel_order when the slot-level timeout is exceeded (dcy-022/REQ-069).
|
||||
|
||||
Best-effort: a failure only logs a warning — the slot is NOT changed here.
|
||||
Actual rollback happens on the next poll tick when the broker confirms the
|
||||
cancel (is_terminal_reject) or partial fill then cancel (is_partial_cancel).
|
||||
This design is safe across the cancel-fill race: if the cancel races with a
|
||||
fill, the next poll sees is_filled and follows the normal fill path (FR-4).
|
||||
"""
|
||||
if slot.timeout_sec is None or slot.updated_at is None:
|
||||
return
|
||||
age = (now - slot.updated_at).total_seconds()
|
||||
if age < slot.timeout_sec:
|
||||
return
|
||||
qty = int(slot.qty or 0)
|
||||
try:
|
||||
accepted = self._broker.cancel_order(
|
||||
slot.broker_order_id,
|
||||
side=side,
|
||||
symbol=slot.symbol or "",
|
||||
qty=qty,
|
||||
)
|
||||
r.timeout_cancelled += 1
|
||||
_log.warning(
|
||||
"timeout cancel sent: slot=%s status=%s broker_order_id=%s "
|
||||
"age_sec=%.0f timeout_sec=%d broker_accepted=%s",
|
||||
slot.slot_id, slot.status.value, slot.broker_order_id,
|
||||
age, slot.timeout_sec, accepted,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - best-effort; do not block the loop
|
||||
_log.warning(
|
||||
"timeout cancel failed (exception): slot=%s broker_order_id=%s "
|
||||
"(will retry next tick if still pending)",
|
||||
slot.slot_id, slot.broker_order_id,
|
||||
)
|
||||
|
||||
def _emit_event(self, event_type: str, slot) -> None:
|
||||
if self._event_emitter is None:
|
||||
return
|
||||
@@ -340,6 +400,44 @@ class ExecutionService:
|
||||
self._ledger_outbox.enqueue(trade)
|
||||
r.ledger_enqueued += 1
|
||||
|
||||
def cancel_pending_slot(self, slot_id) -> str:
|
||||
"""Operator backstop: send cancel_order for a pending slot (dcy-022/REQ-071).
|
||||
|
||||
Returns "ok" (cancel sent), "not_pending" (slot is HOLDING/INACTIVE),
|
||||
or "slot_not_found". Does NOT transition the slot — the execution loop's
|
||||
next poll handles the rollback via the normal is_terminal_reject path.
|
||||
"""
|
||||
from uuid import UUID
|
||||
sid = slot_id if isinstance(slot_id, UUID) else UUID(str(slot_id))
|
||||
slot = self._repo.fetch_slot(sid)
|
||||
if slot is None:
|
||||
return "slot_not_found"
|
||||
if slot.status not in (SlotState.PENDING_BUY, SlotState.PENDING_SELL):
|
||||
return "not_pending"
|
||||
if not slot.broker_order_id:
|
||||
# Claimed but not yet dispatched — execution loop will pick up or rollback.
|
||||
return "not_dispatched"
|
||||
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
|
||||
qty = int(slot.qty or 0)
|
||||
try:
|
||||
self._broker.cancel_order(
|
||||
slot.broker_order_id,
|
||||
side=side,
|
||||
symbol=slot.symbol or "",
|
||||
qty=qty,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - best-effort
|
||||
_log.warning(
|
||||
"operator cancel failed: slot=%s broker_order_id=%s",
|
||||
slot.slot_id, slot.broker_order_id,
|
||||
)
|
||||
return "cancel_failed"
|
||||
_log.warning(
|
||||
"operator cancel sent: slot=%s status=%s broker_order_id=%s",
|
||||
slot.slot_id, slot.status.value, slot.broker_order_id,
|
||||
)
|
||||
return "ok"
|
||||
|
||||
def _rollback(self, slot, *, reason: str) -> None:
|
||||
total = self._balance_map().get(slot.symbol or "", 0)
|
||||
try:
|
||||
|
||||
@@ -59,13 +59,17 @@ class BuyIntent:
|
||||
slot_id: Optional[UUID] = None
|
||||
symbol: Optional[str] = None
|
||||
qty: Optional[float] = None
|
||||
allocated_capital: Optional[float] = None
|
||||
entry_price: Optional[float] = None
|
||||
strategy_id: Optional[str] = None
|
||||
sell_strategy_id: Optional[str] = None
|
||||
forced_close_at: Optional[datetime] = None
|
||||
stop_loss_price: Optional[Decimal] = None # REQ-035
|
||||
take_profit_price: Optional[Decimal] = None # REQ-035
|
||||
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
|
||||
# timeout_sec: cancel_order after N seconds without fill (None = config default or OFF).
|
||||
# slippage_pct: effective limit = entry_price*(1+pct/100) buy / *(1-pct/100) sell.
|
||||
timeout_sec: Optional[int] = None
|
||||
slippage_pct: Optional[float] = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -81,6 +85,9 @@ class SellIntent:
|
||||
# dcy-021 / REQ-062: optional partial-sell quantity. None => sell the whole
|
||||
# position (broker balance); set => partial sell of min(qty, broker balance).
|
||||
qty: Optional[float] = None
|
||||
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
|
||||
timeout_sec: Optional[int] = None
|
||||
slippage_pct: Optional[float] = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -96,6 +103,7 @@ class IntentService:
|
||||
*,
|
||||
instance_key: str = "default",
|
||||
max_active_slots: Optional[int] = None,
|
||||
default_order_timeout_sec: Optional[int] = None,
|
||||
) -> None:
|
||||
self._svc = slot_service
|
||||
self._repo = slot_repo
|
||||
@@ -108,6 +116,9 @@ class IntentService:
|
||||
# expected to set max_active_slots in the instance TOML — design §5).
|
||||
self._instance_key = instance_key
|
||||
self._max_active_slots = max_active_slots
|
||||
# dcy-022 / REQ-068: default timeout when intent omits timeout_sec.
|
||||
# None = OFF = indefinite wait (legacy behavior). Per-intent value overrides.
|
||||
self._default_order_timeout_sec = default_order_timeout_sec
|
||||
|
||||
# ------------------------------------------------------------
|
||||
def lookup(self, intent_id: str) -> Optional[IntentDecision]:
|
||||
@@ -123,7 +134,6 @@ class IntentService:
|
||||
"portfolio_id": intent.portfolio_id,
|
||||
"symbol": intent.symbol,
|
||||
"qty": intent.qty,
|
||||
"allocated_capital": intent.allocated_capital,
|
||||
"entry_price": intent.entry_price,
|
||||
"strategy_id": intent.strategy_id,
|
||||
"sell_strategy_id": intent.sell_strategy_id,
|
||||
@@ -213,6 +223,12 @@ class IntentService:
|
||||
if (not is_increase and self._max_active_slots is not None
|
||||
and self._repo.count_active(self._instance_key) >= self._max_active_slots):
|
||||
return IntentDecision(intent.intent_id, "rejected", None, "capacity_full")
|
||||
# dcy-022 / REQ-068: resolve effective timeout (per-intent > config default).
|
||||
effective_timeout = (
|
||||
intent.timeout_sec
|
||||
if intent.timeout_sec is not None
|
||||
else self._default_order_timeout_sec
|
||||
)
|
||||
try:
|
||||
updated = self._svc.transit_slot(
|
||||
slot.slot_id,
|
||||
@@ -222,7 +238,6 @@ class IntentService:
|
||||
actor="intent_api",
|
||||
symbol=symbol,
|
||||
qty=intent.qty,
|
||||
allocated_capital=intent.allocated_capital,
|
||||
# dcy-012 / REQ-007: buy entry price maps to the slot's
|
||||
# side-agnostic pending limit_price (cleared on fill→HOLDING).
|
||||
limit_price=intent.entry_price,
|
||||
@@ -232,6 +247,8 @@ class IntentService:
|
||||
forced_close_at=intent.forced_close_at,
|
||||
stop_loss_price=intent.stop_loss_price,
|
||||
take_profit_price=intent.take_profit_price,
|
||||
timeout_sec=effective_timeout,
|
||||
slippage_pct=intent.slippage_pct,
|
||||
extra={"intent_id": intent.intent_id, "symbol": symbol},
|
||||
)
|
||||
except SlotStateMismatchError:
|
||||
@@ -247,6 +264,12 @@ class IntentService:
|
||||
return IntentDecision(
|
||||
intent.intent_id, "rejected", None, f"slot_not_holding:{slot.status.value}"
|
||||
)
|
||||
# dcy-022 / REQ-068: resolve effective timeout.
|
||||
effective_timeout = (
|
||||
intent.timeout_sec
|
||||
if intent.timeout_sec is not None
|
||||
else self._default_order_timeout_sec
|
||||
)
|
||||
try:
|
||||
updated = self._svc.transit_slot(
|
||||
slot.slot_id,
|
||||
@@ -261,6 +284,8 @@ class IntentService:
|
||||
# dispatch caps at the broker balance. Entering PENDING_SELL takes
|
||||
# this qty as-is (None = "sell all" sentinel).
|
||||
qty=intent.qty,
|
||||
timeout_sec=effective_timeout,
|
||||
slippage_pct=intent.slippage_pct,
|
||||
extra={"intent_id": intent.intent_id},
|
||||
)
|
||||
except SlotStateMismatchError:
|
||||
|
||||
@@ -80,13 +80,17 @@ def make_intent_rpc_handler(
|
||||
portfolio_id=_opt_str(body.get("portfolio_id")) or None,
|
||||
slot_id=(UUID(str(body["slot_id"])) if body.get("slot_id") else None),
|
||||
qty=_opt_float(body.get("qty")),
|
||||
allocated_capital=_opt_float(body.get("allocated_capital")),
|
||||
# dcy-023 / REQ-007 AC-6: allocated_capital is no longer a
|
||||
# contract field — senders may still include it; it is ignored.
|
||||
entry_price=_opt_float(body.get("entry_price")),
|
||||
strategy_id=_opt_str(body.get("strategy_id")),
|
||||
sell_strategy_id=_opt_str(body.get("sell_strategy_id")),
|
||||
forced_close_at=_opt_dt(body.get("forced_close_at")),
|
||||
stop_loss_price=_opt_decimal(body.get("stop_loss_price")),
|
||||
take_profit_price=_opt_decimal(body.get("take_profit_price")),
|
||||
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
|
||||
timeout_sec=_opt_int(body.get("timeout_sec")),
|
||||
slippage_pct=_opt_float(body.get("slippage_pct")),
|
||||
)
|
||||
d = intent_service.submit_buy(buy)
|
||||
elif intent_type == "sell":
|
||||
@@ -99,6 +103,9 @@ def make_intent_rpc_handler(
|
||||
limit_price=_opt_float(body.get("limit_price")),
|
||||
# dcy-021 / REQ-062: optional partial-sell qty; absent => sell all.
|
||||
qty=_opt_float(body.get("qty")),
|
||||
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
|
||||
timeout_sec=_opt_int(body.get("timeout_sec")),
|
||||
slippage_pct=_opt_float(body.get("slippage_pct")),
|
||||
)
|
||||
d = intent_service.submit_sell(sell)
|
||||
else:
|
||||
@@ -357,11 +364,19 @@ def _err(reason: str) -> RpcResponse:
|
||||
|
||||
# ---- control RPC handler (REQ-043, BAT-012) ---------------------------------
|
||||
|
||||
def make_control_rpc_handler(pause_store: PauseStore, *, kill_switch_service=None):
|
||||
def make_control_rpc_handler(
|
||||
pause_store: PauseStore,
|
||||
*,
|
||||
kill_switch_service=None,
|
||||
cancel_pending_fn=None,
|
||||
):
|
||||
"""RPC handler for `trex.control.req` — action discriminator dispatch (REQ-043).
|
||||
|
||||
Actions: `pause`/`resume`/`list` (REQ-032, delegated), `kill_switch` (REQ-045),
|
||||
`resume_kill_switch` (REQ-046). Unknown actions return an error response.
|
||||
`resume_kill_switch` (REQ-046), `cancel_pending` (dcy-022/REQ-071).
|
||||
Unknown actions return an error response.
|
||||
|
||||
cancel_pending_fn: callable(slot_id: str) -> str — injected from ExecutionService.
|
||||
"""
|
||||
pause_handler = make_pause_rpc_handler(pause_store)
|
||||
|
||||
@@ -374,6 +389,8 @@ def make_control_rpc_handler(pause_store: PauseStore, *, kill_switch_service=Non
|
||||
return _handle_resume_kill_switch(body, kill_switch_service)
|
||||
if action in ("pause", "resume", "list"):
|
||||
return pause_handler(req)
|
||||
if action == "cancel_pending":
|
||||
return _handle_cancel_pending(body, cancel_pending_fn)
|
||||
return _err(f"unknown_action:{action}")
|
||||
|
||||
return handler
|
||||
@@ -409,6 +426,24 @@ def _handle_kill_switch(body: dict, svc) -> RpcResponse:
|
||||
return RpcResponse(payload=payload)
|
||||
|
||||
|
||||
def _handle_cancel_pending(body: dict, cancel_fn) -> RpcResponse:
|
||||
"""dcy-022 / REQ-071: operator backstop cancel for a pending slot."""
|
||||
if cancel_fn is None:
|
||||
return _err("cancel_pending_unsupported")
|
||||
try:
|
||||
slot_id = str(body["slot_id"])
|
||||
except KeyError:
|
||||
return _err("slot_id_required")
|
||||
result = cancel_fn(slot_id)
|
||||
if result == "ok":
|
||||
return RpcResponse(payload={"status": "ok", "slot_id": slot_id})
|
||||
if result == "not_pending":
|
||||
return RpcResponse(payload={"status": "ignored", "slot_id": slot_id, "reason": "not_pending"})
|
||||
if result == "slot_not_found":
|
||||
return RpcResponse(payload={"status": "error", "slot_id": slot_id, "reason": "slot_not_found"})
|
||||
return RpcResponse(payload={"status": "error", "slot_id": slot_id, "reason": result})
|
||||
|
||||
|
||||
def _handle_resume_kill_switch(body: dict, svc) -> RpcResponse:
|
||||
"""REQ-046: clear the day-scoped exclusion (sold slots stay sold)."""
|
||||
if svc is None:
|
||||
@@ -469,6 +504,12 @@ def _opt_float(v) -> Optional[float]:
|
||||
return float(v)
|
||||
|
||||
|
||||
def _opt_int(v) -> Optional[int]:
|
||||
if v is None:
|
||||
return None
|
||||
return int(v)
|
||||
|
||||
|
||||
def _opt_dt(v) -> Optional[datetime]:
|
||||
if v is None:
|
||||
return None
|
||||
|
||||
@@ -313,7 +313,6 @@ def slot_to_dict(s) -> dict:
|
||||
"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,
|
||||
|
||||
Reference in New Issue
Block a user