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) # BAT-024: 운영자 `enter` 시간 기반 자동청산 옵션 — `--forced-close-at` (절대시간) 노출 (issue #17)
- Status: release-candidate - Status: released
- Profile: batch-lite - Profile: batch-lite
- Source Discovery: dcy-016 (issue #17 change-req) - Source Discovery: dcy-016 (issue #17 change-req)
- Included REQ: REQ-032 (CHG-20260609-01) - Included REQ: REQ-032 (CHG-20260609-01)
@@ -1,6 +1,6 @@
# BAT-025: slot-scoped entry_count + closed 슬롯 직접 재할당 (dcy-019) # BAT-025: slot-scoped entry_count + closed 슬롯 직접 재할당 (dcy-019)
- Status: release-candidate - Status: released
- Profile: standard - Profile: standard
- Source Discovery: dcy-019 - 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) - 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-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-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-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-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 | 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-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-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-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-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-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-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-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-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-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-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.intent.req` :: 전략 모듈의 매수/매도 admission RPC (request-reply, BAT-007)
- RabbitMQ `trex.events` :: 체결/슬롯 상태 이벤트 topic exchange (pub/sub, 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` 확장) - 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 :: 슬롯 생명주기 도메인 서비스(전이/사전할당/가용성/재진입 카운트) - SlotService :: 슬롯 생명주기 도메인 서비스(전이/사전할당/가용성/재진입 카운트)
- SlotRepository (Protocol) :: 도메인 ↔ 저장소 경계 계약 - SlotRepository (Protocol) :: 도메인 ↔ 저장소 경계 계약
- BrokerPort (Protocol) :: 단일 브로커 세션 경계(submit/poll/cancel) + `OrderRequest`/`OrderAck`/`FillStatus` - BrokerPort (Protocol) :: 단일 브로커 세션 경계(submit/poll/cancel) + `OrderRequest`/`OrderAck`/`FillStatus`
@@ -87,14 +87,19 @@
"qty": float?, "allocated_capital": float?, "qty": float?, "allocated_capital": float?,
"entry_price": float?, "strategy_id": str?, "sell_strategy_id": str?, "entry_price": float?, "strategy_id": str?, "sell_strategy_id": str?,
"forced_close_at": "ISO-8601"?, "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). - **`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 한다. - 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(트리거 비활성). - `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)가 그 시각에 시장가 청산한다. 상대시간 옵션은 제공하지 않는다. - `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` 필드명은 본 주기에서 유지(하위호환). - **`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) #### `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-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-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-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 재할당 통합 릴리즈 # REL-005: operator enter --forced-close-at + slot entry_count·closed 재할당 통합 릴리즈
- Status: confirmed - Status: feedback-captured
- Profile: app-service - Profile: app-service
- Included Batch: BAT-024, BAT-025 - Included Batch: BAT-024, BAT-025
@@ -65,53 +65,55 @@ confirm-release 전 "운영자가 쓰는 프로그램이 실제로 동작한다"
### Deployment Facts ### Deployment Facts
- Executed At: - Executed At:
- <배포 시작 시각> - 2026-06-10
- Completed At: - Completed At:
- <배포 완료 시각> - 2026-06-10
- Environment: - 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: - Operator:
- <배포 수행자> - Justin Ko
- Revision or Artifact: - Revision or Artifact:
- <커밋 SHA 또는 배포 artifact 식별자> - 작업 트리(`941d324` + dcy-019 미커밋 변경 포함)
- Commands or Procedure: - Commands or Procedure:
- <실행 명령 또는 배포 절차 요약> - `trex-core-daemon --config /tmp/rel005smoke.toml` 기동 후 CLI 스모크(단계 3~5의 Rollout Plan 수행)
### Profile-Specific Checks ### Profile-Specific Checks
- app-service: - app-service:
- Health Endpoint: - Health Endpoint:
- <snapshot RPC degraded=false 확인 결과와 시각> - RabbitMQ 토폴로지 선언(`trex.intent.req.rel005smoke` / `trex.snapshot.req.rel005smoke`) 성공, idle 슬롯 1개 preallocation 완료, 에러 로그 없음, `Normal shutdown` 정상 종료 확인
- Startup Log Fields: - Startup Log Fields:
- <app, event, command, run_id 확인 결과> - 마이그레이션 001~008 적용 후 `daily_slot.entry_count` 컬럼 존재 확인(실 PostgreSQL round-trip, additive·멱등 검증)
- Request Log Fields: - 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: - 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 Outcome
- Rollback Required: - Rollback Required:
- <yes 또는 no> - no
- Rollback Trigger: - Rollback Trigger:
- <발생 시 원인, 없으면 none> - none
- Rollback Action: - Rollback Action:
- <수행한 조치, 없으면 none> - none
### Release Decision Evidence ### Release Decision Evidence
- Released Decision: - Released Decision:
- <released 전환 여부> - released (feedback-captured로 전환)
- Rationale: - Rationale:
- <released 전환 판단 근거> - 배포 실패·회귀 없음. `--forced-close-at` 동작 정상(유효 → admitted·KST 변환, 잘못된 형식 → 사전 거부). `entry_count` 기반 closed 슬롯 재할당 실 데몬 발화 확인. 슬롯 상태 전이 완전. ledger 3행 completed.
- Recorded By: - Recorded By:
- <기록자> - Justin Ko
- Recorded At: - Recorded At:
- <기록 시각> - 2026-06-10
## Feedback Handoff ## Feedback Handoff
- Observation Summary: - 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 Input:
- <후속 discovery 후보> - none (이번 릴리즈 관찰 범위에서 신규 후속 탐색 트리거 없음)
- REQ Input: - REQ Input:
- <후속 req 후보> - none
- Change Request Input: - Change Request Input:
- `daily_symbol_entries`(심볼 재진입 카운터) 제거 — production 소비자 없음 + 심볼 재진입 제한 미사용 확정(dcy-019). REQ-002 change-req 트랙(별도 batch-lite) — REL-005 범위 밖. - `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` 롤백. 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-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-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`. 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안, 과매도·이중기록 방지). - 외부 취소 감지(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 로 남지 않게 하는 안전망. - 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회만 송신. 3. reconcile(재기동): 서빙 전 `list_dispatched_pending` 재폴링으로 크래시 후 복구(중복 주문 0). 미디스패치(broker_order_id NULL) 슬롯은 다음 dispatch 가 1회만 송신.
- 모든 슬롯 쓰기는 SlotService(advisory lock) 단일 경로(불변식 A). 브로커 주문/취소는 BrokerPort 단일 세션만(불변식 B). - 모든 슬롯 쓰기는 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 되어 재매도는 시장가. - 슬롯 `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(모의투자 검증, 실계좌 게이팅). - 어댑터: 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) ## Flow: `체결 → ledger outbox` (BAT-006)
집행 루프 fill 전이 직후 `ExecutionService` 가 ledger 기록을 outbox 에 적재해 슬롯 전이 critical path 와 ledger write 를 분리한다(원장 기록 단일 주체 = 집행 경로, NFR-3). 집행 루프 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 감지). 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 검사 실현). 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 가 없다. 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 ## Impacted Area
- Docs: docs/data-model.md(테이블/계약), dcy-001 부록 A(기준) - 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 통합테스트 + 드리프트 테스트 - Tests: PostgreSQL repo 통합테스트 + 드리프트 테스트
## Notes ## Notes
@@ -271,3 +272,52 @@
#### Validation Plan #### Validation Plan
- dcy-020 스키마/cutover 배치 verification 으로 대체. - 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 #### Validation Plan
- BAT-017: 전량 취소 → holding 복귀, 부분 취소 → 원장 1건 + qty 차감 + cost basis 유지 단위. - 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) 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` 등 인프라 심볼이 노출되지 않는다. 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) 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 ## Impacted Area
- Docs: docs/interface-contract.md, docs/data-model.md (계약 기준 — 큰 변경 없을 수 있음) - 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 에서 이식 단위테스트 + 계약 테스트로 검증 - Tests: REQ-003 에서 이식 단위테스트 + 계약 테스트로 검증
## Notes ## Notes
@@ -271,3 +272,52 @@
#### Validation Plan #### Validation Plan
- dcy-020 상태머신 배치의 verification 으로 대체. - 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) 3. Given 이미 처리된 `intent_id`, When 동일 intent 재전송, Then 슬롯이 중복 전이되지 않고 최초 결정과 동일한 응답을 돌려준다(멱등). (NFR-3)
4. Given 동일 슬롯에 대한 동시 intent, When 병렬 처리, Then advisory lock 으로 직렬화되어 단일 writer 불변식이 유지된다(매수·매도 전략은 슬롯을 직접 쓰지 않음). (NFR-2) 4. Given 동일 슬롯에 대한 동시 intent, When 병렬 처리, Then advisory lock 으로 직렬화되어 단일 writer 불변식이 유지된다(매수·매도 전략은 슬롯을 직접 쓰지 않음). (NFR-2)
5. Given 수락된 intent, When `GET /v1/intents/{intent_id}`, Then 기록된 decision 을 반환한다. 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 ## Impacted Area
- Docs: docs/interface-contract.md(HTTP API 계약), docs/runtime-flows.md(요청 흐름) — REQ-009 에서 정합 - 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 통합테스트(정상/거부/멱등 재전송/동시성) - Tests: API 통합테스트(정상/거부/멱등 재전송/동시성)
## Notes ## Notes
@@ -351,3 +352,52 @@
#### Validation Plan #### Validation Plan
- dcy-020 admission/intent 배치 verification 으로 대체. - 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 예외/자격증명 등 민감정보는 노출되지 않는다. 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) 이고 가격 필드는 없다. 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, 하위호환). 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 ## 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 - 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` 실제 슬롯 상태 표시 - Tests: envelope 스키마 검증(빈 응답 포함), degraded 주입 테스트, `slots[]` 스키마(빈 배열·status enum) + `trex-core slots` 실제 슬롯 상태 표시
## Notes ## Notes
@@ -206,3 +207,52 @@
#### Validation Plan #### 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 수용 잔여. - 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 상태가 보인다. 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` 이 그 값으로 설정된다. 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 제출 전 검증 오류로 거부된다. 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 ## Impacted Area
- Docs: docs/discovery/dcy-005_20260604_portfolio-hub-asset-snapshot-api.md - 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` 매핑 - 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 매핑·형식 검증 - 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 ## Notes
- pause(수동·영속) vs warning(자동·일시, REQ-031)은 독립 overlay 이며 OR 합성(둘 중 하나라도면 마스킹). - pause(수동·영속) vs warning(자동·일시, REQ-031)은 독립 overlay 이며 OR 합성(둘 중 하나라도면 마스킹).
@@ -370,3 +372,93 @@
#### Validation Plan #### 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. - 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 - Author: Justin Ko
- Type: status - Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-031(release-candidate) verification — 단위 378 / 통합 422 passed + run-the-app 사이징 e2e(증액 가중평균·부분 매도 잔량·전량 매도) green. Blocking Issues 없음. - 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 ## Next Requirement ID
- Current: `REQ-068` - Current: `REQ-073`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register ## 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-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-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-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 ## 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-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-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-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-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-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-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-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-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-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 from uuid import UUID
slot = repo.fetch_slot(UUID(resp.payload["slot_id"])) slot = repo.fetch_slot(UUID(resp.payload["slot_id"]))
assert slot.symbol == "005930" and slot.portfolio_id == "PF-9" 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", actor="buy_decision_thread",
symbol="005930", symbol="005930",
qty=10, qty=10,
allocated_capital=1_000_000,
strategy_id="orb_v1", strategy_id="orb_v1",
) )
assert updated.status is SlotState.PENDING_BUY assert updated.status is SlotState.PENDING_BUY
@@ -94,7 +93,7 @@ class TestTransitHappyPath:
service.transit_slot( service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="orb_breakout", actor="buy", symbol="005930", qty=10, 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( rolled = service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE, slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
@@ -102,7 +101,6 @@ class TestTransitHappyPath:
) )
assert rolled.status is SlotState.INACTIVE assert rolled.status is SlotState.INACTIVE
assert rolled.qty is None assert rolled.qty is None
assert rolled.allocated_capital is None
assert rolled.strategy_id is None assert rolled.strategy_id is None
assert rolled.limit_price is None assert rolled.limit_price is None
assert rolled.broker_order_id is None assert rolled.broker_order_id is None
+84 -14
View File
@@ -34,11 +34,11 @@ _SNAPSHOT = {
"slots": [ "slots": [
{"slot_id": "S-1", "status": "holding", {"slot_id": "S-1", "status": "holding",
"symbol": "005930", "qty": 10.0, "avg_price": 28700.0, "entry_price": None, "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}, "portfolio_id": "PF-1", "forced_close_at": None},
{"slot_id": "S-2", "status": "inactive", {"slot_id": "S-2", "status": "inactive",
"symbol": None, "qty": None, "avg_price": None, "entry_price": None, "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}, "portfolio_id": None, "forced_close_at": None},
], ],
} }
@@ -134,7 +134,7 @@ def test_slots_marks_watchdog_owned(capsys):
"slots": [{"slot_id": "S-1", "slots": [{"slot_id": "S-1",
"status": "holding", "symbol": "005930", "qty": 10.0, "status": "holding", "symbol": "005930", "qty": 10.0,
"avg_price": 28700.0, "entry_price": None, "avg_price": 28700.0, "entry_price": None,
"allocated_capital": None, "strategy_id": "orb", "strategy_id": "orb",
"sell_strategy_id": None, "portfolio_id": "PF-1", "sell_strategy_id": None, "portfolio_id": "PF-1",
"forced_close_at": None}], "forced_close_at": None}],
"watchdog": [{"slot_id": "S-1", "portfolio_id": "PF-1", "symbol": "005930", "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 ------------------------------------ # --- enter (buy intent RPC) + safeguards ------------------------------------
def test_enter_aborts_without_confirmation_no_rpc(): def test_enter_aborts_without_confirmation_no_rpc():
rpc = _RpcRecorder(_ADMITTED) 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) rpc=rpc, confirm=lambda _p: False)
assert rc == 1 and rpc.calls == [] # aborted -> no order 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(): def test_enter_with_yes_submits_buy_intent():
rpc = _RpcRecorder(_ADMITTED) 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 rc == 0
p = rpc.calls[0]["payload"] p = rpc.calls[0]["payload"]
assert rpc.calls[0]["queue"] == "trex.intent.req" assert rpc.calls[0]["queue"] == "trex.intent.req"
assert p["intent_type"] == "buy" and p["portfolio_id"] == "PF-1" 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) assert "symbol" not in p # server-resolved (dcy-011)
def test_enter_with_forced_close_at_maps_to_payload(): def test_enter_with_forced_close_at_maps_to_payload():
# REQ-032 AC-5: --forced-close-at (absolute) → buy intent forced_close_at. # REQ-032 AC-5: --forced-close-at (absolute) → buy intent forced_close_at.
rpc = _RpcRecorder(_ADMITTED) 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) "--forced-close-at", "2026-06-09T15:20:00+09:00"], rpc=rpc)
assert rc == 0 assert rc == 0
p = rpc.calls[0]["payload"] 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(): def test_enter_without_forced_close_at_omits_field():
# REQ-032 AC-6: unspecified → field absent (backward compat). # REQ-032 AC-6: unspecified → field absent (backward compat).
rpc = _RpcRecorder(_ADMITTED) 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 rc == 0
assert "forced_close_at" not in rpc.calls[0]["payload"] 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(): def test_enter_rejects_malformed_forced_close_at_before_submit():
# REQ-032 AC-6: invalid timestamp rejected at CLI, no RPC submitted. # REQ-032 AC-6: invalid timestamp rejected at CLI, no RPC submitted.
rpc = _RpcRecorder(_ADMITTED) 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) "--forced-close-at", "notatime"], rpc=rpc)
assert rc == 1 and rpc.calls == [] assert rc == 1 and rpc.calls == []
def test_enter_rejected_returns_nonzero(): def test_enter_rejected_returns_nonzero():
rpc = _RpcRecorder(_REJECTED) 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 assert rc == 1
def test_enter_dry_run_does_not_submit(capsys): def test_enter_dry_run_does_not_submit(capsys):
rpc = _RpcRecorder(_ADMITTED) 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 rc == 0 and rpc.calls == []
assert "dry-run" in capsys.readouterr().out assert "dry-run" in capsys.readouterr().out
def test_enter_json_requires_yes(): def test_enter_json_requires_yes():
rpc = _RpcRecorder(_ADMITTED) 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) 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): def test_live_requires_live_flag(monkeypatch):
monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True)) monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True))
rpc = _RpcRecorder(_ADMITTED) 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 == [] assert rc == 2 and rpc.calls == []
def test_live_proceeds_with_yes_live(monkeypatch): def test_live_proceeds_with_yes_live(monkeypatch):
monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True)) monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True))
rpc = _RpcRecorder(_ADMITTED) 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 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) rc = cli.main(["universe"], rpc=boom)
assert rc == 3 assert rc == 3
assert "daemon unreachable" in capsys.readouterr().err 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_COLUMNS = (
"slot_id, status, symbol, qty, avg_price, limit_price, " "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, " "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: with conn.cursor() as cur:
cur.execute( cur.execute(
"UPDATE daily_slot SET status=%s, symbol=%s, qty=%s, avg_price=%s, " "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, " "broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, "
"forced_close_at=%s, stop_loss_price=%s, take_profit_price=%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", "updated_at=%s WHERE slot_id=%s",
( (
slot.status.value, slot.symbol, slot.qty, slot.avg_price, 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.strategy_id, slot.broker_order_id, slot.portfolio_id,
slot.sell_strategy_id, slot.forced_close_at, slot.sell_strategy_id, slot.forced_close_at,
slot.stop_loss_price, slot.take_profit_price, slot.stop_loss_price, slot.take_profit_price,
slot.instance_key, slot.retired_at, slot.instance_key, slot.retired_at,
slot.timeout_sec, slot.slippage_pct,
slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id, 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 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 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) # 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) # limit_price(5) order_id(6) strategy_id(7)
# broker_order_id(9) portfolio_id(10) sell_strategy_id(11) # broker_order_id(8) portfolio_id(9) sell_strategy_id(10)
# forced_close_at(12) stop_loss_price(13) take_profit_price(14) # forced_close_at(11) stop_loss_price(12) take_profit_price(13)
# created_at(15) updated_at(16) instance_key(17) retired_at(18) # created_at(14) updated_at(15) instance_key(16) retired_at(17)
# timeout_sec(18) slippage_pct(19) [dcy-022/REQ-068]
return Slot( return Slot(
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])), slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
status=SlotState(row[1]), status=SlotState(row[1]),
@@ -277,19 +280,20 @@ class PostgreSQLSlotRepository:
qty=f(row[3]), qty=f(row[3]),
avg_price=f(row[4]), avg_price=f(row[4]),
limit_price=f(row[5]), limit_price=f(row[5]),
allocated_capital=f(row[6]), order_id=row[6],
order_id=row[7], strategy_id=row[7],
strategy_id=row[8], broker_order_id=row[8],
broker_order_id=row[9], portfolio_id=row[9],
portfolio_id=row[10], sell_strategy_id=row[10],
sell_strategy_id=row[11], forced_close_at=row[11],
forced_close_at=row[12], stop_loss_price=d(row[12]),
stop_loss_price=d(row[13]), take_profit_price=d(row[13]),
take_profit_price=d(row[14]), created_at=row[14],
created_at=row[15], updated_at=row[15],
updated_at=row[16], instance_key=row[16] if row[16] is not None else "default",
instance_key=row[17] if row[17] is not None else "default", retired_at=row[17],
retired_at=row[18], 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 from __future__ import annotations
import logging import logging
import math
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Callable, Optional from typing import Callable, Optional
@@ -52,6 +53,7 @@ class ExecutionResult:
ledger_skipped: int = 0 # REQ-018 AC-4: fills without portfolio_id 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 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 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: class ExecutionService:
@@ -134,7 +136,25 @@ class ExecutionService:
# limit_price only. A sell never reuses a buy limit (issue #10): # limit_price only. A sell never reuses a buy limit (issue #10):
# limit_price is cleared on entering HOLDING, so a market sell # limit_price is cleared on entering HOLDING, so a market sell
# carries None -> order_type "01". # 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 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( ack = self._broker.submit_order(
OrderRequest( OrderRequest(
slot_id=str(slot.slot_id), slot_id=str(slot.slot_id),
@@ -170,8 +190,10 @@ class ExecutionService:
r.rolled_back += 1 r.rolled_back += 1
else: else:
# accepted / partially_filled (not cancelled) -> leave for next # 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._check_stale_pending(slot, now, r)
self._try_timeout_cancel(slot, now, side, r)
# ------------------------------------------------------------ # ------------------------------------------------------------
def _balance_map(self) -> dict: def _balance_map(self) -> dict:
@@ -294,6 +316,44 @@ class ExecutionService:
age, self._stale_threshold_sec, 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: def _emit_event(self, event_type: str, slot) -> None:
if self._event_emitter is None: if self._event_emitter is None:
return return
@@ -340,6 +400,44 @@ class ExecutionService:
self._ledger_outbox.enqueue(trade) self._ledger_outbox.enqueue(trade)
r.ledger_enqueued += 1 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: def _rollback(self, slot, *, reason: str) -> None:
total = self._balance_map().get(slot.symbol or "", 0) total = self._balance_map().get(slot.symbol or "", 0)
try: try:
+28 -3
View File
@@ -59,13 +59,17 @@ class BuyIntent:
slot_id: Optional[UUID] = None slot_id: Optional[UUID] = None
symbol: Optional[str] = None symbol: Optional[str] = None
qty: Optional[float] = None qty: Optional[float] = None
allocated_capital: Optional[float] = None
entry_price: Optional[float] = None entry_price: Optional[float] = None
strategy_id: Optional[str] = None strategy_id: Optional[str] = None
sell_strategy_id: Optional[str] = None sell_strategy_id: Optional[str] = None
forced_close_at: Optional[datetime] = None forced_close_at: Optional[datetime] = None
stop_loss_price: Optional[Decimal] = None # REQ-035 stop_loss_price: Optional[Decimal] = None # REQ-035
take_profit_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) 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 # dcy-021 / REQ-062: optional partial-sell quantity. None => sell the whole
# position (broker balance); set => partial sell of min(qty, broker balance). # position (broker balance); set => partial sell of min(qty, broker balance).
qty: Optional[float] = None 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) extra: dict[str, Any] = field(default_factory=dict)
@@ -96,6 +103,7 @@ class IntentService:
*, *,
instance_key: str = "default", instance_key: str = "default",
max_active_slots: Optional[int] = None, max_active_slots: Optional[int] = None,
default_order_timeout_sec: Optional[int] = None,
) -> None: ) -> None:
self._svc = slot_service self._svc = slot_service
self._repo = slot_repo self._repo = slot_repo
@@ -108,6 +116,9 @@ class IntentService:
# expected to set max_active_slots in the instance TOML — design §5). # expected to set max_active_slots in the instance TOML — design §5).
self._instance_key = instance_key self._instance_key = instance_key
self._max_active_slots = max_active_slots 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]: def lookup(self, intent_id: str) -> Optional[IntentDecision]:
@@ -123,7 +134,6 @@ class IntentService:
"portfolio_id": intent.portfolio_id, "portfolio_id": intent.portfolio_id,
"symbol": intent.symbol, "symbol": intent.symbol,
"qty": intent.qty, "qty": intent.qty,
"allocated_capital": intent.allocated_capital,
"entry_price": intent.entry_price, "entry_price": intent.entry_price,
"strategy_id": intent.strategy_id, "strategy_id": intent.strategy_id,
"sell_strategy_id": intent.sell_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 if (not is_increase and self._max_active_slots is not None
and self._repo.count_active(self._instance_key) >= self._max_active_slots): and self._repo.count_active(self._instance_key) >= self._max_active_slots):
return IntentDecision(intent.intent_id, "rejected", None, "capacity_full") 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: try:
updated = self._svc.transit_slot( updated = self._svc.transit_slot(
slot.slot_id, slot.slot_id,
@@ -222,7 +238,6 @@ class IntentService:
actor="intent_api", actor="intent_api",
symbol=symbol, symbol=symbol,
qty=intent.qty, qty=intent.qty,
allocated_capital=intent.allocated_capital,
# dcy-012 / REQ-007: buy entry price maps to the slot's # dcy-012 / REQ-007: buy entry price maps to the slot's
# side-agnostic pending limit_price (cleared on fill→HOLDING). # side-agnostic pending limit_price (cleared on fill→HOLDING).
limit_price=intent.entry_price, limit_price=intent.entry_price,
@@ -232,6 +247,8 @@ class IntentService:
forced_close_at=intent.forced_close_at, forced_close_at=intent.forced_close_at,
stop_loss_price=intent.stop_loss_price, stop_loss_price=intent.stop_loss_price,
take_profit_price=intent.take_profit_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}, extra={"intent_id": intent.intent_id, "symbol": symbol},
) )
except SlotStateMismatchError: except SlotStateMismatchError:
@@ -247,6 +264,12 @@ class IntentService:
return IntentDecision( return IntentDecision(
intent.intent_id, "rejected", None, f"slot_not_holding:{slot.status.value}" 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: try:
updated = self._svc.transit_slot( updated = self._svc.transit_slot(
slot.slot_id, slot.slot_id,
@@ -261,6 +284,8 @@ class IntentService:
# dispatch caps at the broker balance. Entering PENDING_SELL takes # dispatch caps at the broker balance. Entering PENDING_SELL takes
# this qty as-is (None = "sell all" sentinel). # this qty as-is (None = "sell all" sentinel).
qty=intent.qty, qty=intent.qty,
timeout_sec=effective_timeout,
slippage_pct=intent.slippage_pct,
extra={"intent_id": intent.intent_id}, extra={"intent_id": intent.intent_id},
) )
except SlotStateMismatchError: 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, portfolio_id=_opt_str(body.get("portfolio_id")) or None,
slot_id=(UUID(str(body["slot_id"])) if body.get("slot_id") else None), slot_id=(UUID(str(body["slot_id"])) if body.get("slot_id") else None),
qty=_opt_float(body.get("qty")), 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")), entry_price=_opt_float(body.get("entry_price")),
strategy_id=_opt_str(body.get("strategy_id")), strategy_id=_opt_str(body.get("strategy_id")),
sell_strategy_id=_opt_str(body.get("sell_strategy_id")), sell_strategy_id=_opt_str(body.get("sell_strategy_id")),
forced_close_at=_opt_dt(body.get("forced_close_at")), forced_close_at=_opt_dt(body.get("forced_close_at")),
stop_loss_price=_opt_decimal(body.get("stop_loss_price")), stop_loss_price=_opt_decimal(body.get("stop_loss_price")),
take_profit_price=_opt_decimal(body.get("take_profit_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) d = intent_service.submit_buy(buy)
elif intent_type == "sell": elif intent_type == "sell":
@@ -99,6 +103,9 @@ def make_intent_rpc_handler(
limit_price=_opt_float(body.get("limit_price")), limit_price=_opt_float(body.get("limit_price")),
# dcy-021 / REQ-062: optional partial-sell qty; absent => sell all. # dcy-021 / REQ-062: optional partial-sell qty; absent => sell all.
qty=_opt_float(body.get("qty")), 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) d = intent_service.submit_sell(sell)
else: else:
@@ -357,11 +364,19 @@ def _err(reason: str) -> RpcResponse:
# ---- control RPC handler (REQ-043, BAT-012) --------------------------------- # ---- 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). """RPC handler for `trex.control.req` — action discriminator dispatch (REQ-043).
Actions: `pause`/`resume`/`list` (REQ-032, delegated), `kill_switch` (REQ-045), 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) 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) return _handle_resume_kill_switch(body, kill_switch_service)
if action in ("pause", "resume", "list"): if action in ("pause", "resume", "list"):
return pause_handler(req) return pause_handler(req)
if action == "cancel_pending":
return _handle_cancel_pending(body, cancel_pending_fn)
return _err(f"unknown_action:{action}") return _err(f"unknown_action:{action}")
return handler return handler
@@ -409,6 +426,24 @@ def _handle_kill_switch(body: dict, svc) -> RpcResponse:
return RpcResponse(payload=payload) 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: def _handle_resume_kill_switch(body: dict, svc) -> RpcResponse:
"""REQ-046: clear the day-scoped exclusion (sold slots stay sold).""" """REQ-046: clear the day-scoped exclusion (sold slots stay sold)."""
if svc is None: if svc is None:
@@ -469,6 +504,12 @@ def _opt_float(v) -> Optional[float]:
return float(v) return float(v)
def _opt_int(v) -> Optional[int]:
if v is None:
return None
return int(v)
def _opt_dt(v) -> Optional[datetime]: def _opt_dt(v) -> Optional[datetime]:
if v is None: if v is None:
return 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, "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, "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, "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, "strategy_id": s.strategy_id,
"sell_strategy_id": s.sell_strategy_id, "sell_strategy_id": s.sell_strategy_id,
"portfolio_id": s.portfolio_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 # on pending exit (→HOLDING, →INACTIVE) so a buy limit never leaks into a
# sell order (issue #10). None => market order at dispatch. # sell order (issue #10). None => market order at dispatch.
limit_price: Optional[float] = None limit_price: Optional[float] = None
allocated_capital: Optional[float] = None
order_id: Optional[str] = None order_id: Optional[str] = None
strategy_id: Optional[str] = None strategy_id: Optional[str] = None
broker_order_id: Optional[str] = None # BAT-003 / REQ-011 (trex-orbit lineage) 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-scoped. retired_at marks a soft-retired / universe-detached slot.
instance_key: str = "default" instance_key: str = "default"
retired_at: Optional[datetime] = None 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). # Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5).
@@ -60,7 +64,6 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"qty", "qty",
"avg_price", "avg_price",
"limit_price", "limit_price",
"allocated_capital",
"order_id", "order_id",
"strategy_id", "strategy_id",
"broker_order_id", "broker_order_id",
@@ -73,6 +76,8 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"updated_at", "updated_at",
"instance_key", # dcy-020 / REQ-056 (BAT-027) "instance_key", # dcy-020 / REQ-056 (BAT-027)
"retired_at", # 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, qty: Optional[float] = None,
avg_price: Optional[float] = None, avg_price: Optional[float] = None,
limit_price: Optional[float] = None, limit_price: Optional[float] = None,
allocated_capital: Optional[float] = None,
order_id: Optional[str] = None, order_id: Optional[str] = None,
strategy_id: Optional[str] = None, strategy_id: Optional[str] = None,
broker_order_id: Optional[str] = None, broker_order_id: Optional[str] = None,
@@ -74,6 +73,9 @@ class SlotService:
forced_close_at: Optional[datetime] = None, forced_close_at: Optional[datetime] = None,
stop_loss_price: Optional[Decimal] = None, stop_loss_price: Optional[Decimal] = None,
take_profit_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, extra: Optional[dict[str, Any]] = None,
) -> Slot: ) -> Slot:
"""Transition slot from one state to another atomically. """Transition slot from one state to another atomically.
@@ -112,7 +114,6 @@ class SlotService:
qty=qty, qty=qty,
avg_price=avg_price, avg_price=avg_price,
limit_price=limit_price, limit_price=limit_price,
allocated_capital=allocated_capital,
order_id=order_id, order_id=order_id,
strategy_id=strategy_id, strategy_id=strategy_id,
broker_order_id=broker_order_id, broker_order_id=broker_order_id,
@@ -121,6 +122,8 @@ class SlotService:
forced_close_at=forced_close_at, forced_close_at=forced_close_at,
stop_loss_price=stop_loss_price, stop_loss_price=stop_loss_price,
take_profit_price=take_profit_price, take_profit_price=take_profit_price,
timeout_sec=timeout_sec,
slippage_pct=slippage_pct,
) )
self._repo.update_slot(updated) self._repo.update_slot(updated)
@@ -146,7 +149,6 @@ class SlotService:
qty: Optional[float], qty: Optional[float],
avg_price: Optional[float], avg_price: Optional[float],
limit_price: Optional[float], limit_price: Optional[float],
allocated_capital: Optional[float],
order_id: Optional[str], order_id: Optional[str],
strategy_id: Optional[str], strategy_id: Optional[str],
broker_order_id: Optional[str] = None, broker_order_id: Optional[str] = None,
@@ -155,6 +157,8 @@ class SlotService:
forced_close_at: Optional[datetime] = None, forced_close_at: Optional[datetime] = None,
stop_loss_price: Optional[Decimal] = None, stop_loss_price: Optional[Decimal] = None,
take_profit_price: Optional[Decimal] = None, take_profit_price: Optional[Decimal] = None,
timeout_sec: Optional[int] = None,
slippage_pct: Optional[float] = None,
) -> Slot: ) -> Slot:
"""Compute the post-transition Slot row. """Compute the post-transition Slot row.
@@ -174,7 +178,6 @@ class SlotService:
qty=None, qty=None,
avg_price=None, avg_price=None,
limit_price=None, limit_price=None,
allocated_capital=None,
order_id=None, order_id=None,
strategy_id=None, strategy_id=None,
broker_order_id=None, broker_order_id=None,
@@ -182,15 +185,22 @@ class SlotService:
forced_close_at=None, forced_close_at=None,
stop_loss_price=None, stop_loss_price=None,
take_profit_price=None, take_profit_price=None,
timeout_sec=None,
slippage_pct=None,
updated_at=datetime.now(tz=timezone.utc), updated_at=datetime.now(tz=timezone.utc),
) )
# limit_price lifecycle (dcy-012 FR-1): set only on entering a pending # limit_price lifecycle (dcy-012 FR-1): set only on entering a pending
# state (the order's limit); cleared on entering HOLDING/ERROR. # 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}: 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 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_limit_price = None
new_timeout_sec = None
new_slippage_pct = None
# qty semantics during a pending state = the ORDER quantity (dcy-021 D1/D4). # 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 # Entering PENDING_SELL takes the caller qty AS-IS so a full sell
@@ -210,9 +220,6 @@ class SlotService:
qty=new_qty, qty=new_qty,
avg_price=avg_price if avg_price is not None else slot.avg_price, avg_price=avg_price if avg_price is not None else slot.avg_price,
limit_price=new_limit_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, 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, strategy_id=strategy_id if strategy_id is not None else slot.strategy_id,
broker_order_id=( broker_order_id=(
@@ -235,6 +242,8 @@ class SlotService:
take_profit_price=( take_profit_price=(
take_profit_price if take_profit_price is not None else slot.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), updated_at=datetime.now(tz=timezone.utc),
) )
+50 -4
View File
@@ -8,6 +8,7 @@ Subcommands:
slots [--status] — list positions/slots (snapshot RPC) slots [--status] — list positions/slots (snapshot RPC)
enter <portfolio_id> — submit a buy intent (intent RPC; safeguarded) enter <portfolio_id> — submit a buy intent (intent RPC; safeguarded)
exit <slot_id> — submit a sell 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 universe/slots/enter/exit go through the daemon's RabbitMQ RPC surface so the
single-writer admission + invariants are preserved (no direct slot writes, 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")), ("qty", s.get("qty")),
("avg_price", s.get("avg_price")), ("avg_price", s.get("avg_price")),
("entry_price (limit)", s.get("entry_price")), ("entry_price (limit)", s.get("entry_price")),
("allocated_capital", s.get("allocated_capital")),
("strategy_id", s.get("strategy_id")), ("strategy_id", s.get("strategy_id")),
("sell_strategy_id", s.get("sell_strategy_id")), ("sell_strategy_id", s.get("sell_strategy_id")),
("forced_close_at", s.get("forced_close_at")), ("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: 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, # 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. # so a malformed value is rejected at the CLI instead of failing admission.
forced_close_at = getattr(args, "forced_close_at", None) forced_close_at = getattr(args, "forced_close_at", None)
@@ -241,7 +247,7 @@ def _cmd_enter(args, cfg, rpc, confirm) -> int:
args.json) args.json)
return 1 return 1
summary = {"portfolio_id": args.portfolio_id, "qty": args.qty, 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} "forced_close_at": forced_close_at}
short = _intent_guard(args, cfg, summary, "enter(buy)", confirm) short = _intent_guard(args, cfg, summary, "enter(buy)", confirm)
if short is not None: if short is not None:
@@ -249,7 +255,7 @@ def _cmd_enter(args, cfg, rpc, confirm) -> int:
payload = { payload = {
"intent_type": "buy", "intent_id": str(uuid.uuid4()), "intent_type": "buy", "intent_id": str(uuid.uuid4()),
"portfolio_id": args.portfolio_id, "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, "entry_price": args.entry_price, "strategy_id": args.strategy_id,
"sell_strategy_id": args.sell_strategy_id, "sell_strategy_id": args.sell_strategy_id,
"stop_loss_price": args.stop_loss, "take_profit_price": args.take_profit, "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) 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: def _report_intent(resp: dict, as_json: bool) -> int:
if as_json: if as_json:
_emit(resp, as_json=True) _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 = sub.add_parser("enter", help="Submit a buy intent (intent RPC)")
p_enter.add_argument("portfolio_id") p_enter.add_argument("portfolio_id")
p_enter.add_argument("--qty", type=float, default=None) 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("--entry-price", dest="entry_price", type=float, default=None)
p_enter.add_argument("--strategy-id", dest="strategy_id", 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) 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_intent_guards(p_exit)
_add_common(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 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) return _cmd_enter(args, cfg, rpc, confirm)
if args.cmd == "exit": if args.cmd == "exit":
return _cmd_exit(args, cfg, rpc, confirm) 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 except Exception as exc: # noqa: BLE001 - surface RPC/transport failure clearly
_fail(f"{type(exc).__name__}: {exc}", getattr(args, "json", False)) _fail(f"{type(exc).__name__}: {exc}", getattr(args, "json", False))
return 3 return 3
+13
View File
@@ -145,6 +145,9 @@ class DaemonInstanceConfig:
# admission capacity cap. BAT-030: asset-centric is the only model; absent # admission capacity cap. BAT-030: asset-centric is the only model; absent
# means no cap (operators are expected to set it in production). # means no cap (operators are expected to set it in production).
max_active_slots: Optional[int] = None 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 @staticmethod
def from_toml(path: str) -> "DaemonInstanceConfig": def from_toml(path: str) -> "DaemonInstanceConfig":
@@ -163,11 +166,18 @@ class DaemonInstanceConfig:
raise RuntimeError( raise RuntimeError(
f"instance TOML 'max_active_slots' must be > 0, got {raw_max}: {path}" 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( return DaemonInstanceConfig(
instance_key=str(instance_key), instance_key=str(instance_key),
portfolio_hub_token=str(data.get("portfolio_hub_token") or ""), portfolio_hub_token=str(data.get("portfolio_hub_token") or ""),
trading_timezone=str(data.get("trading_timezone") or "Asia/Seoul"), trading_timezone=str(data.get("trading_timezone") or "Asia/Seoul"),
max_active_slots=max_active, 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 # threshold in seconds. 0 disables (default). When >0, dispatched-pending
# slots older than this are surfaced (WARN + counter) without state change. # slots older than this are surfaced (WARN + counter) without state change.
pending_stale_threshold_sec: int = 0 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 # dcy-014 / REQ-048: operator-local timezone (CLI date display etc.). The
# daily preallocation trigger was removed with the pool model (BAT-030). # daily preallocation trigger was removed with the pool model (BAT-030).
trading_timezone: str = "Asia/Seoul" trading_timezone: str = "Asia/Seoul"
+4 -1
View File
@@ -144,6 +144,7 @@ class Daemon:
resolve_symbol=self._resolve_symbol, resolve_symbol=self._resolve_symbol,
instance_key=config.instance_key, instance_key=config.instance_key,
max_active_slots=config.max_active_slots, # dcy-020: asset-centric gate 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.broker = _build_broker(config)
self.outbox = PostgreSQLLedgerOutbox(config.dsn) self.outbox = PostgreSQLLedgerOutbox(config.dsn)
@@ -288,7 +289,9 @@ class Daemon:
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass pass
handler = make_control_rpc_handler( 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( transport.consume_rpc_forever(
topology.control_queue(self.config.instance_key), handler, self.stop) topology.control_queue(self.config.instance_key), handler, self.stop)