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:
2026-06-11 07:42:03 +09:00
co-authored by Claude Sonnet 4.6
parent 5ee5f7f4e2
commit fb05a73130
56 changed files with 3151 additions and 103 deletions
+99 -1
View File
@@ -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: