fix(execution): dcy-012 매도 가격 버그 정정 — slot entry_price→limit_price rename (BAT-016)

매도(sell) 주문이 현재가가 아닌 매수가(entry_price) 지정가로 나가던 버그(issue #10)
정정. 슬롯의 매수 전용 entry_price 를 side 무관 "현재 pending 주문 지정가"
limit_price 로 내부 rename 하고, pending 이탈(→holding/→closed) 시 clear 해
매수 지정가가 매도로 누수되는 경로를 구조적으로 제거.

거버넌스: 전부 change-req (신규 REQ 없음, dcy-012 OQ-3).
- REQ-002: Slot entry_price→limit_price rename + clear-on-exit 생애주기
- REQ-006: daily_slot 컬럼 rename 마이그레이션 006 (idempotent) + persistence 매핑
- REQ-013: _dispatch 가 slot.limit_price 단일 필드로 가격/주문유형 결정
- REQ-007: SellIntent/sell payload optional limit_price + _admit_sell 운반
- REQ-036: SlotService.sell optional 가격 → 자율 매도(safety-sell/kill-switch) 시장가
- REQ-003: 컬럼 계약/드리프트 가드 limit_price 반영
- REQ-015: runtime-flows/interface-contract/data-model 정합

매도 정책(OQ-1 하이브리드): 가격 지정→지정가("00"), 미지정→시장가("01").
자율 매도는 가격 미전달 → 항상 시장가 (손절 미체결 pending_sell 갇힘 방지, issue #9 연계).

검증: 단위 286 passed/32 skipped, 인프라 게이트(PG+RabbitMQ) 318 passed, ruff clean.
신규 test_sell_pricing(recording broker)로 지정가 매수 슬롯의 매도가 매수가
재사용 안 함을 직접 검증. BAT-016 release-candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:41:46 +09:00
co-authored by Claude Opus 4.8
parent aee3512383
commit cc28e1fba5
28 changed files with 1003 additions and 25 deletions
@@ -0,0 +1,65 @@
# BAT-016 Design
- Status: confirmed
- Batch: BAT-016
- Profile: standard
## 설계 목표
- 매도 주문이 매수 지정가(`entry_price`)로 나가는 버그(issue #10)를 도메인 모델 차원에서 구조적으로 제거한다.
- released 집행 경로 변경·컬럼 rename 마이그레이션을 회귀 없이 cutover 한다.
## 1. 슬롯 가격 필드 의미 통일 (REQ-002)
### rename
- `Slot.entry_price` → `Slot.limit_price` (side 무관 "현재 pending 주문 지정가"). `EXPECTED_SLOT_FIELDS` 동시 반영(컬럼 계약 가드가 곧 검출).
- `transit_slot`/`_apply_transition_fields` 의 `entry_price` 파라미터 → `limit_price`.
### limit_price 생애주기 (FR-1, 핵심)
- 결정: limit_price 는 **pending 상태에서만 유효**. `_apply_transition_fields` 의 generic 경로에서:
- `to_state ∈ {PENDING_BUY, PENDING_SELL}` → `limit_price = 입력값 if not None else slot.limit_price`(set on entry).
- `to_state ∈ {HOLDING, CLOSED}` → `limit_price = None`(clear on exit).
- `to_state == IDLE`(롤백/복구) → 기존 reset 분기에서 이미 None clear.
- 효과: PENDING_BUY→HOLDING 에서 매수 지정가가 clear 되므로, 이후 HOLDING→PENDING_SELL 에서 매도 가격 미전달이면 slot.limit_price 는 None → 시장가. 매수 지정가가 매도로 누수될 경로가 구조적으로 없음.
- 전이 매트릭스:
- IDLE→PENDING_BUY: set(매수 지정가) / PENDING_BUY→HOLDING: clear / HOLDING→PENDING_SELL: set(매도 지정가 또는 None) / PENDING_SELL→CLOSED: clear / *→IDLE: clear.
### SlotService.sell (REQ-036)
- `sell(slot_id, actor, reason, *, limit_price=None)`. default None → 자율 매도(safety-sell/kill-switch)는 가격 없이 호출 → 시장가. transit_slot 에 limit_price 전달.
## 2. dispatch 가격 결정 (REQ-013)
- `_dispatch`: side 분기 제거. `limit = int(slot.limit_price) if slot.limit_price else None`; `order_type = "00" if limit is not None else "01"`. 매수가 재사용 경로 삭제.
## 3. sell intent 계약 (REQ-007)
- `SellIntent.limit_price: Optional[float] = None`. `_admit_sell` → `transit_slot(..., limit_price=intent.limit_price)`.
- `_admit_buy`: 기존 `entry_price=intent.entry_price`(BuyIntent 외부 필드 유지) → 슬롯 `limit_price=intent.entry_price` 로 매핑(매수 동작 동일).
- 단일 메커니즘(가격 유무 분기, OQ-2): reason 별 코드 차등 없음.
## 4. 메시지 경계 (REQ-007, NFR-3)
- sell 핸들러: `limit_price=_opt_float(body.get("limit_price"))` 추가.
- buy 핸들러: 외부 payload 필드명 `entry_price` 유지(BuyIntent.entry_price).
- slot read(`_slot_to_dict`): 외부 read 키 `"entry_price"` 유지하되 내부 `s.limit_price` 로 매핑(계약 호환 — 외부 필드명 rename 은 본 주기 밖).
## 5. persistence + 마이그레이션 (REQ-006)
- `006_rename_entry_price_to_limit_price.sql`: `ALTER TABLE daily_slot RENAME COLUMN entry_price TO limit_price` 를 idempotent DO-block(컬럼 존재 && 신컬럼 부재 시에만)으로. 글롭 재적용 모델에서 안전. 순수 rename → 기존 행 데이터 보존(S-3).
- `postgresql.py`: `_SLOT_COLUMNS`/update_slot/_row_to_slot 의 entry_price → limit_price.
## 6. 컬럼 계약 가드 (REQ-003)
- `test_contract.py`(도메인 필드셋)·`test_migration_and_drift.py`(DB 컬럼 = EXPECTED_SLOT_FIELDS)는 상수 참조라 rename 자동 반영; PG 게이트에서 실 컬럼명 limit_price 검증.
## 7. baseline 문서 (REQ-015)
- runtime-flows: dispatch 가격 결정 = slot.limit_price 단일 필드 + limit_price 생애주기.
- interface-contract: sell payload optional limit_price + ORD_DVSN 매핑.
- data-model: 슬롯 limit_price 컬럼 의미/생애주기.
## 하위호환 / 회귀 통제
- 외부 buy/sell payload·slot read 필드명 유지(NFR-3) → 전략 모듈 계약 무파괴.
- sell limit_price optional default None → 기존 재량 매도 호출 시장가로 안전 동작.
- 전체 회귀 green 이 rename 호출부 누락 부재의 1차 게이트.
## 테스트 설계
- 신규 `tests/application/test_sell_pricing.py`(recording paper broker 로 OrderRequest 직접 검증):
- (a) 지정가 매수 슬롯 + 가격 미전달 매도 → sell order_type "01"/limit None(entry_price 미사용 — 핵심 버그 검출).
- (b) 지정가 매수 슬롯 + 가격 전달 매도 → "00"/그 가격.
- (c) SlotService.sell(가격 없음) → 시장가 + closed(S-2).
- (d) 시장가 매수 슬롯 매도 → 시장가(회귀 안전).
- `test_service.py`: PENDING_BUY→HOLDING 시 limit_price clear 단언 추가.
@@ -0,0 +1,48 @@
# BAT-016 Implementation
- Status: completed
- Batch: BAT-016
## 구현 요약
- dcy-012 매도 가격 버그(issue #10)를 슬롯 `entry_price`→`limit_price` rename + clear-on-exit 생애주기 + dispatch 단일 필드 결정으로 정정. 전부 change-req(신규 REQ 없음).
## 변경 내역
### REQ-002 — 슬롯 도메인 rename + 생애주기
- `trex_core/domain/slot/models.py`: `Slot.entry_price` → `limit_price`(주석: side 무관 pending 주문 지정가, clear-on-exit); `EXPECTED_SLOT_FIELDS` 의 entry_price → limit_price.
- `trex_core/domain/slot/service.py`:
- `transit_slot`/`_apply_transition_fields` 파라미터 `entry_price` → `limit_price`.
- IDLE reset 분기: `entry_price=None` → `limit_price=None`.
- generic 경로: `to_state ∈ {PENDING_BUY, PENDING_SELL}` 면 set(입력 or 유지), 아니면(HOLDING/CLOSED) `new_limit_price=None` clear.
- `SlotService.sell(..., *, limit_price=None)` 추가 → transit_slot 에 전달.
### REQ-013 — dispatch 단일 필드
- `trex_core/application/execution.py::_dispatch`: `limit = int(slot.limit_price) if slot.limit_price else None`(side 분기/entry_price 재사용 제거). order_type 은 limit 유무로 "00"/"01".
### REQ-007 — sell intent 가격
- `trex_core/application/intents.py`: `SellIntent.limit_price: Optional[float] = None`; `_admit_sell` → `transit_slot(..., limit_price=intent.limit_price)`; `_admit_buy` → `limit_price=intent.entry_price`(BuyIntent.entry_price 외부 필드 유지).
- `trex_core/application/messaging_handlers.py`: sell 핸들러 `limit_price=_opt_float(body.get("limit_price"))`; buy payload `entry_price` 유지; `_slot_to_dict` 외부 키 `"entry_price"` 유지하되 `s.limit_price` 매핑(NFR-3).
### REQ-036 — 자동 매도 시장가
- `SlotService.sell` optional `limit_price`(default None) → safety-sell/kill-switch 자율 매도는 가격 미전달 → 시장가.
### REQ-006 — 마이그레이션 + persistence
- `trex_core/adapters/persistence/sql/006_rename_entry_price_to_limit_price.sql`(신규): idempotent DO-block rename(컬럼 존재 && 신컬럼 부재 시에만). 순수 rename → 데이터 보존.
- `trex_core/adapters/persistence/postgresql.py`: `_SLOT_COLUMNS`/update_slot SQL+바인딩/_row_to_slot 의 entry_price → limit_price.
### REQ-003 — 컬럼 계약 가드
- `EXPECTED_SLOT_FIELDS` rename 으로 `test_contract.py`/`test_migration_and_drift.py` 자동 반영(상수 참조). PG 게이트에서 실 컬럼 limit_price 검증.
### REQ-015 — baseline 문서
- `docs/runtime-flows.md`: dispatch 가격 결정(slot.limit_price 단일 필드) + limit_price 생애주기 set/clear. Last Updated → BAT-016.
- `docs/interface-contract.md`: sell payload optional limit_price + ORD_DVSN 매핑. Last Updated → BAT-016.
- `docs/data-model.md`: 슬롯 limit_price 컬럼 의미/생애주기. Last Updated → BAT-016.
## 테스트
- 신규 `tests/application/test_sell_pricing.py`(recording paper broker): 4건 — (a) 지정가 매수+시장가 매도(entry_price 미사용), (b) 지정가 매수+지정가 매도, (c) SlotService.sell 시장가+closed, (d) 시장가 매수 매도 회귀.
- `tests/domain/slot/test_service.py`: PENDING_BUY→HOLDING 시 `limit_price is None` 단언 추가; 두 →HOLDING 전이의 obsolete `entry_price=` 인자 제거.
## 검증 실행
- 단위: `TREX_BROKER=paper .venv/bin/python -m pytest -q` → `286 passed / 32 skipped`(`::test_sandbox_token_and_submit` deselect — LIVE `.env` 환경 의존, 본 batch 무관).
- 인프라 게이트: `TREX_POSTGRESQL_TEST_DSN=...(trex_core_test) TREX_RABBITMQ_TEST_URL=... pytest -q` → `318 passed`(006 rename 마이그레이션 적용 + 컬럼 계약 limit_price + 마이그레이션 idempotency 포함).
- `ruff check trex_core tests` → All checks passed.
@@ -0,0 +1,36 @@
# BAT-016: 매도 가격 정책 정정 + 슬롯 entry_price→limit_price rename (sell dispatch 버그)
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-012
- Included REQ: REQ-013 (CHG-20260608-01), REQ-007 (CHG-20260608-02), REQ-036 (CHG-20260608-01), REQ-002 (CHG-20260608-01), REQ-006 (CHG-20260608-01), REQ-003 (CHG-20260608-01), REQ-015 (CHG-20260608-01)
## Summary
- dcy-012(매도 주문이 현재가가 아닌 entry_price 지정가로 나가는 버그, issue #10)를 단일 standard batch 로 delivery 한다. 거버넌스는 dcy-012 OQ-3 결정대로 **전부 change-req, 신규 REQ 없음**.
- 핵심: 슬롯의 매수 전용 `entry_price` 를 side 무관 "현재 pending 주문 지정가" 단일 필드 `limit_price` 로 내부 rename(Option A) 하고, pending 이탈(→HOLDING/→CLOSED) 시 clear 한다. `_dispatch` 는 `slot.limit_price` 한 필드만 읽어 가격/주문유형을 결정(값=지정가 `00`, 없음=시장가 `01`). `SellIntent`/sell payload 에 optional `limit_price` 추가, 자율 매도(safety-sell/kill-switch)는 가격 미전달 → 시장가.
## Scope
- In Scope:
- 슬롯 `entry_price` → `limit_price` 내부 rename(도메인 모델/EXPECTED_SLOT_FIELDS, REQ-002) + pending 이탈 clear 생애주기
- DB 컬럼 rename 마이그레이션 `006_*.sql`(idempotent) + persistence 매핑 (REQ-006)
- `_dispatch` 가 `slot.limit_price` 단일 필드로 가격/주문유형 결정 (REQ-013)
- `SellIntent`/sell payload optional `limit_price` + `_admit_sell` 운반, `SlotService.sell` optional 가격 (REQ-007/REQ-036)
- 컬럼 계약/드리프트 가드 갱신 (REQ-003)
- baseline 문서(runtime-flows/interface-contract/data-model) 정합 (REQ-015)
- Out of Scope:
- 외부 buy/sell payload·slot read 의 필드명(`entry_price`) rename — REQ-007/dcy-011 과 조율할 별도 계약 변경(NFR-3 호환 유지)
- issue #9(KIS 외부 취소 시 pending_sell 갇힘) 자체의 복구 로직 — 본 버그는 #9 의 한 원인 제거, 복구는 후속 분리
- dispatch 시 현재가 자동 조회(`get_current_price`) 기반 지정가 — OQ-1 미채택
- buy 주문 가격 의미 변경(매수 지정가는 동일 limit_price 로 set, 동작 동일)
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- Source discovery 단일(dcy-012). OQ-1(하이브리드)·OQ-2(가격 유무 분기)·OQ-3(전부 change-req) Answered.
- 버그 재현 조건: `entry_price` 가 채워진 **지정가 매수 슬롯**의 매도. 시장가 매수 슬롯은 entry_price=NULL 로 우연히 정상 → 검증은 지정가 매수 슬롯 케이스 필수(S-1).
- `OrderRequest.limit_price`(broker_port) 는 기존 존재 — 슬롯 필드 rename 으로 이름까지 정합.
- 마이그레이션은 글롭 재적용 모델이므로 006 은 idempotent DO-block(컬럼 존재 시에만 rename).
@@ -0,0 +1,55 @@
# BAT-016 Planning
- Status: confirmed
- Batch: BAT-016
- Profile: standard
## Included REQ
- REQ-002 (Interface, change-req): Slot `entry_price`→`limit_price` rename + clear-on-exit(→HOLDING/→CLOSED) 생애주기.
- REQ-006 (Data, change-req): daily_slot 컬럼 rename 마이그레이션 006(idempotent) + persistence 매핑.
- REQ-013 (Integration, change-req): `_dispatch` 가 `slot.limit_price` 단일 필드로 가격/주문유형 결정(매수가 재사용 제거).
- REQ-007 (Interface, change-req): `SellIntent`/sell payload optional `limit_price` + `_admit_sell` 운반; buy entry_price→limit_price 매핑.
- REQ-036 (Interface, change-req): `SlotService.sell` optional `limit_price`(default None → 자율 매도 시장가).
- REQ-003 (Testing, change-req): 컬럼 계약/드리프트 가드 limit_price 반영.
- REQ-015 (Documentation, change-req): runtime-flows/interface-contract/data-model 정합.
## Delivery Plan
1. REQ-002: `models.py` Slot 필드 + EXPECTED_SLOT_FIELDS rename; `service.py` transit_slot/_apply_transition_fields 파라미터 rename + clear-on-exit(→HOLDING/→CLOSED), `SlotService.sell` optional limit_price.
2. REQ-013: `execution.py::_dispatch` 가 `slot.limit_price` 단일 필드 사용.
3. REQ-007: `intents.py` SellIntent.limit_price + _admit_sell 운반 + _admit_buy limit_price 매핑; `messaging_handlers.py` sell payload limit_price, slot read 외부 entry_price→limit_price 매핑.
4. REQ-006: `006_*.sql` rename 마이그레이션(idempotent) + `postgresql.py` 매핑.
5. REQ-003: 컬럼 계약/드리프트 가드 확인(상수 참조 자동 + DB 게이트).
6. REQ-015: baseline 3문서 정합.
7. 신규 테스트(test_sell_pricing) + 전체 회귀 + ruff + 인프라 게이트(PG 마이그레이션/컬럼 계약).
## Dependencies
- Internal:
- dcy-003/BAT-004 broker execution(`_dispatch`) — 버그 지점.
- dcy-001/BAT-001 slot 도메인(REQ-002/003) — rename 대상.
- dcy-002/BAT-002 persistence(REQ-006) — 컬럼/마이그레이션.
- dcy-007/BAT-010 safety-sell(REQ-036 SlotService.sell) — 자율 매도 시장가 경로.
- dcy-011/BAT-015 — slot.symbol 서버 resolve(가정 A-1) 충족.
- External:
- PostgreSQL(rename 마이그레이션). KIS ORD_DVSN 00/01 매핑(OrderRequest 경유).
## Design Gate
- Design Required Before Implementation: yes
- Reason: (a) `entry_price→limit_price` rename 은 도메인 모델·서비스 전이·persistence·마이그레이션·컬럼 계약 가드까지 **교차 절단(cross-cutting)** — 전이별 set/clear 매트릭스를 설계에서 못박지 않으면 매수 지정가 누수/복원 회귀(R-4) 위험. (b) 매도 가격 하이브리드(가격 유무 분기)의 단일 메커니즘 경계(자율=None=시장가, 재량=optional 지정가)를 설계에서 고정해야 reason 별 분기 혼입을 막는다(OQ-2). (c) released 집행 경로(R-1)·컬럼 rename 마이그레이션 idempotency 를 설계에서 명시. → design.md 동일 턴 scaffold.
## Milestones
1. M1 — REQ-002 도메인 rename + clear-on-exit, 단위 green
2. M2 — REQ-013 _dispatch limit_price 단일 필드 + REQ-007/036 sell 가격 경로, test_sell_pricing green
3. M3 — REQ-006 마이그레이션 006 + persistence 매핑, PG 게이트 컬럼 계약 green
4. M4 — REQ-003 가드 + REQ-015 문서 정합
5. M5 — 전체 회귀 + ruff + 인프라 게이트
## Risks
- R-1 (High): released 집행 경로 매도 동작 변경. 대응: paper recording broker 로 손절/지정가/현재가>매수가 시나리오 검증 + change-req 이력. 실거래 배포는 release 단계 검증.
- R-2 (Medium): 재량 지정가 미체결 시 pending_sell 잔류. 대응: 본 주기는 가격 결정 분기에 한정, 미체결 처리는 issue #9/기존 롤백 범위.
- R-3 (Low, OQ-2): reason 별 차등 없이 가격 유무로 분기 → 자율 매도는 default None 으로 항상 시장가(체결성 보장).
- R-4 (Medium): rename/clear-on-exit 가 도메인/persistence/마이그레이션 교차. 대응: 전이별 set/clear 단위 + 컬럼 계약 가드 + idempotent 마이그레이션 + 외부 필드명 유지(경계 매핑).
## Notes
- 1차 게이트: 전체 회귀 green(rename 호출부 누락 부재 확인).
- 버그 검증은 반드시 지정가 매수 슬롯(entry_price 채워짐) 매도 케이스 포함(S-1 측정 조건).
- 마이그레이션은 PG 게이트(실 PostgreSQL) 컬럼 계약/드리프트 테스트로 검증.
@@ -0,0 +1,74 @@
# BAT-016 Verification
- Status: verified
- Batch: BAT-016
## Acceptance Mapping
### REQ-013 (dispatch 단일 필드, change-req)
| AC | Evidence |
|----|----------|
| dispatch 가 slot.limit_price 단일 필드로 결정(매수가 재사용 금지) | tests/application/test_sell_pricing.py::test_limit_buy_then_market_sell_does_not_reuse_buy_limit |
| 기존 buy/sell lifecycle 회귀 | tests/application/test_execution.py (전체) |
### REQ-007 (sell intent 가격, change-req)
| AC | Evidence |
|----|----------|
| sell optional limit_price → 지정가 | test_sell_pricing.py::test_limit_buy_then_limit_sell_uses_intent_price |
| 미지정 → 시장가(호환) | test_sell_pricing.py::test_limit_buy_then_market_sell_does_not_reuse_buy_limit |
| buy entry_price → 슬롯 limit_price 매핑(동작 동일) | test_sell_pricing.py::test_limit_buy_then_market_sell... (buy_req order_type "00"/1000) |
| 멱등/동시성 회귀 | tests/application/test_intent_concurrency.py |
### REQ-036 (자동 매도 시장가, change-req)
| AC | Evidence |
|----|----------|
| SlotService.sell(가격 없음) → 시장가 + closed | test_sell_pricing.py::test_safety_sell_dispatches_at_market |
| idempotency 회귀 | tests/application/test_auto_sell* / 전체 회귀 |
### REQ-002 (도메인 rename + clear-on-exit, change-req)
| AC | Evidence |
|----|----------|
| limit_price clear on →HOLDING | tests/domain/slot/test_service.py::test_pending_buy_to_holding (limit_price is None) |
| clear on →CLOSED / →IDLE | test_service.py::test_closed_to_idle_resets_fields_and_bumps_counter + 전체 회귀 |
| 필드셋 계약 | tests/domain/slot/test_contract.py |
### REQ-006 (마이그레이션 + persistence, change-req)
| AC | Evidence |
|----|----------|
| 컬럼 rename(데이터 보존) | tests/adapters/persistence/test_migration_and_drift.py::test_daily_slot_columns_match_expected_contract (PG 게이트) |
| 마이그레이션 idempotent | ::test_apply_migrations_is_idempotent |
| persistence round-trip | PG 게이트 repository/전이 테스트 전반 |
### REQ-003 (컬럼 계약 가드, change-req)
| AC | Evidence |
|----|----------|
| 도메인 필드셋 = limit_price | tests/domain/slot/test_contract.py |
| DB 컬럼 드리프트 가드 = limit_price | test_migration_and_drift.py::test_daily_slot_columns_match_expected_contract / ::test_drift_detected_when_contract_extended |
### REQ-015 (baseline 문서, change-req)
| AC | Evidence |
|----|----------|
| runtime-flows/interface-contract/data-model 정합 | 문서 diff(BAT-016) |
## Evidence
- Test:
- 단위 전체 회귀: `286 passed / 32 skipped`(신규 sell-pricing 4 + holding clear 단언 포함; `::test_sandbox_token_and_submit` deselect).
- 인프라 게이트(PG trex_core_test + RabbitMQ): `318 passed`(006 rename 마이그레이션 + 컬럼 계약 limit_price + idempotency + 전이 round-trip 포함).
- `ruff check trex_core tests` → All checks passed.
- Logs: `286 passed, 32 skipped, 1 deselected` / `318 passed, 1 deselected ~15.8s`.
## 성공 기준(dcy-012) 대응
- S-1(매도가 매수 지정가 재사용 안 함): test_limit_buy_then_market_sell_does_not_reuse_buy_limit(a) + test_limit_buy_then_limit_sell_uses_intent_price(b) + test_market_buy_slot_sells_at_market_regression(c). **지정가 매수 슬롯** 케이스 포함(측정 필수 조건 충족).
- S-2(손절 시장가 체결, pending_sell 갇힘 없음): test_safety_sell_dispatches_at_market.
- S-3(rename 마이그레이션 데이터 보존 + 생애주기): test_migration_and_drift(컬럼 계약+idempotency) + test_service(holding clear).
- S-4(코드-문서 정합): runtime-flows/interface-contract/data-model 갱신.
## Carried-Over / 비차단
- KIS 시장가 매도(`ORD_DVSN="01"`)의 paper/실계좌 수용·체결 특성(A-4): paper 검증 완료, 실계좌는 release 단계 운영 검증.
- 재량 지정가 미체결 시 pending_sell 잔류(R-2)·issue #9 복구는 본 주기 밖.
- 과거 누락/잘못 체결 데이터 보정은 범위 밖.
## Result
- Ready for Release: true
- Blocking Issues:
- 없음. rename 호출부 전환 누락은 전체 회귀(318 passed) green 으로 부재 확인. 외부 payload/read 필드명 유지로 전략 모듈 계약 무파괴. LIVE sandbox 토큰 테스트 deselect 는 `.env` 환경 의존(본 batch 무관).
+1
View File
@@ -23,3 +23,4 @@
| BAT-013 | standard | release-candidate | REQ-048, REQ-049, REQ-050, REQ-051 | dcy-009 | [bat-013](./bat-013_20260607_multi-instance-topology/) |
| BAT-014 | batch-lite | release-candidate | REQ-052, REQ-053, REQ-054 | dcy-010 | [bat-014](./bat-014_20260608_safety-sell-cooldown/) |
| BAT-015 | standard | release-candidate | REQ-007 (CHG-20260608-01), REQ-018 (CHG-20260608-01), REQ-021 (CHG-20260608-01), REQ-055 | dcy-011 | [bat-015](./bat-015_20260608_buy-intent-portfolio-id-contract/) |
| BAT-016 | standard | release-candidate | REQ-002 (CHG-20260608-01), REQ-003 (CHG-20260608-01), REQ-006 (CHG-20260608-01), REQ-007 (CHG-20260608-02), REQ-013 (CHG-20260608-01), REQ-015 (CHG-20260608-01), REQ-036 (CHG-20260608-01) | dcy-012 | [bat-016](./bat-016_20260608_sell-pricing-limit-price-rename/) |
+3 -2
View File
@@ -4,7 +4,7 @@
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
- Status: draft
- Owner: project maintainers
- Last Updated (KST): 2026-06-04 (BAT-006)
- Last Updated (KST): 2026-06-08 (BAT-016)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery)
## Project Summary
@@ -44,7 +44,8 @@
- trading_date :: date :: 거래일
- status :: SlotState :: 6 상태(idle/pending_buy/holding/pending_sell/closed/error) 중 현재 상태
- symbol :: str? :: 점유 종목
- qty / avg_price / entry_price :: numeric? :: 수량 / 평단 / 진입가
- qty / avg_price :: numeric? :: 수량 / 평단
- limit_price :: numeric? :: 현재 pending 주문 지정가(side 무관, dcy-012/REQ-002 — 과거 `entry_price`에서 rename). pending 진입(매수/매도 지정가) 시 set, pending 이탈(→holding/→closed) 시 None 으로 clear → 매수 지정가가 매도로 누수되지 않음(issue #10). None 이면 dispatch 시 시장가.
- allocated_capital :: numeric? :: 배정 자본
- order_id / broker_order_id :: str? :: 주문 식별자(자유 주석 / 브로커 핸들)
- strategy_id / sell_strategy_id :: str? :: 진입/청산 전략 식별
@@ -0,0 +1,233 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: dcy-012_20260608_sell-order-pricing-entry-price-bug
- 이슈명: 매도 주문이 현재가가 아닌 entry_price(매수가) 지정가로 나가는 버그 — sell dispatch 가격 정책 부재
- 작성 시각(KST): 2026-06-08 12:30 KST
- 마지막 갱신 시각(KST): 2026-06-08 15:40 KST
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조: REQ-013/007/036/002/006/003/015 (전부 change-req, 신규 REQ 없음 — OQ-3). REQ-007=CHG-20260608-02, 그 외=CHG-20260608-01. delivery: BAT-016
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
- 연계 문서 현황:
- 관련 점수 파일: 미생성
- 관련 TODO/메모: GitHub issue #10 (fourblocks2020/trex-core), 관련 issue #9(pending_sell 갇힘)
- 해석:
- 매도(sell) 주문 집행 경로(`_dispatch`)가 buy/sell 구분 없이 `slot.entry_price`(매수가)를 지정가로 사용해, 매도가 현재 시장가가 아닌 매수가에 걸린 지정가 주문으로 나가는 정합성 버그를 정식 SDLC 주기로 착수한다.
- 이번 Discovery에서는 sell dispatch 의 가격 결정 정책(시장가 vs 현재가 지정가 vs 인텐트 전달 가격)을 판정하고, safety-sell(자율 매도)과 RPC 재량 매도에 어떤 정책을 적용할지 범위를 정한다. 이미 Implemented·released(REL-001/003) 된 집행 경로(dcy-003 broker execution) 변경이고, 매도 지정가 운반을 위해 슬롯 `entry_price` 를 side 무관 `limit_price` 로 내부 rename(Option A) 하므로, 거버넌스는 OQ-3 결정에 따라 기존 REQ(REQ-013/007/036/002/006/003/015) `change-req` 로 처리하고 신규 REQ 는 생성하지 않는다.
# 2. 요구사항 판정 결과
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
- 판정: 신규
- 근거
- 기존 Discovery(dcy-001~dcy-011) 중 sell dispatch 의 주문 가격 결정 정책을 다룬 문서는 없다. dcy-003(broker execution + outbox)은 `_dispatch`/주문 제출 경로를 도입했지만 buy/sell 가격 분기를 정의하지 않았고, dcy-007(safety-sell)은 손절/익절 트리거 조건과 `get_current_price` 평가를 다루되 집행 시 매도 주문 가격을 재정의하지 않았다. dcy-010(영속 거부 backoff)은 거부 재시도만 다룬다.
- 변경이 이미 Implemented·released 된 집행 경로(`_dispatch`)의 주문 가격/주문유형(지정가 vs 시장가) 의미를 바꾸고, 매도 인텐트 계약(`SellIntent`)에 optional 가격 필드를 추가하므로, 기존 Discovery 미세 조정이 아니라 독립 주기로 추적하는 것이 명확하다.
# 3. 문제점의 요약
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다.
- 현재 상태
- `trex_core/application/execution.py` 의 `_dispatch`(execution.py:84~)는 `side = "buy" if PENDING_BUY else "sell"` 로 side 를 구하지만, 바로 다음 `limit = int(slot.entry_price) if slot.entry_price else None`(execution.py:91)에서 side 를 무시하고 buy/sell 공통으로 `entry_price`(매수가)를 지정가로 쓴다. `order_type` 은 `"00"(지정가) if limit is not None else "01"(시장가)`(execution.py:99)로 결정되므로, 매도도 매수가에 걸린 지정가 주문으로 나간다.
- 버그의 발현은 슬롯의 매수 유형에 의존한다. `entry_price` 는 지정가 매수일 때만 채워지고 시장가 매수에서는 NULL 로 남으며(체결가는 `avg_price` 에만 기록, 매수 체결 시 `entry_price` 미갱신), 매수 후에는 `_dispatch` 의 limit 계산이 유일한 행위적 소비처다. 따라서 (a) 지정가 매수로 진입한 슬롯은 매도 시 `entry_price`(매수가)로 지정가 매도가 나가 버그가 발현되고, (b) 시장가 매수로 진입한 슬롯은 `entry_price=NULL → limit=None → order_type="01"` 로 우연히 시장가 매도가 나가 정상 동작한다. 즉 issue #10 은 지정가 매수 슬롯에서 재현된다.
- `trex_core/application/intents.py` 의 `SellIntent`(intents.py:68~)는 `intent_id/slot_id/reason/sell_strategy_id/extra` 만 가지며 가격 필드가 없다. `_admit_sell`(intents.py:193~)은 `HOLDING → PENDING_SELL` 상태 전이만 수행하고 가격 파라미터를 넘기지 않아, 슬롯의 `entry_price` 는 매수 때 값 그대로 유지된다.
- `trex_core/application/broker_port.py` 의 `get_current_price(symbol)`(broker_port.py:94)는 존재하지만 `_dispatch` 에서 호출되지 않는다. 현재 이 메서드는 safety-sell 워커의 손절/익절 평가에서만 쓰인다(`trex_core/application/safety_sell.py:182`).
- `trex_core/domain/slot/models.py` 의 Slot 은 `avg_price`(매수 체결가, models.py:27)·`entry_price`(매수 지정가, models.py:28)만 가지며 매도 시점 시장가를 담을 필드가 없다.
- `docs/interface-contract.md`(92행)의 sell payload 계약은 `{intent_type:"sell", intent_id, slot_id, reason, sell_strategy_id}` 로, 가격 필드가 없다(현 코드와 일치).
- baseline 문서(`docs/project-structure.md`, `docs/runtime-flows.md`)는 존재하며 최신이다. sell 집행은 기존 runtime flow(주문 dispatch)에 포함돼 있고, 이번 변경은 그 흐름의 매도 가격 결정 단계를 정정한다.
- 기대 상태
- 매도(`pending_sell`) 집행 시 매수 지정가가 아니라 OQ-1 하이브리드 정책에 맞는 가격으로 주문이 나간다 — 인텐트가 가격을 지정하면 그 지정가, 미지정이면 시장가. (현재가 자동 조회 지정가는 미채택.)
- 손절(stop-loss) 상황에서는 가격 미전달로 시장가 매도가 나가 의도대로 체결되어, 미체결 매도가 `pending_sell` 에 갇히는 문제(issue #9)의 직접 원인이 제거된다.
- 이해관계자
- 문서 작성자(AI agent) — Discovery/REQ/Batch 산출물 작성
- 리뷰어/승인자 — 매도 가격 정책 및 집행 경로 변경 승인
- 운영 담당자 — released 집행 경로 변경에 따른 실거래 영향 관리
- 전략 모듈 개발자 — 재량 매도(RPC) 인텐트 호출부 변경 가능성
- 제약사항
- 이미 released(REL-001/003) 된 집행 경로 변경이므로 ad-hoc 수정이 아니라 정식 SDLC(change-req 또는 신규 REQ) 경유가 필요하다.
- KIS 주문 계약상 `ORD_DVSN`(00 지정가 / 01 시장가)과 `ORD_UNPR` 의미가 정해져 있어, 매도 가격 유무가 곧 주문유형·가격 필드 매핑을 결정한다.
## 주요 사용자 시나리오
사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다.
- safety-sell 워커가 손절 조건(현재가 < stop_loss_price)으로 매도를 트리거했는데, 집행이 매수가 지정가로 나가 체결되지 않고 슬롯이 `pending_sell` 에 갇힌다 → 정책에 맞는 가격으로 즉시 체결되어야 한다.
- 전략 모듈이 RabbitMQ 로 재량 매도(sell intent)를 보내면, 현재 시장가 기반으로 매도가 집행되어 의도한 청산이 이뤄진다.
- 현재가가 매수가보다 높은 상황에서 매도 시, 매수가에 걸린 불리한 지정가가 아니라 시장가(또는 인텐트가 지정한 가격)로 체결되어 손실 없이 청산된다.
# 4. 이번에 정의할 변경
이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다.
- 슬롯의 매수 전용 `entry_price` 를 **side 무관 "현재 pending 주문 지정가" 단일 필드(내부 rename: `limit_price`)** 로 재정의한다. pending 진입 시(매수: buy intent 지정가 / 매도: sell intent optional 가격) set 하고, pending 이탈(→HOLDING, →CLOSED) 시 None 으로 clear 한다(매수 지정가의 매도 누수 = #10 근본 원인을 구조적으로 차단). 감사 이력은 기존처럼 transition_log 가 보존한다.
- `_dispatch` 는 side 분기로 가격을 고르지 않고 `slot.limit_price` 하나만 읽는다 — 값이 있으면 지정가(`order_type="00"`), 없으면 시장가(`order_type="01"`)(OQ-1 하이브리드).
- `SellIntent`/sell payload 에 optional 매도 가격 필드를 추가하고, `_admit_sell`(및 `SlotService.sell` optional 파라미터)이 그 가격을 슬롯 `limit_price` 로 운반한다. safety-sell/kill-switch 는 가격 미전달 → 시장가.
- 인터페이스 계약 문서(`docs/interface-contract.md`)와 sell 집행 runtime flow 기술을 새 정책에 맞춰 갱신한다. (단, buy/sell payload 의 외부 필드명 `entry_price` rename 은 dcy-012 범위 밖 — REQ-007/dcy-011 과 조율; 본 주기는 내부 슬롯 컬럼/도메인 필드 rename 까지.)
## 영향 범위
변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다.
- 코드 영향:
- `trex_core/domain/slot/models.py` — 슬롯 필드 `entry_price` → `limit_price`(side 무관 "현재 pending 주문 지정가") 내부 rename
- `trex_core/domain/slot/service.py` — `transit_slot`/`_apply_transition_fields` 에서 →HOLDING·→CLOSED 시 `limit_price` 를 None 으로 clear(현 →IDLE 리셋 분기 패턴 재사용), `SlotService.sell` 에 optional 매도 지정가 파라미터 추가(safety-sell/kill-switch 미전달 → 시장가), 매수 경로는 buy 지정가를 `limit_price` 로 set
- `trex_core/application/execution.py` — `_dispatch` 가 `slot.limit_price` 단일 필드만 읽어 가격/주문유형 결정(side 분기 제거, 핵심 버그 지점)
- `trex_core/application/intents.py` — `SellIntent` optional 매도 가격 필드, `_admit_sell` 이 가격을 슬롯 `limit_price` 로 운반. `BuyIntent`/`_admit_buy` 의 매수 지정가도 `limit_price` 로 매핑
- `trex_core/application/messaging_handlers.py` — 외부 payload 필드명(`entry_price`)과 내부 `limit_price` 의 경계 매핑(외부 필드명 rename 은 본 주기 밖)
- `trex_core/adapters/persistence/postgresql.py`, `trex_core/adapters/persistence/sql/` — 슬롯 컬럼 `entry_price` → `limit_price` rename 마이그레이션 + 매핑 갱신
- `trex_core/adapters/broker/kis.py` — `submit_order` 의 `ORD_DVSN`/`ORD_UNPR` 매핑은 OrderRequest 변경에 따라 자동 반영(직접 수정 최소)
- 문서 영향:
- `docs/interface-contract.md` — sell payload 매도 가격 필드 추가 및 주문유형 매핑 갱신
- `docs/runtime-flows.md` — sell 집행 흐름의 가격 결정 단계 정정 + limit_price 생애주기(set/clear)
- `docs/data-model.md` — 슬롯 `entry_price` → `limit_price` 컬럼 의미/이름 갱신
- 운영 영향:
- released 집행 경로의 매도 동작 변경 + 슬롯 컬럼 rename 마이그레이션 → 실거래 매도 체결 방식 변화 및 스키마 변경, 배포 전 검증·마이그레이션 절차 필요
# 5. 요구사항 목록
실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다.
## 기능 요구사항
- FR-1: 슬롯의 매수 전용 `entry_price` 를 side 무관 단일 지정가 필드 `limit_price`(내부 rename)로 재정의한다. `limit_price` 는 pending 진입 시 해당 주문(매수/매도)의 지정가로 set 되고, pending 이탈(→HOLDING, →CLOSED) 시 None 으로 clear 되어 매수 지정가가 매도로 누수되지 않는다.
- FR-2: `_dispatch` 는 side 분기 없이 `slot.limit_price` 만 읽어 주문유형/가격을 결정한다 — 값이 있으면 `order_type="00"`(지정가, 그 가격), 없으면 `order_type="01"`(시장가). 매수가(과거 entry_price 의미)를 매도 지정가로 재사용하지 않는다.
- FR-3: `SellIntent` 및 sell payload 계약에 optional 매도 가격 필드를 추가하고, `_admit_sell`(및 `SlotService.sell` optional 파라미터)이 이를 슬롯 `limit_price` 로 전달한다. 매수 경로는 buy 지정가를 동일 `limit_price` 로 set 한다.
- FR-4: safety-sell·kill-switch(자율 매도)는 가격 미전달로 시장가, RPC 재량 매도는 가격 지정 시 지정가로 동작한다 — 단일 메커니즘(가격 유무 분기)을 모든 매도에 일관 적용한다(OQ-2).
- FR-5: `docs/interface-contract.md`·`docs/runtime-flows.md`·`docs/data-model.md` 를 확정된 매도 가격 정책과 `limit_price` 생애주기(set/clear)·컬럼 rename 에 맞춰 갱신한다.
## 비기능 요구사항
성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다.
- NFR-1: 체결성 — 매도 트리거(특히 손절)는 가격 미전달로 시장가가 되어 의도대로 즉시 체결되고, 미체결로 `pending_sell` 에 갇히는 케이스(issue #9 연계)를 유발하지 않아야 한다.
- NFR-2: 가격 안전성 — 매도가 매수 지정가에 걸려 불리하게 체결되거나 영원히 미체결되는 정합성 오류가 재발하지 않아야 한다. `limit_price` 의 clear-on-exit 로 매수 지정가가 매도 주문에 잔존하지 않아야 한다.
- NFR-3: 계약 호환 — sell payload 의 매도 가격 필드는 optional 이어야 하며, 미지정(기존 호출 형태)은 시장가로 안전하게 동작해 기존 재량 매도 호출을 깨지 않아야 한다. 외부 payload/read 필드명(`entry_price`)은 본 주기에서 rename 하지 않아 기존 계약 호환을 유지한다.
## 범위 경계
이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다.
- In Scope
- 슬롯 `entry_price` → `limit_price` 내부 rename(도메인 모델/DB 컬럼/persistence) + pending 이탈 시 clear 생애주기
- `_dispatch` 가 `slot.limit_price` 단일 필드로 가격/주문유형 결정(side 분기 제거)
- `SellIntent`/sell payload optional 가격 필드 + 슬롯 `limit_price` 운반(`_admit_sell`/`SlotService.sell`)
- 인터페이스/런타임/데이터모델 문서 갱신, 슬롯 컬럼 rename 마이그레이션
- Out of Scope
- buy 주문 가격 의미 변경(매수 지정가는 그대로 `limit_price` 로 set — 동작 동일, 저장 위치명만 통일)
- 외부 buy/sell payload·slot read 의 필드명(`entry_price`) rename — REQ-007/dcy-011 과 조율할 별도 계약 변경
- issue #9(KIS 외부 취소 시 pending_sell 갇힘) 자체의 복구 로직 — 본 버그는 #9 의 한 원인을 제거하나, #9 의 별도 복구는 후속 이슈로 분리
- dispatch 시 현재가 자동 조회(`get_current_price`) 기반 지정가 — OQ-1 에서 미채택(급락 손절 시 재차 미체결 위험)
# 6. 리스크/가정 목록
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다.
## 리스크
- R-1 (High): released 집행 경로의 매도 동작을 바꾸므로, 정책 선택이 잘못되면 실거래 매도가 의도와 다르게 체결될 수 있다.
- 완화 방안: paper-broker/스텁으로 시나리오(손절/익절/현재가>매수가/현재가<매수가) 검증 후 배포하고, change-req 로 REQ-013/007/036/002/006/003/015 변경 이력을 남긴다.
- R-2 (Medium): 재량 매도가 지정가로 들어오면, 시장가가 그 지정가에 닿지 않을 때 미체결로 `pending_sell` 에 머물 수 있다(지정가의 본질적 특성).
- 완화 방안: 지정가 미체결 처리(취소/재시도/타임아웃)는 기존 dispatch 폴/롤백 경로와 issue #9 복구 범위에서 다루고, 본 주기는 가격 결정 분기에 집중한다. 운영자는 재량 매도 지정가의 미체결 리스크를 인지한다.
- R-3 (Low, OQ-2 결정으로 해소): reason 별 코드 차등을 두지 않고 가격 유무로 분기하므로, safety-sell 은 가격 미전달로 항상 시장가가 되어 손절 체결성이 보장되고 재량 매도만 선택적으로 지정가를 쓴다.
- 완화 방안: `SlotService.sell` 의 가격 파라미터 기본값을 None(시장가)으로 두어 자율 매도 경로가 실수로 지정가를 받지 않도록 한다.
- R-4 (Medium): `entry_price → limit_price` 슬롯 컬럼 rename 과 clear-on-exit 도입은 도메인/persistence/마이그레이션을 건드려, 누락 시 매수 지정가 복원이나 영속 매핑이 깨질 수 있다.
- 완화 방안: 컬럼 rename 마이그레이션과 전이별 set/clear(특히 →HOLDING·→CLOSED clear, →PENDING set)를 단위 테스트로 검증하고, 외부 payload/read 필드명은 그대로 유지(경계 매핑)해 계약 호환을 보존한다.
## 가정
아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다.
- A-1: dcy-011(BAT-015) 이 Implemented 되어 매수 시 `symbol` 이 항상 서버 resolve 로 슬롯에 채워지므로, 매도 dispatch 의 KIS 주문에 쓸 `slot.symbol` 이 신뢰성 있게 존재한다(가정 충족 확인됨).
- A-2: safety-sell([safety_sell.py:98](trex_core/application/safety_sell.py))·kill-switch([killswitch.py:61](trex_core/application/killswitch.py))는 `SlotService.sell(slot_id, actor, reason)` 로 가격을 전달하지 않으므로, 가격 파라미터를 optional(default None)로 추가해도 자율 매도 경로는 자동으로 시장가가 된다.
- A-3: 매수 주문 가격 의미는 그대로 유지된다 — buy 지정가는 동일 `limit_price` 로 set 되어 매수 동작은 변하지 않고, 슬롯 상태 전이 규칙(허용 전이 집합)도 유지된다. 변경은 가격 필드의 의미 통일(side 무관)과 pending 이탈 시 clear 추가뿐이다.
- A-4: KIS 시장가 매도(`ORD_DVSN="01"`)가 paper 및 실계좌에서 정상 수용된다(DATA 로 확인).
## 오픈 질문
답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다.
- OQ-1: 매도 가격 정책 — (1) 시장가 매도, (2) 현재가 기반 지정가, (3) 인텐트 전달 가격 중 무엇을 채택하는가(단일 또는 조합)?
- 상태: Answered (2026-06-08, 사용자 결정 — 하이브리드: 인텐트가 가격을 지정하면 지정가, 미지정이면 시장가)
- 결정: `SellIntent`/sell payload 에 optional 매도 가격 필드를 둔다. 가격이 있으면 `order_type="00"`(지정가)로 그 가격에, 없으면 `order_type="01"`(시장가)로 매도한다. 매수가(`entry_price`)는 매도 가격으로 절대 사용하지 않는다. dispatch 에서 `get_current_price` 조회는 하지 않는다(옵션 2 미채택).
- 반영 위치: FR-1/FR-2/FR-3, 4. 변경 항목, 영향 범위
- OQ-2: 정책 적용 범위 — safety-sell(손절/익절/긴급)과 RPC 재량 매도에 동일 정책을 적용하는가, reason 별 차등(예: 손절=시장가, 재량=지정가)을 두는가?
- 상태: Answered (2026-06-08, 사용자 결정 — 가격 유무로 분기, reason 별 코드 차등 없음)
- 결정: 단일 메커니즘(가격 유무 분기)을 모든 매도에 적용한다. safety-sell/kill-switch 는 `SlotService.sell(slot_id, actor, reason)` 로 가격을 전달하지 않으므로 자동으로 시장가가 되어 체결성이 보장된다. 재량 매도(RPC)만 가격을 optional 로 실어 지정가 통제를 쓸 수 있다.
- 반영 위치: FR-4, A-2, R-3
- OQ-3: 거버넌스 경로 — 본 변경을 기존 REQ(dcy-003 집행 계열, sell 인텐트 계약 REQ)의 change-req 로 처리할지, 신규 REQ 로 추적할지?
- 상태: Answered (2026-06-08, 사용자 결정 — change-req 우선)
- 결정: 기존 Implemented REQ 본문 수정(change-req)으로 처리한다. change-req 대상 — REQ-013(주문 집행 루프: `_dispatch` 가 `slot.limit_price` 단일 필드로 결정), REQ-007(intent 제출 API: sell payload optional 매도 가격 필드), REQ-036(자동 매도 내부 경로: `SlotService.sell` optional 가격 파라미터), REQ-002(slot 순수 도메인: `entry_price→limit_price` rename + pending 이탈 clear 전이 의미), REQ-006(PostgreSQL 영속화+스키마: 컬럼 rename 마이그레이션), REQ-003(컬럼 계약 가드 테스트: 컬럼명 변경 반영), REQ-015(브로커/집행 baseline 문서 정합). 하이브리드+rename 모두 기존 REQ 범위 안에서 표현 가능하므로 신규 REQ 는 생성하지 않는다. (Option A 의 rename 으로 change-req 대상이 slot 도메인/persistence 까지 넓어짐 — 비용은 거버넌스 footprint 증가, 이득은 매수 지정가의 매도 누수 구조적 차단.)
- 반영 위치: 1. 거버넌스 해석, R-1 완화 방안, 8. 확인 체크
### 오픈 질문 처리 기준
- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다.
- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다.
- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다.
- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다.
# 7. 초기 성공 기준
이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다.
- S-1: 매도 dispatch 가 매수 지정가를 재사용하지 않음을 코드/테스트로 보장한다 — `limit_price` 가 pending 이탈 시 clear 되어, 가격 미전달 매도는 `order_type="01"`(시장가), 가격 전달 매도는 `order_type="00"`(해당 지정가)로 발행.
- S-2: safety-sell 손절 트리거 시나리오(가격 미전달)에서 매도가 시장가로 정상 체결되어 `pending_sell` 갇힘이 발생하지 않는다.
- S-3: `entry_price → limit_price` 컬럼 rename 마이그레이션 후 기존 슬롯 데이터가 보존되고, `limit_price` 생애주기(→PENDING set / →HOLDING·→CLOSED clear)가 의도대로 동작한다.
- S-4: 변경된 매도 가격 정책·rename 이 `docs/interface-contract.md`·`docs/runtime-flows.md`·`docs/data-model.md` 에 반영되고, 코드(execution.py, intents.py, slot service/models, persistence)와 문서가 일치한다.
## 측정 방식 및 데이터 출처
각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다.
- S-1 측정 방식: 단위/통합 테스트(가격 미전달→시장가, 가격 전달→지정가, entry_price 미사용 케이스) 통과 / 데이터 출처: `_dispatch` 테스트, paper-broker 주문 기록(OrderRequest)
- 검증 필수 조건: 버그는 `entry_price` 가 채워진 **지정가 매수 슬롯**의 매도에서만 재현되므로, 검증 시나리오는 반드시 **지정가 매수(entry_price 채워짐)로 진입한 슬롯의 매도** 케이스를 포함해야 한다. 시장가 매수(entry_price=NULL) 슬롯만 테스트하면 수정 전후 모두 시장가 매도가 나가 버그를 검출하지 못한다. 권장 케이스 — (a) 지정가 매수 슬롯 + 가격 미전달 매도 → 시장가(entry_price 미사용 증명), (b) 지정가 매수 슬롯 + 가격 전달 매도 → 그 지정가, (c) 시장가 매수 슬롯 매도 → 시장가(회귀 안전성).
- S-2 측정 방식: safety-sell 손절 시나리오 e2e 검증 후 슬롯 상태가 closed 로 전이 / 데이터 출처: 슬롯 상태 스냅샷, 주문/체결 로그
- S-3 측정 방식: 마이그레이션 실행 후 슬롯 행 보존 확인 + 전이별 set/clear 단위 테스트(→PENDING set, →HOLDING·→CLOSED clear) 통과 / 데이터 출처: 마이그레이션 SQL, `transit_slot` 테스트, persistence 라운드트립
- S-4 측정 방식: 코드-문서 정합 리뷰(diff 대조) / 데이터 출처: `docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md`, 변경 코드 diff
# 8. REQ로 넘기기 전 확인 체크
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
- [x] 범위(In Scope/Out of Scope) 확정
- 기준: 매도 가격 정책(OQ-1 하이브리드)·적용 범위(OQ-2 가격 유무 분기)·거버넌스(OQ-3) 모두 확정됨
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-1~S-4 의 테스트/검증 출처가 실제 실행 가능한 형태로 지정됨
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: R-1(실거래 매도 동작 변경)·R-4(컬럼 rename/마이그레이션)의 change-req 대상(REQ-013/007/036/002/006/003/015)이 OQ-3 으로 확정됨. 해소 실무 책임자 배정은 REQ/batch planning 단계로 넘김(비차단).
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ-1~OQ-3 모두 `Answered`, `Open` 0개
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: Confirmed By = Justin (사람 승인자 지정 완료)
# 9. 파일 처리 결과
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
- 처리 결과: 생성
- 생성 경로: docs/discovery/dcy-012_20260608_sell-order-pricing-entry-price-bug.md
- 참조 문서:
- GitHub issue #10 (fourblocks2020/trex-core) — 매도가 entry_price 지정가로 나가는 버그
- docs/discovery/dcy-003_20260604_broker-execution-and-outbox.md — `_dispatch`/주문 제출 경로 도입 근거
- docs/discovery/dcy-007_20260606_core-autonomous-safety-sell.md — safety-sell 트리거·`get_current_price` 평가
- docs/discovery/dcy-011_20260608_buy-intent-portfolio-id-required-symbol-resolve.md — slot.symbol 서버 resolve 보장(가정 A-1)
- trex_core/application/execution.py:84-104 — `_dispatch` 매도 가격 버그 지점(limit 줄 91)
# 10. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
## DECIDE
- (없음 — OQ-1/OQ-2 확정으로 매도 가격 정책 해소)
> 결정 완료(2026-06-08): OQ-1 매도 가격 정책 = 하이브리드(가격 지정 시 지정가, 미지정 시 시장가) / OQ-2 적용 범위 = 가격 유무 분기 단일 메커니즘(reason 별 차등 없음).
## CONFIRM
- 없음 — 거버넌스 경로는 OQ-3 으로 확정됨(REQ-013/007/036/002/006/003/015 change-req, 신규 REQ 없음).
## DATA
- (비차단) KIS 시장가 매도(`ORD_DVSN="01"`)의 paper/실계좌 수용 및 체결 특성 — REQ/delivery 단계의 acceptance/verification 에서 계측·확인할 운영 데이터(A-4). Discovery 본문만으로 FR/NFR·범위·성공 기준이 REQ 초안으로 충분하므로 confirm 차단 사유가 아니다.
# 11. Discovery Freeze
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
## 섹션 참조
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: 승인
- Handoff Rationale: confirm-discovery 완료. Option A(슬롯 `entry_price→limit_price` 내부 rename + clear-on-exit) 설계 반영. 오픈 질문 OQ-1~OQ-3 모두 Answered (매도 가격=하이브리드: 가격 지정 시 지정가/미지정 시 시장가 / 가격 유무 분기, reason 차등 없음 / 거버넌스=REQ-013/007/036/002/006/003/015 change-req, 신규 REQ 없음). C 유형 플레이스홀더 0개, 저장소 사실관계 일치(줄번호 정정 포함). DATA(KIS 시장가 매도)는 REQ/delivery 단계 계측 비차단. REQ 초안 작성 인계 가능.
- Ready for REQ Drafting: true
- Confirmed By: Justin
- Confirmed At (KST): 2026-06-08 14:50 KST
+1
View File
@@ -17,3 +17,4 @@
| dcy-009_20260607_multi-instance-rabbitmq-topology | 20260607 | 인스턴스별 독립 RabbitMQ 채널 토폴로지 — trex-core 다중 인스턴스 운영을 위한 Topic Exchange + Fanout Exchange per-instance 큐 분리 | [Discovery](./dcy-009_20260607_multi-instance-rabbitmq-topology.md) | confirmed |
| dcy-010_20260607_safety-sell-persistent-rejection-backoff | 20260607 | safety-sell 영속 거부 재시도 백오프 — 장 휴장·잔고 부족·규제 등 지속 거부 상황에서 KIS 주문 spam 방지를 위한 슬롯별 재시도 backoff/cooldown 정책 | [Discovery](./dcy-010_20260607_safety-sell-persistent-rejection-backoff.md) | confirmed |
| dcy-011_20260608_buy-intent-portfolio-id-required-symbol-resolve | 20260608 | buy intent — portfolio_id를 필수 키로, symbol은 universe(SnapshotStore)에서 resolve (+ issue #11 portfolio_id 누락 체결 ledger silent skip 가시화 흡수) | [Discovery](./dcy-011_20260608_buy-intent-portfolio-id-required-symbol-resolve.md) | confirmed |
| dcy-012_20260608_sell-order-pricing-entry-price-bug | 20260608 | 매도 주문이 현재가가 아닌 entry_price(매수가) 지정가로 나가는 버그 — sell dispatch 가격 정책 부재 (issue #10) | [Discovery](./dcy-012_20260608_sell-order-pricing-entry-price-bug.md) | confirmed |
+3 -2
View File
@@ -4,7 +4,7 @@
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
- Status: draft
- Owner: project maintainers
- Last Updated (KST): 2026-06-08 (BAT-015)
- Last Updated (KST): 2026-06-08 (BAT-016)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
@@ -89,7 +89,8 @@
```
- **`portfolio_id` 필수(dcy-011/REQ-007/021)**: 매수의 정식 식별자. 누락 시 admission 에서 `portfolio_id_required` 로 거부. **`symbol` 은 client payload 에 없다** — core 가 admission 시 universe(`SnapshotStore`)에서 `portfolio_id → symbol` 을 resolve 해 슬롯/KIS 주문에 사용한다. universe 에 portfolio_id 가 없으면 `unknown_portfolio` 로 거부(하위호환·symbol fallback 없음, hard cutover).
- `stop_loss_price`/`take_profit_price`(선택, REQ-035/038): 매수 시 설정하면 워치독 만료 후 safety-sell 가격 트리거의 기준이 된다. 미포함 시 NULL(트리거 비활성).
sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason":..., "sell_strategy_id":...}`.
sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason":..., "sell_strategy_id":..., "limit_price": 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` 필드명은 본 주기에서 유지(하위호환).
#### `trex.intent.req` `update_thresholds` 액션 (BAT-011 / REQ-040)
```
+3 -1
View File
@@ -4,7 +4,7 @@
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
- Status: draft
- Owner: project maintainers
- Last Updated (KST): 2026-06-08 (BAT-015)
- Last Updated (KST): 2026-06-08 (BAT-016)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
@@ -187,10 +187,12 @@
데몬 lifecycle 에서 시작되는 단일 백그라운드 execution 루프가 주기적으로(설정 간격) 수행:
1. dispatch: `find_pending_for_dispatch`(status pending_* AND `broker_order_id IS NULL`, `FOR UPDATE SKIP LOCKED`) → `BrokerPort.submit_order` → `mark_dispatched(broker_order_id)`(멱등 가드). 거부 시 `pending_buy→idle` / `pending_sell→holding` 롤백.
- 가격/주문유형 결정(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 연계).
2. poll: `list_dispatched_pending`(broker_order_id NOT NULL) → `BrokerPort.poll_fill` → 체결 시 `pending_buy→holding`(avg_price/qty) / `pending_sell→closed`(+재진입), 거부 시 롤백. 전이 후 `clear_dispatch`.
3. reconcile(재기동): 서빙 전 `list_dispatched_pending` 재폴링으로 크래시 후 복구(중복 주문 0). 미디스패치(broker_order_id NULL) 슬롯은 다음 dispatch 가 1회만 송신.
- 모든 슬롯 쓰기는 SlotService(advisory lock) 단일 경로(불변식 A). 브로커 주문/취소는 BrokerPort 단일 세션만(불변식 B).
- 슬롯 `limit_price` 생애주기(dcy-012/REQ-002): IDLE→PENDING_BUY(매수 지정가 set) → PENDING_BUY→HOLDING(clear) → HOLDING→PENDING_SELL(매도 지정가 set 또는 None=시장가) → PENDING_SELL→CLOSED(clear). 롤백·복구(→IDLE) 전이에서도 clear.
- 어댑터: paper(결정적 async 더블) / KIS(모의투자 검증, 실계좌 게이팅).
## Flow: `체결 → ledger outbox` (BAT-006)
@@ -152,3 +152,42 @@
#### Validation Plan
- BAT-002 verification evidence 재참조로 충분.
### CHG-20260608-01
- Date: 2026-06-08
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-012) `daily_slot.entry_price` → `limit_price` 컬럼 rename 마이그레이션(`006_rename_entry_price_to_limit_price.sql`, idempotent DO-block) + persistence 매핑(`_SLOT_COLUMNS`/update_slot/_row_to_slot) 갱신. 순수 rename 으로 기존 행 데이터 보존(S-3). BAT-016 재구현·재검증 완료 → Implemented 유지.
#### Intent
- 도메인 필드 rename(REQ-002)을 영속 스키마/매핑에 반영하면서 기존 데이터를 보존.
#### Acceptance Criteria Delta
- Updated:
- daily_slot 컬럼 entry_price → limit_price (마이그레이션 006, 데이터 보존). persistence read/write 매핑 동기화.
#### Impacted Area Delta
- Modules: `trex_core/adapters/persistence/postgresql.py`, `trex_core/adapters/persistence/sql/006_rename_entry_price_to_limit_price.sql`(신규)
- Tests: tests/adapters/persistence/test_migration_and_drift.py(컬럼 계약 = limit_price, 마이그레이션 idempotent)
#### Delivery Trace Delta
- Batches: BAT-016
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (BAT-016 에서 완료)
- Existing Implementation Invalidated: yes (entry_price 컬럼/매핑)
- Status Recommendation: Implemented (change-req → BAT-016 재검증)
- Follow-up Action: confirm-req-implemented(BAT-016).
#### Validation Plan
- BAT-016: PG 게이트 마이그레이션 round-trip + 컬럼 계약(limit_price) + idempotency.
@@ -147,3 +147,41 @@
#### Validation Plan
- BAT-004 verification evidence 재참조로 충분.
### CHG-20260608-01
- Date: 2026-06-08
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-012) baseline 문서를 새 매도 가격 정책·`limit_price` 생애주기·컬럼 rename 에 맞춰 정합: `docs/runtime-flows.md`(dispatch 가격 결정 = slot.limit_price 단일 필드 + 생애주기 set/clear), `docs/interface-contract.md`(sell payload optional limit_price + ORD_DVSN 매핑), `docs/data-model.md`(슬롯 limit_price 컬럼). BAT-016 정합 완료 → Implemented 유지.
#### Intent
- 코드(execution/intents/slot/persistence) 변경과 baseline 문서를 일치(S-4)시켜 운영자/전략 개발자 참조 정합 유지.
#### Acceptance Criteria Delta
- Updated:
- runtime-flows/interface-contract/data-model 의 매도 가격·limit_price 기술 정합.
#### Impacted Area Delta
- Docs: docs/runtime-flows.md, docs/interface-contract.md, docs/data-model.md
#### Delivery Trace Delta
- Batches: BAT-016
- Releases: none
#### Revalidation Impact
- Reverification Needed: no (문서 정합)
- Existing Implementation Invalidated: no
- Status Recommendation: Implemented (change-req → BAT-016 정합)
- Follow-up Action: confirm-req-implemented(BAT-016).
#### Validation Plan
- BAT-016: 코드-문서 diff 대조(S-4).
@@ -150,3 +150,42 @@
#### Validation Plan
- BAT-004 verification evidence 재참조로 충분.
### CHG-20260608-01
- Date: 2026-06-08
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-012/issue #10) `_dispatch` 의 매도 가격 버그 정정: side 분기로 `entry_price`(매수가)를 지정가로 재사용하던 로직을 제거하고, side 무관 슬롯 `limit_price` **단일 필드**만 읽어 주문유형/가격을 결정한다 — 값 있으면 `order_type="00"`(그 지정가), 없으면 `order_type="01"`(시장가). BAT-016 재구현·재검증 완료 → Implemented 유지.
#### Intent
- 매도가 매수 지정가에 걸린 지정가로 나가 미체결되는 정합성 버그(issue #10) 제거. dispatch 의 가격 결정을 `slot.limit_price` 로 일원화.
#### Acceptance Criteria Delta
- Updated:
- dispatch 가격/주문유형은 `slot.limit_price` 한 필드로 결정(side 분기 없음, 매수가 재사용 금지).
#### Impacted Area Delta
- Modules: `trex_core/application/execution.py`(`_dispatch`)
- Tests: tests/application/test_sell_pricing.py(지정가 매수 슬롯의 시장가/지정가 매도, 회귀)
#### Delivery Trace Delta
- Batches: BAT-016
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (BAT-016 에서 완료)
- Existing Implementation Invalidated: yes (entry_price 재사용 dispatch 경로)
- Status Recommendation: Implemented (change-req → BAT-016 재검증)
- Follow-up Action: confirm-req-implemented(BAT-016).
#### Validation Plan
- BAT-016: 지정가 매수 슬롯 + 가격 미전달 매도 → 시장가(entry_price 미사용 증명), + 가격 전달 매도 → 그 지정가, 시장가 매수 슬롯 회귀.
@@ -152,3 +152,42 @@
#### Validation Plan
- BAT-001 verification evidence 재참조로 충분.
### CHG-20260608-01
- Date: 2026-06-08
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-012/issue #10) 슬롯 도메인 필드 `entry_price` → `limit_price`(side 무관 "현재 pending 주문 지정가") 내부 rename + 생애주기 정의: pending 진입 시 set, pending 이탈(→HOLDING/→CLOSED) 시 None clear. `transit_slot`/`_apply_transition_fields` 파라미터·`EXPECTED_SLOT_FIELDS` 반영. BAT-016 재구현·재검증 완료 → Implemented 유지.
#### Intent
- 매수 지정가가 매도 주문에 누수되는 구조적 원인을 도메인 모델에서 제거(clear-on-exit). 가격 필드 의미를 side 무관으로 통일.
#### Acceptance Criteria Delta
- Updated:
- Slot.`entry_price` → `limit_price`; →HOLDING/→CLOSED 전이 시 limit_price clear, →PENDING_* 전이 시 set.
#### Impacted Area Delta
- Modules: `trex_core/domain/slot/models.py`(Slot/EXPECTED_SLOT_FIELDS), `trex_core/domain/slot/service.py`(transit_slot/_apply_transition_fields clear-on-exit)
- Tests: tests/domain/slot/test_service.py(holding clear), tests/domain/slot/test_contract.py(필드셋)
#### Delivery Trace Delta
- Batches: BAT-016
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (BAT-016 에서 완료)
- Existing Implementation Invalidated: yes (entry_price 필드명/유지 의미)
- Status Recommendation: Implemented (change-req → BAT-016 재검증)
- Follow-up Action: confirm-req-implemented(BAT-016).
#### Validation Plan
- BAT-016: 전이별 set/clear 단위 + 컬럼 계약 가드(REQ-003) 정합.
@@ -195,3 +195,43 @@
#### Validation Plan
- BAT-015 verification: portfolio_id_required/unknown_portfolio/symbol resolve 단위 + 전체 회귀(314 passed).
### CHG-20260608-02
- Date: 2026-06-08
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-012) sell intent 계약에 optional 매도 가격 필드 추가: `SellIntent.limit_price`(+ sell payload `limit_price`) → `_admit_sell` 이 슬롯 `limit_price` 로 운반. 지정 시 지정가 매도, 미지정 시 시장가(자율 매도 기본 경로). buy 의 `entry_price` 는 동일 슬롯 `limit_price` 로 매핑(동작 동일). BAT-016 재구현·재검증 완료 → Implemented 유지.
#### Intent
- 매도 가격 하이브리드 정책(가격 유무 분기, dcy-012 OQ-1/OQ-2)을 intent 제출 계약에 반영. 기존 재량 매도 호출(가격 미지정)은 시장가로 안전 동작(NFR-3).
#### Acceptance Criteria Delta
- Added:
- sell intent/payload optional `limit_price`(미지정 → 시장가, 지정 → 지정가). admission 이 슬롯 limit_price 로 운반.
#### Impacted Area Delta
- Modules: `trex_core/application/intents.py`(SellIntent/_admit_sell, _admit_buy limit_price 매핑), `trex_core/application/messaging_handlers.py`(sell payload limit_price)
- Docs: docs/interface-contract.md
- Tests: tests/application/test_sell_pricing.py
#### Delivery Trace Delta
- Batches: BAT-016
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (BAT-016 에서 완료)
- Existing Implementation Invalidated: no (optional 추가, 기존 sell 호출 시장가로 호환)
- Status Recommendation: Implemented (change-req → BAT-016 재검증)
- Follow-up Action: confirm-req-implemented(BAT-016).
#### Validation Plan
- BAT-016: 가격 전달 매도 → 지정가 / 미전달 → 시장가 단위 + 핸들러 payload.
@@ -110,3 +110,42 @@
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs 후 batch 배정
### CHG-20260608-01
- Date: 2026-06-08
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-012) `SlotService.sell` 에 optional `limit_price` 파라미터(default None) 추가. 자율 매도(safety-sell/kill-switch)는 가격 미전달 → 시장가로 즉시 체결(미체결 pending_sell 갇힘 방지), 재량 경로만 지정가 전달 가능. BAT-016 재구현·재검증 완료 → Implemented 유지.
#### Intent
- 자동 매도 내부 경로가 매수 지정가에 걸리지 않고 시장가로 체결되도록(issue #10/#9 연계), 가격 파라미터를 optional 로 추가하되 기본값 None 으로 자율 경로 체결성을 보장.
#### Acceptance Criteria Delta
- Updated:
- `SlotService.sell(..., limit_price=None)` — 미전달 시 시장가(슬롯 limit_price None), 전달 시 지정가.
#### Impacted Area Delta
- Modules: `trex_core/domain/slot/service.py`(`sell`)
- Tests: tests/application/test_sell_pricing.py::test_safety_sell_dispatches_at_market
#### Delivery Trace Delta
- Batches: BAT-016
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (BAT-016 에서 완료)
- Existing Implementation Invalidated: no (시그니처 확장, 기존 호출 default None 동일 동작)
- Status Recommendation: Implemented (change-req → BAT-016 재검증)
- Follow-up Action: confirm-req-implemented(BAT-016).
#### Validation Plan
- BAT-016: svc.sell 가격 미전달 → 시장가 + closed 전이 단위.
@@ -152,3 +152,42 @@
#### Validation Plan
- BAT-001 verification evidence 재참조로 충분.
### CHG-20260608-01
- Date: 2026-06-08
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-012) 컬럼 계약 가드를 `entry_price` → `limit_price` rename 에 맞춰 갱신. `EXPECTED_SLOT_FIELDS`(REQ-002)와 daily_slot 실제 컬럼(information_schema) 대조가 `limit_price` 기준으로 통과. BAT-016 재구현·재검증 완료 → Implemented 유지.
#### Intent
- 도메인/스키마 rename 후에도 코드 계약 ↔ DB 컬럼 드리프트 가드가 정확히 작동하도록 동기화.
#### Acceptance Criteria Delta
- Updated:
- 컬럼 계약/드리프트 테스트 기준 필드 entry_price → limit_price.
#### Impacted Area Delta
- Tests: tests/domain/slot/test_contract.py, tests/adapters/persistence/test_migration_and_drift.py
- Modules: (없음 — 가드는 EXPECTED_SLOT_FIELDS 상수 참조)
#### Delivery Trace Delta
- Batches: BAT-016
- Releases: none
#### Revalidation Impact
- Reverification Needed: yes (BAT-016 에서 완료)
- Existing Implementation Invalidated: no (가드 로직 불변, 기대 필드셋만 rename 반영)
- Status Recommendation: Implemented (change-req → BAT-016 재검증)
- Follow-up Action: confirm-req-implemented(BAT-016).
#### Validation Plan
- BAT-016: 도메인 필드셋 가드 + PG 컬럼 드리프트 가드 green.
+1 -1
View File
@@ -139,8 +139,8 @@
## Recent Change Log Summary
- 2026-06-08 :: REQ-013/007/036/002/006/003/015 change-req (dcy-012, BAT-016): 매도 주문이 매수가(entry_price) 지정가로 나가던 버그(issue #10) 정정. 슬롯 `entry_price`→`limit_price`(side 무관 pending 지정가) rename + pending 이탈 clear(REQ-002), 컬럼 rename 마이그레이션 006(REQ-006), `_dispatch` 가 slot.limit_price 단일 필드로 가격/주문유형 결정(REQ-013), SellIntent/sell payload optional limit_price·_admit_sell 운반(REQ-007), SlotService.sell optional 가격→자율 매도 시장가(REQ-036), 컬럼 계약 가드(REQ-003), baseline 3문서 정합(REQ-015). 전부 change-req(신규 REQ 없음, OQ-3). 단위 286 passed, 인프라 게이트 318 passed, ruff clean. (change-req/confirm-req-implemented)
- 2026-06-08 :: REQ-007/018/021 change-req + REQ-055 생성·Implemented (dcy-011, BAT-015): buy intent 계약 hard cutover — portfolio_id 필수화 + symbol 서버 resolve(SnapshotStore) + unknown_portfolio/portfolio_id_required 거부(REQ-007), buy payload symbol 제거(REQ-021), ledger skip 가시화(WARN+ledger_skipped 카운터, REQ-018), closed↔outbox anti-join 운영 점검 `find_ledger_gap_slots` 신규(REQ-055). 전체 인프라 게이트 314 passed, ruff clean. (change-req/draft-req/confirm-req-implemented)
- 2026-06-08 :: REQ-052~054 Approved → Implemented (confirm-req-implemented): BAT-014(batch-lite) verify 완료(신규 8 단위, 전체 278 passed, ruff clean). SafetySellService per-slot 쿨다운(skip/만료재시도/경고/prune, market_close 제외), SafetySellConfig env 2종(min_retry_interval_sec 600/max_retry_warn_count 3), runtime-flows 정합. (confirm-req-implemented)
- 2026-06-08 :: REQ-052~054 Proposed → Approved (confirm-req): dcy-010 safety-sell 쿨다운 3개 전원 승인 게이트 통과. dcy-010(confirmed) FR/OQ 와 모순 없음, 필수 필드·구체 AC 완비. 피드백 채널·DB 영속은 Out of Scope 비핵심. (confirm-req)
- 2026-06-08 :: REQ-052~054 생성 (draft-req): dcy-010(safety-sell 영속 거부 재시도 백오프, confirmed) → 3개 REQ 초안. per-slot 쿨다운+경고(052), 쿨다운 env 2종(053), runtime-flows 정합(054). OQ-1(600s)/OQ-2(3회 경고)/OQ-3(단일 per-slot) Answered. (draft-req)
- 2026-06-07 :: REQ-048~051 Approved → Implemented (confirm-req-implemented): BAT-013 verify 완료(신규 9 단위 + RabbitMQ 라우팅 2, 단위 271 passed, 인프라 게이트 301 passed(directed 격리 S-2 + broadcast dual S-3 + default 무회귀 S-4), ruff clean). per-instance TOML 로더+`--config`, instance_key 동적 큐명(default 리터럴 하위호환), trex.direct/trex.broadcast exchange+바인딩, baseline 3문서 정합. (confirm-req-implemented)
+125
View File
@@ -0,0 +1,125 @@
"""Sell-order pricing tests (dcy-012 / issue #10).
Verifies the slot's side-agnostic ``limit_price`` drives dispatch and that a buy
limit never leaks into the sell order (the entry_price-reuse bug). A recording
paper broker captures each OrderRequest so order_type/limit_price are asserted
directly. Critical: the bug only reproduces on a LIMIT-buy slot (limit_price
populated), so every scenario enters via a limit buy.
"""
from __future__ import annotations
from datetime import date
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
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
DAY = date(2026, 6, 4)
class _RecordingPaper(PaperBroker):
"""PaperBroker that records every submitted OrderRequest."""
def __init__(self, **kw) -> None:
super().__init__(**kw)
self.requests = []
def submit_order(self, req):
self.requests.append(req)
return super().submit_order(req)
def _wire(broker):
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore())
execution = ExecutionService(svc, repo, broker)
svc.preallocate_daily_slots(DAY, 5)
return repo, svc, intents, execution
def _limit_buy_to_holding(intents, execution, *, entry_price=1000):
"""Admit a LIMIT buy (entry_price set) and run to holding."""
d = intents.submit_buy(
BuyIntent(
intent_id="b1", trading_date=DAY, portfolio_id="PF-1",
symbol="005930", qty=10, entry_price=entry_price,
)
)
execution.run_once() # dispatch buy + fill -> holding
return d.slot_id
def test_limit_buy_then_market_sell_does_not_reuse_buy_limit():
"""S-1 (a): the core bug — limit-buy slot sells at MARKET, not the buy limit."""
broker = _RecordingPaper(fill_after_polls=0)
repo, svc, intents, execution = _wire(broker)
sid = _limit_buy_to_holding(intents, execution)
# buy went out as a limit order at the entry price
buy_req = broker.requests[0]
assert buy_req.side == "buy" and buy_req.order_type == "00" and buy_req.limit_price == 1000
# holding slot's limit_price is cleared (no buy-limit leak)
assert repo.fetch_slot(sid).limit_price is None
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid)) # no price => market
execution.run_once() # dispatch sell + fill -> closed
sell_req = broker.requests[1]
assert sell_req.side == "sell"
assert sell_req.order_type == "01" # market, NOT "00"
assert sell_req.limit_price is None # buy entry price NOT reused
assert repo.fetch_slot(sid).status is SlotState.CLOSED
def test_limit_buy_then_limit_sell_uses_intent_price():
"""S-1 (b): a sell intent that carries a price dispatches as a limit order."""
broker = _RecordingPaper(fill_after_polls=0)
repo, svc, intents, execution = _wire(broker)
sid = _limit_buy_to_holding(intents, execution)
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid, limit_price=2000))
assert repo.fetch_slot(sid).limit_price == 2000 # set on pending_sell entry
execution.run_once()
sell_req = broker.requests[1]
assert sell_req.side == "sell"
assert sell_req.order_type == "00" # limit
assert sell_req.limit_price == 2000 # the intent's price, not the buy entry
def test_safety_sell_dispatches_at_market():
"""S-2: SlotService.sell (no price) => market sell, slot closes (no pending_sell trap)."""
broker = _RecordingPaper(fill_after_polls=0)
repo, svc, intents, execution = _wire(broker)
sid = _limit_buy_to_holding(intents, execution)
svc.sell(sid, actor="safety_sell", reason="stop_loss") # autonomous, no price
assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL
assert repo.fetch_slot(sid).limit_price is None
execution.run_once()
sell_req = broker.requests[1]
assert sell_req.order_type == "01" and sell_req.limit_price is None
assert repo.fetch_slot(sid).status is SlotState.CLOSED
def test_market_buy_slot_sells_at_market_regression():
"""S-1 (c): market-buy slot (entry_price=None) keeps selling at market."""
broker = _RecordingPaper(fill_after_polls=0)
repo, svc, intents, execution = _wire(broker)
intents.submit_buy(
BuyIntent(
intent_id="b1", trading_date=DAY, portfolio_id="PF-1",
symbol="005930", qty=10, entry_price=None, # market buy
)
)
execution.run_once()
buy_req = broker.requests[0]
assert buy_req.order_type == "01" and buy_req.limit_price is None
sid = repo.fetch_slot # noqa: F841 - keep readability
# find the holding slot
holding = next(s for s in repo._slots.values() if s.status is SlotState.HOLDING)
intents.submit_sell(SellIntent(intent_id="s1", slot_id=holding.slot_id))
execution.run_once()
sell_req = broker.requests[1]
assert sell_req.order_type == "01" and sell_req.limit_price is None
+4 -2
View File
@@ -104,11 +104,13 @@ class TestTransitHappyPath:
service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="order_exec",
avg_price=70_000, entry_price=70_000,
avg_price=70_000,
)
cur = repo.fetch_slot(slot.slot_id)
assert cur.status is SlotState.HOLDING
assert cur.avg_price == 70_000
# dcy-012: limit_price (pending order limit) is cleared on entering holding.
assert cur.limit_price is None
def test_pending_buy_to_idle_rollback_preserves_symbol_until_recovery(
self, service, repo, trading_day,
@@ -146,7 +148,7 @@ class TestClosedToIdleRecovery:
)
service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="oe", avg_price=70_000, entry_price=70_000,
reason="filled", actor="oe", avg_price=70_000,
)
service.transit_slot(
slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
+5 -5
View File
@@ -27,7 +27,7 @@ from trex_core.application.ports import IntentRecord
from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord
_SLOT_COLUMNS = (
"slot_id, trading_date, status, symbol, qty, avg_price, entry_price, "
"slot_id, trading_date, status, symbol, qty, avg_price, limit_price, "
"allocated_capital, order_id, strategy_id, broker_order_id, portfolio_id, "
"sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, "
"created_at, updated_at"
@@ -121,13 +121,13 @@ class PostgreSQLSlotRepository:
with conn.cursor() as cur:
cur.execute(
"UPDATE daily_slot SET status=%s, symbol=%s, qty=%s, avg_price=%s, "
"entry_price=%s, allocated_capital=%s, order_id=%s, strategy_id=%s, "
"limit_price=%s, allocated_capital=%s, order_id=%s, strategy_id=%s, "
"broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, "
"forced_close_at=%s, stop_loss_price=%s, take_profit_price=%s, "
"updated_at=%s WHERE slot_id=%s",
(
slot.status.value, slot.symbol, slot.qty, slot.avg_price,
slot.entry_price, slot.allocated_capital, slot.order_id,
slot.limit_price, slot.allocated_capital, slot.order_id,
slot.strategy_id, slot.broker_order_id, slot.portfolio_id,
slot.sell_strategy_id, slot.forced_close_at,
slot.stop_loss_price, slot.take_profit_price,
@@ -236,7 +236,7 @@ class PostgreSQLSlotRepository:
f = lambda v: float(v) if v is not None else None # noqa: E731
d = lambda v: Decimal(str(v)) if v is not None else None # noqa: E731
# Column order: slot_id(0) trading_date(1) status(2) symbol(3) qty(4)
# avg_price(5) entry_price(6) allocated_capital(7) order_id(8)
# avg_price(5) limit_price(6) allocated_capital(7) order_id(8)
# strategy_id(9) broker_order_id(10) portfolio_id(11) sell_strategy_id(12)
# forced_close_at(13) stop_loss_price(14) take_profit_price(15)
# created_at(16) updated_at(17)
@@ -247,7 +247,7 @@ class PostgreSQLSlotRepository:
symbol=row[3],
qty=f(row[4]),
avg_price=f(row[5]),
entry_price=f(row[6]),
limit_price=f(row[6]),
allocated_capital=f(row[7]),
order_id=row[8],
strategy_id=row[9],
@@ -0,0 +1,18 @@
-- BAT-016 / dcy-012 / REQ-006: rename daily_slot.entry_price -> limit_price.
-- The column becomes the side-agnostic "current pending order limit price"
-- (cleared on entering holding/closed), fixing the sell-uses-buy-limit bug
-- (issue #10). Pure rename preserves existing row data (S-3).
-- Idempotent: only renames when the old column still exists and the new one
-- does not, so re-running the migration set is safe.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'daily_slot' AND column_name = 'entry_price'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'daily_slot' AND column_name = 'limit_price'
) THEN
ALTER TABLE daily_slot RENAME COLUMN entry_price TO limit_price;
END IF;
END $$;
+5 -1
View File
@@ -88,7 +88,11 @@ class ExecutionService:
return
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
qty = int(slot.qty or 0)
limit = int(slot.entry_price) if slot.entry_price else None
# dcy-012 / REQ-013: side-agnostic — use the slot's pending-order
# limit_price only. A sell never reuses a buy limit (issue #10):
# limit_price is cleared on entering HOLDING, so a market sell
# carries None -> order_type "01".
limit = int(slot.limit_price) if slot.limit_price else None
ack = self._broker.submit_order(
OrderRequest(
slot_id=str(slot.slot_id),
+8 -1
View File
@@ -70,6 +70,9 @@ class SellIntent:
slot_id: UUID
reason: str = "discretionary_sell"
sell_strategy_id: Optional[str] = None
# dcy-012 / REQ-007: optional sell limit price. None => market sell
# (the path used by safety-sell / kill-switch). Set => limit sell at price.
limit_price: Optional[float] = None
extra: dict[str, Any] = field(default_factory=dict)
@@ -176,7 +179,9 @@ class IntentService:
symbol=symbol,
qty=intent.qty,
allocated_capital=intent.allocated_capital,
entry_price=intent.entry_price,
# dcy-012 / REQ-007: buy entry price maps to the slot's
# side-agnostic pending limit_price (cleared on fill→HOLDING).
limit_price=intent.entry_price,
strategy_id=intent.strategy_id,
sell_strategy_id=intent.sell_strategy_id,
portfolio_id=intent.portfolio_id,
@@ -206,6 +211,8 @@ class IntentService:
reason=intent.reason,
actor="intent_api",
sell_strategy_id=intent.sell_strategy_id,
# dcy-012 / REQ-007: optional sell limit (None => market sell).
limit_price=intent.limit_price,
extra={"intent_id": intent.intent_id},
)
except SlotStateMismatchError:
+6 -1
View File
@@ -94,6 +94,8 @@ def make_intent_rpc_handler(
slot_id=UUID(str(body["slot_id"])),
reason=str(body.get("reason") or "discretionary_sell"),
sell_strategy_id=_opt_str(body.get("sell_strategy_id")),
# dcy-012 / REQ-007: optional sell limit price; absent => market sell.
limit_price=_opt_float(body.get("limit_price")),
)
d = intent_service.submit_sell(sell)
else:
@@ -427,7 +429,10 @@ def _slot_to_dict(s) -> dict:
"symbol": s.symbol,
"qty": float(s.qty) if s.qty is not None else None,
"avg_price": float(s.avg_price) if s.avg_price is not None else None,
"entry_price": float(s.entry_price) if s.entry_price is not None else None,
# dcy-012 / NFR-3: external read field name kept as "entry_price" for
# contract compatibility; internally backed by the renamed limit_price
# (the pending order limit, cleared once holding).
"entry_price": float(s.limit_price) if s.limit_price is not None else None,
"allocated_capital": float(s.allocated_capital) if s.allocated_capital is not None else None,
"strategy_id": s.strategy_id,
"sell_strategy_id": s.sell_strategy_id,
+6 -2
View File
@@ -25,7 +25,11 @@ class Slot:
symbol: Optional[str] = None
qty: Optional[float] = None
avg_price: Optional[float] = None
entry_price: Optional[float] = None
# dcy-012 / REQ-002: side-agnostic "current pending order limit price"
# (renamed from entry_price). Set on pending entry (buy/sell limit), cleared
# on pending exit (→HOLDING, →CLOSED) so a buy limit never leaks into a sell
# order (issue #10). None => market order at dispatch.
limit_price: Optional[float] = None
allocated_capital: Optional[float] = None
order_id: Optional[str] = None
strategy_id: Optional[str] = None
@@ -50,7 +54,7 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"symbol",
"qty",
"avg_price",
"entry_price",
"limit_price",
"allocated_capital",
"order_id",
"strategy_id",
+29 -6
View File
@@ -66,7 +66,7 @@ class SlotService:
symbol: Optional[str] = None,
qty: Optional[float] = None,
avg_price: Optional[float] = None,
entry_price: Optional[float] = None,
limit_price: Optional[float] = None,
allocated_capital: Optional[float] = None,
order_id: Optional[str] = None,
strategy_id: Optional[str] = None,
@@ -114,7 +114,7 @@ class SlotService:
symbol=symbol,
qty=qty,
avg_price=avg_price,
entry_price=entry_price,
limit_price=limit_price,
allocated_capital=allocated_capital,
order_id=order_id,
strategy_id=strategy_id,
@@ -156,7 +156,7 @@ class SlotService:
symbol: Optional[str],
qty: Optional[float],
avg_price: Optional[float],
entry_price: Optional[float],
limit_price: Optional[float],
allocated_capital: Optional[float],
order_id: Optional[str],
strategy_id: Optional[str],
@@ -171,6 +171,10 @@ class SlotService:
Special: closed -> idle resets occupancy fields (FR-2 #7).
Special: pending_buy -> idle resets occupancy (REQ-011 AC-6 rollback).
dcy-012 / REQ-002: limit_price is set only while a slot is in a pending
state (the live order's limit); entering any non-pending state
(IDLE/HOLDING/CLOSED) clears it so a buy limit never leaks into the next
sell order (issue #10 root cause).
"""
if to_state is SlotState.IDLE and slot.status in {
SlotState.CLOSED,
@@ -184,7 +188,7 @@ class SlotService:
symbol=None,
qty=None,
avg_price=None,
entry_price=None,
limit_price=None,
allocated_capital=None,
order_id=None,
strategy_id=None,
@@ -197,6 +201,13 @@ class SlotService:
updated_at=datetime.now(tz=timezone.utc),
)
# limit_price lifecycle (dcy-012 FR-1): set only on entering a pending
# state (the order's limit); cleared on entering HOLDING/CLOSED.
if to_state in {SlotState.PENDING_BUY, SlotState.PENDING_SELL}:
new_limit_price = limit_price if limit_price is not None else slot.limit_price
else: # HOLDING / CLOSED — pending order limit no longer applies.
new_limit_price = None
# Generic update: caller-supplied fields override; None means "keep".
return replace(
slot,
@@ -204,7 +215,7 @@ class SlotService:
symbol=symbol if symbol is not None else slot.symbol,
qty=qty if qty is not None else slot.qty,
avg_price=avg_price if avg_price is not None else slot.avg_price,
entry_price=entry_price if entry_price is not None else slot.entry_price,
limit_price=new_limit_price,
allocated_capital=(
allocated_capital if allocated_capital is not None else slot.allocated_capital
),
@@ -236,12 +247,23 @@ class SlotService:
# ------------------------------------------------------------
# sell (REQ-036 / BAT-010)
# ------------------------------------------------------------
def sell(self, slot_id: UUID, actor: str, reason: str) -> None:
def sell(
self,
slot_id: UUID,
actor: str,
reason: str,
*,
limit_price: Optional[float] = None,
) -> None:
"""Trigger an autonomous safety-sell for a holding slot (REQ-036).
Idempotent: silently returns if the slot is already pending_sell.
Raises SlotNotFoundError if slot_id is unknown.
Raises SlotTransitionError if the slot is neither holding nor pending_sell.
dcy-012 / REQ-036: ``limit_price`` defaults to None so autonomous sells
(safety-sell / kill-switch) dispatch at market; only callers that pass a
price (discretionary RPC sell) get a limit order.
"""
slot = self._repo.fetch_slot(slot_id)
if slot is None:
@@ -258,6 +280,7 @@ class SlotService:
SlotState.PENDING_SELL,
reason=reason,
actor=actor,
limit_price=limit_price,
)
# ------------------------------------------------------------