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
+26 -22
View File
@@ -28,9 +28,10 @@ from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransi
_SLOT_COLUMNS = (
"slot_id, status, symbol, qty, avg_price, limit_price, "
"allocated_capital, order_id, strategy_id, broker_order_id, portfolio_id, "
"order_id, strategy_id, broker_order_id, portfolio_id, "
"sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, "
"created_at, updated_at, instance_key, retired_at"
"created_at, updated_at, instance_key, retired_at, "
"timeout_sec, slippage_pct"
)
@@ -166,18 +167,19 @@ class PostgreSQLSlotRepository:
with conn.cursor() as cur:
cur.execute(
"UPDATE daily_slot SET status=%s, symbol=%s, qty=%s, avg_price=%s, "
"limit_price=%s, allocated_capital=%s, order_id=%s, strategy_id=%s, "
"limit_price=%s, order_id=%s, strategy_id=%s, "
"broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, "
"forced_close_at=%s, stop_loss_price=%s, take_profit_price=%s, "
"instance_key=%s, retired_at=%s, "
"instance_key=%s, retired_at=%s, timeout_sec=%s, slippage_pct=%s, "
"updated_at=%s WHERE slot_id=%s",
(
slot.status.value, slot.symbol, slot.qty, slot.avg_price,
slot.limit_price, slot.allocated_capital, slot.order_id,
slot.limit_price, slot.order_id,
slot.strategy_id, slot.broker_order_id, slot.portfolio_id,
slot.sell_strategy_id, slot.forced_close_at,
slot.stop_loss_price, slot.take_profit_price,
slot.instance_key, slot.retired_at,
slot.timeout_sec, slot.slippage_pct,
slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id,
),
)
@@ -266,10 +268,11 @@ class PostgreSQLSlotRepository:
f = lambda v: float(v) if v is not None else None # noqa: E731
d = lambda v: Decimal(str(v)) if v is not None else None # noqa: E731
# Column order: slot_id(0) status(1) symbol(2) qty(3) avg_price(4)
# limit_price(5) allocated_capital(6) order_id(7) strategy_id(8)
# broker_order_id(9) portfolio_id(10) sell_strategy_id(11)
# forced_close_at(12) stop_loss_price(13) take_profit_price(14)
# created_at(15) updated_at(16) instance_key(17) retired_at(18)
# limit_price(5) order_id(6) strategy_id(7)
# broker_order_id(8) portfolio_id(9) sell_strategy_id(10)
# forced_close_at(11) stop_loss_price(12) take_profit_price(13)
# created_at(14) updated_at(15) instance_key(16) retired_at(17)
# timeout_sec(18) slippage_pct(19) [dcy-022/REQ-068]
return Slot(
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
status=SlotState(row[1]),
@@ -277,19 +280,20 @@ class PostgreSQLSlotRepository:
qty=f(row[3]),
avg_price=f(row[4]),
limit_price=f(row[5]),
allocated_capital=f(row[6]),
order_id=row[7],
strategy_id=row[8],
broker_order_id=row[9],
portfolio_id=row[10],
sell_strategy_id=row[11],
forced_close_at=row[12],
stop_loss_price=d(row[13]),
take_profit_price=d(row[14]),
created_at=row[15],
updated_at=row[16],
instance_key=row[17] if row[17] is not None else "default",
retired_at=row[18],
order_id=row[6],
strategy_id=row[7],
broker_order_id=row[8],
portfolio_id=row[9],
sell_strategy_id=row[10],
forced_close_at=row[11],
stop_loss_price=d(row[12]),
take_profit_price=d(row[13]),
created_at=row[14],
updated_at=row[15],
instance_key=row[16] if row[16] is not None else "default",
retired_at=row[17],
timeout_sec=int(row[18]) if row[18] is not None else None,
slippage_pct=f(row[19]),
)
@@ -0,0 +1,6 @@
-- dcy-022 / REQ-068 (BAT-032): execution policy fields.
-- timeout_sec: order TTL in seconds (NULL = no timeout, infinite).
-- slippage_pct: slippage protection band as percentage (NULL = no band).
-- Both are set on PENDING entry and cleared on HOLDING/INACTIVE exit.
ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS timeout_sec INTEGER;
ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS slippage_pct FLOAT;
@@ -0,0 +1,5 @@
-- BAT-034 / dcy-023 / REQ-006 (CHG-20260610-02): drop dead column (비가역).
-- allocated_capital 은 사이징·회계·전략 결정 어디에도 소비되지 않는 죽은 메타데이터
-- (dcy-023 §3 전수 추적). 실제 주문 수량은 slot.qty 가 단독 결정(dcy-021 계약).
-- Idempotent via IF EXISTS. Rollback = 사전 백업 복원 (BAT-030/011 선례 준용).
ALTER TABLE daily_slot DROP COLUMN IF EXISTS allocated_capital;
+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:
+28 -3
View File
@@ -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:
+44 -3
View File
@@ -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
-1
View File
@@ -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,
+7 -2
View File
@@ -31,7 +31,6 @@ class Slot:
# on pending exit (→HOLDING, →INACTIVE) so a buy limit never leaks into a
# sell order (issue #10). None => market order at dispatch.
limit_price: Optional[float] = None
allocated_capital: Optional[float] = None
order_id: Optional[str] = None
strategy_id: Optional[str] = None
broker_order_id: Optional[str] = None # BAT-003 / REQ-011 (trex-orbit lineage)
@@ -47,6 +46,11 @@ class Slot:
# instance-scoped. retired_at marks a soft-retired / universe-detached slot.
instance_key: str = "default"
retired_at: Optional[datetime] = None
# dcy-022 / REQ-068: execution policy — set on PENDING entry, cleared on exit.
# timeout_sec: cancel_order is sent after this many seconds of no fill.
# slippage_pct: effective limit = entry_price * (1 ± pct/100); None = no band.
timeout_sec: Optional[int] = None
slippage_pct: Optional[float] = None
# Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5).
@@ -60,7 +64,6 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"qty",
"avg_price",
"limit_price",
"allocated_capital",
"order_id",
"strategy_id",
"broker_order_id",
@@ -73,6 +76,8 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"updated_at",
"instance_key", # dcy-020 / REQ-056 (BAT-027)
"retired_at", # dcy-020 / REQ-056 (BAT-027)
"timeout_sec", # dcy-022 / REQ-068 (BAT-032)
"slippage_pct", # dcy-022 / REQ-068 (BAT-032)
})
+17 -8
View File
@@ -65,7 +65,6 @@ class SlotService:
qty: Optional[float] = None,
avg_price: Optional[float] = None,
limit_price: Optional[float] = None,
allocated_capital: Optional[float] = None,
order_id: Optional[str] = None,
strategy_id: Optional[str] = None,
broker_order_id: Optional[str] = None,
@@ -74,6 +73,9 @@ class SlotService:
forced_close_at: Optional[datetime] = None,
stop_loss_price: Optional[Decimal] = None,
take_profit_price: Optional[Decimal] = None,
# dcy-022 / REQ-068: execution policy (set on PENDING entry, cleared on exit).
timeout_sec: Optional[int] = None,
slippage_pct: Optional[float] = None,
extra: Optional[dict[str, Any]] = None,
) -> Slot:
"""Transition slot from one state to another atomically.
@@ -112,7 +114,6 @@ class SlotService:
qty=qty,
avg_price=avg_price,
limit_price=limit_price,
allocated_capital=allocated_capital,
order_id=order_id,
strategy_id=strategy_id,
broker_order_id=broker_order_id,
@@ -121,6 +122,8 @@ class SlotService:
forced_close_at=forced_close_at,
stop_loss_price=stop_loss_price,
take_profit_price=take_profit_price,
timeout_sec=timeout_sec,
slippage_pct=slippage_pct,
)
self._repo.update_slot(updated)
@@ -146,7 +149,6 @@ class SlotService:
qty: Optional[float],
avg_price: Optional[float],
limit_price: Optional[float],
allocated_capital: Optional[float],
order_id: Optional[str],
strategy_id: Optional[str],
broker_order_id: Optional[str] = None,
@@ -155,6 +157,8 @@ class SlotService:
forced_close_at: Optional[datetime] = None,
stop_loss_price: Optional[Decimal] = None,
take_profit_price: Optional[Decimal] = None,
timeout_sec: Optional[int] = None,
slippage_pct: Optional[float] = None,
) -> Slot:
"""Compute the post-transition Slot row.
@@ -174,7 +178,6 @@ class SlotService:
qty=None,
avg_price=None,
limit_price=None,
allocated_capital=None,
order_id=None,
strategy_id=None,
broker_order_id=None,
@@ -182,15 +185,22 @@ class SlotService:
forced_close_at=None,
stop_loss_price=None,
take_profit_price=None,
timeout_sec=None,
slippage_pct=None,
updated_at=datetime.now(tz=timezone.utc),
)
# limit_price lifecycle (dcy-012 FR-1): set only on entering a pending
# state (the order's limit); cleared on entering HOLDING/ERROR.
# execution policy (dcy-022/REQ-068): same lifecycle as limit_price.
if to_state in {SlotState.PENDING_BUY, SlotState.PENDING_SELL}:
new_limit_price = limit_price if limit_price is not None else slot.limit_price
else: # HOLDING / ERROR — pending order limit no longer applies.
new_timeout_sec = timeout_sec if timeout_sec is not None else slot.timeout_sec
new_slippage_pct = slippage_pct if slippage_pct is not None else slot.slippage_pct
else: # HOLDING / ERROR — pending order policy no longer applies.
new_limit_price = None
new_timeout_sec = None
new_slippage_pct = None
# qty semantics during a pending state = the ORDER quantity (dcy-021 D1/D4).
# Entering PENDING_SELL takes the caller qty AS-IS so a full sell
@@ -210,9 +220,6 @@ class SlotService:
qty=new_qty,
avg_price=avg_price if avg_price is not None else slot.avg_price,
limit_price=new_limit_price,
allocated_capital=(
allocated_capital if allocated_capital is not None else slot.allocated_capital
),
order_id=order_id if order_id is not None else slot.order_id,
strategy_id=strategy_id if strategy_id is not None else slot.strategy_id,
broker_order_id=(
@@ -235,6 +242,8 @@ class SlotService:
take_profit_price=(
take_profit_price if take_profit_price is not None else slot.take_profit_price
),
timeout_sec=new_timeout_sec,
slippage_pct=new_slippage_pct,
updated_at=datetime.now(tz=timezone.utc),
)
+50 -4
View File
@@ -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
+13
View File
@@ -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"
+4 -1
View File
@@ -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)