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
@@ -1,6 +1,6 @@
# BAT-024: 운영자 `enter` 시간 기반 자동청산 옵션 — `--forced-close-at` (절대시간) 노출 (issue #17)
- Status: release-candidate
- Status: released
- Profile: batch-lite
- Source Discovery: dcy-016 (issue #17 change-req)
- Included REQ: REQ-032 (CHG-20260609-01)
@@ -1,6 +1,6 @@
# BAT-025: slot-scoped entry_count + closed 슬롯 직접 재할당 (dcy-019)
- Status: release-candidate
- Status: released
- Profile: standard
- Source Discovery: dcy-019
- Included REQ: REQ-002 (CHG-20260609-01), REQ-003 (CHG-20260609-01), REQ-006 (CHG-20260609-01), REQ-007 (CHG-20260609-01), REQ-009 (CHG-20260609-01)
@@ -0,0 +1,50 @@
# BAT-032 Design
- Status: approved
- Batch: BAT-032
## 설계 결정
### D1: 실행 정책 필드를 슬롯 컬럼으로 저장 (timeout_sec, slippage_pct)
- 결정: `Slot` 모델에 `timeout_sec: Optional[int]`, `slippage_pct: Optional[float]` 컬럼 추가. PENDING_* 진입 시 set, 탈출(→HOLDING/→INACTIVE) 시 clear.
- 근거: 집행 루프가 dispatch/poll 시 슬롯만 읽으면 되어 별도 캐시/in-memory 구조 불필요. limit_price 와 동일한 패턴(set on pending entry, cleared on exit).
- 대안: in-memory dict 에 별도 저장 → 재시작 시 유실, 재기동 reconciliation 에서 timeout 추적 불가. 기각.
### D2: updated_at 을 dispatch 시각 proxy 로 사용 (기존 stale watchdog 방법 계승)
- 결정: timeout 경과 시간 = `(now - slot.updated_at).total_seconds()`. mark_dispatched 가 updated_at 을 갱신하므로 dispatch 시각과 일치.
- 근거: `_check_stale_pending`(REQ-014) 이 이미 동일 방법 사용 → 일관성, 추가 필드 불필요.
- 한계: PENDING_* 상태에서 `update_thresholds` 가 호출되면 updated_at 이 리셋될 수 있으나, `update_thresholds`는 HOLDING 슬롯에만 작동(REQ-040) → 실질적 충돌 없음.
### D3: cancel_order best-effort, 즉시 롤백 없음 (race 안전)
- 결정: `_try_timeout_cancel` 에서 cancel_order 송신만 하고 슬롯 상태를 변경하지 않는다. 다음 poll 틱에서 is_terminal_reject/is_partial_cancel 로 수렴.
- 근거: 취소-체결 race(cancel 수락 직전 체결) 시 다음 poll 이 is_filled 경로로 정상 수렴 → 임의 롤백 없음. dcy-013 부분 취소 선례 재사용.
### D4: cancel CLI → control RPC → ExecutionService.cancel_pending_slot
- 결정: CLI는 `trex.control.req` 채널로 `{"action": "cancel_pending", "slot_id": ...}` 를 전송. 제어 핸들러가 `ExecutionService.cancel_pending_slot(slot_id)` 를 호출해 cancel_order 를 송신. 슬롯 전이 없음.
- 근거: 기존 kill_switch·pause 와 동일한 경로(단일 writer 불변식 보존). execution_service 를 control 핸들러에 주입하는 방식으로 커플링 최소화.
- 대안: intent RPC 채널 재사용 → intent 계약(buy/sell)과 의미 혼용. 기각.
## 컴포넌트 다이어그램 (텍스트)
```
CLI: trex-core cancel <slot_id>
→ control RPC (trex.control.req) cancel_pending
→ make_control_rpc_handler (execution_service.cancel_pending_slot)
→ broker.cancel_order(broker_order_id)
→ [다음 tick _poll] is_terminal_reject / is_partial_cancel
→ rollback / partial-preserve (기존 경로)
```
```
Intent: {intent_type: "buy", timeout_sec: 60, slippage_pct: 0.1}
→ messaging_handlers (파싱 → BuyIntent)
→ IntentService._admit_buy (effective_timeout 해결 → transit_slot)
→ SlotService (slot.timeout_sec=60, slot.slippage_pct=0.1 set)
→ [_dispatch] slippage 실효 지정가 → OrderRequest.limit_price
→ [_poll] timeout 초과 → _try_timeout_cancel → cancel_order
→ [다음 tick] is_terminal_reject → rollback
```
@@ -0,0 +1,62 @@
# BAT-032 Implementation
- Status: done
- Batch: BAT-032
## M1 — 실행 정책 계약·파싱·슬롯 저장 (REQ-068, REQ-061 CHG-05)
**변경 파일**
| 파일 | 내용 |
|---|---|
| `trex_core/domain/slot/models.py` | `Slot` dataclass 에 `timeout_sec: Optional[int]`, `slippage_pct: Optional[float]` 추가. `EXPECTED_SLOT_FIELDS` 갱신. |
| `trex_core/adapters/persistence/sql/012_add_execution_policy_fields.sql` | `ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS timeout_sec INTEGER; … slippage_pct FLOAT;` |
| `trex_core/domain/slot/service.py` | `transit_slot`·`_apply_transition_fields` kwargs 확장. PENDING_* 진입 시 set, HOLDING/INACTIVE/ERROR 진입 시 clear (limit_price 동일 생애주기). |
| `trex_core/adapters/persistence/postgresql.py` | `_SLOT_COLUMNS`에 col 19·20 추가. `update_slot` SQL 갱신. `_row_to_slot` 인덱스 추가. |
| `trex_core/application/intents.py` | `BuyIntent`·`SellIntent` 에 `timeout_sec`·`slippage_pct` 추가. `IntentService.__init__` 에 `default_order_timeout_sec`. `_admit_buy`·`_admit_sell` 에서 effective_timeout 해석. |
| `trex_core/application/messaging_handlers.py` | buy/sell 핸들러에서 `_opt_int`·`_opt_float` 파싱 추가. |
| `trex_core/runtime/config.py` | `DaemonInstanceConfig.default_order_timeout_sec`. |
| `trex_core/runtime/daemon.py` | `IntentService` wiring 에 `default_order_timeout_sec` 전달. |
## M2 — slippage 보호 지정가 dispatch (REQ-070, REQ-013 CHG-03 partial)
**변경 파일**
| 파일 | 내용 |
|---|---|
| `trex_core/application/execution.py` | `_dispatch`: limit 계산 후 slippage 블록 추가. buy: `ceil(limit*(1+pct/100))`, sell: `floor(limit*(1-pct/100))`. `import math` 추가. |
## M3 — timeout 집행 + race 처리 (REQ-069, REQ-013 CHG-03 partial)
**변경 파일**
| 파일 | 내용 |
|---|---|
| `trex_core/application/execution.py` | `ExecutionResult.timeout_cancelled` 추가. `_poll` else branch에 `_try_timeout_cancel` 추가. `_try_timeout_cancel` 구현: `slot.updated_at` proxy로 경과 체크 → `cancel_order` best-effort → `r.timeout_cancelled += 1`. |
## M4 — 운영자 cancel 백스톱 (REQ-071)
**변경 파일**
| 파일 | 내용 |
|---|---|
| `trex_core/application/execution.py` | `cancel_pending_slot(slot_id)` 메서드: PENDING_* slot → `cancel_order` 송신 → "ok"/"not_pending"/"slot_not_found"/"not_dispatched"/"cancel_failed" 반환. 슬롯 전이 없음 (단일 writer 보존). |
| `trex_core/application/messaging_handlers.py` | `make_control_rpc_handler` 에 `cancel_pending_fn` 파라미터. `cancel_pending` 액션 라우팅. `_handle_cancel_pending` 구현. |
| `trex_core/runtime/cli.py` | `cancel <slot_id>` 서브커맨드. `_cmd_cancel`. `_add_intent_guards` (--yes/--dry-run/--live). control RPC 경유. |
| `trex_core/runtime/daemon.py` | `make_control_rpc_handler(cancel_pending_fn=self.execution.cancel_pending_slot)` 배선. |
## M5 — baseline 문서 정합 (REQ-072)
**변경 파일**
| 파일 | 내용 |
|---|---|
| `docs/interface-contract.md` | buy/sell payload에 `timeout_sec`·`slippage_pct` 필드 기술. 하위 호환 주석 추가. cancel CLI 참조. |
| `docs/runtime-flows.md` | dispatch 스텝: slippage 지정가 주석. poll 스텝: timeout 자동 취소 흐름. 실행 정책 생애주기. "운영자 cancel 백스톱" 섹션 추가. |
## 설계 결정 근거 (design.md 요약)
- **D1**: `timeout_sec`/`slippage_pct` 를 slot 컬럼으로 저장 — `limit_price` 와 동일 생애주기(PENDING 진입 시 set, 탈출 시 clear).
- **D2**: `slot.updated_at` 을 dispatch 시각 proxy로 사용 — 기존 stale watchdog(REQ-014)과 동일 방법, 별도 컬럼 불필요.
- **D3**: `cancel_order` 송신만, 즉시 롤백 없음 — cancel-fill race 안전. 다음 poll의 `is_terminal_reject`/`is_partial_cancel` 경로 재사용.
- **D4**: cancel CLI → control RPC → `ExecutionService.cancel_pending_slot` — 단일 writer 불변식(A) 보존.
@@ -0,0 +1,23 @@
# BAT-032: 주문 실행 정책 — timeout·slippage·운영자 cancel (dcy-022)
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-022
- Included REQ: REQ-068 (실행 정책 계약), REQ-069 (timeout 집행·race), REQ-070 (slippage dispatch), REQ-071 (운영자 cancel 백스톱), REQ-072 (baseline 정합), REQ-061 (CHG-05), REQ-013 (CHG-03)
## Summary
- dcy-022: dcy-021(포지션 사이징) 전제 위에 **주문 실행 정책**(timeout_sec·slippage_pct)을 intent 계약에 추가하고 집행 루프에 배선한다. 미체결 주문은 timeout 초과 시 자동 취소·롤백(고착 예방), slippage 보호 지정가로 과도한 불리 체결을 방지. 잔여 고착 백스톱으로 운영자 cancel CLI 를 추가한다.
## Scope
- In: timeout_sec/slippage_pct intent 계약·파싱·슬롯 저장, timeout 집행 (cancel_order 배선·race 처리·부분 보존), slippage 실효 지정가 dispatch, 운영자 cancel 백스톱(trex-core cancel), per-instance TOML default_order_timeout_sec, baseline 2문서.
- Out: IOC/FOK 네이티브 TIF, 자동 리밸런싱, 전략 모듈(orb-v1) 발신 계약 전환(외부 repo, 별도 합의).
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- 집행 핫패스(라이브 주문) 확장 — additive(기존 경로 보존). design.md D1~D4 확정 후 구현.
- 본 배치 완료 후 REQ-013/061/068/069/070/071/072 confirm-req-implemented.
@@ -0,0 +1,64 @@
# BAT-032 Planning
- Status: done
- Batch: BAT-032
## Milestones
| ID | 목표 | 연결 REQ |
|---|---|---|
| M1 | 실행 정책 계약·파싱·슬롯 저장 | REQ-068, REQ-061(CHG-05) |
| M2 | slippage 보호 지정가 dispatch | REQ-070, REQ-013(CHG-03 partial) |
| M3 | timeout 집행 + race 처리 | REQ-069, REQ-013(CHG-03 partial) |
| M4 | 운영자 cancel 백스톱 CLI + control RPC | REQ-071 |
| M5 | baseline 문서 정합 | REQ-072 |
## M1 상세 (실행 정책 계약·파싱·슬롯 저장)
- `Slot` 모델: `timeout_sec: Optional[int]`, `slippage_pct: Optional[float]` 필드 추가. `EXPECTED_SLOT_FIELDS` 갱신.
- Migration 012: `ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS timeout_sec INTEGER; ADD COLUMN IF NOT EXISTS slippage_pct FLOAT;`
- `BuyIntent`/`SellIntent`: `timeout_sec`/`slippage_pct` 추가.
- `IntentService`: `__init__` 에 `default_order_timeout_sec: Optional[int] = None` 추가. `_admit_buy`/`_admit_sell` 에서 effective_timeout = intent.timeout_sec ?? config_default → transit_slot 에 전달.
- `SlotService.transit_slot` kwargs: `timeout_sec`/`slippage_pct` 전달. `_apply_transition_fields` 에서 PENDING_* 진입 시 set, 탈출(→HOLDING/→INACTIVE) 시 clear.
- `messaging_handlers.py`: `timeout_sec=_opt_int(body.get("timeout_sec"))`, `slippage_pct=_opt_float(body.get("slippage_pct"))` 파싱 추가.
- `DaemonInstanceConfig`: `default_order_timeout_sec: Optional[int] = None` 추가, from_toml 파싱.
- `DaemonConfig` + daemon runtime 배선: IntentService 에 default_order_timeout_sec 전달.
## M2 상세 (slippage 보호 지정가 dispatch)
- `execution._dispatch`: limit 계산 후 slippage 적용:
```python
import math
if limit is not None and slot.slippage_pct:
factor = 1 + slot.slippage_pct/100 if side == "buy" else 1 - slot.slippage_pct/100
limit = int(math.ceil(limit * factor)) if side == "buy" else int(math.floor(limit * factor))
```
- `_log.debug(...)` 로 slippage 적용 기록.
## M3 상세 (timeout 집행)
- `ExecutionResult`: `timeout_cancelled: int = 0` 추가.
- `_poll` 의 else branch(`_check_stale_pending` 호출)에서 timeout 체크 추가:
```python
self._check_stale_pending(slot, now, r) # Alert-only (REQ-014, no change)
self._try_timeout_cancel(slot, now, r) # NEW: cancel if timeout_sec exceeded
```
- `_try_timeout_cancel`: `slot.timeout_sec` 있고 경과 시간 초과 시 → `broker.cancel_order(...)` best-effort → `r.timeout_cancelled += 1` → WARNING 로그. 실패 시 WARNING, 상태 불변.
## M4 상세 (운영자 cancel 백스톱)
- `messaging_handlers.make_control_rpc_handler`: `cancel_pending` 액션 추가. `cancel_pending_fn` 콜백(slot_id → bool) 주입.
- `execution.ExecutionService.cancel_pending_slot(slot_id)`: 슬롯 조회 → PENDING_* 이면 `broker.cancel_order` 송신. 슬롯 직접 전이 없음.
- `cli.py`: `cancel <slot_id>` 서브커맨드 추가. `--dry-run`/`--yes`/`--live` 안전장치. control RPC 경유.
- daemon.py 배선: make_control_rpc_handler 에 cancel_pending_fn 전달.
## M5 상세 (baseline 문서)
- `docs/interface-contract.md`: buy/sell payload 에 `timeout_sec`/`slippage_pct` 추가.
- `docs/runtime-flows.md`: 집행 루프·dispatch 섹션에 timeout 취소·slippage·cancel 백스톱 흐름 추가.
## 위험 요소
- **R-1 (취소-체결 race)**: cancel_order 송신 후 즉시 롤백하지 않고 다음 poll 수렴 → 기존 is_terminal_reject/is_partial_cancel 경로 재사용 완화.
- **R-2 (부분 체결 보존)**: timeout cancel → is_partial_cancel → 기존 `_on_partial_cancel` 선례(dcy-013) 재사용.
- **R-3 (단일 writer)**: cancel CLI 는 cancel_order 만 송신; 슬롯 전이는 집행 루프 단독 → 불변식 보존.
@@ -0,0 +1,69 @@
# BAT-032 Verification
- Status: approved
- Batch: BAT-032
## Test Evidence
단위 테스트 425 passed, 45 skipped (통합 제외). 신규 71 tests.
```
.venv/bin/python -m pytest tests/ -q --ignore=tests/integration
425 passed, 45 skipped in 1.18s
```
## 신규 테스트 파일
| 파일 | 커버리지 |
|---|---|
| `tests/domain/slot/test_execution_policy_lifecycle.py` | REQ-068: PENDING 진입 시 set, HOLDING/INACTIVE 탈출 시 clear (10 cases) |
| `tests/application/test_execution_policy.py` | REQ-069/070/071: slippage dispatch (buy ceil, sell floor, market=skip, no-slippage=unchanged, zero) + timeout cancel (exceeded→cancel sent, not exceeded→no cancel, no timeout_sec→no cancel, exception best-effort, state unchanged, cancel-fill race) + cancel_pending_slot (ok, not_pending, slot_not_found, not_dispatched, cancel_failed, state unchanged) |
| `tests/application/test_execution_policy_handler.py` | REQ-068/071 핸들러: buy/sell intent payload 파싱 (timeout_sec/slippage_pct), default_order_timeout_sec 계층적 해석, control cancel_pending 액션 (ok/not_pending/slot_not_found/no-fn/cancel_failed/missing-slot_id) |
| `tests/runtime/test_cli_operator.py` (기존 확장) | REQ-071 cancel CLI: 확인 없이 중단, --yes 제출, --dry-run 스킵, --json 출력, ignored/not_found 응답 처리 |
## REQ 별 AC 검증
### REQ-068 (실행 정책 계약)
- AC-1 (timeout_sec 파싱·저장): `test_buy_timeout_sec_parsed_and_stored_on_slot`, `test_timeout_sec_set_on_pending_buy_entry`
- AC-2 (slippage_pct 파싱·저장): `test_buy_slippage_pct_parsed_and_stored_on_slot`, `test_slippage_pct_set_on_pending_buy_entry`
- AC-3 (config default): `test_config_default_applied_when_intent_has_no_timeout`
- AC-4 (하위 호환): `test_buy_policy_fields_absent_means_none`, `test_policy_fields_default_to_none_when_not_given`
- AC-5 (per-intent override): `test_per_intent_timeout_overrides_config_default`
- AC-6 (cleared on exit): `test_policy_cleared_on_transition_to_holding`, `test_policy_cleared_on_transition_to_inactive`
### REQ-069 (timeout 집행·race)
- AC-1 (timeout 초과 → cancel_order): `test_timeout_exceeded_sends_cancel_order`
- AC-2 (is_terminal_reject → rollback): race 수렴은 기존 `test_full_external_cancel_returns_slot_to_holding` 재사용
- AC-3 (is_partial_cancel → 부분 보존): 기존 `test_partial_fill_then_cancel_records_and_keeps_remainder` 재사용
- AC-4 (cancel 후 fill race → is_filled 경로): `test_timeout_cancel_then_fill_race_handled`
- AC-5 (timeout_sec 없으면 cancel 없음): `test_no_timeout_sec_no_cancel`
- AC-6 (exception best-effort): `test_timeout_cancel_best_effort_on_exception`
### REQ-070 (slippage 지정가)
- AC-1 (buy ceil): `test_buy_slippage_ceil_applied_to_limit`
- AC-2 (sell floor): `test_sell_slippage_floor_applied_to_limit`
- AC-3 (market = ignore): `test_no_slippage_when_market_order_no_entry_price`
- AC-4 (slippage 없으면 기존 동작): `test_no_slippage_when_slippage_pct_is_none`
- AC-5 (0% = 기본 가격): `test_zero_slippage_uses_base_price`
- AC-6 (분수 입력 ceil/floor): `test_buy_slippage_fractional_ceil`
### REQ-071 (운영자 cancel 백스톱)
- AC-1 (PENDING_BUY cancel): `test_ok_sends_cancel_and_returns_ok`
- AC-2 (루프 수렴): 단일 writer 설계로 execution loop 수렴 보장 (`test_cancel_does_not_change_slot_state`)
- AC-3 (PENDING_SELL same): cancel_pending_slot 구현에 PENDING_SELL 포함 (코드 검증)
- AC-4 (HOLDING ignored): `test_not_pending_returns_not_pending`
- AC-5 (not_found): `test_slot_not_found_returns_slot_not_found`
- AC-6 (--dry-run): `test_cancel_dry_run_does_not_submit`
### REQ-072 (baseline 정합)
- AC-1~4: docs/interface-contract.md, docs/runtime-flows.md 수동 확인 완료 (M5).
## 기존 테스트 회귀 없음
전체 425 passed — REQ-013/061 기존 AC 포함 회귀 없음.
| 항목 | 결과 |
|---|---|
| 단위 테스트 (non-integration) | 425 passed, 45 skipped |
| 신규 tests | 71 |
| 회귀 | 없음 |
@@ -0,0 +1,38 @@
# BAT-033 Implementation
- Status: done
- Batch: BAT-033
## 변경 파일
| 파일 | 변경 유형 | 연결 REQ/AC |
|------|----------|-------------|
| `trex_core/runtime/cli.py` | 수정 | REQ-032 CHG-20260610-06 AC-7 |
| `tests/runtime/test_cli_operator.py` | 수정 | AC-7 검증 + 기존 enter 테스트 --qty 보완 |
## M1: `enter --qty` 미지정 검증
### `trex_core/runtime/cli.py`
`_cmd_enter` 함수 상단(forced_close_at 검증 앞)에 qty 검증 블록 추가:
```python
# 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
```
- `--qty` 없음(None) 또는 0 → 즉시 `rc=1`, RPC 미호출
- `--dry-run` 경로도 동일하게 걸림 (qty 검증이 `_intent_guard` 진입 전에 위치)
- 기존 `--qty` 있는 경우 동작 불변
### `tests/runtime/test_cli_operator.py`
**신규 테스트 (AC-7):**
- `test_enter_without_qty_errors_before_rpc` — `--qty` 없이 enter → rc=1, rpc.calls==[], stderr에 `--qty` 포함
**기존 테스트 수정 (회귀 방지):**
기존 enter 테스트 9개에 `"--qty", "10"` (또는 적절한 값) 추가. qty 검증이 가장 앞에 위치하므로, 각 테스트가 본래 의도한 guard(confirm abort, live guard, json guard, forced_close_at 검증 등)에 도달하도록 보정.
@@ -0,0 +1,22 @@
# BAT-033: enter --qty 필수 검증 (issue #23)
- Status: release-candidate
- Profile: batch-lite
- Source Discovery: REQ-032 change-req (issue #23)
- Included REQ: REQ-032 (CHG-20260610-06, AC-7)
## Summary
- `trex-core enter`에 `--qty` 미지정 시 RPC 제출 전 즉시 오류 반환. 기존 `qty=None` → admitted → 집행 루프 KIS 거절 → 롤백이라는 "접수 성공처럼 보이지만 매수 안 됨" 혼동 제거.
## Scope
- In: `trex_core/runtime/cli.py` `enter` 서브커맨드에 `--qty` 미지정 검증 + 단위 테스트
- Out: IntentService admission 수량 검증(서비스 레이어), 도메인 변경, DB 스키마 변경
## Documents
- Planning: [planning.md](./planning.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- batch-lite minor-change fast path: 구조/인터페이스/런타임 흐름 영향 없음 (CLI argparse 단일 파일).
- Design Gate: design 불필요 (planning.md 참조).
@@ -0,0 +1,30 @@
# BAT-033 Planning
- Status: done
- Batch: BAT-033
## Milestones
| ID | 목표 | 연결 REQ |
|---|---|---|
| M1 | `enter --qty` 미지정 검증 + 테스트 | REQ-032 (CHG-20260610-06 AC-7) |
## M1 상세 (enter --qty 미지정 검증)
`trex_core/runtime/cli.py` `_cmd_enter` 함수 진입부에 qty 검증 추가:
- `--qty` 가 없거나 `None`이면 즉시 오류 출력 + `return 1` (RPC 호출 없음)
- `--dry-run` 경로에서도 동일하게 미리 검증
- 기존 `--qty` 있는 경우는 동작 불변 (회귀 없음)
단위 테스트 추가 (`tests/runtime/test_cli_operator.py`):
- `enter` without `--qty` → rc=1, rpc.calls==[] (AC-7)
- 기존 `test_enter_with_yes_submits_buy_intent` 회귀 없음 확인
## Design Gate
- Design 불필요: CLI argparse 단일 파일 변경. 구조/인터페이스/런타임 흐름 영향 없음.
- 검증: 단위 테스트 + 기존 suite 회귀 없음으로 충분.
## 위험 요소
- R-1 (dry-run 경로 누락): dry-run에서도 qty 체크를 해야 오류 메시지를 미리 볼 수 있음 → 동일 검증 로직 적용으로 해소.
@@ -0,0 +1,21 @@
# BAT-033 Verification
- Status: approved
- Batch: BAT-033
## 검증 항목
| AC | 시나리오 | 테스트 | 결과 |
|----|----------|--------|------|
| AC-7 | `enter` without `--qty` → rc=1, rpc.calls==[], stderr에 `--qty` 포함 | `test_enter_without_qty_errors_before_rpc` | PASS |
| AC-7 | `--dry-run` 경로에서도 qty 미지정 → rc=1, rpc.calls==[] | `test_enter_without_qty_errors_before_rpc` (dry-run 미호출이므로 동일 경로) | PASS |
| 회귀 | 기존 `enter` 테스트 9개 — qty 지정 시 동작 불변 | 기존 테스트 전체 | PASS |
## 실행 결과
```
tests/runtime/test_cli_operator.py — 31 passed (0 failed)
전체 suite — 426 passed, 45 skipped, 0 failed
```
커맨드: `python -m pytest tests/runtime/test_cli_operator.py -q`
@@ -0,0 +1,50 @@
# BAT-034 Design
- Status: done
- Batch: BAT-034
## D1. 제거 지점 전수 목록 (소스 grep 기준)
| # | 파일 | 위치 | 제거 내용 | REQ |
|---|------|------|----------|-----|
| 1 | `trex_core/domain/slot/models.py` | `Slot.allocated_capital` 필드 + `EXPECTED_SLOT_FIELDS` 항목 | 도메인 모델·필드셋 계약 | REQ-002 |
| 2 | `trex_core/domain/slot/service.py` | `transit_slot` 파라미터, `_apply_transition_fields` 파라미터·INACTIVE clear·carry-forward `replace(...)` | 설정/전이 경로 | REQ-002 |
| 3 | `trex_core/application/intents.py` | `BuyIntent.allocated_capital` 필드, `submit_buy` dedup payload 키, `_admit_buy`→`transit_slot` 전달 | intent 계약·admission | REQ-007 |
| 4 | `trex_core/application/messaging_handlers.py` | buy payload `_opt_float(body.get("allocated_capital"))` 파싱 | 수신 무시(즉시 제거) | REQ-007 |
| 5 | `trex_core/adapters/persistence/postgresql.py` | `_SLOT_COLUMNS` 문자열, UPDATE 절, `_row_to_slot` 인덱스 매핑(컬럼 제거로 인덱스 시프트 — 주석 포함 재정렬) | flat 매핑 | REQ-006 |
| 6 | `trex_core/adapters/persistence/sql/013_drop_allocated_capital.sql` | 신규 — `ALTER TABLE daily_slot DROP COLUMN IF EXISTS allocated_capital;` | 스키마 | REQ-006 |
| 7 | `trex_core/application/portfolio.py` | `slots[]` 항목의 `"allocated_capital"` 키 | snapshot 노출 | REQ-028 |
| 8 | `trex_core/runtime/cli.py` | `--capital` argparse 인자, `_cmd_enter` summary·payload, `_print_slot_detail` 행 | CLI 표면 | REQ-032 |
테스트: `tests/` 전수 grep 으로 fixture·assert 의 allocated_capital/`--capital` 참조 정리 (BAT-033 의 AC-7 테스트는 보존).
## D2. 마이그레이션 013 (비가역 drop)
```sql
-- 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;
```
- 적용: 코드 배포와 동시(`apply_migrations` 데몬 기동 시 자동, dcy-023 OQ-2 운영자 결정).
- 롤백: 구 코드 UPDATE 절이 컬럼을 참조하므로 공존 불가 — release 문서에 (1) 적용 전 `daily_slot` 백업, (2) 롤백 시 백업 복원 + 구 리비전 절차 명시.
- 검증: 테스트 DSN(`trex_core_test`)에서만 round-trip — 운영 DB 미사용.
## D3. 수신-무시 시맨틱 (REQ-007 AC-6)
- messaging handler 의 파싱 제거가 곧 수신-무시다 — RabbitMQ payload 는 관용적 계약이라 외부 발신자(trex-orb-v1, `intent_builder.py:56`)가 키를 계속 보내도 읽히지 않는 unknown key 가 될 뿐.
- 검증 테스트: `allocated_capital` 키를 포함한 buy payload → 정상 admit + 슬롯·intent_log 에 미반영.
- `submit_buy` dedup payload(intent_log)에서 키 제거 — dedup 은 intent_id 기준이라 동작 영향 없음.
## D4. 인덱스 시프트 주의 (postgresql.py)
- `_SLOT_COLUMNS` 에서 `allocated_capital`(6번째) 제거 시 `_row_to_slot` 의 이후 모든 row 인덱스가 1씩 당겨진다 — 위치 주석(`# limit_price(5) allocated_capital(6) order_id(7) ...`)을 실제 순서로 재작성하고, 드리프트 테스트(`EXPECTED_SLOT_FIELDS` ↔ information_schema)가 정합을 강제한다.
## D5. 변경하지 않는 것
- `qty` 기반 집행 경로(`qty = int(slot.qty or 0)`) — 불변 (NFR-3).
- BAT-033 의 `--qty` 필수 guard(AC-7) — 보존.
- 외부 전략 모듈 송신 코드 — 범위 밖(운영자 확인).
- baseline `docs/project-structure.md`/`docs/runtime-flows.md` — allocated_capital 직접 기술 없음 확인(dcy-023 confirm 시점) → 갱신 불요, 검증 단계에서 재확인만.
@@ -0,0 +1,27 @@
# BAT-034 Implementation
- Status: done
- Batch: BAT-034
## 변경 파일
| 파일 | 변경 | Milestone / REQ |
|------|------|----------------|
| `trex_core/domain/slot/models.py` | `Slot.allocated_capital` 필드 + `EXPECTED_SLOT_FIELDS` 항목 제거 | M1 / REQ-002 |
| `trex_core/domain/slot/service.py` | `transit_slot`·`_apply_transition_fields` 파라미터, INACTIVE clear, carry-forward 제거 | M1 / REQ-002 |
| `trex_core/application/intents.py` | `BuyIntent.allocated_capital` 필드, `submit_buy` dedup payload 키, `_admit_buy`→`transit_slot` 전달 제거 | M1 / REQ-007 |
| `trex_core/application/messaging_handlers.py` | buy payload 파싱 제거 + 수신-무시 주석 (AC-6) | M1 / REQ-007 |
| `trex_core/adapters/persistence/postgresql.py` | `_SLOT_COLUMNS`·UPDATE 절·`_row_to_slot`(인덱스 6 제거, 이후 1씩 시프트 + 위치 주석 재작성) | M2 / REQ-006 |
| `trex_core/adapters/persistence/sql/013_drop_allocated_capital.sql` | 신규 — `DROP COLUMN IF EXISTS`(멱등) | M2 / REQ-006 |
| `trex_core/application/portfolio.py` | snapshot `slots[]` 항목의 `allocated_capital` 키 제거 | M3 / REQ-028 |
| `trex_core/runtime/cli.py` | `--capital` argparse 인자, `_cmd_enter` summary/payload, `_print_slot_detail` 행 제거 | M3 / REQ-032 |
| `tests/domain/slot/test_service.py` | `allocated_capital` 인자/assert 제거 | M4 |
| `tests/runtime/test_cli_operator.py` | fixture 키·`--capital` 인자 제거, `test_enter_rejects_capital_option`(AC-8) 신규, `test_enter_with_yes_submits_buy_intent` 에 payload 부재 assert | M4 |
| `tests/application/test_messaging_intent_consumer.py` | `test_buy_payload_allocated_capital_is_ignored`(AC-6 unknown-key 무시) 신규 | M4 |
| `tests/application/test_execution_policy.py` | 잔여 lint 정리(F401/F841 — BAT-032 잔재, 본 배치 변경과 무관) | M4 |
## 설계 정합
- design.md D1 제거 지점 8개 전부 처리. D4 인덱스 시프트는 위치 주석 재작성 + 드리프트 테스트로 검증.
- D3 수신-무시: 파싱 제거 = unknown key 무시. 전용 테스트로 고정.
- D5 불변 확인: `qty` 집행 경로·BAT-033 AC-7 guard·외부 모듈·baseline 문서 모두 미변경.
@@ -0,0 +1,24 @@
# BAT-034: allocated_capital 죽은 필드 제거 — CLI·intent·도메인·영속·snapshot 정리 (dcy-023)
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-023 (issue #22)
- Included REQ: REQ-002 (CHG-20260610-02), REQ-006 (CHG-20260610-02), REQ-007 (CHG-20260610-02), REQ-028 (CHG-20260610-01), REQ-032 (CHG-20260610-07)
## Summary
- dcy-023: 주문 사이징·회계·전략 결정 어디에도 쓰이지 않는 죽은 필드 `allocated_capital` 을 설정(CLI `--capital`)→운반(buy intent)→영속(daily_slot 컬럼)→표시(snapshot `slots[]`·CLI detail) 전 경로에서 제거한다. dcy-021 사이징 계약(전략이 qty 계산, core 미관여) 확정의 귀결로, capital 기반 사이징 오해를 원천 제거한다.
## Scope
- In: CLI `--capital` argparse 제거 + `slots` detail 행 제거(REQ-032), BuyIntent 필드·admission·payload 파싱 즉시 제거(REQ-007), Slot 모델·`EXPECTED_SLOT_FIELDS`·SlotService 경로 제거(REQ-002), 마이그레이션 013 컬럼 drop + persistence flat 매핑 제거(REQ-006), snapshot `slots[]` 노출 제거(REQ-028), 관련 테스트 정리.
- Out: 외부 전략 모듈(trex-orb-v1) 송신 코드 수정(운영자 확인: 무시 — unknown key 무해), capital 기반 자동 사이징 도입(dcy-021 에서 불채택 확정), 다른 슬롯 필드 정리.
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- 마이그레이션 013 은 비가역 drop — BAT-030 의 011 선례 준용(코드 배포와 동시 적용, release 문서에 사전 백업·롤백 절차 명시, dcy-023 OQ-2).
- BAT-033(REQ-032 AC-7, release-candidate)과 같은 CLI/테스트 파일을 수정하므로 enter 테스트 정합 유지.
- 본 배치 완료 후 REQ-002/006/007/028/032 confirm-req-implemented.
@@ -0,0 +1,34 @@
# BAT-034 Planning
- Status: done
- Batch: BAT-034
## Milestones
| ID | 목표 | 연결 REQ |
|---|---|---|
| M1 | 도메인·intent 계약 제거 — Slot 모델/EXPECTED_SLOT_FIELDS/SlotService + BuyIntent/admission/payload 파싱 | REQ-002 (CHG-02), REQ-007 (CHG-02) |
| M2 | 영속 제거 — 마이그레이션 013(`DROP COLUMN IF EXISTS`) + postgresql.py flat 매핑 | REQ-006 (CHG-02) |
| M3 | 표면 제거 — snapshot `slots[]` 노출 + CLI `--capital` argparse·summary·payload·`slots` detail 행 | REQ-028 (CHG-01), REQ-032 (CHG-07) |
| M4 | 테스트 정리 + 회귀 — allocated_capital 참조 전수 정리, unknown key 무시 테스트, argparse 거부 테스트, 전체 suite green | 전 REQ |
## 작업 순서 근거
- M1(안쪽 계약)→M2(영속)→M3(바깥 표면) 순서로 제거하면 각 단계에서 컴파일/테스트 오류가 다음 제거 지점을 가리킨다. M4 는 전 단계 누락 검출.
- 모든 변경이 "제거"라 단계 간 의존이 단방향(도메인 필드가 사라지면 매핑·노출이 따라 사라져야 함).
## Design Gate
- design 필요: **yes** — 5개 REQ·7개 모듈에 걸친 데이터 계약 변경 + 비가역 마이그레이션. 제거 지점 전수 목록과 마이그레이션/롤백 절차를 design.md 에 고정한다.
## 위험 요소
- R-1 (비가역 drop): 마이그레이션 013 적용 후 구 리비전과 공존 불가(구 코드 UPDATE 절이 컬럼 참조) → release 문서에 사전 백업 + 롤백(컬럼 재추가 또는 백업 복원) 절차 명시. 검증은 테스트 DSN 에서만.
- R-2 (참조 누락): 테스트·fixture 의 allocated_capital 잔존 참조로 suite 실패 → `grep -rn allocated_capital trex_core/ tests/` 전수 0건을 M4 DoD 로 설정.
- R-3 (BAT-033 정합): 같은 CLI/테스트 파일 수정 — enter 테스트의 `--capital` 인자를 제거하면서 AC-7(`--qty` 필수) 테스트는 보존.
## 검증 계획 (요약)
- 단위: argparse 거부, payload allocated_capital 부재, unknown key 무시(admit 정상 + 미반영), 필드셋 계약, slots[] 스키마
- 통합(테스트 DSN): 마이그레이션 013 멱등 적용 + information_schema 대조 + round-trip
- 회귀: 전체 suite green + `grep` 전수 0건
@@ -0,0 +1,26 @@
# BAT-034 Verification
- Status: approved
- Batch: BAT-034
## Acceptance Mapping
| REQ / AC | 검증 | 테스트/Evidence | 결과 |
|----------|------|----------------|------|
| REQ-002 AC-5 | Slot 모델·EXPECTED_SLOT_FIELDS·SlotService 경로에서 allocated_capital 부재 | `tests/domain/slot/test_contract.py`(필드셋 = dataclass fields 정합, 자동 강제) + grep 전수 0건 | PASS |
| REQ-006 AC-5 | 마이그레이션 013 멱등 적용 + 컬럼 부재 + 드리프트 green | 테스트 DSN(trex_core_test)에서 `apply_migrations` 2회 → `013_drop_allocated_capital.sql` 적용·재적용 무해, information_schema 에 컬럼 없음 확인. PG 게이트 suite 21 passed(드리프트 가드 포함) | PASS |
| REQ-007 AC-6 | BuyIntent 필드 부재 + unknown key 무시 | `test_buy_payload_allocated_capital_is_ignored` — allocated_capital 포함 payload 정상 admit + 슬롯 미반영 | PASS |
| REQ-028 AC-5 | snapshot slots[] 에 키 부재 | `build_snapshot_envelope` 매핑 제거 + `test_cli_operator` fixture 정합(snapshot 스키마 테스트 green) | PASS |
| REQ-032 AC-8 | `--capital` argparse 거부 + payload 부재 + detail 행 제거 | `test_enter_rejects_capital_option`(SystemExit 2, RPC 미호출, stderr 에 --capital) + `test_enter_with_yes_submits_buy_intent`(payload 에 키 부재) | PASS |
| 회귀 (NFR-2/3) | 전체 suite + qty 집행 경로 불변 + BAT-033 AC-7 보존 | 전체 **428 passed, 45 skipped**, PG 게이트 **21 passed**, `ruff check` All checks passed. AC-7 테스트(`test_enter_without_qty_errors_before_rpc`) green | PASS |
| S-1 (dcy-023) | 소스/테스트 grep 전수 | `grep -rn allocated_capital trex_core/ tests/` → 수신-무시 주석·전용 테스트 외 0건 | PASS |
## 환경
- 단위/통합: in-memory + 테스트 PostgreSQL `trex_core_test`(TREX_POSTGRESQL_TEST_DSN 파생, 운영 DB 미사용)
- 명령: `python -m pytest -q` (전체), `python -m pytest tests/adapters/persistence/ -q` (PG 게이트)
## 운영 이월 (release 시 필수)
- 마이그레이션 013 은 비가역 — 운영 적용 전 `daily_slot` 백업, 롤백 = 백업 복원 + 구 리비전(BAT-030/011 선례). release 문서 Rollout/Rollback 에 명시할 것.
- 운영자 스크립트/습관의 `enter --capital` 사용분은 배포 후 argparse 오류로 즉시 표면화(silent 실패 아님).
+5 -2
View File
@@ -31,11 +31,14 @@
| BAT-021 | standard | released | REQ-034 (CHG-20260608-02), REQ-021 (CHG-20260608-03), REQ-037 (CHG-20260608-01), REQ-038 (CHG-20260608-01), REQ-039 (CHG-20260608-01), REQ-032 (CHG-20260608-02), REQ-042 (CHG-20260608-01) | dcy-017 | [bat-021](./bat-021_20260608_watchdog-ownership-visibility/) |
| BAT-022 | standard | released | REQ-028 (CHG-20260608-01), REQ-032 (CHG-20260608-03) | dcy-016 | [bat-022](./bat-022_20260608_slot-state-snapshot-visibility/) |
| BAT-023 | standard | released | REQ-017 (CHG-20260608-02), REQ-019 (CHG-20260608-02), REQ-029 (CHG-20260608-01), REQ-030 (CHG-20260608-01, Approved→Implemented) | dcy-018 | [bat-023](./bat-023_20260608_ledger-cash-position-writeback-option-d/) |
| BAT-024 | batch-lite | release-candidate | REQ-032 (CHG-20260609-01) | dcy-016 (issue #17) | [bat-024](./bat-024_20260609_operator-enter-forced-close-at/) |
| BAT-025 | standard | release-candidate | REQ-002 (CHG-20260609-01), REQ-003 (CHG-20260609-01), REQ-006 (CHG-20260609-01), REQ-007 (CHG-20260609-01), REQ-009 (CHG-20260609-01) | dcy-019 (issue #18) | [bat-025](./bat-025_20260609_slot-entry-count-and-closed-reallocation/) |
| BAT-024 | batch-lite | released | REQ-032 (CHG-20260609-01) | dcy-016 (issue #17) | [bat-024](./bat-024_20260609_operator-enter-forced-close-at/) |
| BAT-025 | standard | released | REQ-002 (CHG-20260609-01), REQ-003 (CHG-20260609-01), REQ-006 (CHG-20260609-01), REQ-007 (CHG-20260609-01), REQ-009 (CHG-20260609-01) | dcy-019 (issue #18) | [bat-025](./bat-025_20260609_slot-entry-count-and-closed-reallocation/) |
| BAT-026 | batch-lite | release-candidate | REQ-048 (CHG-20260609-01), REQ-010 (CHG-20260609-01) | 없음 (trex-orb-v1 정합) | [bat-026](./bat-026_20260609_config-dir-env-and-config-path/) |
| BAT-027 | standard | release-candidate | REQ-056, REQ-057 | dcy-020 (BAT-α) | [bat-027](./bat-027_20260610_asset-permanent-slot-foundation/) |
| BAT-028 | standard | release-candidate | REQ-058, REQ-059, REQ-060 | dcy-020 (BAT-β) | [bat-028](./bat-028_20260610_universe-reconcile-and-active-admission/) |
| BAT-029 | standard | release-candidate | REQ-061, REQ-062, REQ-063 | dcy-020 (BAT-γ) | [bat-029](./bat-029_20260610_asset-keyed-intent-and-divergence/) |
| BAT-030 | standard | release-candidate | REQ-064, REQ-062, REQ-056·057·065(잔여) | dcy-020 (BAT-δ) | [bat-030](./bat-030_20260610_asset-centric-cutover/) |
| BAT-031 | standard | release-candidate | REQ-066, REQ-067, REQ-057·061·062(CHG) | dcy-021 | [bat-031](./bat-031_20260610_position-sizing/) |
| BAT-032 | standard | release-candidate | REQ-068, REQ-069, REQ-070, REQ-071, REQ-072, REQ-061(CHG-05), REQ-013(CHG-03) | dcy-022 | [bat-032](./bat-032_20260610_execution-policy/) |
| BAT-033 | batch-lite | release-candidate | REQ-032 (CHG-20260610-06) | REQ-032 change-req (issue #23) | [bat-033](./bat-033_20260610_enter-qty-validation/) |
| BAT-034 | standard | release-candidate | REQ-002 (CHG-20260610-02), REQ-006 (CHG-20260610-02), REQ-007 (CHG-20260610-02), REQ-028 (CHG-20260610-01), REQ-032 (CHG-20260610-07) | dcy-023 (issue #22) | [bat-034](./bat-034_20260610_drop-allocated-capital/) |
@@ -0,0 +1,220 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: dcy-022_20260610_stuck-pending-order-recovery
- 이슈명: 전략 발신 intent 실행 정책 — 주문 timeout(TIF)/슬리피지 허용 + 미체결 고착 백스톱 (issue #21, dcy-020/021 모델 전제)
- 작성 시각(KST): 2026-06-10 17:43 KST
- 마지막 갱신 시각(KST): 2026-06-10 19:30 KST (confirm-discovery 승인 — OQ-1~4 Answered, 저장소 사실 재확인(cancel_order 미배선·payload 무 timeout/slippage), C형 0 → 상태 confirmed)
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조: 없음
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
- 연계 문서 현황:
- 관련 점수 파일: 미생성
- 관련 TODO/메모: GitHub issue #21(pending 고착). dcy-020(자산 중심 영구 슬롯·slot_id 정본·상태머신 — 토대, BAT-027~030 delivery), dcy-021(포지션 사이징·부분 매도 — BAT-031), dcy-013(stale 워치독 Alert-only + 외부취소 cncl_yn 롤백 선례).
- 해석:
- 본래 issue #21 은 "미체결 고착을 운영자/코어가 사후 정리"하는 반응적 문제였다. 그러나 더 나은 근원 해법은 **전략이 intent 제출 시 실행 정책(주문 timeout, 슬리피지 허용)을 함께 실어** core 가 이를 집행하는 것이다 — 미체결 주문은 전략이 지정한 timeout 에 자동 취소되어 애초에 고착되지 않는다.
- 따라서 본 Discovery 의 **주축**은 intent 실행 정책(timeout=TIF + slippage)이고, 운영자 강제 취소·코어 fallback 은 **백스톱**으로 둔다. 가격(`entry_price`)·청산시간(`forced_close_at`)·수량(`qty`)은 이미 계약에 있으므로 **신규는 timeout 과 slippage 두 가지**다. 브로커 `cancel_order` 는 어댑터에 이미 존재(미배선)라 timeout 집행 배선만 필요하다.
# 2. 요구사항 판정 결과
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
- 판정: 신규
- 근거
- dcy-013(REQ-014)은 stale pending 을 **Alert-only**(상태 변경 없음)로 의도 고정했다. 본 주기는 전략이 명시한 실행 정책(timeout)을 core 가 집행하므로 "코어 임의 취소" 우려가 해소되며, 능동적 취소·실행 정책을 정의하는 신규 주기다.
- intent 에 실행 정책(timeout/slippage)을 추가하는 것은 매수/매도 계약(REQ-007/021/061)의 확장이며, 기존 dcy 어디에도 정의되지 않은 신규 능력이다.
# 3. 문제점의 요약
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다.
- 현재 상태
- **주문에 실행 정책이 없다**: buy/sell intent payload(`interface-contract.md`)는 `entry_price`(지정가)·`qty`·`forced_close_at`(청산시간)·`stop_loss_price`/`take_profit_price` 는 있으나, **주문 유효시간(TIF/timeout)·슬리피지 허용**을 지정할 수단이 없다. 지정가 미체결 시 무기한 대기.
- **미체결 시 무기한 pending**: 집행 루프(`execution.py`)는 poll 결과가 "접수/부분(취소 아님)"이면 상태를 두고 재polling 한다 — 체결·외부취소·거부가 없으면 자산 슬롯이 pending_buy/pending_sell 에 영구 고착(issue #21).
- **stale 워치독은 Alert-only**: `_check_stale_pending`(dcy-013/REQ-014)은 임계 초과 시 WARN+카운터만 — 자동 조치 없음. 의도는 "코어가 전략 의도를 몰라 임의 취소 불가"였다.
- **브로커 취소 송신 미배선**: 어댑터에 `cancel_order(...)`가 이미 존재(`paper.py`/`kis.py`)하나 어떤 자동/운영자 흐름에도 호출되지 않는다.
- **운영자 처분 경로 부재**: `trex-core exit` 는 holding 대상이라 pending 에 적용 불가.
- **(dcy-021 후) 부분 매도 고착**: 포지션 사이징 부분 매도 주문도 pending_sell 로 나가 미체결 시 같은 고착 + 보유 슬롯 조정 잠금.
- baseline(`docs/interface-contract.md` payload, `docs/runtime-flows.md` 집행) 갱신 대상.
- 기대 상태
- 전략이 intent 에 `timeout_sec`(예 60)·`slippage_pct`(예 0.1%)를 실으면, core 가 이를 집행한다 — timeout 초과 미체결 주문은 자동 취소·롤백(고착 예방), slippage 로 보호 지정가를 산출해 과도한 불리 체결을 막는다.
- timeout 이 없는(또는 timeout 취소 자체가 실패한) 잔여 고착은 운영자 강제 취소 + (선택) 코어 fallback 으로 처리한다(백스톱).
- 가격·청산시간·수량과 함께 실행 정책이 하나의 주문 명세로 통합되어, 전략이 "어떻게 체결할지"를 선언한다.
- 이해관계자
- 문서 작성자(AI agent) — Discovery/REQ/Batch 산출물
- 리뷰어/승인자 — 실행 정책 계약·취소 집행·안전성 승인
- 전략 모듈(orb-v1 등) — intent 에 timeout/slippage 발신(외부 repo 발신 측 변경)
- 운영 담당자 — 잔여 고착 백스톱 처분·관측
- 제약사항
- 취소·롤백은 단일 writer·멱등·불변식(advisory lock, instance 스코프 dcy-020)을 보존하고, 이미 체결된 부분 수량을 잃지 않아야 한다(dcy-013 부분 취소 회계 계승).
- 취소 요청 송신과 실제 취소 확인(cncl_yn/poll)을 분리 — 취소-체결 race 처리.
- 슬리피지·timeout 미지정 시 기존 동작(무기한 지정가/시장가)과 호환(하위호환·옵션 필드).
## 주요 사용자 시나리오
사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다.
- orb-v1 이 매수 신호를 줄 때 `entry_price`·`qty`·`forced_close_at` 과 함께 `timeout_sec=60`·`slippage_pct=0.1` 을 실어 보낸다. 60초 내 미체결이면 core 가 주문을 취소하고 슬롯을 inactive 로 롤백한다(고착 없음).
- 시장가 매수에 `slippage_pct=0.1` 을 주면 core 가 현재가×(1+0.1%) 보호 지정가로 제출해 과도한 불리 체결을 막는다. 밴드 밖이면 미체결 → timeout 취소로 수렴.
- 부분 매도(dcy-021)에 timeout 을 주면 미체결 시 자동 취소되어 보유로 되돌아가고 부분 체결분은 보존된다.
- (백스톱) timeout 없는 옛 주문이 고착되면 운영자가 `trex-core cancel <slot_id>` 로 취소·롤백한다.
# 4. 이번에 정의할 변경
이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다.
- **intent 실행 정책 필드 추가(주축)**: buy/sell payload 에 optional `timeout_sec`(주문 유효시간, TIF)·`slippage_pct`(슬리피지 허용) 추가. 계약(REQ-007/021/061) 확장. 운영자 config 기본 timeout(per-instance TOML `default_order_timeout_sec`, opt-in) + per-intent override(OQ-4). deprecate 없음.
- **timeout 집행**: 집행 루프가 dispatched-pending 주문의 경과시간을 추적해 `timeout_sec` 초과 시 브로커 `cancel_order` 송신 → 취소 확인 후 롤백(pending_buy→inactive / pending_sell→holding, 부분 체결 보존). 기존 어댑터 `cancel_order` 배선.
- **슬리피지 집행(OQ-2/3)**: `slippage_pct` 는 `entry_price` 기준 밴드 — 실효 지정가 = 매수 `entry_price*(1+pct)`/매도 `entry_price*(1-pct)`(호가단위 반올림). `entry_price` 없으면 무시(순수 시장가). 즉 `entry_price`=기준/목표, `slippage_pct`=허용폭, 실효 limit=둘의 곱(`entry_price` 단독이면 하드 지정가=현행).
- **고착 백스톱**: timeout 미지정/취소 실패 잔여 고착에 운영자 강제 취소(`cancel`) + (선택) 코어 fallback. dcy-013 Alert-only 는 기본 유지.
- baseline 문서(`docs/interface-contract.md` payload, `docs/runtime-flows.md` 집행/취소 흐름) 반영.
## 영향 범위
변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다.
- 코드 영향:
- `trex_core/application/intents.py` / `messaging_handlers.py` — payload `timeout_sec`/`slippage_pct` 파싱·운반, 슬롯/주문에 전달
- `trex_core/application/execution.py` — dispatched-pending 경과시간 추적 + timeout 취소 송신, slippage→limit_price 산출, 취소 확인 후 롤백
- `trex_core/adapters/broker/*` — 기존 `cancel_order` 배선(신규 메서드 아님), 보호 지정가 주문
- `trex_core/domain/slot/service.py` / `models.py` — (필요 시) 주문 정책/제출시각 보유 + 취소 롤백 전이(부분 보존)
- `trex_core/runtime/cli.py` / control RPC — 운영자 `cancel` 백스톱
- `trex_core/runtime/config.py`(`DaemonInstanceConfig`) + `config/*.toml` — `default_order_timeout_sec` opt-in 기본값(REQ-048 per-instance TOML 계열, OQ-4)
- 문서 영향:
- `docs/interface-contract.md`(buy/sell payload 에 timeout/slippage), `docs/runtime-flows.md`(집행·timeout 취소·slippage·백스톱)
- 운영 영향:
- 미체결 고착 예방으로 활성 capacity 회복. 전략 모듈(외부) 발신 계약 변경 조율 필요.
# 5. 요구사항 목록
실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다.
## 기능 요구사항
- FR-1: buy/sell intent payload 에 optional `timeout_sec`(주문 유효시간)·`slippage_pct`(슬리피지 허용)를 추가하고 core 가 파싱·운반한다(REQ-007/021/061 확장). core 는 수신 측 먼저 배포 가능(필드 누락 시 무시 — orb-v1 발신과 lockstep 불필요, 하위호환). `timeout_sec` 미지정 시 운영자 config 기본값(per-instance TOML `default_order_timeout_sec`, 미설정=OFF=무기한)을 적용하고 per-intent 값이 override 한다(OQ-4). `slippage_pct` 는 기본값 없음(미지정=보호밴드 없음).
- FR-2: 집행 루프는 dispatched-pending 주문이 `timeout_sec` 를 초과하면 브로커 `cancel_order` 를 송신하고, 취소 확인 후 슬롯을 롤백한다(pending_buy→inactive / pending_sell→holding). 이미 체결된 부분 수량은 보존한다.
- FR-3: `slippage_pct` 는 **기준가(`entry_price`)가 있을 때만** 적용한다(OQ-2) — 실효 지정가 = 매수 `entry_price*(1+pct)` / 매도 `entry_price*(1-pct)`(KRX 호가단위로 반올림: 체결 보장 측 — 매수 올림/매도 내림). `entry_price` 가 없으면(순수 시장가) `slippage_pct` 는 **무시**(무제한 시장가). 밴드 밖 미체결은 timeout 취소(FR-2)로 수렴한다.
- FR-4: 취소 요청 송신과 실제 취소 확인(cncl_yn/poll)을 분리한다 — 취소가 거부(이미 체결)되면 정상 체결 경로로 수렴하고 슬롯을 임의 변경하지 않는다(취소-체결 race).
- FR-5: (백스톱) 운영자가 자산 슬롯의 고착 pending 을 강제 취소·롤백할 수 있다(timeout 미지정/취소 실패 대비). dcy-013 Alert-only 기본 동작은 회귀 없이 유지한다.
- FR-6: `docs/interface-contract.md`·`docs/runtime-flows.md` 에 실행 정책·timeout 취소·slippage·백스톱을 반영한다.
## 비기능 요구사항
성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다.
- NFR-1: 안전·멱등 — 취소·롤백은 단일 writer·advisory lock(instance 스코프)·멱등을 보존하고, 중복 취소/경합에서 부분 체결분을 잃지 않는다.
- NFR-2: 비차단 — timeout 취소 송신 실패·지연이 집행 루프·다른 슬롯 처리를 막지 않는다(best-effort, 요청/확인 분리).
- NFR-3: 하위호환 — `timeout_sec`/`slippage_pct` 미지정 시 기존 매수/매도 경로와 byte 동등(회귀 없음).
- NFR-4: 관측성 — timeout 취소·slippage 적용·백스톱 처분이 로그+카운터로 가시화된다(stale 카운터 계승).
## 범위 경계
이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다.
- In Scope
- intent `timeout_sec`/`slippage_pct` 계약 + core 집행(timeout 취소·보호 지정가)
- 브로커 `cancel_order` 배선 + 취소-체결 race 처리(부분 보존)
- 운영자 강제 취소 백스톱 + runtime-flows/interface-contract 정합
- Out of Scope
- 브로커 `cancel_order` 신규 구현(이미 존재 — 배선만)
- 전략 모듈(orb-v1) 내부 신호/사이징 로직(외부 repo, 발신 계약만 합의)
- IOC/FOK 등 네이티브 TIF 분류 확장 — **후속 주기**(본 주기는 코어-타이밍 `timeout_sec` 만, OQ-1 Answered)
# 6. 리스크/가정 목록
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다.
## 리스크
- R-1 (High): 취소 요청과 실제 체결의 경쟁(race) — timeout 취소 송신 직전/직후 체결되면 슬롯과 브로커 실제가 어긋날 수 있다.
- 완화 방안: 취소 송신 후 즉시 롤백하지 말고 cncl_yn/poll 확인 후 전이(FR-4). 이미 체결 시 정상 경로로 수렴.
- R-2 (Medium): 슬리피지 보호 지정가 산출 오류로 의도와 다른 체결/미체결.
- 완화 방안: 방향별 부호·산출식을 명확히 정의(OQ-2), 단위 테스트. 밴드 밖은 timeout 취소로 안전 수렴.
- R-3 (Medium): 부분 체결분 처리 오류로 회계/포지션 불일치.
- 완화 방안: dcy-013 부분 취소 회계 선례 재사용 + 브로커 정본 대조(REQ-062) + 테스트.
## 가정
아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다.
- A-1: dcy-020(자산 중심 영구 슬롯·slot_id 정본)·dcy-021(부분 매도)이 구현 완료된 상태다(BAT-027~031).
- A-2: 브로커 어댑터 `cancel_order` 가 동작하며 cncl_yn 으로 취소 결과를 관측 가능하다(dcy-013).
- A-3: 전략 모듈이 intent 에 실행 정책 필드를 발신하도록 합의·변경 가능하다(외부 repo).
## 오픈 질문
답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다.
- OQ-1: timeout 시맨틱 범위 — 단순 `timeout_sec`(미체결 시 취소)만 우선 도입하고 IOC/FOK 등 TIF 분류는 후속으로 둘지, 본 주기에 함께 정의할지.
- 상태: Answered (2026-06-10, 사용자 결정)
- 결정: **timeout 만 도입** — `timeout_sec`(N초 미체결 시 코어가 `cancel_order` 송신·롤백, GTD/timeout 성격, 브로커 무관). **IOC/FOK 등 네이티브 TIF 는 후속 주기**(브로커 네이티브 플래그·부분 체결 규칙 별도). 반영: §4, FR-1/2, §5 범위 경계.
- OQ-2: 슬리피지 산출 규칙.
- 상태: Answered (2026-06-10, 사용자 결정)
- 결정: `slippage_pct` 는 **기준가 `entry_price` 가 있을 때만** 적용 — 실효 지정가 = 매수 `entry_price*(1+pct)` / 매도 `entry_price*(1-pct)`(KRX 호가단위 반올림, 체결 보장 측). `entry_price` 없으면(순수 시장가) **무시**(무제한). 시장가엔 곱할 기준이 없다는 점에서 "기준가 없으면 슬리피지 없음". 반영: FR-3, §4.
- OQ-3: 필드 상호작용/우선순위.
- 상태: Answered (2026-06-10, OQ-2 동반 결정)
- 결정: `entry_price`=기준/목표, `slippage_pct`=허용폭 → 실효 limit = `entry_price*(1±pct)`. `entry_price` 단독(slippage 없음)=하드 지정가(현행). `timeout_sec`=주문 유효시간(미체결 취소), `forced_close_at`=포지션 만기 청산(별개 축, REQ-066/037). 네 필드 역할 분리 명확. 반영: FR-3, §4, payload 계약(REQ 단계 interface-contract).
- OQ-4: 계약/발신 위치 — `timeout_sec`/`slippage_pct` 를 buy/sell payload optional 로 추가(core 수신)하고, 전략 모듈(orb-v1) 발신 변경을 어떻게 조율할지(기본값·하위호환·deprecate 정책).
- 상태: Answered (2026-06-10, 사용자 결정)
- 결정: (1) buy/sell payload 에 **optional** 필드 추가 — core 수신 측 먼저 배포(누락 시 무시), orb-v1 발신은 별도 일정(lockstep 불필요·하위호환). (2) `timeout_sec` 기본값 = **운영자 config opt-in**(per-instance TOML `default_order_timeout_sec`, 미설정=OFF=현행), per-intent override. `slippage_pct` 기본값 없음. (3) **deprecate 없음**(전부 신규 optional 필드, `entry_price` 등 기존 필드 유지). 반영: FR-1, §4, 영향 범위(config).
# 7. 초기 성공 기준
이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다.
- S-1: `timeout_sec=N` 을 실은 intent 가 N초 내 미체결이면 core 가 주문을 취소하고 슬롯을 롤백하며 부분 체결분을 보존한다(고착 없음).
- S-2: `slippage_pct` 가 주어진 주문이 방향별 보호 지정가로 제출되고, 밴드 밖이면 미체결 → timeout 취소로 수렴한다. 미지정 주문은 기존 동작과 동일(회귀 없음).
- S-3: 취소-체결 race 에서 이미 체결된 경우 정상 체결 경로로 수렴(임의 롤백 없음). 백스톱 운영자 `cancel` 로 timeout 없는 고착도 해소된다.
## 측정 방식 및 데이터 출처
각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다.
- S-1 측정 방식: timeout 경과 픽스처 → 취소·롤백·부분 보존 단위/통합 / 데이터 출처: 슬롯 전이, 브로커 cancel_order/poll
- S-2 측정 방식: 시장가/지정가 + slippage 산출 단언 + 미지정 회귀 / 데이터 출처: OrderRequest limit_price, 슬롯
- S-3 측정 방식: 취소-체결 동시 시나리오 수렴 + 운영자 cancel 백스톱 / 데이터 출처: poll_fill/cncl_yn, control RPC
# 8. REQ로 넘기기 전 확인 체크
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
- [x] 범위(In Scope/Out of Scope) 확정
- 기준: TIF 범위(OQ-1 timeout만)·슬리피지 규칙(OQ-2 entry_price 밴드) 합의 → In/Out 경계 명확
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-1~3 측정 시나리오·데이터 출처가 테스트 가능한 형태로 정의됨
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: R-1(취소-체결 race)은 FR-4(취소 송신·확인 분리)로 해소 방향 확정. 해소 책임은 구현 batch.
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ-1~4 전부 Answered (2026-06-10 사용자 결정)
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 승인자 Justin Ko confirm-discovery 수행
# 9. 파일 처리 결과
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
- 처리 결과: 갱신 (주축 재작성)
- 생성 경로: docs/discovery/dcy-022_20260610_stuck-pending-order-recovery.md
- 참조 문서:
- docs/discovery/dcy-020_20260610_asset-centric-slot-model.md (자산 중심 영구 슬롯·slot_id 정본·상태머신 — 토대)
- docs/discovery/dcy-021_20260610_position-sizing-target-intent.md (부분 매도 — 실행 정책 적용 대상)
- docs/discovery/dcy-013_20260608_kis-external-cancel-pending-sell-stuck.md (stale Alert-only + cncl_yn 롤백·부분 취소 회계 선례)
- docs/interface-contract.md(buy/sell payload), docs/runtime-flows.md (baseline — 갱신 대상)
- GitHub issue #21(pending 고착)
# 10. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
## DECIDE
- 없음 (OQ-1~4 전부 Answered — §6 참조)
## CONFIRM
- 없음 (전제 dcy-020/021 구현은 BAT-027~031 로 충족 — A-1)
## DATA
- 없음 (외부 데이터 의존 없음 — 내부 도메인/브로커 계약 + 전략 발신 합의)
# 11. Discovery Freeze
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
## 섹션 참조
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: 승인
- Handoff Rationale: OQ-1~4 전부 Answered, C형 플레이스홀더 0, 저장소 사실 재확인 일치(cancel_order 어댑터 존재·미배선, payload 무 timeout/slippage, stale Alert-only). REQ Drafting 인계 가능.
- Ready for REQ Drafting: true
- Confirmed By: Justin Ko
- Confirmed At (KST): 2026-06-10 19:30 KST
@@ -0,0 +1,208 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: dcy-023_20260610_allocated-capital-dead-field-removal
- 이슈명: allocated_capital 죽은 필드 제거 — `enter --capital` 옵션 + slot/intent/snapshot 계약 정리 (issue #22)
- 작성 시각(KST): 2026-06-10 21:27
- 마지막 갱신 시각(KST): 2026-06-10 21:42
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조: 신규 REQ 없음 — change-req 5건: REQ-002(CHG-20260610-02)·REQ-006(CHG-20260610-02)·REQ-007(CHG-20260610-02)·REQ-028(CHG-20260610-01)·REQ-032(CHG-20260610-07)
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
- 연계 문서 현황:
- 관련 점수 파일: 미생성
- 관련 TODO/메모: GitHub issue #22 (`trex-core enter --capital 옵션 및 slot allocated_capital 필드 제거`)
- 해석:
- issue #22 가 지적한 대로 `allocated_capital` 은 주문 사이징·회계·전략 결정 어디에도 쓰이지 않는 죽은 메타데이터다 — 설정(CLI)·운반(intent)·영속(daily_slot)·표시(snapshot) 경로만 존재한다.
- dcy-021(포지션 사이징)에서 "전략이 cash+quantity 로 qty 를 계산해 명령형 intent 로 제출, core 는 사이징하지 않음" 계약이 확정되어 capital 기반 자동 사이징의 미래 용도도 사라졌으므로, 필드 제거의 전제(OQ-3)가 사실상 해소된 상태에서 제거 범위·마이그레이션 정책만 결정하면 된다.
# 2. 요구사항 판정 결과
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
- 판정: 신규
- 근거
- 기존 Discovery 22건 중 `allocated_capital` 제거를 다루는 문서 없음. 가장 가까운 dcy-021(포지션 사이징)은 qty 사이징 계약 확립이 주제이고, 본 건은 그 결정의 귀결인 죽은 필드 정리로 문제 정의가 다르다.
- 슬롯 데이터 계약·영속(컬럼 drop)·intent payload·CLI·snapshot 노출에 걸치는 다중 REQ change-req 묶음이라 독립 주기로 추적하는 편이 명확하다.
# 3. 문제점의 요약
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다.
- 현재 상태
- `allocated_capital` 소비처는 전수 추적 결과 설정·운반·영속·표시뿐이다: `trex_core/runtime/cli.py`(`--capital`→payload, slot detail 표시), `trex_core/application/intents.py`(BuyIntent 필드 + admission 전달), `trex_core/application/messaging_handlers.py`(payload 파싱), `trex_core/domain/slot/models.py`(Slot 필드 + `EXPECTED_SLOT_FIELDS`), `trex_core/domain/slot/service.py`(buy/전이 시 설정·클리어), `trex_core/adapters/persistence/postgresql.py`(daily_slot 컬럼 SELECT/UPDATE/복원), `trex_core/application/portfolio.py`(snapshot `slots[]` 노출). 의사결정/회계/사이징 소비자는 없다.
- 집행 수량은 `qty = int(slot.qty or 0)` 로 `slot.qty` 에서만 온다. issue #22 가 지적한 footgun(`--capital` 만 주고 `--qty` 누락 → 0주 주문)의 CLI 측면은 BAT-033(REQ-032 AC-7, `--qty` 필수 검증)으로 이미 차단됐으나, 필드 자체는 슬롯 계약·영속·스냅샷에 잔존해 유지 비용과 "capital 이 사이징한다"는 오해 여지가 남아 있다.
- 마이그레이션은 012 까지 존재하며, BAT-030 의 011(`drop_pool_model_columns`)이 비가역 컬럼 drop + 사전 백업 운영 절차의 선례를 남겼다.
- baseline 문서 `docs/project-structure.md`, `docs/runtime-flows.md` 존재 — 확인 결과 두 문서 모두 `allocated_capital` 을 직접 기술하지 않으므로 FR-5(baseline 정합)는 제거 후 재확인 수준의 경량 항목이다.
- 기대 상태
- `trex-core enter` 에서 `--capital` 옵션이 제거되고, buy intent payload·Slot 모델·daily_slot 컬럼·snapshot `slots[]` 에서 `allocated_capital` 이 사라진다.
- 슬롯 데이터 계약이 실제 소비되는 필드만 남아 계약·영속·스냅샷·테스트의 유지 비용이 줄고, capital 기반 사이징 오해가 원천 제거된다.
- 이해관계자
- 문서 작성자(AI agent) — Discovery/REQ/batch 산출물 작성
- 운영자(Justin Ko) — CLI 인터페이스 변경(`--capital` 제거)과 마이그레이션(컬럼 drop) 승인
- 전략 모듈 발신자 — buy intent payload 계약 변경의 영향 대상(현재 `allocated_capital` 송신 여부 확인 필요)
- 제약사항
- daily_slot 컬럼 drop 은 비가역 — BAT-030 선례처럼 사전 백업 + 롤백 절차 명시 필수.
- 운영 라이브 DB 에 대한 파괴적 작업 금지 원칙 — 마이그레이션 검증은 테스트 DSN(trex_core_test)에서 수행.
## 주요 사용자 시나리오
사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다.
- 운영자가 `trex-core enter <pf> --qty 10 --yes` 로 진입한다 — `--capital` 은 더 이상 받지 않으며, 잘못 입력하면 argparse 가 즉시 거부해 "capital 로 사이징된다"는 오해가 생길 여지가 없다.
- 전략 모듈이 buy intent 를 제출할 때 payload 에 `allocated_capital` 을 넣지 않는다(또는 deprecated 기간 동안 넣어도 무시된다) — 계약 문서와 실제 동작이 일치한다.
- 개발자가 Slot 모델/persistence/snapshot 코드를 읽을 때 죽은 필드가 없어 실제 소비되는 계약만 추적하면 된다.
# 4. 이번에 정의할 변경
이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다.
- `trex-core enter` 의 `--capital` 옵션 제거 + buy intent payload 의 `allocated_capital` 필드 폐기(즉시 제거 vs deprecated 수신-무시 중 OQ-1 결정).
- Slot 도메인 모델·`EXPECTED_SLOT_FIELDS`·persistence flat 매핑·daily_slot 컬럼(마이그레이션 013, drop)·snapshot `slots[]` 노출에서 `allocated_capital` 제거.
- 데이터 계약 변경에 따른 baseline 문서(`docs/project-structure.md`, `docs/runtime-flows.md`) 슬롯 필드 기술 정합 갱신(해당 기술이 있는 경우).
## 영향 범위
변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다.
- 코드 영향:
- `trex_core/runtime/cli.py`(`--capital` 인자·summary·payload·slot detail 표시), `trex_core/application/intents.py`(BuyIntent 필드·admission), `trex_core/application/messaging_handlers.py`(payload 파싱), `trex_core/domain/slot/models.py`(+`EXPECTED_SLOT_FIELDS`), `trex_core/domain/slot/service.py`(설정/클리어 경로), `trex_core/adapters/persistence/postgresql.py`(컬럼 매핑), `trex_core/application/portfolio.py`(snapshot 노출)
- 신규 마이그레이션 `013_drop_allocated_capital.sql`(ALTER TABLE daily_slot DROP COLUMN IF EXISTS, 멱등) + 관련 단위 테스트 정리
- 문서 영향:
- REQ-002(슬롯 도메인)·REQ-006(persistence/마이그레이션)·REQ-007(buy intent payload)·REQ-028(snapshot slots[])·REQ-032(운영자 CLI) change-req
- baseline `docs/project-structure.md`/`docs/runtime-flows.md` 슬롯 필드 기술 정합(존재 시)
- 운영 영향:
- 마이그레이션 013 적용(비가역 drop — 사전 백업 절차), `enter --capital` 사용 스크립트/습관 변경 공지. 데몬 재기동 1회.
# 5. 요구사항 목록
실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다.
## 기능 요구사항
- FR-1: `trex-core enter` 는 `--capital` 옵션을 받지 않는다. 지정 시 argparse 수준에서 unknown argument 로 거부된다.
- FR-2: buy intent payload 계약에서 `allocated_capital` 이 즉시 제거된다 — messaging handler 의 파싱 제거로 외부 발신자가 키를 계속 보내도 unknown key 로 무시된다(OQ-1 결정: 즉시 제거).
- FR-3: Slot 모델·`EXPECTED_SLOT_FIELDS`·persistence 매핑·daily_slot 컬럼에서 `allocated_capital` 이 제거된다(마이그레이션 013, `DROP COLUMN IF EXISTS` 멱등).
- FR-4: snapshot `slots[]` 응답과 CLI `slots` detail 표시에서 `allocated_capital` 필드가 사라진다.
- FR-5: baseline 문서(`docs/project-structure.md`, `docs/runtime-flows.md`)에 슬롯 필드/intent 계약 기술이 있으면 제거 결과와 정합하도록 갱신한다.
## 비기능 요구사항
성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다.
- NFR-1: 마이그레이션 013 은 멱등(`IF EXISTS`)이며, 구 코드와의 공존이 불가능한 비가역 drop 이므로 적용 전 백업·롤백 절차를 release 문서에 명시한다(BAT-030 011 선례 준용).
- NFR-2: 전체 단위 테스트 suite 회귀 없음(제거 대상 필드를 참조하던 테스트는 함께 정리).
- NFR-3: 변경 후에도 buy intent admission·집행 경로의 런타임 동작(qty 기반 사이징)은 불변이다 — 죽은 필드 제거가 거래 동작을 바꾸지 않는다.
## 범위 경계
이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다.
- In Scope
- CLI `--capital` 제거, buy intent `allocated_capital` 폐기, Slot/persistence/snapshot 필드 제거, 마이그레이션 013, 관련 REQ change-req 5건, baseline 정합
- Out of Scope
- capital 기반 자동 사이징(capital→qty) 도입 — dcy-021 에서 "전략이 사이징, core 미관여" 로 확정되어 채택하지 않음
- `entry_price`/`limit_price` 등 다른 슬롯 필드 정리 — 본 건과 무관
- trex-orb-v1 등 외부 전략 모듈 저장소의 발신 코드 수정 — 사용자 확인 결과 무시(범위 제외). RabbitMQ payload 는 관용적 계약이라 송신 잔존이 무해함.
# 6. 리스크/가정 목록
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다.
## 리스크
- R-1 (Low): 외부 발신자(trex-orb-v1, `intent_builder.py:56`)가 `allocated_capital` 을 계속 송신함 — 파싱 제거 후에는 unknown key 로 무시되어 동작 영향 없음(사용자 확인: 외부 발신자 무시).
- 완화 방안: messaging handler 파싱 제거만으로 충분. 외부 저장소 수정은 범위 제외.
- R-2 (Medium): daily_slot 컬럼 drop 은 비가역 — 롤백 시 구 코드는 해당 컬럼을 UPDATE 절에 포함하므로 컬럼 부재 시 구 리비전과 공존 불가.
- 완화 방안: BAT-030 011 선례 준용 — 적용 전 daily_slot 백업, release 문서에 롤백 절차(컬럼 재추가 + 백업 복원) 명시, 테스트 DSN 에서 round-trip 검증 후 적용.
- R-3 (Low): 제거 대상 필드를 참조하는 테스트·스냅샷 fixture 누락으로 suite 가 깨질 수 있음.
- 완화 방안: `allocated_capital` 전수 grep(소스+테스트) 후 일괄 정리, 전체 suite green 을 DoD 로 설정.
## 가정
아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다.
- A-1: capital 기반 자동 사이징은 향후에도 core 책임이 아니다 — dcy-021 확정 계약(전략이 cash+quantity 로 qty 계산, 명령형 intent)이 유지된다.
- A-2: 운영 daily_slot 의 기존 `allocated_capital` 값은 보존 가치가 없다(의사결정·회계 소비자가 없었으므로 이력 데이터로도 무의미). 필요 시 drop 전 백업으로 충분하다.
- A-3: BAT-033 의 `--qty` 필수 검증이 release 되면 `--capital` 단독 입력 footgun 은 이미 차단된 상태에서 본 건은 순수 계약 정리다.
## 오픈 질문
답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다.
- OQ-1: buy intent payload 의 `allocated_capital` 을 즉시 제거(수신 시 무시)할지, deprecated 명시 후 유예 기간을 둘지.
- 상태: Answered — **즉시 제거** (2026-06-10, 운영자 결정)
- 근거: RabbitMQ payload 는 관용적 계약이라 파싱 제거 = 수신-무시이며 유예가 주는 추가 안전이 없음. trex-orb-v1 이 실제 송신 중이나(intent_builder.py:56) 사용자 확인으로 무시(범위 제외). FR-2 에 반영 완료.
- 종료 조건: 충족 (FR-2 본문 반영).
- OQ-2: 마이그레이션 013 의 적용 시점 — 코드 배포와 동시 적용 vs 코드 먼저(컬럼 미사용화) 배포 후 별도 시점 drop.
- 상태: Answered — **동시 적용** (2026-06-10, 운영자 결정)
- 근거: 단일 인스턴스 운영 + BAT-030 마이그레이션 011(pool-model 컬럼 drop) 동시 적용 선례. 롤백은 "백업 복원 + 구 리비전"으로 동일하게 커버. `apply_migrations` 가 데몬 기동 시 자동 적용하므로 별도 절차 불필요.
- 종료 조건: 충족 (REQ-006 change-req 에 동시 적용 + 사전 백업 절차로 반영 예정).
### 오픈 질문 처리 기준
- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다.
- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다.
- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다.
- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다.
- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다.
- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다.
- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다.
# 7. 초기 성공 기준
이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다.
- S-1: 소스 트리(`trex_core/`)에서 `allocated_capital` 참조가 0건이 된다(deprecated 유예를 택한 경우 messaging handler 의 무시 주석 1건만 예외).
- S-2: 마이그레이션 013 적용 후 daily_slot 에 `allocated_capital` 컬럼이 없고, `EXPECTED_SLOT_FIELDS` ↔ information_schema 계약 가드가 green 이다.
- S-3: 전체 단위 테스트 suite green(회귀 0) + `trex-core enter --capital ...` 이 argparse 오류로 거부된다.
## 측정 방식 및 데이터 출처
각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다.
- S-1 측정 방식: `grep -rn allocated_capital trex_core/ tests/` 결과 0건(또는 예외 1건) / 데이터 출처: 저장소 소스 트리
- S-2 측정 방식: 테스트 DSN 에서 013 적용 후 information_schema 조회 + 컬럼 계약 가드 테스트 / 데이터 출처: trex_core_test PostgreSQL
- S-3 측정 방식: `pytest -q` 전체 green + CLI 수동 스모크(`enter --capital` 거부 확인) / 데이터 출처: 테스트 실행 로그
# 8. REQ로 넘기기 전 확인 체크
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
- [x] 범위(In Scope/Out of Scope) 확정
- 기준: 외부 전략 모듈 저장소 수정 포함 여부 확인 완료 — 운영자 확인으로 범위 제외 확정 (2026-06-10)
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-1~S-3 측정 방식에 대한 사용자 이견 없음 — grep 전수·계약 가드 테스트·suite green 모두 저장소 근거로 측정 가능
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: R-2(비가역 drop) 완화 절차(백업·롤백, BAT-030 011 선례 준용)를 운영자가 OQ-2 동시 적용 결정과 함께 승인 (2026-06-10)
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ-1(즉시 제거)·OQ-2(동시 적용) 모두 Answered (2026-06-10)
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: Justin Ko 지정 (2026-06-10, 사용자 명시)
# 9. 파일 처리 결과
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
- 처리 결과: 생성
- 생성 경로: docs/discovery/dcy-023_20260610_allocated-capital-dead-field-removal.md
- 참조 문서:
- GitHub issue #22 (trex-core enter --capital 옵션 및 slot allocated_capital 필드 제거)
- docs/discovery/dcy-021_20260610_position-sizing-target-intent.md (사이징 계약 확정 — capital 사이징 불채택 근거)
- trex_core/adapters/persistence/sql/011_drop_pool_model_columns.sql (비가역 컬럼 drop 선례)
# 10. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
## DECIDE
- ~~OQ-1 — buy intent `allocated_capital` 즉시 제거 vs deprecated 유예~~ → **즉시 제거 확정** (2026-06-10, 운영자)
- ~~OQ-2 — 마이그레이션 013 동시 적용 vs 2단계 분리~~ → **동시 적용 확정** (2026-06-10, 운영자)
## CONFIRM
- ~~외부 전략 모듈(trex-orb-v1 등)의 송신 코드 정리 범위 포함 여부~~ → **무시(범위 제외) 확정** (2026-06-10, 운영자)
## DATA
- ~~trex-orb-v1 의 `allocated_capital` 송신 여부~~ → **송신함** (`trex_orb_v1/runtime/intent_builder.py:56`). 단 사이징 입력은 자체 계산(`daemon.py:83`)이며 trex-core snapshot 의존 아님. 운영자 결정으로 무시.
# 11. Discovery Freeze
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
## 섹션 참조
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: 승인
- Handoff Rationale: OQ-1(즉시 제거)·OQ-2(동시 적용) 모두 운영자 결정으로 Answered, 외부 발신자 확인 완료(무시·범위 제외). C 유형 플레이스홀더 0건, baseline 문서 모순 없음(allocated_capital 직접 기술 없음 확인). REQ change-req 5건(REQ-002·006·007·028·032) 인계 준비 완료.
- Ready for REQ Drafting: true
- Confirmed By: Justin Ko
- Confirmed At (KST): 2026-06-10 21:42
+2
View File
@@ -27,3 +27,5 @@
| dcy-019_20260609_slot-entry-count-and-closed-reallocation | 20260609 | slot entry_count 추가 및 closed 슬롯 재할당 정책 (issue #18) | [Discovery](./dcy-019_20260609_slot-entry-count-and-closed-reallocation.md) | confirmed |
| dcy-020_20260610_asset-centric-slot-model | 20260610 | 자산 중심 슬롯 모델 재설계 — portfolio-hub universe 와 1:1 슬롯 + 활성 슬롯 개수 제어 (일반 슬롯 풀 모델 대체) | [Discovery](./dcy-020_20260610_asset-centric-slot-model.md) | confirmed |
| dcy-021_20260610_position-sizing-target-intent | 20260610 | 포지션 사이징 — 활성 자산 슬롯 보유 수량 변경(HOLDING→HOLDING)·부분 매도·선언형 target intent (issue #1, dcy-020 OQ-4 후속) | [Discovery](./dcy-021_20260610_position-sizing-target-intent.md) | confirmed |
| dcy-022_20260610_stuck-pending-order-recovery | 20260610 | 전략 발신 intent 실행 정책 — 주문 timeout(TIF)/슬리피지 허용 + 미체결 고착 백스톱 (issue #21, dcy-020/021 모델 전제) | [Discovery](./dcy-022_20260610_stuck-pending-order-recovery.md) | confirmed |
| dcy-023_20260610_allocated-capital-dead-field-removal | 20260610 | allocated_capital 죽은 필드 제거 — `enter --capital` 옵션 + slot/intent/snapshot 계약 정리 (issue #22) | [Discovery](./dcy-023_20260610_allocated-capital-dead-field-removal.md) | confirmed |
+8 -3
View File
@@ -29,7 +29,7 @@
- RabbitMQ `trex.intent.req` :: 전략 모듈의 매수/매도 admission RPC (request-reply, BAT-007)
- RabbitMQ `trex.events` :: 체결/슬롯 상태 이벤트 topic exchange (pub/sub, BAT-007)
- RabbitMQ `trex.control.req` :: 운영자 제어 RPC — pause/resume/list + kill_switch/resume_kill_switch (request-reply, BAT-012; 구 `trex.pause.req` 확장)
- `trex-core` CLI :: 운영 명령 — pause/resume/paused(direct-DB, BAT-008) + universe/slots/enter/exit(데몬 RPC, dcy-016/BAT-020)
- `trex-core` CLI :: 운영 명령 — pause/resume/paused(direct-DB, BAT-008) + universe/slots/enter/exit(데몬 RPC, dcy-016/BAT-020) + cancel(pending 취소 백스톱, dcy-022/BAT-032)
- SlotService :: 슬롯 생명주기 도메인 서비스(전이/사전할당/가용성/재진입 카운트)
- SlotRepository (Protocol) :: 도메인 ↔ 저장소 경계 계약
- BrokerPort (Protocol) :: 단일 브로커 세션 경계(submit/poll/cancel) + `OrderRequest`/`OrderAck`/`FillStatus`
@@ -87,14 +87,19 @@
"qty": float?, "allocated_capital": float?,
"entry_price": float?, "strategy_id": str?, "sell_strategy_id": str?,
"forced_close_at": "ISO-8601"?,
"stop_loss_price": number?, "take_profit_price": number? }
"stop_loss_price": number?, "take_profit_price": number?,
"timeout_sec": int?, # dcy-022/REQ-068: N초 미체결 시 자동 취소·롤백 (optional)
"slippage_pct": float? } # dcy-022/REQ-068: entry_price*(1+pct/100) 실효 지정가 (optional)
```
- **`slot_id` 정본(dcy-020/REQ-061, BAT-029/030)**: 매수의 정식 키 — 전략 모듈은 snapshot `slots[]` 의 `slot_id` 를 그대로 회신한다. 미스 시 `unknown_slot` 거부. `portfolio_id` 는 legacy dual-accept(미스 시 `unknown_portfolio`); 둘 다 없으면 `slot_id_or_portfolio_required`. `trading_date` 는 계약에서 제거(BAT-030).
- admission(REQ-059): 대상 영구 자산 슬롯이 active(holding/pending) 면 `already_active`; instance active 수 ≥ `max_active_slots`(M) 면 `capacity_full`(FCFS·무치환). **`symbol` 은 client payload 에 없다** — core 가 슬롯의 `portfolio_id` 로 universe 에서 resolve 한다.
- `stop_loss_price`/`take_profit_price`(선택, REQ-035/038): 매수 시 설정하면 워치독 만료 후 safety-sell 가격 트리거의 기준이 된다. 미포함 시 NULL(트리거 비활성).
- `forced_close_at`(선택, REQ-066/037): 시간 기반 자동청산(만기) 절대시각. 운영자 CLI `enter --forced-close-at <ISO8601>`(절대시간)로 설정하며(REQ-032 CHG-20260609-01, issue #17, BAT-024), 미지정 시 payload 에 미포함(NULL). 설정 시 forced-close 만기 매도 루프(REQ-037)가 그 시각에 시장가 청산한다. 상대시간 옵션은 제공하지 않는다.
sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason":..., "sell_strategy_id":..., "limit_price": float?}`.
- **`timeout_sec`(선택, dcy-022/REQ-068)**: 주문 유효시간(초). N초 내 미체결이면 core 가 `cancel_order` 를 브로커에 송신하고 다음 poll 에서 슬롯을 롤백한다(pending_buy→inactive 또는 pending_sell→holding). 미지정 시 per-instance TOML `default_order_timeout_sec`(opt-in, 미설정=OFF=무기한). per-intent 값이 우선.
- **`slippage_pct`(선택, dcy-022/REQ-068)**: 체결 보호 밴드(%). `entry_price` 있을 때만 유효 — 실효 지정가 = `entry_price*(1+pct/100)` (매수, 올림) / `*(1-pct/100)` (매도, 내림). `entry_price` 없으면(시장가) 무시. 미지정=보호밴드 없음.
sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason":..., "sell_strategy_id":..., "limit_price": float?, "qty": float?, "timeout_sec": int?, "slippage_pct": float?}`.
- **`limit_price` 선택(dcy-012/REQ-007)**: 매도 지정가. **지정 시 지정가 매도(`ORD_DVSN="00"`, 그 가격)**, **미지정 시 시장가 매도(`ORD_DVSN="01"`)**. 자율 매도(safety-sell/kill-switch)는 가격을 싣지 않아 항상 시장가로 체결된다. 매수가(과거 `entry_price`)는 매도 가격으로 **재사용하지 않는다**(issue #10 — 슬롯 `limit_price` 는 pending 이탈 시 clear). buy payload 의 `entry_price` 필드명은 본 주기에서 유지(하위호환).
- **하위호환(dcy-022/REQ-068)**: `timeout_sec`·`slippage_pct` 두 필드 모두 optional — 미포함 시 기존 동작(무기한 지정가/시장가)과 동일(byte-동등). core 수신 측 먼저 배포 가능(발신 측과 lockstep 불필요).
#### `trex.intent.req` `update_thresholds` 액션 (BAT-011 / REQ-040)
```
+2 -1
View File
@@ -12,4 +12,5 @@
| REL-002 | tooling | feedback-captured | BAT-009 | Change-req: paper execution_id Redis(#4) | [rel-002](rel-002_20260606_paper-broker-env-tuning.md) |
| REL-003 | app-service | feedback-captured | BAT-010, BAT-011, BAT-012, BAT-013 | dcy-007 (자율 안전 매도) + dcy-008 (kill-switch) + dcy-009 (다중 인스턴스 토폴로지) — 통합 릴리즈 | [rel-003](rel-003_20260607_safety-killswitch-multi-instance.md) |
| REL-004 | app-service | feedback-captured | BAT-014, BAT-015, BAT-016, BAT-017, BAT-018, BAT-019, BAT-020, BAT-021, BAT-022, BAT-023 | dcy-010 (safety-sell 쿨다운) + dcy-011~017 (buy/sell 계약·KIS 취소·trading_date TZ·ledger 백오프 버그픽스 + 운영자 CLI + 워치독 가시화) + dcy-016 후속(슬롯 상태 가시화) + dcy-018 (ledger 현금/포지션 write-back, option D) — 통합 릴리즈 | [rel-004](rel-004_20260608_safety-cooldown-bugfix-hardening-cli.md) |
| REL-005 | app-service | confirmed | BAT-024, BAT-025 | dcy-016 후속/issue #17 (operator enter --forced-close-at) + dcy-019/issue #18 (slot entry_count + closed 슬롯 재할당) — 통합 릴리즈 | [rel-005](rel-005_20260609_operator-forced-close-and-slot-reallocation.md) |
| REL-005 | app-service | feedback-captured | BAT-024, BAT-025 | dcy-016 후속/issue #17 (operator enter --forced-close-at) + dcy-019/issue #18 (slot entry_count + closed 슬롯 재할당) — 통합 릴리즈 | [rel-005](rel-005_20260609_operator-forced-close-and-slot-reallocation.md) |
| REL-006 | app-service | confirmed | BAT-026, BAT-027, BAT-028, BAT-029, BAT-030, BAT-031, BAT-032, BAT-033, BAT-034 | dcy-020 (자산 중심 영구 슬롯 α~δ cutover) + dcy-021 (포지션 사이징/issue #1·#5) + dcy-022 (실행 정책/issue #21) + dcy-023 (allocated_capital 제거/issue #22) + REQ-048·010 CHG (config-dir) + REQ-032 CHG (enter --qty/issue #23) — 통합 릴리즈 | [rel-006](rel-006_20260611_asset-centric-sizing-execution-policy.md) |
@@ -1,6 +1,6 @@
# REL-005: operator enter --forced-close-at + slot entry_count·closed 재할당 통합 릴리즈
- Status: confirmed
- Status: feedback-captured
- Profile: app-service
- Included Batch: BAT-024, BAT-025
@@ -65,53 +65,55 @@ confirm-release 전 "운영자가 쓰는 프로그램이 실제로 동작한다"
### Deployment Facts
- Executed At:
- <배포 시작 시각>
- 2026-06-10
- Completed At:
- <배포 완료 시각>
- 2026-06-10
- Environment:
- <배포 대상 환경>
- paper 브로커 + 테스트 PostgreSQL(`trex_core_test`) + 테스트 RabbitMQ + portfolio-hub stub(`pf-005930`/`pf-000660`) + `TREX_TOTAL_SLOTS=1` + instance_key `rel005smoke`(운영/기존 데몬과 큐 격리). 실계좌(LIVE KIS) 미사용 — paper 한정.
- Operator:
- <배포 수행자>
- Justin Ko
- Revision or Artifact:
- <커밋 SHA 또는 배포 artifact 식별자>
- 작업 트리(`941d324` + dcy-019 미커밋 변경 포함)
- Commands or Procedure:
- <실행 명령 또는 배포 절차 요약>
- `trex-core-daemon --config /tmp/rel005smoke.toml` 기동 후 CLI 스모크(단계 3~5의 Rollout Plan 수행)
### Profile-Specific Checks
- app-service:
- Health Endpoint:
- <snapshot RPC degraded=false 확인 결과와 시각>
- RabbitMQ 토폴로지 선언(`trex.intent.req.rel005smoke` / `trex.snapshot.req.rel005smoke`) 성공, idle 슬롯 1개 preallocation 완료, 에러 로그 없음, `Normal shutdown` 정상 종료 확인
- Startup Log Fields:
- <app, event, command, run_id 확인 결과>
- 마이그레이션 001~008 적용 후 `daily_slot.entry_count` 컬럼 존재 확인(실 PostgreSQL round-trip, additive·멱등 검증)
- Request Log Fields:
- <buy intent / forced_close / reallocation 전이 로그 확인>
- 전이 로그(slot 4fc104cf): `idle→pending_buy → pending_buy→holding → holding→pending_sell → pending_sell→closed → closed→pending_buy → pending_buy→holding` — dcy-019 신규 전이 `closed→pending_buy` 실 데몬 발화 확인. `ledger_outbox` 3행(buy/sell/buy) 모두 `status=completed`.
- Smoke or Manual Verification:
- <enter --forced-close-at + closed 재할당 e2e 결과>
- **BAT-024 PASS**: `trex-core enter pf-005930 --capital 1000000 --strategy-id STRAT-A --forced-close-at "2026-06-09T15:00:00+09:00" --yes --json` → `{"status":"admitted","slot_id":"4fc104cf-…"}`. DB: `status=holding symbol=005930 entry_count=1 forced_close_at=2026-06-09 06:00:00+00`(=KST 15:00 정확 변환). 잘못된 형식 `--forced-close-at "not-a-time"` → 제출 전 거부 `{"error":"invalid --forced-close-at (expected ISO8601): 'not-a-time'"}`, exit=1.
- **BAT-025 PASS**: 진입 시 `entry_count` 0→1 증가. `trex-core exit` → `pending_sell → closed`(entry_count=1 보존). idle 0개 상태에서 `trex-core enter pf-000660 --capital 500000 --strategy-id STRAT-B --yes` → 동일 물리 슬롯 admitted(closed→pending_buy 직접 재할당). 재할당 후 DB: `status=holding symbol=000660 entry_count=2 forced_close_at=NULL strategy_id=STRAT-B`(clean-slate·entry_count 누적 확인).
### Rollback Outcome
- Rollback Required:
- <yes 또는 no>
- no
- Rollback Trigger:
- <발생 시 원인, 없으면 none>
- none
- Rollback Action:
- <수행한 조치, 없으면 none>
- none
### Release Decision Evidence
- Released Decision:
- <released 전환 여부>
- released (feedback-captured로 전환)
- Rationale:
- <released 전환 판단 근거>
- 배포 실패·회귀 없음. `--forced-close-at` 동작 정상(유효 → admitted·KST 변환, 잘못된 형식 → 사전 거부). `entry_count` 기반 closed 슬롯 재할당 실 데몬 발화 확인. 슬롯 상태 전이 완전. ledger 3행 completed.
- Recorded By:
- <기록자>
- Justin Ko
- Recorded At:
- <기록 시각>
- 2026-06-10
## Feedback Handoff
- Observation Summary:
- <운영 관찰 사실 요약 또는 없음>
- REL-005 동작 모두 confirmed. `--forced-close-at` 사전 검증·UTC 변환 정상. closed 슬롯 재할당(entry_count 최저 우선)이 실 데몬에서 발화됨. entry_count 누적·clean-slate 교체 정상. 전이 로그 완전. ledger 3행 completed. 배포 실패·롤백 없음.
- 한계 이월: 실계좌(LIVE KIS) 주문 미검증(paper 한정), portfolio-hub stub 사용(실 #8 연동 미검증 — 슬롯 도메인/CLI 범위 밖).
- Discovery Input:
- <후속 discovery 후보>
- none (이번 릴리즈 관찰 범위에서 신규 후속 탐색 트리거 없음)
- REQ Input:
- <후속 req 후보>
- none
- Change Request Input:
- `daily_symbol_entries`(심볼 재진입 카운터) 제거 — production 소비자 없음 + 심볼 재진입 제한 미사용 확정(dcy-019). REQ-002 change-req 트랙(별도 batch-lite) — REL-005 범위 밖.
@@ -0,0 +1,127 @@
# REL-006: 자산 중심 영구 슬롯 cutover + 포지션 사이징 + 실행 정책 + 운영 정리 통합 릴리즈
- Status: confirmed
- Profile: app-service
- Included Batch: BAT-026, BAT-027, BAT-028, BAT-029, BAT-030, BAT-031, BAT-032, BAT-033, BAT-034
## Scope
- Included:
- BAT-026 (batch-lite / REQ-048·010 CHG): config 디렉터리 기반 설정 관리 — `--config <name>` + `--config-path <dir>` + config-dir `.env` 결정적 해석(`--env-file` 우선), legacy 루트 `.env` 폴백 유지(완전 하위호환). 데몬·운영자 CLI 양쪽 적용.
- BAT-027 (dcy-020 BAT-α / REQ-056·057): **자산 1:1 영구 슬롯 기반** — `(instance_key, portfolio_id)` 키 영구 슬롯 + `inactive↔pending_buy↔holding↔pending_sell` 상태머신, 인스턴스 스코프 advisory lock. 마이그레이션 009(instance_key·retired_at)·010(literal inactive).
- BAT-028 (dcy-020 BAT-β / REQ-058·059·060): universe reconcile(슬롯 생성/retire, 대량 소멸 가드) + 자산 중심 buy admission(`max_active_slots` 동시 활성 상한).
- BAT-029 (dcy-020 BAT-γ / REQ-061·062·063): intent 계약 자산 키 통일(slot_id 정본 + portfolio_id 수용) + 수량 정본=hub·core 미러 divergence reconcile + slots 단일 뷰(universe_detached 표기).
- BAT-030 (dcy-020 BAT-δ / REQ-064·065 + 056·057·062 잔여): **pool 모델 폐기 cutover** — daily preallocation 제거(영구 슬롯은 universe reconcile 이 소유), 마이그레이션 011(`trading_date`·`entry_count` DROP, 비가역).
- BAT-031 (dcy-021 / REQ-066·067 + 057·061·062 CHG): 포지션 사이징 — 보유 슬롯 증액(holding→pending_buy, avg_price 가중평균), 부분 매도(집행 수량=min(intent,브로커잔고), 잔량 holding/전량 inactive, cost basis 유지), 부분 체결 실제만 반영.
- BAT-032 (dcy-022 / REQ-068~072 + 013·061 CHG): 주문 실행 정책 — intent `timeout_sec`(미체결 자동 취소·cancel-fill race 처리)·`slippage_pct`(보호 지정가 buy ceil/sell floor), per-instance TOML `default_order_timeout_sec`, 운영자 `trex-core cancel` 백스톱. 마이그레이션 012(execution policy 필드).
- BAT-033 (batch-lite / REQ-032 CHG-06, issue #23): `trex-core enter` `--qty` 필수 검증 — 미지정 시 RPC 전 즉시 오류(0주 주문 silent 실패 제거).
- BAT-034 (dcy-023 / REQ-002·006·007·028·032 CHG, issue #22): 죽은 필드 `allocated_capital` 전 경로 제거 — CLI `--capital` argparse 거부, BuyIntent·payload 수신 무시, Slot/`EXPECTED_SLOT_FIELDS`, snapshot `slots[]`. 마이그레이션 013(컬럼 DROP, 비가역).
- Excluded:
- BAT-001~025 (REL-001~005 에서 released)
- trex-orb-v1 등 외부 전략 모듈 저장소 변경(발신 계약 전환·allocated_capital 송신 정리 — 별도 합의)
- `daily_symbol_entries` 잔재 제거(REL-005 환류 — 별도 change-req 트랙)
## Rollout Plan
1. 배포 전: 거래 세션 외 시간 확보, 현재 운영 리비전 SHA 기록, 본 릴리즈 작업 트리를 main 에 커밋해 배포 SHA 확정.
2. **DB 백업(필수)**: 마이그레이션 011·013 은 비가역 컬럼 DROP — 적용 전 운영 `daily_slot`(권장: DB 전체) 백업. BAT-030 design §4 절차 준용.
3. 신 리비전 배포 + 데몬 재기동. `apply_migrations` 가 009~013 자동 적용(전부 멱등). 기동 로그에서 마이그레이션 적용·RUNNING·비-degraded 확인.
4. **cutover 확인**: daily preallocation 부재가 정상 — 영구 자산 슬롯은 snapshot scheduler 의 universe reconcile 이 생성/retire 한다. 기동 직후 universe fetch → 슬롯 생성 로그 확인, `trex-core slots` 로 자산 수 = 슬롯 수 확인.
5. 운영자 CLI 스모크(paper 또는 비거래 시간):
- `trex-core slots` — inactive/holding 상태와 universe_detached 표기 (BAT-027~030)
- `trex-core enter <pf> --yes` → `--qty` 누락 즉시 오류 (BAT-033); `--qty N --yes` → admitted (BAT-029 자산 키)
- `trex-core enter ... --capital ...` → argparse 거부 (BAT-034)
- 증액/부분 매도 intent e2e (BAT-031), timeout_sec/slippage_pct intent + `trex-core cancel` (BAT-032)
6. 전략 모듈(trex-orb-v1) 재연결 후 buy intent 정상 admit 확인 — `allocated_capital` 송신 잔존은 unknown key 로 무시되어 무해.
7. 거래 세션 재개.
## Rollback Plan
- 조건: 기동 실패, 마이그레이션 오류, universe reconcile 슬롯 생성 실패(전 자산 미생성), buy admission 회귀, 집행 루프(timeout/slippage) 오작동, CLI 회귀.
- 절차: 직전 released 리비전(REL-005 시점)으로 롤백 + 데몬 재기동 + **백업 복원**.
- 마이그레이션: 011(`trading_date`·`entry_count` DROP)·013(`allocated_capital` DROP)은 구 코드의 SELECT/UPDATE 가 해당 컬럼을 참조하므로 **구 리비전과 공존 불가** — 롤백은 반드시 2번 백업 복원과 함께 수행한다. 009·010·012 는 additive/호환이라 단독으로는 무해.
- 데이터: cutover 후 생성된 영구 슬롯 행은 구 pool 모델 코드와 호환되지 않음 — 복원 시점 이후 거래가 없도록 비거래 시간 배포를 전제로 한다.
## Verification Checklist
- 배포 전 (각 batch verification evidence):
- BAT-026: 단위 23 + 전체 375 passed, ruff clean, 라이브 스모크(name·path 동일 결과)
- BAT-027~030 (dcy-020): 단위 367 / PG 29 / MQ 21 passed + run-the-app 자산 중심 e2e(영구 슬롯 생성·reconcile·slot_id 매수)
- BAT-031 (dcy-021): 단위 378 / 통합 422 passed + 사이징 e2e(증액 가중평균·부분 매도 잔량·전량 매도)
- BAT-032 (dcy-022): 425 passed(신규 71 tests — policy lifecycle·timeout·slippage·cancel), 회귀 없음
- BAT-033: 426 passed (AC-7 `--qty` guard + 기존 enter 회귀 보완)
- BAT-034 (dcy-023): 428 passed + PG 게이트 21 passed + ruff clean, 마이그레이션 013 테스트 DSN 멱등 round-trip + 컬럼 부재, `grep allocated_capital` 전수 0건
- SRS: 포함 REQ 전부 Implemented(Approved/Proposed 0건), batch register 9건 release-candidate
- 배포 후 (app-service profile):
- 데몬 RUNNING + 비-degraded, 마이그레이션 009~013 적용 로그, 백그라운드 스레드(snapshot scheduler·execution loop) 기동
- universe reconcile 슬롯 생성: 자산 수 = 슬롯 수, retire/tombstone 동작
- `trex-core enter`(--qty 필수·--capital 거부)·`slots`·`cancel` 스모크
- buy→fill→증액→부분 매도 e2e + timeout/slippage 정책 발화 확인
- `ledger_outbox` completed, `daily_slot` 에 `trading_date`/`entry_count`/`allocated_capital` 컬럼 부재 확인
## Pre-Release Run Evidence (operator-facing smoke)
confirm-release 전 run-the-app 증거 (2026-06-11, `run-trex-core-daemon` 프로젝트 skill 경로). 실 데몬(RabbitMQ + 실 PostgreSQL) 기동 + 메시징/CLI 구동으로 본 릴리즈의 신규 동작을 검증했다.
- 환경: paper 브로커 + **테스트 PostgreSQL `trex_core_test`**(운영 DB 미사용) + 테스트 RabbitMQ, portfolio-hub **stub**(pf-005930/pf-000660), instance_key `rel006smoke`(큐 격리), `--config /tmp/rel006smoke/rel006smoke.toml --env-file /tmp/rel006smoke/.env`(BAT-026 config-dir 경로 실사용), `max_active_slots=5`.
- **기동 + cutover (BAT-027~030) — PASS**: 토폴로지 선언(`trex.intent.req.rel006smoke` 등) 후 데몬 ready. daily preallocation 없이 snapshot scheduler 의 universe reconcile 이 영구 자산 슬롯 생성: `slot reconcile: {'created': 2, 'retired': 0, 'tombstoned': 0}` — 자산 수(2) = 슬롯 수(2), status `inactive`.
- **e2e 메시징 smoke (`smoke.py`) — PASS**: snapshot RPC(slots=2, available_slots=5) → slot_id 키 buy admitted → 동일 intent_id 중복 재전송 identical(멱등) → paper fill 로 `holding qty=10 avg_price=1000` → sell admitted → `inactive`(점유 해제·자산 바인딩 유지).
- **BAT-033 (`--qty` 필수) — PASS**: `enter pf-005930 --yes`(qty 없음) → `error: --qty is required...`, rc=1, RPC 미발생.
- **BAT-034 (`--capital` 제거) — PASS**: `enter ... --capital 100000` → argparse `unrecognized arguments: --capital`, rc=2. `slots` detail 에 allocated_capital 행 없음.
- **BAT-031 (포지션 사이징) — PASS**: holding(10@1000) 슬롯에 증액 매수 `--qty 5 --entry-price 2000` → admitted(holding→pending_buy) → fill 후 `qty=15.0 avg_price=1333.3333`(정확한 가중평균). 전량 매도 → `inactive qty=None portfolio_id 유지`.
- **BAT-032 (운영자 cancel 백스톱) — PASS**: holding 슬롯에 `trex-core cancel` → `ignored: not_pending`, rc=0 (control RPC 경로 end-to-end).
- **DB 계약 (마이그레이션 009~013) — PASS**: `daily_slot` 에 `trading_date`/`entry_count`(011)·`allocated_capital`(013) 컬럼 **부재** 확인. `ledger_outbox` 5행 전부 `completed`.
- 종료: SIGTERM → `Normal shutdown`, 에러/traceback 0건(로그의 error-매칭 라인은 전부 pika INFO).
- **한계(운영 배포 검증으로 이월)**: LIVE KIS 주문 미검증(paper 한정), portfolio-hub stub(실 universe/ledger 연동은 배포 후 확인), timeout_sec/slippage_pct 정책 발화는 전략 intent 필드라 CLI 미노출 — BAT-032 단위(71 tests) evidence 로 갈음하고 배포 후 전략 모듈 경유로 확인.
## Release Execution Evidence
- Release Status Transition Target: released
### Deployment Facts
- Executed At:
- <배포 시작 시각>
- Completed At:
- <배포 완료 시각>
- Environment:
- <배포 대상 환경>
- Operator:
- <배포 수행자>
- Revision or Artifact:
- <커밋 SHA — 배포 전 main 커밋으로 확정>
- Commands or Procedure:
- <실행 명령 또는 배포 절차 요약>
### Profile-Specific Checks
- app-service:
- Health Endpoint:
- <snapshot RPC degraded=false 확인 결과와 시각>
- Startup Log Fields:
- <마이그레이션 009~013 적용 + universe reconcile 슬롯 생성 로그>
- Request Log Fields:
- <buy/sizing intent admission·집행·timeout/slippage·cancel 전이 로그>
- Smoke or Manual Verification:
- <Rollout 5~6 단계 스모크 결과>
### Rollback Outcome
- Rollback Required:
- <yes 또는 no>
- Rollback Trigger:
- <발생 시 원인, 없으면 none>
- Rollback Action:
- <수행한 조치, 없으면 none>
### Release Decision Evidence
- Released Decision:
- <released 전환 여부>
- Rationale:
- <released 전환 판단 근거>
- Recorded By:
- <기록자>
- Recorded At:
- <기록 시각>
## Feedback Handoff
- Observation Summary:
- <운영 관찰 사실 요약 또는 없음>
- Discovery Input:
- <후속 discovery 후보 — 후보 백로그: 자율 청산 OFF 모드(issue #15), 중장기 전략 슬롯(issue #20)>
- REQ Input:
- <후속 req 후보>
- Change Request Input:
- `daily_symbol_entries` 잔재 제거(REL-005 환류 승계 — REQ-002/006 change-req 트랙), paper 브로커 order seq Redis 영속화(issue #4 — REQ-011 change-req 트랙)
+11
View File
@@ -220,15 +220,26 @@
1. dispatch: `list_undispatched_pending(instance_key)`(status pending_* AND `broker_order_id IS NULL`; 인스턴스 스코프, REQ-056) → `BrokerPort.submit_order` → `mark_dispatched(broker_order_id)`(멱등 가드). 거부 시 `pending_buy→inactive` / `pending_sell→holding` 롤백.
- **매도 수량 = 브로커 잔고 정본**(dcy-020/REQ-062, D3): 매도 dispatch 는 `broker.get_balance()` 의 해당 symbol `hldg_qty` 로 수량을 정한다(슬롯 미러 `qty` fallback 없음). 브로커 0 이면 주문 미제출 + WARN + `sell_skipped_no_balance` 카운터(divergence 는 reconcile 경로로 표면화). skip 된 매도가 큐를 막지 않도록 리스트 기반 dispatch.
- 가격/주문유형 결정(dcy-012/REQ-013, issue #10): `_dispatch` 는 side 분기 없이 슬롯 `limit_price` **한 필드**만 읽는다 — 값이 있으면 지정가(`order_type="00"`, 그 가격), 없으면 시장가(`order_type="01"`). `limit_price` 는 pending 진입 시 set 되고 pending 이탈(→holding/→closed) 시 clear 되므로, 매수 지정가(과거 `entry_price`)가 매도 주문에 재사용되지 않는다. 자율 매도(safety-sell/kill-switch)는 가격 미전달 → 시장가로 즉시 체결(미체결 `pending_sell` 갇힘 방지, issue #9 연계).
- **슬리피지 보호 지정가(dcy-022/REQ-070)**: 슬롯에 `slippage_pct` 가 있고 `limit_price` 도 있으면, 실효 지정가 = `limit_price×(1+pct/100)` (매수, ceil) / `limit_price×(1-pct/100)` (매도, floor). `limit_price` 없으면(시장가) `slippage_pct` 무시. 미지정 슬롯은 기존 동작(회귀 없음).
2. poll: `list_dispatched_pending(instance_key)`(broker_order_id NOT NULL) → `BrokerPort.poll_fill` → 체결 시 `pending_buy→holding`(avg_price/qty) / `pending_sell→inactive`(점유 초기화·자산 정체성 보존), 거부 시 롤백. 전이 후 `clear_dispatch`.
- 외부 취소 감지(dcy-013/REQ-012/013, issue #9): 계좌에서 직접 취소된 주문을 KIS `poll_fill` 이 `cncl_yn="Y"` 로 파싱한다. **체결 0 취소** → `FillStatus(state="cancelled")` → 기존 롤백 경로(`pending_sell→holding`, `clear_dispatch`)로 자동 복구(영구 갇힘 제거). **부분 체결 후 취소**(`is_partial_cancel`) → 체결분 원장 1회 기록 + 슬롯 qty 차감(매도: 잔여 held, cost basis 유지 / 매수: 체결분만) + `holding` 복귀(OQ-1 A안, 과매도·이중기록 방지).
- pending stale 워치독(dcy-013/REQ-014 FR-3, Alert-only): `accepted`/`partially_filled`(취소 아님)로 다음 tick 대기 중인 슬롯이 `TREX_PENDING_STALE_THRESHOLD`(초, 0=비활성)를 초과해 머물면 WARN 로그 + `ExecutionResult.stale_pending` 카운터로 가시화한다. **상태는 변경하지 않는다**(auto-remediation 후속) — 취소 감지 누락(R-1) 등 미지 원인의 무한 갇힘이 silent 로 남지 않게 하는 안전망.
- **timeout 자동 취소(dcy-022/REQ-069)**: `accepted`/`partially_filled` 상태 슬롯에 `timeout_sec` 가 있고 경과 시간(`now - slot.updated_at`)이 초과하면 `broker.cancel_order` 를 best-effort 로 송신하고 `ExecutionResult.timeout_cancelled` 를 증가시킨다. **슬롯 상태는 이 시점에 변경하지 않는다** — 다음 poll 에서 `is_terminal_reject`(전량 취소) → rollback / `is_partial_cancel`(부분 체결 후 취소) → 부분 보존(기존 경로 재사용). 취소-체결 race: `cancel_order` 가 이미 체결된 주문을 받으면 브로커가 거부 → 다음 poll `is_filled` → 정상 체결 경로 수렴(임의 롤백 없음, FR-4). 기존 `timeout_sec` 없는 슬롯은 stale-alert 만 실행(REQ-014 회귀 없음).
3. reconcile(재기동): 서빙 전 `list_dispatched_pending` 재폴링으로 크래시 후 복구(중복 주문 0). 미디스패치(broker_order_id NULL) 슬롯은 다음 dispatch 가 1회만 송신.
- 모든 슬롯 쓰기는 SlotService(advisory lock) 단일 경로(불변식 A). 브로커 주문/취소는 BrokerPort 단일 세션만(불변식 B).
- 슬롯 `limit_price` 생애주기(dcy-012/REQ-002): INACTIVE→PENDING_BUY(매수 지정가 set) → PENDING_BUY→HOLDING(clear) → HOLDING→PENDING_SELL(매도 지정가 set 또는 None=시장가) → PENDING_SELL→INACTIVE(clear). 롤백(→INACTIVE) 전이에서도 clear. 외부 취소 후 holding 복귀에서도 clear 되어 재매도는 시장가.
- **실행 정책 생애주기(dcy-022/REQ-068)**: `timeout_sec`·`slippage_pct` 는 `limit_price` 와 동일한 생애주기(PENDING_* 진입 시 set, HOLDING/INACTIVE 탈출 시 clear). 슬롯의 `updated_at` 이 dispatch 시각 proxy 로 사용된다(mark_dispatched 가 갱신, stale-alert 와 동일 방법).
- 어댑터: paper(결정적 async 더블) / KIS(모의투자 검증, 실계좌 게이팅).
### 운영자 cancel 백스톱 (dcy-022/REQ-071)
timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:
1. 운영자 `trex-core cancel <slot_id>` → `trex.control.req` 에 `{action:"cancel_pending", slot_id}` 전송.
2. 제어 핸들러가 `ExecutionService.cancel_pending_slot(slot_id)` 호출 → 슬롯 pending 여부 확인 → `broker.cancel_order` 송신(슬롯 직접 전이 없음).
3. 다음 집행 루프 poll 에서 `is_terminal_reject`/`is_partial_cancel` 경로로 rollback/부분 보존 수렴(단일 writer 불변식 A 보존).
- 비-pending(holding/inactive) 슬롯 → `{status:"ignored", reason:"not_pending"}`. 미존재 → `{status:"error", reason:"slot_not_found"}`.
## Flow: `체결 → ledger outbox` (BAT-006)
집행 루프 fill 전이 직후 `ExecutionService` 가 ledger 기록을 outbox 에 적재해 슬롯 전이 critical path 와 ledger write 를 분리한다(원장 기록 단일 주체 = 집행 경로, NFR-3).
@@ -16,10 +16,11 @@
2. Given PostgreSQL `SlotRepository`, When `SlotService` 를 이 repo 로 구동, Then 도메인 단위/통합테스트가 in-memory 와 동일하게 통과한다(전이/재진입+1/race 감지).
3. Given 드리프트 테스트, When `EXPECTED_SLOT_FIELDS` ↔ information_schema 대조, Then 일치하고 컬럼 누락 시 실패한다(dcy-001 OQ-2 DB-side 검사 실현).
4. Given 상태 변경, When transit_slot, Then `pg_advisory_xact_lock` 하 단일 트랜잭션으로 직렬화되고 lock 보유 중 외부 I/O 가 없다.
5. (CHG-20260610-02, dcy-023/issue #22) Given 마이그레이션 013(`DROP COLUMN IF EXISTS allocated_capital`, 멱등) 적용, Then `daily_slot` 에 `allocated_capital` 컬럼이 없고 persistence flat 매핑(SELECT/UPDATE/행 복원)에서 해당 컬럼 참조가 제거되며, `EXPECTED_SLOT_FIELDS` ↔ information_schema 드리프트 테스트가 green 이다. 적용은 코드 배포와 동시(`apply_migrations` 자동, dcy-023 OQ-2), 비가역 drop 이므로 적용 전 백업 절차를 release 문서에 명시한다.
## Impacted Area
- Docs: docs/data-model.md(테이블/계약), dcy-001 부록 A(기준)
- Modules: trex_core/ 하위 persistence 어댑터(psycopg) + 마이그레이션 SQL; 도메인 패키지는 무의존 유지(NFR-1)
- Modules: trex_core/ 하위 persistence 어댑터(psycopg) + 마이그레이션 SQL; 도메인 패키지는 무의존 유지(NFR-1); (CHG-20260610-02) postgresql.py flat 매핑 allocated_capital 제거 + 013_drop_allocated_capital.sql
- Tests: PostgreSQL repo 통합테스트 + 드리프트 테스트
## Notes
@@ -271,3 +272,52 @@
#### Validation Plan
- dcy-020 스키마/cutover 배치 verification 으로 대체.
### CHG-20260610-02
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-023/issue #22) `daily_slot.allocated_capital` 컬럼 제거 — 신규 마이그레이션 `013_drop_allocated_capital.sql`(`DROP COLUMN IF EXISTS`, 멱등) + persistence flat 매핑(SELECT 컬럼 목록/UPDATE 절/행 복원)에서 해당 컬럼 참조 삭제. 코드 배포와 동시 적용(dcy-023 OQ-2 운영자 결정), 비가역 drop — BAT-030 의 011 선례 준용(사전 백업 + 롤백 절차 release 문서 명시). AC-5 추가, Implemented→Approved(재구현 대기).
#### Intent
- 소비처 없는 죽은 컬럼을 영속 계약에서 제거해 `EXPECTED_SLOT_FIELDS` ↔ DB 계약을 실사용 필드로 정렬.
#### Acceptance Criteria Delta
- Added:
- AC-5: 마이그레이션 013 적용 후 allocated_capital 컬럼 부재 + flat 매핑 참조 제거 + 드리프트 테스트 green + 동시 적용·백업 절차.
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Modules: trex_core/adapters/persistence/postgresql.py(flat 매핑), trex_core/adapters/persistence/sql/013_drop_allocated_capital.sql(신규)
- Tests: 컬럼 계약 가드(드리프트) + persistence round-trip 테스트 정리
#### Delivery Trace Delta
- Batches: (예정) dcy-023 batch
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (dcy-023 batch 에서 수행 — 테스트 DSN round-trip)
- Existing Implementation Invalidated: yes (컬럼·매핑 제거 — 기존 컬럼 계약 evidence 무효)
- Status Recommendation: Approved (Implemented→Approved 되돌림)
- Follow-up Action: dcy-023 batch 구현·검증 후 confirm-req-implemented. release 시 백업·롤백 절차 명시.
#### Validation Plan
- dcy-023 batch: 테스트 DSN 에서 013 멱등 적용 + information_schema 대조 + round-trip + 전체 suite green.
### CHG-20260610-IMPL
- Date: 2026-06-10
- Author: Justin Ko
- Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-034(release-candidate) verification — allocated_capital 전 경로 제거(도메인/intent/영속/snapshot/CLI), 마이그레이션 013 테스트 DSN 멱등 round-trip + 컬럼 부재 확인, 전체 428 passed + PG 게이트 21 passed + ruff clean, grep 전수 0건. Blocking Issues 없음.
@@ -0,0 +1,82 @@
# req-072: 실행 정책 baseline 문서 정합 — interface-contract / runtime-flows
- Status: Implemented
- Type: Documentation
- Priority: Medium
- Owner: project maintainers
## Intent
- dcy-022 실행 정책 변경(timeout_sec·slippage_pct intent 계약, timeout 집행 흐름, slippage dispatch, 운영자 cancel 백스톱)을 baseline 문서에 반영해 docs 와 코드 일관성을 유지한다. (dcy-022 FR-6)
## Requirement
- `docs/interface-contract.md`: buy/sell intent payload 에 optional `timeout_sec`(주문 유효시간), `slippage_pct`(체결 보호밴드) 필드 추가 및 설명 반영. `docs/runtime-flows.md`: 집행 루프 흐름에 timeout 취소(cancel_order 송신·race·rollback)·slippage dispatch·운영자 cancel 백스톱 흐름 추가. 하위호환 및 default 정책(config 기본값·미지정=OFF) 명시.
## Acceptance Criteria
1. Given `docs/interface-contract.md`, When 확인, Then buy/sell payload 에 `timeout_sec`(optional int), `slippage_pct`(optional float) 필드 명세와 하위호환 설명이 포함된다.
2. Given `docs/runtime-flows.md`, When 확인, Then 집행 루프 섹션에 timeout 초과 → cancel_order 송신 → 다음 poll 수렴 흐름이 기술된다.
3. Given `docs/runtime-flows.md`, When 확인, Then dispatch 섹션에 slippage_pct 실효 지정가 산출 흐름(entry_price*(1±pct), entry_price 없으면 무시)이 기술된다.
4. Given `docs/runtime-flows.md`, When 확인, Then 운영자 cancel 백스톱 흐름(trex-core cancel → control RPC → cancel_order 송신)이 기술된다.
## Impacted Area
- Docs: docs/interface-contract.md, docs/runtime-flows.md
## Notes
- 출처: dcy-022 FR-6.
## Change Log
### CHG-20260610-01
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- dcy-022 FR-6 로부터 최초 생성(Proposed). 실행 정책 baseline 2문서 정합.
#### Intent
- 실행 정책 변경을 baseline 에 동기화.
#### Acceptance Criteria Delta
- Added: AC-1~4 (interface-contract payload, runtime-flows timeout·slippage·cancel 흐름 4개 기준)
#### Impacted Area Delta
- Docs: docs/interface-contract.md, docs/runtime-flows.md
#### Delivery Trace Delta
- Batches: none
#### Revalidation Impact
- Reverification Needed: no / Existing Implementation Invalidated: no / Status Recommendation: keep-current
#### Validation Plan
- 문서 확인(커밋 메시지 + 내용 일치).
### CHG-20260610-02
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- Status Proposed → Approved (confirm-req).
#### Intent
- delivery input 으로 승격.
#### Acceptance Criteria Delta
- Added: 없음 / Updated: 없음 / Removed: 없음
#### Impacted Area Delta
- Docs: docs/srs/index.md (Register Approved)
#### Delivery Trace Delta
- Batches: none
#### Revalidation Impact
- Reverification Needed: no / Existing Implementation Invalidated: no / Status Recommendation: keep-current / Follow-up Action: BAT-032.
#### Validation Plan
- 승인 후 batch planning 에서 AC 검증 기준으로 사용.
### CHG-20260611-IMPL
- Date: 2026-06-11
- Author: Justin Ko
- Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-032(release-candidate) verification — 실행 정책(timeout_sec·slippage_pct) 계약·집행·운영자 cancel 백스톱 전 AC acceptance mapping 존재(신규 71 tests), 단위 425 passed 회귀 없음, baseline 2문서 수동 확인(M5). Blocking Issues 없음.
@@ -228,3 +228,52 @@
#### Validation Plan
- BAT-017: 전량 취소 → holding 복귀, 부분 취소 → 원장 1건 + qty 차감 + cost basis 유지 단위.
### CHG-20260610-03
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-022 실행 정책) 집행 루프에 두 가지 확장: ① **slippage dispatch**: `_dispatch` 가 슬롯의 `slippage_pct` 를 읽어 `entry_price*(1±pct)` 실효 지정가로 주문을 제출(매수 올림/매도 내림, KRX 호가단위). ② **timeout 취소**: `_poll` 이 dispatched-pending 슬롯의 `timeout_sec` 초과 시 `broker.cancel_order` 를 송신(best-effort, 취소-체결 race는 기존 is_terminal_reject/is_partial_cancel 경로로 수렴). Implemented → Approved 되돌림(신규 집행 로직 미구현, BAT-032 에서 재구현).
#### Intent
- 실행 정책(slippage + timeout)을 집행 루프에 배선해 미체결 고착 예방 + 과도한 불리 체결 방지.
#### Acceptance Criteria Delta
- Added:
- dispatch 시 슬롯 `slippage_pct` 가 있으면 `limit_price*(1+pct)` (buy) / `limit_price*(1-pct)` (sell) 실효 지정가로 제출한다(`entry_price` 없으면 무시).
- dispatched-pending 슬롯이 `timeout_sec` 를 초과하면 `broker.cancel_order` 를 송신하고, 다음 poll 에서 is_terminal_reject / is_partial_cancel 경로로 롤백/부분 보존이 수렴한다.
- timeout/slippage 미지정 슬롯은 기존 dispatch·poll 경로와 동일(회귀 없음).
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Modules: `trex_core/application/execution.py`(`_dispatch` slippage, `_poll` timeout cancel), ExecutionResult(+timeout_cancelled)
- Tests: tests/application/test_execution.py(slippage dispatch), test_execution_cancel.py(timeout cancel)
#### Delivery Trace Delta
- Batches: BAT-032
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (BAT-032 에서 완료 예정)
- Existing Implementation Invalidated: no (신규 분기 추가, 기존 dispatch/poll 경로 보존)
- Status Recommendation: revert-to-approved
- Follow-up Action: BAT-032 구현 후 confirm-req-implemented.
#### Validation Plan
- BAT-032: slippage 실효 지정가 단위 + timeout 취소 → 롤백/부분 보존 단위 + 회귀.
### CHG-20260611-IMPL
- Date: 2026-06-11
- Author: Justin Ko
- Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-032(release-candidate) verification — 실행 정책(timeout_sec·slippage_pct) 계약·집행·운영자 cancel 백스톱 전 AC acceptance mapping 존재(신규 71 tests), 단위 425 passed 회귀 없음, baseline 2문서 수동 확인(M5). Blocking Issues 없음.
@@ -0,0 +1,95 @@
# req-069: 주문 timeout 집행 — timeout 초과 시 cancel_order 송신·롤백·부분 보존
- Status: Implemented
- Type: Integration
- Priority: High
- Owner: project maintainers
## Intent
- 집행 루프가 dispatched-pending 슬롯의 `timeout_sec` 를 추적해 초과 시 `broker.cancel_order` 를 송신(best-effort)하고, 취소 확인 후 롤백하며 이미 체결된 부분 수량을 보존한다. 취소-체결 race 는 기존 poll 경로로 자연 수렴한다. (dcy-022 FR-2, FR-4)
## Requirement
- `_poll` 루프에서 dispatched-pending 슬롯이 `timeout_sec` 를 가지고 경과 시간이 초과하면 `broker.cancel_order(broker_order_id, ...)` 를 best-effort 로 송신한다(송신 실패는 WARNING 로그, 슬롯 상태 불변). 취소 요청 송신 직후 슬롯을 즉시 전이하지 않는다 — 다음 poll 틱에서 `is_terminal_reject`(전량 취소)·`is_partial_cancel`(부분 체결 후 취소) 경로로 수렴한다(기존 롤백/부분 보존 경로 재사용, REQ-013 CHG-03). `timeout_sec` 없는 슬롯은 기존 stale-alert 만(`_check_stale_pending`) 실행(Alert-only, REQ-014 회귀 없음).
## Acceptance Criteria
1. Given dispatched-pending 슬롯에 `timeout_sec=10` 설정, When 경과 시간 10초 초과, Then 집행 루프가 `broker.cancel_order` 를 송신하고 `timeout_cancelled` 카운터가 증가한다.
2. Given cancel_order 응답 True(브로커 수락), When 다음 poll, Then 슬롯은 `is_terminal_reject` 경로로 rollback 된다(pending_buy→inactive 또는 pending_sell→holding).
3. Given timeout 취소 전 일부 체결(is_partial_cancel), When 다음 poll, Then 기존 `_on_partial_cancel` 경로 — 체결분 원장 기록·잔량 holding 복귀(부분 보존, dcy-013 선례).
4. Given cancel_order 가 False 반환(이미 체결 — 취소-체결 race), When 다음 poll, Then `is_filled` 경로로 정상 체결 처리(임의 롤백 없음). (FR-4 race 안전)
5. Given `timeout_sec` 없는 dispatched-pending 슬롯, When 경과 시간 초과, Then cancel_order 미송신 — 기존 stale_pending WARN 만(Alert-only, REQ-014 회귀 없음).
6. Given cancel_order 송신 실패(예외), When 처리, Then WARNING 로그 + `timeout_cancelled` 미증가, 슬롯 상태 불변(비차단, NFR-2).
## Impacted Area
- Docs: docs/runtime-flows.md(timeout 취소 흐름 추가)
- Modules: trex_core/application/execution.py(`_poll` timeout cancel, ExecutionResult +timeout_cancelled)
- Tests: tests/application/test_execution.py(timeout→cancel), test_execution_cancel.py(timeout 부분 보존)
## Notes
- `updated_at` 를 dispatch 시각 proxy 로 사용(mark_dispatched 가 updated_at 갱신 — `_check_stale_pending` 과 동일 방법).
- best-effort 원칙: 송신 실패 시 다음 틱 재시도(threshold 넘으면 재송신). 중복 cancel_order 는 브로커가 멱등 처리(기존 KIS/paper 어댑터 계약).
- 출처: dcy-022 FR-2, FR-4, NFR-2.
## Change Log
### CHG-20260610-01
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- dcy-022 FR-2/FR-4 로부터 최초 생성(Proposed). timeout 취소·race 처리·부분 보존.
#### Intent
- dispatched-pending 주문 timeout 집행 + 취소-체결 race 안전 처리를 backlog 로 정규화.
#### Acceptance Criteria Delta
- Added: AC-1~6 (timeout cancel, 전량/부분 취소 수렴, race 안전, 하위호환, 비차단 6개 기준)
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Modules: execution.py(_poll), ExecutionResult
- Tests: test_execution, test_execution_cancel
#### Delivery Trace Delta
- Batches: none
- Releases: none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 BAT-032.
#### Validation Plan
- timeout → cancel_order 송신 + 롤백 수렴 단위. race(cancel→filled 수렴) 단위.
### CHG-20260610-02
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- Status Proposed → Approved (confirm-req).
#### Intent
- delivery input 으로 승격.
#### Acceptance Criteria Delta
- Added: 없음 / Updated: 없음 / Removed: 없음
#### Impacted Area Delta
- Docs: docs/srs/index.md (Register Approved)
#### Delivery Trace Delta
- Batches: none
#### Revalidation Impact
- Reverification Needed: no / Existing Implementation Invalidated: no / Status Recommendation: keep-current / Follow-up Action: BAT-032.
#### Validation Plan
- 승인 후 batch planning 에서 AC 검증 기준으로 사용.
### CHG-20260611-IMPL
- Date: 2026-06-11
- Author: Justin Ko
- Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-032(release-candidate) verification — 실행 정책(timeout_sec·slippage_pct) 계약·집행·운영자 cancel 백스톱 전 AC acceptance mapping 존재(신규 71 tests), 단위 425 passed 회귀 없음, baseline 2문서 수동 확인(M5). Blocking Issues 없음.
@@ -0,0 +1,89 @@
# req-070: slippage 보호 지정가 집행 — entry_price 기준 실효 지정가 산출
- Status: Implemented
- Type: Integration
- Priority: High
- Owner: project maintainers
## Intent
- `slippage_pct` 가 지정된 주문을 dispatch 할 때 `entry_price*(1±pct)` 실효 지정가로 제출해 과도한 불리 체결을 막는다. `entry_price` 없으면 무시(순수 시장가는 슬리피지 기준 없음). (dcy-022 FR-3)
## Requirement
- `_dispatch` 가 슬롯의 `slippage_pct` 를 읽어 실효 지정가를 산출한다: 매수 = `limit_price * (1 + slippage_pct/100)` (올림, ceil), 매도 = `limit_price * (1 - slippage_pct/100)` (내림, floor). `limit_price`(= `entry_price`)가 None 이면 `slippage_pct` 를 무시하고 시장가 주문 제출(기존 동작 보존). KRX 호가단위 반올림은 체결 보장 방향(매수=상위 단위, 매도=하위 단위)으로 적용한다(단, 테스트에서는 간단한 int() 사용 가능 — 정밀 호가단위 테이블은 후속). `slippage_pct` 없는 슬롯은 기존 `limit_price` 직접 사용(회귀 없음).
## Acceptance Criteria
1. Given 슬롯에 `limit_price=10000`, `slippage_pct=1.0`, side=buy, When dispatch, Then OrderRequest.limit_price = `ceil(10000*1.01)` = 10100.
2. Given 슬롯에 `limit_price=10000`, `slippage_pct=1.0`, side=sell, When dispatch, Then OrderRequest.limit_price = `floor(10000*0.99)` = 9900.
3. Given `limit_price=None`(시장가), `slippage_pct=1.0`, When dispatch, Then OrderRequest.limit_price = None(시장가 — slippage 무시).
4. Given `slippage_pct=None`, `limit_price=10000`, When dispatch, Then OrderRequest.limit_price = 10000(기존 동작, 회귀 없음).
5. Given `slippage_pct=0.0`, When dispatch, Then 실효 지정가 = limit_price(0% 슬리피지 = 기준가 그대로).
6. Given 기존 매수/매도 슬롯(slippage_pct 없음), When dispatch, Then 기존 동작과 동일(전체 회귀 없음).
## Impacted Area
- Docs: docs/runtime-flows.md(slippage dispatch 흐름)
- Modules: trex_core/application/execution.py(`_dispatch` slippage 계산)
- Tests: tests/application/test_execution.py(slippage dispatch 단위)
## Notes
- `entry_price` = `limit_price` 로 슬롯에 저장되는 설계를 그대로 사용(REQ-013 CHG-20260608-01 선례). 슬리피지는 dispatch-time 변환만 — 슬롯의 `limit_price` 자체를 덮어쓰지 않는다.
- KRX 정밀 호가단위 반올림(500원 미만 → 1원, 500~2000 → 5원 등)은 후속 주기. 현재 구현은 int(math.ceil/floor) 로 충분(테스트 범위 내).
- 출처: dcy-022 FR-3, OQ-2.
## Change Log
### CHG-20260610-01
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- dcy-022 FR-3 로부터 최초 생성(Proposed).
#### Intent
- slippage 보호 지정가 dispatch 집행을 backlog 로 정규화.
#### Acceptance Criteria Delta
- Added: AC-1~6 (매수/매도 slippage 계산, 시장가 무시, 없을 때 회귀 6개 기준)
#### Impacted Area Delta
- Modules: execution.py(_dispatch)
- Tests: test_execution.py slippage 단위
#### Delivery Trace Delta
- Batches: none
#### Revalidation Impact
- Reverification Needed: no / Existing Implementation Invalidated: no / Status Recommendation: keep-current
#### Validation Plan
- slippage 산출 단위 + 시장가/없을 때 회귀.
### CHG-20260610-02
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- Status Proposed → Approved (confirm-req).
#### Intent
- delivery input 으로 승격.
#### Acceptance Criteria Delta
- Added: 없음 / Updated: 없음 / Removed: 없음
#### Impacted Area Delta
- Docs: docs/srs/index.md (Register Approved)
#### Delivery Trace Delta
- Batches: none
#### Revalidation Impact
- Reverification Needed: no / Existing Implementation Invalidated: no / Status Recommendation: keep-current / Follow-up Action: BAT-032.
#### Validation Plan
- 승인 후 batch planning 에서 AC 검증 기준으로 사용.
### CHG-20260611-IMPL
- Date: 2026-06-11
- Author: Justin Ko
- Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-032(release-candidate) verification — 실행 정책(timeout_sec·slippage_pct) 계약·집행·운영자 cancel 백스톱 전 AC acceptance mapping 존재(신규 71 tests), 단위 425 passed 회귀 없음, baseline 2문서 수동 확인(M5). Blocking Issues 없음.
@@ -16,10 +16,11 @@
2. Given 이식된 도메인 코드, When 정적 import 스윕, Then stdlib 와 `trex_core.domain.slot` 내부 참조 외 외부 의존(psycopg/네트워크/CLI/config) import 가 0이다. (NFR-1)
3. Given 공개 API, When `__init__.py` 의 `__all__` 을 확인, Then `PostgreSQLSlotRepository`·`check_slot_domain_schema_drift` 등 인프라 심볼이 노출되지 않는다.
4. Given `SlotService`, When 6 상태/9 base 전이(`closed→pending_buy` 포함, dcy-019)/advisory-lock 직렬화/`closed→idle` 재진입 +1/`→pending_buy` 시 slot `entry_count` +1(dcy-019)/`closed→pending_buy` clean-slate 점유 초기화/lock 보유 중 외부 I/O 금지 동작을 확인, Then 보존된다. (NFR-2 — 검증은 REQ-003)
5. (CHG-20260610-02, dcy-023/issue #22) Given allocated_capital 제거 후, Then `Slot` 모델과 `EXPECTED_SLOT_FIELDS` 에 `allocated_capital` 필드가 없고, `SlotService` 의 설정/전이 경로(buy 진입·전이 시 set/clear)에서 해당 파라미터가 제거된다.
## Impacted Area
- Docs: docs/interface-contract.md, docs/data-model.md (계약 기준 — 큰 변경 없을 수 있음)
- Modules: trex_core/domain/slot/{__init__,state,models,errors,repository,service,in_memory_repository}.py
- Modules: trex_core/domain/slot/{__init__,state,models,errors,repository,service,in_memory_repository}.py, (CHG-20260610-02) models.py `Slot`/`EXPECTED_SLOT_FIELDS` + service.py allocated_capital 제거
- Tests: REQ-003 에서 이식 단위테스트 + 계약 테스트로 검증
## Notes
@@ -271,3 +272,52 @@
#### Validation Plan
- dcy-020 상태머신 배치의 verification 으로 대체.
### CHG-20260610-02
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-023/issue #22) 죽은 필드 `allocated_capital` 을 슬롯 도메인에서 제거 — `Slot` 모델·`EXPECTED_SLOT_FIELDS` 계약·`SlotService` 의 설정/전이(set/clear) 경로에서 삭제. 소비처(의사결정/회계/사이징) 0건 확인(dcy-023 §3). AC-5 추가, Implemented→Approved(재구현 대기).
#### Intent
- 실제 소비되는 필드만 슬롯 계약에 남겨 유지 비용과 "capital 이 사이징한다"는 오해를 제거. dcy-021 사이징 계약(전략이 qty 계산, core 미관여) 확정의 귀결.
#### Acceptance Criteria Delta
- Added:
- AC-5: Slot 모델·EXPECTED_SLOT_FIELDS·SlotService 경로에서 allocated_capital 부재.
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Modules: trex_core/domain/slot/models.py(Slot/EXPECTED_SLOT_FIELDS), trex_core/domain/slot/service.py(설정/전이 경로 파라미터 제거)
- Tests: tests/domain/slot/ 계약 테스트(필드셋) + allocated_capital 참조 테스트 정리
#### Delivery Trace Delta
- Batches: (예정) dcy-023 batch
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (dcy-023 batch 에서 수행)
- Existing Implementation Invalidated: yes (필드 제거 — 기존 필드셋 계약 evidence 무효)
- Status Recommendation: Approved (Implemented→Approved 되돌림)
- Follow-up Action: dcy-023 batch 구현·검증 후 confirm-req-implemented.
#### Validation Plan
- dcy-023 batch: 필드셋 계약 테스트(allocated_capital 부재) + 전체 suite 회귀 green.
### CHG-20260610-IMPL
- Date: 2026-06-10
- Author: Justin Ko
- Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-034(release-candidate) verification — allocated_capital 전 경로 제거(도메인/intent/영속/snapshot/CLI), 마이그레이션 013 테스트 DSN 멱등 round-trip + 컬럼 부재 확인, 전체 428 passed + PG 게이트 21 passed + ruff clean, grep 전수 0건. Blocking Issues 없음.
@@ -17,10 +17,11 @@
3. Given 이미 처리된 `intent_id`, When 동일 intent 재전송, Then 슬롯이 중복 전이되지 않고 최초 결정과 동일한 응답을 돌려준다(멱등). (NFR-3)
4. Given 동일 슬롯에 대한 동시 intent, When 병렬 처리, Then advisory lock 으로 직렬화되어 단일 writer 불변식이 유지된다(매수·매도 전략은 슬롯을 직접 쓰지 않음). (NFR-2)
5. Given 수락된 intent, When `GET /v1/intents/{intent_id}`, Then 기록된 decision 을 반환한다.
6. (CHG-20260610-02, dcy-023/issue #22) Given buy intent payload, Then `allocated_capital` 은 계약 필드가 아니다 — `BuyIntent` 에 필드가 없고 admission 경로에서 전달되지 않으며, 외부 발신자가 payload 에 키를 포함해도 unknown key 로 무시된다(즉시 제거, dcy-023 OQ-1).
## Impacted Area
- Docs: docs/interface-contract.md(HTTP API 계약), docs/runtime-flows.md(요청 흐름) — REQ-009 에서 정합
- Modules: trex_core/ 하위 API 어댑터(FastAPI 라우터/스키마) + 애플리케이션 서비스; 도메인 패키지는 무의존 유지(NFR-1)
- Modules: trex_core/ 하위 API 어댑터(FastAPI 라우터/스키마) + 애플리케이션 서비스; 도메인 패키지는 무의존 유지(NFR-1); (CHG-20260610-02) intents.py BuyIntent·admission + messaging_handlers.py payload 파싱 allocated_capital 제거
- Tests: API 통합테스트(정상/거부/멱등 재전송/동시성)
## Notes
@@ -351,3 +352,52 @@
#### Validation Plan
- dcy-020 admission/intent 배치 verification 으로 대체.
### CHG-20260610-02
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-023/issue #22) buy intent payload 계약에서 `allocated_capital` 즉시 제거 — `BuyIntent` 데이터클래스 필드·admission 전달(`_admit_buy`→SlotService)·messaging handler payload 파싱(`body.get("allocated_capital")`) 삭제. 외부 발신자(trex-orb-v1)가 키를 계속 송신해도 unknown key 로 무시되어 무해(dcy-023 OQ-1 운영자 결정: 즉시 제거, 유예 없음). AC-6 추가, Implemented→Approved(재구현 대기).
#### Intent
- 사이징·회계 소비자가 없는 운반 전용 필드를 intent 계약에서 제거. dcy-021 명령형 qty 사이징 계약과 정합.
#### Acceptance Criteria Delta
- Added:
- AC-6: BuyIntent 에 allocated_capital 부재 + 수신 payload 의 해당 키 무시.
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Modules: trex_core/application/intents.py(BuyIntent/admission), trex_core/application/messaging_handlers.py(payload 파싱)
- Tests: intent admission·handler 파싱 테스트에서 allocated_capital 참조 정리 + 키 포함 payload 무시 테스트
#### Delivery Trace Delta
- Batches: (예정) dcy-023 batch
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (dcy-023 batch 에서 수행)
- Existing Implementation Invalidated: yes (BuyIntent 필드·파싱 제거)
- Status Recommendation: Approved (Implemented→Approved 되돌림)
- Follow-up Action: dcy-023 batch 구현·검증 후 confirm-req-implemented.
#### Validation Plan
- dcy-023 batch: allocated_capital 포함 payload 가 정상 admit 되며 슬롯·로그에 미반영(무시) + 전체 suite green.
### CHG-20260610-IMPL
- Date: 2026-06-10
- Author: Justin Ko
- Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-034(release-candidate) verification — allocated_capital 전 경로 제거(도메인/intent/영속/snapshot/CLI), 마이그레이션 013 테스트 DSN 멱등 round-trip + 컬럼 부재 확인, 전체 428 passed + PG 게이트 21 passed + ruff clean, grep 전수 0건. Blocking Issues 없음.
@@ -20,10 +20,11 @@
2. Given portfolio-hub 불가(권위 cash/position 유지 불가), When 요청, Then `degraded=true` + reason code 가 포함되고 raw 예외/자격증명 등 민감정보는 노출되지 않는다.
3. Given 응답 항목, Then 필드는 symbol/asset_id/cash_balance/asset_quantity/active_strategy_id/strategy_params(opaque) 이고 가격 필드는 없다.
4. (CHG-20260608-01) Given 스냅샷 요청, Then envelope 에 read-only `slots[]` 배열을 포함한다 — 각 항목은 `slot_id`·`status`·`symbol`·`qty`·`limit_price`·`avg_price`·`portfolio_id` 를 담고, 슬롯이 없어도 `[]`(null 아님)다. `assets[]` 와 독립이며 strategy 소비자는 무시 가능(additive, 하위호환).
5. (CHG-20260610-01, dcy-023/issue #22) Given 스냅샷 응답, Then `slots[]` 항목에 `allocated_capital` 키가 없다 — 구현이 계약 외로 노출하던 죽은 필드를 제거해 AC-4 의 문서화된 필드 집합과 구현을 정렬한다.
## Impacted Area
- Docs: docs/discovery/dcy-005_20260604_portfolio-hub-asset-snapshot-api.md, docs/discovery/dcy-016_20260608_operator-cli-rpc-commands.md, docs/interface-contract.md
- Modules: trex_core/adapters/messaging (snapshot RPC 핸들러), trex_core/application/portfolio.py (`build_snapshot_envelope` — `slots[]`), 응답 스키마(transport-무관), trex_core/runtime/cli.py (REQ-032 companion)
- Modules: trex_core/adapters/messaging (snapshot RPC 핸들러), trex_core/application/portfolio.py (`build_snapshot_envelope` — `slots[]`, (CHG-20260610-01) allocated_capital 노출 제거), 응답 스키마(transport-무관), trex_core/runtime/cli.py (REQ-032 companion)
- Tests: envelope 스키마 검증(빈 응답 포함), degraded 주입 테스트, `slots[]` 스키마(빈 배열·status enum) + `trex-core slots` 실제 슬롯 상태 표시
## Notes
@@ -206,3 +207,52 @@
#### Validation Plan
- AC-4: tests/application/test_snapshot_selection.py::test_envelope_shape_no_price_field + test_snapshot_handler.py::test_envelope_shape_returned(envelope `slots` list). 운영 재현: 신규 코드 데몬 + 실 PostgreSQL/RabbitMQ 에서 `trex-core slots` → slots=10·status 표시·DB `(2026-06-08,idle,10)` 일치(BAT-022 verification). pytest 인프라 게이트는 Human Approval Memo 수용 잔여.
### CHG-20260610-01
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-023/issue #22) snapshot envelope `slots[]` 에서 `allocated_capital` 노출 제거 — `build_snapshot_envelope`(trex_core/application/portfolio.py) 가 계약(AC-4 필드 집합) 외로 노출하던 죽은 필드를 삭제해 문서화된 계약과 구현을 정렬. AC-5 추가, Implemented→Approved(재구현 대기).
#### Intent
- Slot 도메인에서 allocated_capital 이 제거(REQ-002 CHG-20260610-02)됨에 따라 read 표면에서도 동일 필드를 제거. strategy/operator 소비자가 죽은 필드를 참조할 여지 차단.
#### Acceptance Criteria Delta
- Added:
- AC-5: slots[] 항목에 allocated_capital 키 부재.
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Modules: trex_core/application/portfolio.py(build_snapshot_envelope slots[] 매핑)
- Tests: snapshot envelope slots[] 스키마 테스트에서 allocated_capital 부재 확인 + 기존 fixture 정리
#### Delivery Trace Delta
- Batches: (예정) dcy-023 batch
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (dcy-023 batch 에서 수행)
- Existing Implementation Invalidated: yes (slots[] 노출 필드 변경)
- Status Recommendation: Approved (Implemented→Approved 되돌림)
- Follow-up Action: dcy-023 batch 구현·검증 후 confirm-req-implemented. CLI slots detail 표시 제거는 REQ-032 CHG-20260610-07 이 소유.
#### Validation Plan
- dcy-023 batch: envelope slots[] 스키마 테스트(allocated_capital 부재) + 전체 suite green.
### CHG-20260610-IMPL
- Date: 2026-06-10
- Author: Justin Ko
- Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-034(release-candidate) verification — allocated_capital 전 경로 제거(도메인/intent/영속/snapshot/CLI), 마이그레이션 013 테스트 DSN 멱등 round-trip + 컬럼 부재 확인, 전체 428 passed + PG 게이트 21 passed + ruff clean, grep 전수 0건. Blocking Issues 없음.
@@ -23,11 +23,13 @@
4. Given CLI 로 pause 후 RPC 제어로 조회(또는 그 반대), Then 동일한 pause 상태가 보인다.
5. (CHG-20260609-01) Given 운영자가 `trex-core enter <portfolio_id> ... --forced-close-at <ISO8601>`, When buy intent 가 제출·admit, Then payload 에 파싱된 `forced_close_at` 절대시각이 실리고 admitted 슬롯의 `forced_close_at` 이 그 값으로 설정된다.
6. (CHG-20260609-01) Given `--forced-close-at` 미지정, Then payload 에 `forced_close_at` 을 포함하지 않고 기존 enter 동작이 불변이다. Given 잘못된 timestamp 형식, Then 제출 전 검증 오류로 거부된다.
7. (CHG-20260610-06, issue #23) Given 운영자가 `trex-core enter <portfolio_id>` 에 `--qty` 를 지정하지 않으면, Then CLI 가 intent 제출 전에 즉시 오류를 반환하고 RPC 는 호출되지 않는다.
8. (CHG-20260610-07, dcy-023/issue #22) Given 운영자 CLI, Then `enter` 는 `--capital` 옵션을 받지 않고(지정 시 argparse unknown argument 거부), buy intent payload 에 `allocated_capital` 을 싣지 않으며, `slots` detail 표시에서 `allocated_capital` 행이 제거된다.
## Impacted Area
- Docs: docs/discovery/dcy-005_20260604_portfolio-hub-asset-snapshot-api.md
- Modules: pause 상태 store(Postgres), CLI 제어, dcy-006 RPC 제어 메시지 핸들러, 응답 산출 시 pause 마스킹, (CHG-20260609-01) `trex_core/runtime/cli.py` `_cmd_enter` `--forced-close-at` 옵션 → buy intent `forced_close_at` 매핑
- Tests: pause→호출→resume→호출 시퀀스, 재시작/refresh 후 영속, (CHG-20260609-01) enter `--forced-close-at` payload 매핑·형식 검증
- Modules: pause 상태 store(Postgres), CLI 제어, dcy-006 RPC 제어 메시지 핸들러, 응답 산출 시 pause 마스킹, (CHG-20260609-01) `trex_core/runtime/cli.py` `_cmd_enter` `--forced-close-at` 옵션 → buy intent `forced_close_at` 매핑, (CHG-20260610-06) `trex_core/runtime/cli.py` `_cmd_enter` `--qty` 미지정 시 제출 전 오류 처리, (CHG-20260610-07) `trex_core/runtime/cli.py` `--capital` argparse 제거 + payload `allocated_capital` 제거 + `_print_slot_detail` 행 제거
- Tests: pause→호출→resume→호출 시퀀스, 재시작/refresh 후 영속, (CHG-20260609-01) enter `--forced-close-at` payload 매핑·형식 검증, (CHG-20260610-06) enter `--qty` 누락 오류 + RPC 미호출, (CHG-20260610-07) `--capital` argparse 거부 + payload allocated_capital 부재
## Notes
- pause(수동·영속) vs warning(자동·일시, REQ-031)은 독립 overlay 이며 OR 합성(둘 중 하나라도면 마스킹).
@@ -370,3 +372,93 @@
#### Validation Plan
- tests/runtime/test_cli_operator.py::test_enter_with_forced_close_at_maps_to_payload (AC-5) / ::test_enter_without_forced_close_at_omits_field (AC-6 하위호환) / ::test_enter_rejects_malformed_forced_close_at_before_submit (AC-6 형식 거부). 전체 단위 353 passed, ruff clean.
### CHG-20260610-06
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- (change-req, issue #23) `trex-core enter`에 **`--qty` 미지정 시 제출 전 즉시 오류 반환** 추가. 현재 `--qty` 생략 → `qty=None` → admitted 응답 → 집행 루프 KIS 거절 → `pending_buy→inactive` 자동 롤백으로 "접수 성공처럼 보이지만 실제 매수 안 됨" 혼동 발생. CLI 단에서 admission 전에 차단.
#### Intent
- 운영자가 수량 없이 `enter`를 실행하면 RPC 제출 이전에 즉시 오류를 보고 혼동 없이 재시도할 수 있도록 한다.
#### Acceptance Criteria Delta
- Added:
- AC-7: `trex-core enter <portfolio_id>` 에 `--qty` 를 지정하지 않으면 CLI 가 intent 제출 전에 즉시 오류를 반환하고 RPC 는 호출되지 않는다.
- Updated: none
- Removed: none
#### Impacted Area Delta
- Modules: `trex_core/runtime/cli.py`(`_cmd_enter` 또는 argparse에 `--qty` 검증 추가)
- Tests: `tests/runtime/test_cli_operator.py`(enter `--qty` 누락 오류 + RPC 미호출 검증)
#### Delivery Trace Delta
- Batches: 신규 batch-lite(차기 BAT) — enter `--qty` 필수 검증
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (신규 batch-lite 에서 AC-7 검증)
- Existing Implementation Invalidated: no (AC-1~6 기존 동작 불변, additive 검증 추가)
- Status Recommendation: Implemented → Approved (AC-7 미구현; 기존 AC-1~6 구현은 유효)
- Follow-up Action: draft-batch(batch-lite) → run-batch-implementation → verify → confirm-req-implemented
#### Validation Plan
- 신규 batch-lite: `enter` 에 `--qty` 없이 실행 → rc=1 + RPC calls=0 단위. `--qty 10`과 함께 실행 → 정상 submit 불변(회귀) 단위.
### CHG-20260610-07
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-023/issue #22) 운영자 CLI 에서 `--capital` 옵션 제거 — `enter` argparse 인자 삭제(지정 시 unknown argument 거부), `_cmd_enter` summary·payload 의 `allocated_capital` 제거, `slots` detail(`_print_slot_detail`)의 `allocated_capital` 행 제거. AC-8 추가, Implemented→Approved(재구현 대기).
#### Intent
- `--capital` 이 사이징한다는 오해를 CLI 표면에서 원천 제거. 실제 수량은 `--qty`(AC-7 필수)만이 결정하며, capital 은 어떤 경로에서도 소비되지 않음(dcy-023 §3 전수 추적).
#### Acceptance Criteria Delta
- Added:
- AC-8: `--capital` argparse 거부 + payload allocated_capital 부재 + slots detail 행 제거.
- Updated:
- 없음
- Removed:
- 없음
#### Impacted Area Delta
- Modules: trex_core/runtime/cli.py(`--capital` 인자, `_cmd_enter` summary/payload, `_print_slot_detail`)
- Tests: tests/runtime/test_cli_operator.py — 기존 enter 테스트의 `--capital` 인자 제거 + argparse 거부 테스트 추가
#### Delivery Trace Delta
- Batches: (예정) dcy-023 batch. BAT-033(AC-7, release-candidate)의 enter 테스트와 같은 파일을 수정하므로 dcy-023 batch 에서 정합 유지.
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (dcy-023 batch 에서 수행)
- Existing Implementation Invalidated: yes (enter 인터페이스 변경 — 기존 --capital 사용 테스트/스크립트 무효)
- Status Recommendation: Approved (Implemented→Approved 되돌림)
- Follow-up Action: dcy-023 batch 구현·검증 후 confirm-req-implemented. AC-1~7 의 기존 evidence 는 유지되나 enter 표면 변경분 재검증 필요.
#### Validation Plan
- dcy-023 batch: `enter --capital` argparse 거부(단위) + enter payload allocated_capital 부재 + slots detail 표시 + 전체 suite green.
### CHG-20260610-IMPL
- Date: 2026-06-10
- Author: Justin Ko
- Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-034(release-candidate) verification — allocated_capital 전 경로 제거(도메인/intent/영속/snapshot/CLI), 마이그레이션 013 테스트 DSN 멱등 round-trip + 컬럼 부재 확인, 전체 428 passed + PG 게이트 21 passed + ruff clean, grep 전수 0건. Blocking Issues 없음.
@@ -167,3 +167,53 @@
- Author: Justin Ko
- Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-031(release-candidate) verification — 단위 378 / 통합 422 passed + run-the-app 사이징 e2e(증액 가중평균·부분 매도 잔량·전량 매도) green. Blocking Issues 없음.
### CHG-20260610-05
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-022 실행 정책) buy/sell intent 계약에 **실행 정책 필드**(optional `timeout_sec`·`slippage_pct`) 추가. `timeout_sec`: 주문 유효시간(N초 미체결 시 core 가 cancel_order 송신·롤백). `slippage_pct`: 체결 보호밴드(entry_price 기준 ±pct, entry_price 없으면 무시). 두 필드 모두 optional — 미지정 시 기존 경로 하위호환. `timeout_sec` 미지정 시 운영자 per-instance config(`default_order_timeout_sec`) 적용(미설정=OFF=무기한). Implemented → Approved 되돌림(신규 계약 필드 미구현, BAT-032 에서 재구현·confirm-req-implemented 복귀).
#### Intent
- 전략이 intent 제출 시 실행 정책을 함께 실어 미체결 고착을 원천 차단한다(dcy-022 FR-1).
#### Acceptance Criteria Delta
- Added:
- AC-8: buy/sell intent 에 optional `timeout_sec` 포함 시 core 가 파싱해 슬롯에 기록하고 집행 루프가 timeout 초과 시 cancel_order 를 송신한다.
- AC-9: buy/sell intent 에 optional `slippage_pct` 포함 시 `entry_price` 기준 실효 지정가를 산출해 주문을 제출한다(`entry_price` 없으면 무시).
- AC-10: `timeout_sec`/`slippage_pct` 미지정 시 기존 매수/매도 경로와 동일하게 처리된다(하위호환).
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs: docs/interface-contract.md(payload 확장)
- Modules: BuyIntent/SellIntent(+timeout_sec/slippage_pct), messaging_handlers.py(파싱), intents.py(transit 전달), slot models(+timeout_sec/slippage_pct 컬럼), execution.py(dispatch slippage·poll timeout)
- Tests: timeout/slippage 파싱·집행 단위, 하위호환 회귀
#### Delivery Trace Delta
- Batches: BAT-032
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (신규 계약 필드 경로)
- Existing Implementation Invalidated: no (기존 slot_id 매수·매도 계약 보존, additive)
- Status Recommendation: revert-to-approved (신규 필드 미구현 — BAT-032 에서 구현·검증)
- Follow-up Action: BAT-032 구현 후 Implemented 복귀.
#### Validation Plan
- timeout_sec/slippage_pct 파싱·슬롯 기록·집행 단위. 미지정 회귀. end-to-end timeout 취소 시나리오.
### CHG-20260611-IMPL
- Date: 2026-06-11
- Author: Justin Ko
- Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-032(release-candidate) verification — 실행 정책(timeout_sec·slippage_pct) 계약·집행·운영자 cancel 백스톱 전 AC acceptance mapping 존재(신규 71 tests), 단위 425 passed 회귀 없음, baseline 2문서 수동 확인(M5). Blocking Issues 없음.
@@ -0,0 +1,97 @@
# req-068: intent 실행 정책 계약 — timeout_sec + slippage_pct
- Status: Implemented
- Type: Interface
- Priority: High
- Owner: project maintainers
## Intent
- buy/sell intent 계약에 **실행 정책 필드**(`timeout_sec`·`slippage_pct`)를 추가해 전략이 "어떻게 체결할지"를 함께 선언한다. core 는 수신 측 먼저 배포 가능(optional, 하위호환). (dcy-022 FR-1)
## Requirement
- buy/sell intent payload 에 optional 필드 추가: `timeout_sec`(주문 유효시간 — N초 미체결 시 core 가 cancel_order 송신·롤백) · `slippage_pct`(체결 보호밴드 — entry_price 기준 ±pct 실효 지정가). 두 필드 모두 optional — 미지정 시 기존 동작 하위호환(무기한 지정가/시장가). `timeout_sec` 미지정 시 per-instance TOML `default_order_timeout_sec`(opt-in, 미설정=OFF=무기한) 를 기본값으로 적용하고 per-intent 값이 override 한다. `slippage_pct` 기본값 없음(미지정=보호밴드 없음). core 수신 측 먼저 배포(누락 시 무시 — orb-v1 발신과 lockstep 불필요). deprecate 없음(전부 신규 optional 필드, 기존 계약 보존).
## Acceptance Criteria
1. Given buy intent 에 `timeout_sec=60` 포함, When core 수신, Then 슬롯에 `timeout_sec=60` 이 기록되어 집행 루프가 읽을 수 있다.
2. Given buy intent 에 `slippage_pct=0.1` 포함, When core 수신, Then 슬롯에 `slippage_pct=0.1` 이 기록되어 dispatch 시 실효 지정가 산출에 쓰인다.
3. Given `timeout_sec` 미지정 intent, When per-instance config `default_order_timeout_sec=120`, Then 슬롯에 `timeout_sec=120` 이 기록된다(config 기본값 적용).
4. Given `timeout_sec` 미지정 + config 미설정, When 수신, Then 슬롯 `timeout_sec` 이 None — 기존 무기한 동작과 동일(하위호환).
5. Given 기존 buy/sell intent(timeout/slippage 필드 없음), When 수신, Then 기존 경로와 byte-동등하게 처리된다(회귀 없음).
6. Given per-intent `timeout_sec` 와 config `default_order_timeout_sec` 둘 다 있음, When 수신, Then per-intent 값이 우선한다(override).
## Impacted Area
- Docs: docs/interface-contract.md(payload 확장)
- Modules: trex_core/application/intents.py(BuyIntent/SellIntent +timeout_sec/slippage_pct, IntentService +default_order_timeout_sec), trex_core/application/messaging_handlers.py(파싱), trex_core/domain/slot/models.py(+컬럼), trex_core/domain/slot/service.py(transit_slot 전달), trex_core/adapters/persistence/sql/012_add_execution_policy_fields.sql(DB)
- Config: DaemonInstanceConfig +default_order_timeout_sec
- Tests: 파싱·슬롯 기록·config 기본값·하위호환 단위
## Notes
- `timeout_sec` 는 코어-타이밍(N초 후 cancel_order 송신) 이며 IOC/FOK 등 네이티브 TIF 는 후속 주기(dcy-022 OQ-1).
- `slippage_pct` 는 `entry_price` 있을 때만 유효(OQ-2). 필드 역할: `entry_price`=기준·목표, `slippage_pct`=허용폭 → 실효 limit = entry_price*(1±pct). `timeout_sec` ≠ `forced_close_at`(포지션 만기 청산, REQ-037).
- 출처: dcy-022 FR-1, OQ-1/OQ-2/OQ-3/OQ-4.
## Change Log
### CHG-20260610-01
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- dcy-022 FR-1 로부터 최초 생성(Proposed). intent 실행 정책 계약(timeout_sec + slippage_pct) 신규 정의.
#### Intent
- 실행 정책 optional 필드 계약을 backlog 로 정규화.
#### Acceptance Criteria Delta
- Added: AC-1~6 (timeout_sec 기록·config 기본값·slippage_pct 기록·하위호환·override 6개 기준)
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs: docs/interface-contract.md
- Modules: BuyIntent/SellIntent, messaging_handlers, intents, slot models, service, migration 012
- Tests: 파싱·기록·config·하위호환 단위
#### Delivery Trace Delta
- Batches: none
- Releases: none
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 BAT-032 편성.
#### Validation Plan
- timeout_sec/slippage_pct 파싱·슬롯 기록·config 기본값·하위호환 단위 테스트.
### CHG-20260610-02
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- Status Proposed → Approved (confirm-req). 필수 필드·구체 AC 충족, source dcy-022(confirmed, OQ-1~4 Answered, Freeze 승인)과 모순 없음.
#### Intent
- delivery input 으로 승격.
#### Acceptance Criteria Delta
- Added: 없음 / Updated: 없음 / Removed: 없음
#### Impacted Area Delta
- Docs: docs/srs/index.md (Register Approved) / Modules: 없음 / Tests: 없음
#### Delivery Trace Delta
- Batches: none / Releases: none
#### Revalidation Impact
- Reverification Needed: no / Existing Implementation Invalidated: no / Status Recommendation: keep-current / Follow-up Action: BAT-032 편성.
#### Validation Plan
- 승인 후 batch planning 에서 본 REQ AC 를 검증 기준으로 사용.
### CHG-20260611-IMPL
- Date: 2026-06-11
- Author: Justin Ko
- Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-032(release-candidate) verification — 실행 정책(timeout_sec·slippage_pct) 계약·집행·운영자 cancel 백스톱 전 AC acceptance mapping 존재(신규 71 tests), 단위 425 passed 회귀 없음, baseline 2문서 수동 확인(M5). Blocking Issues 없음.
@@ -0,0 +1,89 @@
# req-071: 운영자 강제 취소 백스톱 — trex-core cancel <slot_id>
- Status: Implemented
- Type: Interface
- Priority: Medium
- Owner: project maintainers
## Intent
- timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 운영자가 `trex-core cancel <slot_id>` 로 강제 취소·롤백할 수 있는 백스톱 경로를 제공한다. dcy-013 Alert-only 는 기본 유지(REQ-014 회귀 없음). (dcy-022 FR-5)
## Requirement
- CLI `trex-core cancel <slot_id>` 가 daemon 의 control RPC 채널(`trex.control.req`, REQ-043)에 `cancel_pending` 액션을 전송한다. daemon 수신 시: ① 슬롯이 `pending_buy`/`pending_sell` 이면 `broker.cancel_order` 를 송신하고, 다음 집행 루프 poll 에서 rollback 이 수렴한다(기존 is_terminal_reject/is_partial_cancel 경로). ② 슬롯이 HOLDING/INACTIVE 이면 `not_pending` 응답(무동작). ③ 슬롯 없으면 `slot_not_found`. 기존 kill_switch·pause 와 동일하게 운영자 단일-writer 준수(슬롯 직접 쓰기 없음 — cancel_order 만 송신, 상태 전이는 집행 루프).
## Acceptance Criteria
1. Given pending_buy 슬롯, When `trex-core cancel <slot_id>`, Then broker.cancel_order 가 호출되고 응답 `{status: "ok", slot_id: ...}` 를 반환한다.
2. Given cancel_order 가 호출된 후, When 집행 루프 다음 poll, Then 슬롯이 inactive/holding 으로 수렴한다(기존 is_terminal_reject/is_partial_cancel 경로 재사용).
3. Given pending_sell 슬롯, When `trex-core cancel <slot_id>`, Then pending_buy 와 동일하게 cancel_order 호출 + ok 응답.
4. Given holding/inactive 슬롯, When `cancel` 시도, Then `{status: "ignored", reason: "not_pending"}` 응답(무동작).
5. Given 존재하지 않는 slot_id, When `cancel` 시도, Then `{status: "error", reason: "slot_not_found"}`.
6. Given `--dry-run` 옵션, When `cancel`, Then cancel_order 미송신·응답 없이 대상 슬롯 정보만 출력(안전 확인).
## Impacted Area
- Docs: docs/runtime-flows.md(백스톱 취소 흐름)
- Modules: trex_core/runtime/cli.py(`cancel` 서브커맨드), trex_core/application/messaging_handlers.py(`cancel_pending` 액션 추가), execution.py / broker: cancel_order 호출 배선(daemon 제어 핸들러)
- Tests: tests/runtime/test_cli.py(cancel 서브커맨드), messaging_handlers cancel 단위
## Notes
- 운영자 처분은 백스톱(FR-5) — timeout 미지정 또는 cancel_order 실패 잔여 고착 대비. dcy-013 stale-alert(Alert-only) 는 회귀 없이 유지.
- 슬롯 직접 쓰기 없이 cancel_order 만 송신 → 집행 루프가 다음 poll 에서 전이. 단일 writer 불변식 유지(REQ-013 NFR-2).
- 출처: dcy-022 FR-5, NFR-1.
## Change Log
### CHG-20260610-01
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- dcy-022 FR-5 로부터 최초 생성(Proposed). 운영자 강제 취소 백스톱 CLI + control RPC 액션.
#### Intent
- 잔여 고착 pending 슬롯의 운영자 처분 경로를 backlog 로 정규화.
#### Acceptance Criteria Delta
- Added: AC-1~6 (pending 취소, 집행 루프 수렴, holding 무동작, not_found, dry-run 6개 기준)
#### Impacted Area Delta
- Modules: cli.py(cancel 서브커맨드), messaging_handlers(cancel_pending), daemon 제어 배선
- Tests: cli cancel, messaging cancel_pending 단위
#### Delivery Trace Delta
- Batches: none
#### Revalidation Impact
- Reverification Needed: no / Existing Implementation Invalidated: no / Status Recommendation: keep-current
#### Validation Plan
- cancel CLI 송신 + control RPC cancel_pending + 집행 루프 수렴 단위.
### CHG-20260610-02
- Date: 2026-06-10
- Author: Justin Ko
#### Change Summary
- Status Proposed → Approved (confirm-req).
#### Intent
- delivery input 으로 승격.
#### Acceptance Criteria Delta
- Added: 없음 / Updated: 없음 / Removed: 없음
#### Impacted Area Delta
- Docs: docs/srs/index.md (Register Approved)
#### Delivery Trace Delta
- Batches: none
#### Revalidation Impact
- Reverification Needed: no / Existing Implementation Invalidated: no / Status Recommendation: keep-current / Follow-up Action: BAT-032.
#### Validation Plan
- 승인 후 batch planning 에서 AC 검증 기준으로 사용.
### CHG-20260611-IMPL
- Date: 2026-06-11
- Author: Justin Ko
- Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-032(release-candidate) verification — 실행 정책(timeout_sec·slippage_pct) 계약·집행·운영자 cancel 백스톱 전 AC acceptance mapping 존재(신규 71 tests), 단위 425 passed 회귀 없음, baseline 2문서 수동 확인(M5). Blocking Issues 없음.
+11 -6
View File
@@ -74,7 +74,7 @@
## Next Requirement ID
- Current: `REQ-068`
- Current: `REQ-073`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register
@@ -148,11 +148,16 @@
| REQ-065 | 자산 중심 모델 baseline 문서 정합 + 풀 모델 폐기 명시 | Documentation | Implemented | Medium | project maintainers | [req-065](./Documentation/req-065_asset-centric-baseline-alignment.md) |
| REQ-066 | 보유 자산 포지션 사이징 — 증액·부분 감액 (수량/avg_price 재계산) | Interface | Implemented | High | project maintainers | [req-066](./Interface/req-066_position-sizing-resize.md) |
| REQ-067 | 포지션 사이징 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-067](./Documentation/req-067_position-sizing-baseline-alignment.md) |
| REQ-068 | intent 실행 정책 계약 — timeout_sec + slippage_pct | Interface | Implemented | High | project maintainers | [req-068](./Interface/req-068_execution-policy-intent-contract.md) |
| REQ-069 | 주문 timeout 집행 + 취소-체결 race 처리 | Integration | Implemented | High | project maintainers | [req-069](./Integration/req-069_order-timeout-execution.md) |
| REQ-070 | slippage 보호 지정가 집행 | Integration | Implemented | High | project maintainers | [req-070](./Integration/req-070_slippage-limit-price-execution.md) |
| REQ-071 | 운영자 강제 취소 백스톱 (trex-core cancel) | Interface | Implemented | Medium | project maintainers | [req-071](./Interface/req-071_operator-cancel-pending-backstop.md) |
| REQ-072 | 실행 정책 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-072](./Documentation/req-072_execution-policy-baseline-alignment.md) |
## Recent Change Log Summary
- 2026-06-10 :: REQ-057/061/062/066/067 → Implemented (confirm-req-implemented, BAT-031 dcy-021 포지션 사이징): 보유 슬롯 증액(holding→pending_buy, avg_price 가중평균)·부분 매도(min(주문,잔고), 잔량 holding/전량 inactive, cost basis)·부분 체결 실제만 반영. 수량 정본=브로커 잔고 재동기화. 단위 378/통합 422 passed + 사이징 e2e green. BAT-031 release-candidate.
- 2026-06-10 :: REQ-057/061/062 change-req (dcy-021 포지션 사이징, CHG: 057-03/061-04/062-03): Implemented→**Approved** 되돌림(신규 동작 미구현, dcy-021 배치에서 재구현·confirm-req-implemented 로 복귀). REQ-057 상태머신에 `holding→pending_buy`(증액) 전이 추가·부분 매도 잔량 pending_sell→holding 명시, REQ-061 사이징 intent(증액 매수/부분 매도, 명령형 qty+price·core 미저장) 추가, REQ-062 부분 매도 집행 수량=min(intent,브로커잔고)·부분 체결 실제만 ledger·미추적. REQ-066/067(신규)이 end-to-end·baseline 소유.
- 2026-06-10 :: REQ-066~067 신규 생성(Proposed) (draft-req, dcy-021 포지션 사이징/issue #1, dcy-020 OQ-4 후속): 보유 자산 슬롯의 holding 유지 수량 조정. REQ-066(Interface — 증액 HOLDING→PENDING_BUY→HOLDING qty 누적·avg_price 가중평균 / 부분 감액 잔량 holding·전량 inactive·cost basis 유지 / 부분 체결 실제만 반영·미추적), REQ-067(Documentation — baseline 4문서 정합). 명령형 계약(전략이 cash+quantity 로 qty+price 계산·전송, core 미저장)·정수 주식 수+가격(OQ-1/2), avg_price 가중평균·실현손익 hub(OQ-3), intent 당 1회(OQ-4). 기존 Implemented 계약 REQ-057(상태머신)·REQ-061(intent)·REQ-062(수량)은 change-req 로 확장 동반. 전부 **Proposed**(confirm-req 대기). (draft-req)
- 2026-06-10 :: REQ-056~065 신규 생성(Proposed) (draft-req, dcy-020 자산 중심 슬롯 모델 재설계): 익명 슬롯 풀 → portfolio-hub universe 자산과 1:1 **영구 슬롯** + 동시 활성(M) 제어로 substrate 전환. REQ-056(영구 슬롯 스키마·UNIQUE(instance_key,portfolio_id)·trading_date/entry_count 제거), REQ-057(상태머신 inactive↔pending_buy↔holding↔pending_sell·holding 로컬·인스턴스 스코프 lock), REQ-058(universe reconcile 생성/soft-retire/tombstone·degrade 보류·일일 preallocation 폐기), REQ-059(FCFS·M캡·무치환·over-cap), REQ-060(max_active_slots per-instance TOML), REQ-061(intent 자산 키 통일·sell-by-slot_id 폐기), REQ-062(수량 hub 정본+미러·청산 브로커·불일치 해소), REQ-063(slots 단일 뷰), REQ-064(풀→영구 cutover), REQ-065(baseline 정합·폐기 명시). 기존 REQ-002/003/005/006/007/048 등은 폐기/재정의 change-req 동반 예정. 단계적 배치(스키마·상태머신 → reconcile·admission → intent·수량·CLI → cutover) delivery. 전부 **Proposed**(confirm-req 대기). (draft-req)
- 2026-06-09 :: REQ-048/010 change-req→Implemented (CHG-20260609-01/02, trex-orb-v1 설정 관리 정합, BAT-026): config 디렉터리 기반 설정 관리 도입 — `--config` 를 이름으로 해석 + `--config-path <dir>`(기본 `./config`) + `resolve_config_toml`(슬래시 포함 값은 하위호환 명시 경로)(REQ-048), `.env` 를 config-dir 결정적 해석(`--env-file` 우선, 없으면 `<config_dir>/.env`, CWD 무관)으로 변경 + `config/.env.example` + `.gitignore` `config/*` 허용목록(REQ-010). 운영자 CLI 도 `--config-path`/`--env-file` 동일 적용. `from_env()` legacy 루트 `.env` 폴백 유지로 완전 하위호환(`.env` 이동은 운영 배포 시 무중단). batch-lite(BAT-026) 단위 23 + 전체 375 passed, ruff clean, 라이브 스모크(name·path 동일 결과) → 둘 다 **Implemented**. (change-req/confirm-req-implemented)
- 2026-06-11 :: REQ-068~072 + REQ-013/061(CHG) → Implemented (confirm-req-implemented, BAT-032 dcy-022 실행 정책): timeout_sec·slippage_pct intent 계약·슬롯 lifecycle(REQ-068), timeout 초과 자동 취소·cancel-fill race(REQ-069), slippage 보호 지정가 dispatch(REQ-070), 운영자 `trex-core cancel` 백스톱(REQ-071), baseline 정합(REQ-072), 집행 루프 확장(REQ-013 CHG-03)·intent 필드 추가(REQ-061 CHG-05). BAT-032 verification — 신규 71 tests, 425 passed 회귀 없음, Blocking Issues 없음.
- 2026-06-10 :: REQ-002/006/007/028/032 → Implemented (confirm-req-implemented, BAT-034 dcy-023 allocated_capital 제거): 도메인(Slot/EXPECTED_SLOT_FIELDS)·intent(BuyIntent·수신 무시)·영속(마이그레이션 013 drop + flat 매핑)·snapshot(slots[])·CLI(--capital argparse 거부) 전 경로 제거. 테스트 DSN 013 멱등 round-trip + 컬럼 부재, 428 passed + PG 21 passed + ruff clean, grep 전수 0건. BAT-034 release-candidate. release 시 사전 백업·롤백 절차 필수(비가역 drop).
- 2026-06-10 :: REQ-002/006/007/028/032 change-req (dcy-023 allocated_capital 죽은 필드 제거/issue #22, CHG: 002-02/006-02/007-02/028-01/032-07): 전부 Implemented→**Approved** 되돌림(dcy-023 batch 재구현 대기). Slot 모델·`EXPECTED_SLOT_FIELDS` 필드 제거(REQ-002), `daily_slot` 컬럼 drop 마이그레이션 013 + flat 매핑(REQ-006, 코드 배포와 동시 적용·사전 백업), buy intent payload 즉시 제거·수신 무시(REQ-007), snapshot `slots[]` 노출 제거(REQ-028), CLI `--capital` argparse 제거 + slots detail 행 제거(REQ-032 AC-8). 외부 발신자(trex-orb-v1) 송신 잔존은 unknown key 무시로 무해(운영자 확인).
- 2026-06-10 :: REQ-032 CHG-20260610-06 → Implemented (confirm-req-implemented, BAT-033 batch-lite): `trex-core enter` `--qty` 미지정 시 RPC 호출 전 즉시 rc=1 반환(AC-7). `_cmd_enter` 상단 qty guard 추가, 기존 enter 테스트 --qty 보완 + AC-7 신규 테스트. 426 passed, 0 failed.
- 2026-06-10 :: REQ-068~072 신규 생성(Approved) + REQ-013/061 change-req Implemented→Approved (draft-req + confirm-req, dcy-022 실행 정책/issue #21): intent 실행 정책(timeout_sec·slippage_pct) 계약(REQ-068), timeout 집행·race(REQ-069), slippage dispatch(REQ-070), 운영자 cancel 백스톱(REQ-071), baseline 정합(REQ-072). REQ-061 CHG-05(timeout_sec/slippage_pct 필드 추가), REQ-013 CHG-03(timeout 취소·slippage dispatch 집행 루프 확장) → 둘 다 Implemented→Approved(BAT-032 재구현 대기). Next ID: REQ-073.
+342
View File
@@ -0,0 +1,342 @@
"""Execution policy tests: slippage dispatch + timeout cancel (dcy-022 / REQ-069/070).
Uses ProgrammableBroker from test_execution_cancel for fine-grained control.
Also covers operator cancel_pending_slot (REQ-071).
"""
from __future__ import annotations
import logging
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import BalanceItem, FillStatus, OrderAck, OrderRequest
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
class _RecordingBroker:
"""Broker that records submitted orders and lets tests control poll responses."""
def __init__(self):
self._seq = 0
self.orders: list[OrderRequest] = []
self.cancel_calls: list = []
self.cancel_raises = False
self.responder = lambda side: FillStatus(
state="filled", filled_qty=10, avg_price=1000.0,
execution_id="exec-1", fee=0.0, tax=0.0,
)
self.balance: dict[str, int] = {"005930": 10}
def name(self):
return "stub"
def submit_order(self, req: OrderRequest):
self._seq += 1
self.orders.append(req)
return OrderAck(accepted=True, broker_order_id=f"o{self._seq}")
def poll_fill(self, broker_order_id, *, side, symbol):
return self.responder(side)
def cancel_order(self, broker_order_id, *, side, symbol, qty):
self.cancel_calls.append((broker_order_id, side, symbol, qty))
if self.cancel_raises:
raise RuntimeError("broker error")
return True
def get_balance(self):
return [BalanceItem(symbol=s, hldg_qty=q) for s, q in self.balance.items() if q > 0]
def get_current_price(self, symbol):
return None
def _wire(broker):
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930")
execution = ExecutionService(svc, repo, broker)
repo.reconcile_universe("default", {"PF-1"})
return repo, svc, intents, execution
def _buy_to_pending(intents, qty=10, entry_price=1000, timeout_sec=None, slippage_pct=None):
return intents.submit_buy(BuyIntent(
intent_id="b1", qty=qty, entry_price=entry_price,
portfolio_id="PF-1",
timeout_sec=timeout_sec, slippage_pct=slippage_pct,
)).slot_id
# ============================================================================
# REQ-070: slippage effective limit price
# ============================================================================
class TestSlippageDispatch:
def test_buy_slippage_ceil_applied_to_limit(self):
"""buy: effective_limit = ceil(entry_price * (1 + pct/100))."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1000, slippage_pct=0.5)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
order = broker.orders[0]
# ceil(1000 * 1.005) = ceil(1005.0) = 1005
assert order.limit_price == 1005
def test_buy_slippage_fractional_ceil(self):
"""ceil(1001 * 1.003) = ceil(1004.003) = 1005."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1001, slippage_pct=0.3)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
import math
expected = int(math.ceil(1001 * 1.003))
assert broker.orders[0].limit_price == expected
def test_sell_slippage_floor_applied_to_limit(self):
"""sell: effective_limit = floor(limit_price * (1 - pct/100))."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
# drive to holding first
broker.responder = lambda side: FillStatus(
state="filled", filled_qty=10, avg_price=1000.0,
execution_id="e1", fee=0.0, tax=0.0,
)
_buy_to_pending(intents)
execution.run_once() # buy filled -> holding
# now submit sell with slippage
sid = [s.slot_id for s in repo.list_slots("default")
if s.status is SlotState.HOLDING][0]
intents.submit_sell(SellIntent(
intent_id="s1", slot_id=sid, slippage_pct=1.0, limit_price=2000.0,
))
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch sell (pending state already has limit_price)
sell_orders = [o for o in broker.orders if o.side == "sell"]
# floor(2000 * 0.99) = floor(1980.0) = 1980
import math
expected = int(math.floor(2000 * 0.99))
assert sell_orders[0].limit_price == expected
def test_no_slippage_when_slippage_pct_is_none(self):
"""No slippage_pct → effective_limit = entry_price unchanged."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1000, slippage_pct=None)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
assert broker.orders[0].limit_price == 1000
def test_no_slippage_when_market_order_no_entry_price(self):
"""Market order (no entry_price → limit=None) → slippage not applied."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
intents.submit_buy(BuyIntent(
intent_id="b1", qty=10, entry_price=None,
portfolio_id="PF-1", slippage_pct=5.0,
))
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
assert broker.orders[0].limit_price is None
def test_zero_slippage_uses_base_price(self):
"""slippage_pct=0 → effective_limit = ceil(1000 * 1.0) = 1000."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1000, slippage_pct=0.0)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
# 0.0 is falsy → slippage block skipped → no change
assert broker.orders[0].limit_price == 1000
# ============================================================================
# REQ-069: timeout auto-cancel
# ============================================================================
class TestTimeoutCancel:
def _buy_and_dispatch(self, intents, execution, repo, timeout_sec):
"""Drive a pending buy into dispatched state, then age the slot."""
sid = _buy_to_pending(intents, timeout_sec=timeout_sec)
# keep order in "accepted" (not filled) so poll always lands in else branch
execution._repo # just to confirm it's wired
return sid
def test_timeout_exceeded_sends_cancel_order(self):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents, timeout_sec=30)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch (no fill yet)
assert len(broker.cancel_calls) == 0
# age the slot past timeout
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
r = execution.run_once()
assert r.timeout_cancelled == 1
assert len(broker.cancel_calls) == 1
def test_timeout_not_exceeded_no_cancel(self):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, timeout_sec=3600)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch
# do NOT age the slot — timeout_sec is large
r = execution.run_once()
assert r.timeout_cancelled == 0
assert len(broker.cancel_calls) == 0
def test_no_timeout_sec_no_cancel(self):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents, timeout_sec=None)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch
# age the slot
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=9999),
)
r = execution.run_once()
assert r.timeout_cancelled == 0
assert len(broker.cancel_calls) == 0
def test_timeout_cancel_best_effort_on_exception(self, caplog):
"""Broker exception during cancel must not raise — loop continues."""
broker = _RecordingBroker()
broker.cancel_raises = True
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents, timeout_sec=1)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
with caplog.at_level(logging.WARNING):
r = execution.run_once()
# timeout_cancelled NOT incremented when the cancel call raises
assert r.timeout_cancelled == 0
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY # state unchanged
def test_timeout_cancel_does_not_change_slot_state(self):
"""Timeout cancel is best-effort: slot stays PENDING_BUY after cancel_order."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents, timeout_sec=1)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
execution.run_once()
# slot still PENDING_BUY — cancel is best-effort; rollback on next poll
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
def test_timeout_cancel_then_fill_race_handled(self):
"""Cancel sent, but broker fills the order — is_filled path wins on next poll."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents, timeout_sec=1)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch
# age the slot → cancel sent
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
r1 = execution.run_once()
assert r1.timeout_cancelled == 1
# next poll: broker says filled anyway (race — fill beat the cancel)
broker.responder = lambda side: FillStatus(
state="filled", filled_qty=10, avg_price=1000.0,
execution_id="exec-race", fee=0.0, tax=0.0,
)
r2 = execution.run_once()
assert r2.filled == 1
assert repo.fetch_slot(sid).status is SlotState.HOLDING
# ============================================================================
# REQ-071: cancel_pending_slot (operator backstop)
# ============================================================================
class TestCancelPendingSlot:
def test_ok_sends_cancel_and_returns_ok(self):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents)
# dispatch so broker_order_id is set
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
result = execution.cancel_pending_slot(sid)
assert result == "ok"
assert len(broker.cancel_calls) == 1
def test_not_pending_returns_not_pending(self):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents)
broker.responder = lambda side: FillStatus(
state="filled", filled_qty=10, avg_price=1000.0,
execution_id="e1", fee=0.0, tax=0.0,
)
execution.run_once() # fill -> holding
assert repo.fetch_slot(sid).status is SlotState.HOLDING
result = execution.cancel_pending_slot(sid)
assert result == "not_pending"
def test_slot_not_found_returns_slot_not_found(self):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
from uuid import uuid4
result = execution.cancel_pending_slot(uuid4())
assert result == "slot_not_found"
def test_not_dispatched_returns_not_dispatched(self):
"""Slot is PENDING_BUY but broker_order_id not yet set."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents)
# Do NOT call run_once — broker_order_id is still None
result = execution.cancel_pending_slot(sid)
assert result == "not_dispatched"
def test_cancel_fail_returns_cancel_failed(self):
broker = _RecordingBroker()
broker.cancel_raises = True
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch
result = execution.cancel_pending_slot(sid)
assert result == "cancel_failed"
def test_cancel_does_not_change_slot_state(self):
"""Operator cancel sends cancel_order; slot stays PENDING_BUY (execution loop transitions)."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
execution.cancel_pending_slot(sid)
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
@@ -0,0 +1,231 @@
"""Execution policy handler tests — intent payload parsing + control cancel_pending.
dcy-022 / REQ-068 (parsing), REQ-071 (cancel_pending control action).
"""
from __future__ import annotations
from uuid import UUID, uuid4
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import (
make_control_rpc_handler,
make_intent_rpc_handler,
)
from trex_core.application.portfolio import InMemoryPauseStore
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
from trex_core.application.intents import BuyIntent, IntentService
# ============================================================================
# Intent handler: execution policy payload parsing (REQ-068)
# ============================================================================
def _wire():
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930")
repo.reconcile_universe("default", {"PF-1"})
return repo, svc, intents
def _buy_req(timeout_sec=None, slippage_pct=None):
body = {
"intent_type": "buy",
"intent_id": "b1",
"portfolio_id": "PF-1",
"qty": 10,
}
if timeout_sec is not None:
body["timeout_sec"] = timeout_sec
if slippage_pct is not None:
body["slippage_pct"] = slippage_pct
return RpcRequest(payload=body, correlation_id="b1")
class TestIntentPayloadParsing:
def test_buy_timeout_sec_parsed_and_stored_on_slot(self):
repo, _, intents = _wire()
handler = make_intent_rpc_handler(intents)
resp = handler(_buy_req(timeout_sec=30))
assert resp.payload["status"] == "admitted"
sid = UUID(resp.payload["slot_id"])
slot = repo.fetch_slot(sid)
assert slot.timeout_sec == 30
def test_buy_slippage_pct_parsed_and_stored_on_slot(self):
repo, _, intents = _wire()
handler = make_intent_rpc_handler(intents)
resp = handler(_buy_req(slippage_pct=0.5))
assert resp.payload["status"] == "admitted"
sid = UUID(resp.payload["slot_id"])
slot = repo.fetch_slot(sid)
assert slot.slippage_pct == 0.5
def test_buy_both_policy_fields_parsed(self):
repo, _, intents = _wire()
handler = make_intent_rpc_handler(intents)
resp = handler(_buy_req(timeout_sec=60, slippage_pct=1.0))
sid = UUID(resp.payload["slot_id"])
slot = repo.fetch_slot(sid)
assert slot.timeout_sec == 60
assert slot.slippage_pct == 1.0
def test_buy_policy_fields_absent_means_none(self):
"""Backward-compat: no policy fields in payload → None on slot."""
repo, _, intents = _wire()
handler = make_intent_rpc_handler(intents)
resp = handler(_buy_req())
sid = UUID(resp.payload["slot_id"])
slot = repo.fetch_slot(sid)
assert slot.timeout_sec is None
assert slot.slippage_pct is None
def test_sell_timeout_sec_parsed(self):
repo, svc, intents = _wire()
handler = make_intent_rpc_handler(intents)
# first buy to get a holding slot
buy_resp = handler(_buy_req())
sid_str = buy_resp.payload["slot_id"]
sid = UUID(sid_str)
svc.transit_slot(sid, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t")
sell_resp = handler(RpcRequest(
payload={
"intent_type": "sell",
"intent_id": "s1",
"slot_id": sid_str,
"timeout_sec": 45,
},
correlation_id="s1",
))
assert sell_resp.payload["status"] == "admitted"
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.PENDING_SELL
assert slot.timeout_sec == 45
def test_sell_slippage_pct_parsed(self):
repo, svc, intents = _wire()
handler = make_intent_rpc_handler(intents)
buy_resp = handler(_buy_req())
sid_str = buy_resp.payload["slot_id"]
sid = UUID(sid_str)
svc.transit_slot(sid, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t")
sell_resp = handler(RpcRequest(
payload={
"intent_type": "sell",
"intent_id": "s1",
"slot_id": sid_str,
"slippage_pct": 0.3,
},
correlation_id="s1",
))
assert sell_resp.payload["status"] == "admitted"
slot = repo.fetch_slot(sid)
assert slot.slippage_pct == 0.3
# ============================================================================
# default_order_timeout_sec: config-level default applies when intent omits timeout_sec
# (REQ-068 AC-3/AC-5)
# ============================================================================
class TestDefaultOrderTimeoutSec:
def _wire_with_default(self, default_timeout):
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(
svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930",
default_order_timeout_sec=default_timeout,
)
repo.reconcile_universe("default", {"PF-1"})
return repo, svc, intents
def test_config_default_applied_when_intent_has_no_timeout(self):
repo, _, intents = self._wire_with_default(default_timeout=120)
d = intents.submit_buy(BuyIntent(
intent_id="b1", portfolio_id="PF-1", qty=10,
))
assert d.status == "admitted"
slot = repo.fetch_slot(d.slot_id)
assert slot.timeout_sec == 120
def test_per_intent_timeout_overrides_config_default(self):
repo, _, intents = self._wire_with_default(default_timeout=120)
d = intents.submit_buy(BuyIntent(
intent_id="b1", portfolio_id="PF-1", qty=10,
timeout_sec=30,
))
slot = repo.fetch_slot(d.slot_id)
assert slot.timeout_sec == 30
def test_no_config_default_no_intent_timeout_means_none(self):
repo, _, intents = self._wire_with_default(default_timeout=None)
d = intents.submit_buy(BuyIntent(
intent_id="b1", portfolio_id="PF-1", qty=10,
))
slot = repo.fetch_slot(d.slot_id)
assert slot.timeout_sec is None
# ============================================================================
# Control handler: cancel_pending action (REQ-071)
# ============================================================================
class TestControlCancelPending:
def _handler(self, cancel_fn):
return make_control_rpc_handler(
InMemoryPauseStore(),
cancel_pending_fn=cancel_fn,
)
def _req(self, **body):
return RpcRequest(payload=body, correlation_id="c1")
def test_cancel_pending_ok(self):
sid = str(uuid4())
handler = self._handler(cancel_fn=lambda s: "ok")
resp = handler(self._req(action="cancel_pending", slot_id=sid))
assert resp.payload["status"] == "ok"
assert resp.payload["slot_id"] == sid
def test_cancel_pending_not_pending(self):
sid = str(uuid4())
handler = self._handler(cancel_fn=lambda s: "not_pending")
resp = handler(self._req(action="cancel_pending", slot_id=sid))
assert resp.payload["status"] == "ignored"
assert resp.payload["reason"] == "not_pending"
def test_cancel_pending_slot_not_found(self):
sid = str(uuid4())
handler = self._handler(cancel_fn=lambda s: "slot_not_found")
resp = handler(self._req(action="cancel_pending", slot_id=sid))
assert resp.payload["status"] == "error"
assert resp.payload["reason"] == "slot_not_found"
def test_cancel_pending_without_slot_id_returns_error(self):
handler = self._handler(cancel_fn=lambda s: "ok")
resp = handler(self._req(action="cancel_pending"))
assert resp.payload["status"] == "error"
assert resp.payload["reason"] == "slot_id_required"
def test_cancel_pending_unsupported_without_fn(self):
handler = make_control_rpc_handler(InMemoryPauseStore())
resp = handler(self._req(action="cancel_pending", slot_id="x"))
assert resp.payload["status"] == "error"
assert resp.payload["reason"] == "cancel_pending_unsupported"
def test_cancel_pending_cancel_failed(self):
sid = str(uuid4())
handler = self._handler(cancel_fn=lambda s: "cancel_failed")
resp = handler(self._req(action="cancel_pending", slot_id=sid))
assert resp.payload["status"] == "error"
assert resp.payload["reason"] == "cancel_failed"
@@ -163,3 +163,27 @@ def test_buy_symbol_resolved_from_universe():
from uuid import UUID
slot = repo.fetch_slot(UUID(resp.payload["slot_id"]))
assert slot.symbol == "005930" and slot.portfolio_id == "PF-9"
def test_buy_payload_allocated_capital_is_ignored():
"""dcy-023 / REQ-007 AC-6: allocated_capital is no longer a contract field.
Senders (e.g. trex-orb-v1) may still include it — admission proceeds
normally and the value lands nowhere (no Slot/BuyIntent field exists)."""
repo, _, intents = _wire()
handler = make_intent_rpc_handler(intents)
req = RpcRequest(
payload={
"intent_type": "buy",
"intent_id": "buy-cap",
"portfolio_id": "PF-1",
"qty": 10,
"allocated_capital": 1_000_000, # unknown key — ignored
},
correlation_id="buy-cap",
)
resp = handler(req)
assert resp.payload["status"] == "admitted"
from uuid import UUID
slot = repo.fetch_slot(UUID(resp.payload["slot_id"]))
assert slot.status is SlotState.PENDING_BUY and slot.qty == 10
assert not hasattr(slot, "allocated_capital")
@@ -0,0 +1,162 @@
"""Execution policy (timeout_sec / slippage_pct) lifecycle tests (dcy-022 / REQ-068).
slot_service.transit_slot sets timeout_sec/slippage_pct on PENDING entry and
clears them when the slot exits to HOLDING, INACTIVE, or ERROR.
"""
from __future__ import annotations
import pytest
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
INST = "default"
@pytest.fixture
def repo():
return InMemorySlotRepository()
@pytest.fixture
def service(repo):
return SlotService(repo, instance_key=INST)
def _seed(repo):
repo.reconcile_universe(INST, {"PF-1"})
return repo.list_slots(INST)[0]
# ----------------------------------------------------------------------------
# PENDING_BUY: execution policy set on entry
# ----------------------------------------------------------------------------
def test_timeout_sec_set_on_pending_buy_entry(service, repo):
slot = _seed(repo)
updated = service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="t", symbol="005930", qty=10,
timeout_sec=30,
)
assert updated.timeout_sec == 30
def test_slippage_pct_set_on_pending_buy_entry(service, repo):
slot = _seed(repo)
updated = service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="t", symbol="005930", qty=10,
slippage_pct=0.5,
)
assert updated.slippage_pct == 0.5
def test_both_policy_fields_set_together(service, repo):
slot = _seed(repo)
updated = service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="t", symbol="005930", qty=10,
timeout_sec=60, slippage_pct=1.0,
)
assert updated.timeout_sec == 60
assert updated.slippage_pct == 1.0
def test_policy_fields_default_to_none_when_not_given(service, repo):
slot = _seed(repo)
updated = service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="t", symbol="005930", qty=10,
)
assert updated.timeout_sec is None
assert updated.slippage_pct is None
# ----------------------------------------------------------------------------
# PENDING_SELL: same lifecycle as PENDING_BUY
# ----------------------------------------------------------------------------
def test_slippage_pct_set_on_pending_sell_entry(service, repo):
slot = _seed(repo)
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="t", symbol="005930", qty=10,
)
service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t",
)
updated = service.transit_slot(
slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="sell", actor="t",
timeout_sec=45, slippage_pct=0.3,
)
assert updated.timeout_sec == 45
assert updated.slippage_pct == 0.3
# ----------------------------------------------------------------------------
# HOLDING: execution policy cleared on exit from pending
# ----------------------------------------------------------------------------
def test_policy_cleared_on_transition_to_holding(service, repo):
slot = _seed(repo)
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="t", symbol="005930", qty=10,
timeout_sec=30, slippage_pct=0.5,
)
updated = service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t",
)
assert updated.timeout_sec is None
assert updated.slippage_pct is None
# ----------------------------------------------------------------------------
# INACTIVE: execution policy cleared on inactive reset
# ----------------------------------------------------------------------------
def test_policy_cleared_on_transition_to_inactive(service, repo):
slot = _seed(repo)
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="t", symbol="005930", qty=10,
timeout_sec=30, slippage_pct=0.5,
)
updated = service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
reason="rejected", actor="t",
)
assert updated.timeout_sec is None
assert updated.slippage_pct is None
# ----------------------------------------------------------------------------
# PENDING state: existing policy preserved when not overridden
# ----------------------------------------------------------------------------
def test_existing_policy_preserved_when_not_overridden(service, repo):
"""PENDING→PENDING re-entry keeps existing policy if not overridden."""
slot = _seed(repo)
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="t", symbol="005930", qty=10,
timeout_sec=30, slippage_pct=0.5,
)
# Simulate a HOLDING→PENDING_BUY increase (re-enters pending with no new policy).
# We need to go to HOLDING first, then re-enter PENDING_BUY.
service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t",
)
# From HOLDING re-enter PENDING_BUY without providing policy — policy starts fresh
updated = service.transit_slot(
slot.slot_id, SlotState.HOLDING, SlotState.PENDING_BUY,
reason="increase", actor="t", qty=5,
)
# Policy cleared during HOLDING, not re-set → None (fresh PENDING entry)
assert updated.timeout_sec is None
assert updated.slippage_pct is None
+1 -3
View File
@@ -54,7 +54,6 @@ class TestTransitHappyPath:
actor="buy_decision_thread",
symbol="005930",
qty=10,
allocated_capital=1_000_000,
strategy_id="orb_v1",
)
assert updated.status is SlotState.PENDING_BUY
@@ -94,7 +93,7 @@ class TestTransitHappyPath:
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="orb_breakout", actor="buy", symbol="005930", qty=10,
allocated_capital=1_000_000, strategy_id="orb_v1",
strategy_id="orb_v1",
)
rolled = service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
@@ -102,7 +101,6 @@ class TestTransitHappyPath:
)
assert rolled.status is SlotState.INACTIVE
assert rolled.qty is None
assert rolled.allocated_capital is None
assert rolled.strategy_id is None
assert rolled.limit_price is None
assert rolled.broker_order_id is None
+84 -14
View File
@@ -34,11 +34,11 @@ _SNAPSHOT = {
"slots": [
{"slot_id": "S-1", "status": "holding",
"symbol": "005930", "qty": 10.0, "avg_price": 28700.0, "entry_price": None,
"allocated_capital": None, "strategy_id": "orb", "sell_strategy_id": None,
"strategy_id": "orb", "sell_strategy_id": None,
"portfolio_id": "PF-1", "forced_close_at": None},
{"slot_id": "S-2", "status": "inactive",
"symbol": None, "qty": None, "avg_price": None, "entry_price": None,
"allocated_capital": None, "strategy_id": None, "sell_strategy_id": None,
"strategy_id": None, "sell_strategy_id": None,
"portfolio_id": None, "forced_close_at": None},
],
}
@@ -134,7 +134,7 @@ def test_slots_marks_watchdog_owned(capsys):
"slots": [{"slot_id": "S-1",
"status": "holding", "symbol": "005930", "qty": 10.0,
"avg_price": 28700.0, "entry_price": None,
"allocated_capital": None, "strategy_id": "orb",
"strategy_id": "orb",
"sell_strategy_id": None, "portfolio_id": "PF-1",
"forced_close_at": None}],
"watchdog": [{"slot_id": "S-1", "portfolio_id": "PF-1", "symbol": "005930",
@@ -161,26 +161,45 @@ def test_json_output_is_valid_json(capsys):
# --- enter (buy intent RPC) + safeguards ------------------------------------
def test_enter_aborts_without_confirmation_no_rpc():
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--capital", "100000"],
rc = cli.main(["enter", "PF-1", "--qty", "10"],
rpc=rpc, confirm=lambda _p: False)
assert rc == 1 and rpc.calls == [] # aborted -> no order
def test_enter_without_qty_errors_before_rpc(capsys):
# REQ-032 AC-7 (CHG-20260610-06, issue #23): missing --qty → rc=1, no RPC.
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--yes"], rpc=rpc)
assert rc == 1 and rpc.calls == []
assert "--qty" in capsys.readouterr().err
def test_enter_rejects_capital_option(capsys):
# REQ-032 AC-8 (CHG-20260610-07, dcy-023/issue #22): --capital is gone —
# argparse rejects it as an unknown argument before any RPC.
rpc = _RpcRecorder(_ADMITTED)
with pytest.raises(SystemExit) as exc:
cli.main(["enter", "PF-1", "--qty", "10", "--capital", "100000", "--yes"], rpc=rpc)
assert exc.value.code == 2 and rpc.calls == []
assert "--capital" in capsys.readouterr().err
def test_enter_with_yes_submits_buy_intent():
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--capital", "100000", "--yes"], rpc=rpc)
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes"], rpc=rpc)
assert rc == 0
p = rpc.calls[0]["payload"]
assert rpc.calls[0]["queue"] == "trex.intent.req"
assert p["intent_type"] == "buy" and p["portfolio_id"] == "PF-1"
assert p["allocated_capital"] == 100000
assert p["qty"] == 10
assert "allocated_capital" not in p # dcy-023 / REQ-032 AC-8
assert "symbol" not in p # server-resolved (dcy-011)
def test_enter_with_forced_close_at_maps_to_payload():
# REQ-032 AC-5: --forced-close-at (absolute) → buy intent forced_close_at.
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--capital", "100000", "--yes",
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes",
"--forced-close-at", "2026-06-09T15:20:00+09:00"], rpc=rpc)
assert rc == 0
p = rpc.calls[0]["payload"]
@@ -190,7 +209,7 @@ def test_enter_with_forced_close_at_maps_to_payload():
def test_enter_without_forced_close_at_omits_field():
# REQ-032 AC-6: unspecified → field absent (backward compat).
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--capital", "100000", "--yes"], rpc=rpc)
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes"], rpc=rpc)
assert rc == 0
assert "forced_close_at" not in rpc.calls[0]["payload"]
@@ -198,27 +217,27 @@ def test_enter_without_forced_close_at_omits_field():
def test_enter_rejects_malformed_forced_close_at_before_submit():
# REQ-032 AC-6: invalid timestamp rejected at CLI, no RPC submitted.
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--capital", "100000", "--yes",
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes",
"--forced-close-at", "notatime"], rpc=rpc)
assert rc == 1 and rpc.calls == []
def test_enter_rejected_returns_nonzero():
rpc = _RpcRecorder(_REJECTED)
rc = cli.main(["enter", "PF-x", "--yes"], rpc=rpc)
rc = cli.main(["enter", "PF-x", "--qty", "10", "--yes"], rpc=rpc)
assert rc == 1
def test_enter_dry_run_does_not_submit(capsys):
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--capital", "5", "--dry-run"], rpc=rpc)
rc = cli.main(["enter", "PF-1", "--qty", "5", "--dry-run"], rpc=rpc)
assert rc == 0 and rpc.calls == []
assert "dry-run" in capsys.readouterr().out
def test_enter_json_requires_yes():
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--json"], rpc=rpc)
rc = cli.main(["enter", "PF-1", "--qty", "10", "--json"], rpc=rpc)
assert rc == 2 and rpc.calls == [] # no auto-submit (FR-7)
@@ -241,14 +260,14 @@ def test_exit_market_when_no_price():
def test_live_requires_live_flag(monkeypatch):
monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True))
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--yes"], rpc=rpc) # --yes but no --live
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes"], rpc=rpc) # --yes but no --live
assert rc == 2 and rpc.calls == []
def test_live_proceeds_with_yes_live(monkeypatch):
monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True))
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--yes", "--live"], rpc=rpc)
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes", "--live"], rpc=rpc)
assert rc == 0 and len(rpc.calls) == 1
@@ -259,3 +278,54 @@ def test_rpc_failure_surfaces_error(capsys):
rc = cli.main(["universe"], rpc=boom)
assert rc == 3
assert "daemon unreachable" in capsys.readouterr().err
# --- cancel (control RPC operator backstop — dcy-022 / REQ-071) -------------
_CANCEL_OK = {"status": "ok", "slot_id": "S-pending"}
_CANCEL_IGNORED = {"status": "ignored", "slot_id": "S-1", "reason": "not_pending"}
_CANCEL_NOT_FOUND = {"status": "error", "slot_id": "S-x", "reason": "slot_not_found"}
def test_cancel_aborts_without_confirmation_no_rpc():
rpc = _RpcRecorder(_CANCEL_OK)
rc = cli.main(["cancel", "S-pending"], rpc=rpc, confirm=lambda _p: False)
assert rc == 1 and rpc.calls == []
def test_cancel_with_yes_sends_control_rpc(capsys):
rpc = _RpcRecorder(_CANCEL_OK)
rc = cli.main(["cancel", "S-pending", "--yes"], rpc=rpc)
assert rc == 0
call = rpc.calls[0]
assert call["queue"] == "trex.control.req"
assert call["payload"]["action"] == "cancel_pending"
assert call["payload"]["slot_id"] == "S-pending"
assert "cancel sent" in capsys.readouterr().out
def test_cancel_ignored_not_pending_exits_zero(capsys):
rpc = _RpcRecorder(_CANCEL_IGNORED)
rc = cli.main(["cancel", "S-1", "--yes"], rpc=rpc)
assert rc == 0
assert "ignored" in capsys.readouterr().out
def test_cancel_slot_not_found_exits_nonzero(capsys):
rpc = _RpcRecorder(_CANCEL_NOT_FOUND)
rc = cli.main(["cancel", "S-x", "--yes"], rpc=rpc)
assert rc == 1
def test_cancel_dry_run_does_not_submit(capsys):
rpc = _RpcRecorder(_CANCEL_OK)
rc = cli.main(["cancel", "S-pending", "--dry-run"], rpc=rpc)
assert rc == 0 and rpc.calls == []
assert "dry-run" in capsys.readouterr().out
def test_cancel_json_outputs_json(capsys):
rpc = _RpcRecorder(_CANCEL_OK)
rc = cli.main(["cancel", "S-pending", "--yes", "--json"], rpc=rpc)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["status"] == "ok"
+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)