From cc28e1fba56fe82df724be07ef8634e28b2a2ce7 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Mon, 8 Jun 2026 13:41:46 +0900 Subject: [PATCH] =?UTF-8?q?fix(execution):=20dcy-012=20=EB=A7=A4=EB=8F=84?= =?UTF-8?q?=20=EA=B0=80=EA=B2=A9=20=EB=B2=84=EA=B7=B8=20=EC=A0=95=EC=A0=95?= =?UTF-8?q?=20=E2=80=94=20slot=20entry=5Fprice=E2=86=92limit=5Fprice=20ren?= =?UTF-8?q?ame=20(BAT-016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 매도(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 --- .../design.md | 65 +++++ .../implementation.md | 48 ++++ .../index.md | 36 +++ .../planning.md | 55 +++++ .../verification.md | 74 ++++++ docs/batches/index.md | 1 + docs/data-model.md | 5 +- ...0608_sell-order-pricing-entry-price-bug.md | 233 ++++++++++++++++++ docs/discovery/index.md | 3 +- docs/interface-contract.md | 5 +- docs/runtime-flows.md | 4 +- .../req-006_postgresql-slot-persistence.md | 39 +++ ...015_broker-execution-baseline-alignment.md | 38 +++ .../req-013_order-execution-loop.md | 39 +++ .../req-002_slot-pure-domain-port.md | 39 +++ .../req-007_intent-submission-api.md | 40 +++ .../req-036_auto-sell-internal-path.md | 39 +++ ...03_slot-domain-tests-and-contract-guard.md | 39 +++ docs/srs/index.md | 2 +- tests/application/test_sell_pricing.py | 125 ++++++++++ tests/domain/slot/test_service.py | 6 +- trex_core/adapters/persistence/postgresql.py | 10 +- .../006_rename_entry_price_to_limit_price.sql | 18 ++ trex_core/application/execution.py | 6 +- trex_core/application/intents.py | 9 +- trex_core/application/messaging_handlers.py | 7 +- trex_core/domain/slot/models.py | 8 +- trex_core/domain/slot/service.py | 35 ++- 28 files changed, 1003 insertions(+), 25 deletions(-) create mode 100644 docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/design.md create mode 100644 docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/implementation.md create mode 100644 docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/index.md create mode 100644 docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/planning.md create mode 100644 docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/verification.md create mode 100644 docs/discovery/dcy-012_20260608_sell-order-pricing-entry-price-bug.md create mode 100644 tests/application/test_sell_pricing.py create mode 100644 trex_core/adapters/persistence/sql/006_rename_entry_price_to_limit_price.sql diff --git a/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/design.md b/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/design.md new file mode 100644 index 0000000..5875194 --- /dev/null +++ b/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/design.md @@ -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 단언 추가. diff --git a/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/implementation.md b/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/implementation.md new file mode 100644 index 0000000..f58f4fb --- /dev/null +++ b/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/implementation.md @@ -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. diff --git a/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/index.md b/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/index.md new file mode 100644 index 0000000..370bb08 --- /dev/null +++ b/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/index.md @@ -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). diff --git a/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/planning.md b/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/planning.md new file mode 100644 index 0000000..bc60d95 --- /dev/null +++ b/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/planning.md @@ -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) 컬럼 계약/드리프트 테스트로 검증. diff --git a/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/verification.md b/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/verification.md new file mode 100644 index 0000000..8d632b2 --- /dev/null +++ b/docs/batches/bat-016_20260608_sell-pricing-limit-price-rename/verification.md @@ -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 무관). diff --git a/docs/batches/index.md b/docs/batches/index.md index 380cd1e..6e509d1 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -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/) | diff --git a/docs/data-model.md b/docs/data-model.md index 5eeae48..fa098ab 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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? :: 진입/청산 전략 식별 diff --git a/docs/discovery/dcy-012_20260608_sell-order-pricing-entry-price-bug.md b/docs/discovery/dcy-012_20260608_sell-order-pricing-entry-price-bug.md new file mode 100644 index 0000000..04ac920 --- /dev/null +++ b/docs/discovery/dcy-012_20260608_sell-order-pricing-entry-price-bug.md @@ -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 diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 8f82079..37c922a 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -16,4 +16,5 @@ | dcy-008_20260607_emergency-stop-kill-switch | 20260607 | 비상 정지/강제 청산(kill-switch) — portfolio/전역 슬롯 청산 + 당일 전략 제외 | [Discovery](./dcy-008_20260607_emergency-stop-kill-switch.md) | confirmed | | 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 | \ No newline at end of file +| 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 | \ No newline at end of file diff --git a/docs/interface-contract.md b/docs/interface-contract.md index dc326e0..4a8e4a3 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -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) ``` diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 865687f..ca52d14 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -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) diff --git a/docs/srs/Data/req-006_postgresql-slot-persistence.md b/docs/srs/Data/req-006_postgresql-slot-persistence.md index 3c7f42d..2733f52 100644 --- a/docs/srs/Data/req-006_postgresql-slot-persistence.md +++ b/docs/srs/Data/req-006_postgresql-slot-persistence.md @@ -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. diff --git a/docs/srs/Documentation/req-015_broker-execution-baseline-alignment.md b/docs/srs/Documentation/req-015_broker-execution-baseline-alignment.md index 1d98393..fab7208 100644 --- a/docs/srs/Documentation/req-015_broker-execution-baseline-alignment.md +++ b/docs/srs/Documentation/req-015_broker-execution-baseline-alignment.md @@ -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). diff --git a/docs/srs/Integration/req-013_order-execution-loop.md b/docs/srs/Integration/req-013_order-execution-loop.md index be7cadf..8a3cf53 100644 --- a/docs/srs/Integration/req-013_order-execution-loop.md +++ b/docs/srs/Integration/req-013_order-execution-loop.md @@ -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 미사용 증명), + 가격 전달 매도 → 그 지정가, 시장가 매수 슬롯 회귀. diff --git a/docs/srs/Interface/req-002_slot-pure-domain-port.md b/docs/srs/Interface/req-002_slot-pure-domain-port.md index cce9a72..ea5e1cd 100644 --- a/docs/srs/Interface/req-002_slot-pure-domain-port.md +++ b/docs/srs/Interface/req-002_slot-pure-domain-port.md @@ -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) 정합. diff --git a/docs/srs/Interface/req-007_intent-submission-api.md b/docs/srs/Interface/req-007_intent-submission-api.md index 44abd2f..2c219e0 100644 --- a/docs/srs/Interface/req-007_intent-submission-api.md +++ b/docs/srs/Interface/req-007_intent-submission-api.md @@ -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. diff --git a/docs/srs/Interface/req-036_auto-sell-internal-path.md b/docs/srs/Interface/req-036_auto-sell-internal-path.md index cc899d2..a17636f 100644 --- a/docs/srs/Interface/req-036_auto-sell-internal-path.md +++ b/docs/srs/Interface/req-036_auto-sell-internal-path.md @@ -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 전이 단위. diff --git a/docs/srs/Testing/req-003_slot-domain-tests-and-contract-guard.md b/docs/srs/Testing/req-003_slot-domain-tests-and-contract-guard.md index 91a7a2c..7e53649 100644 --- a/docs/srs/Testing/req-003_slot-domain-tests-and-contract-guard.md +++ b/docs/srs/Testing/req-003_slot-domain-tests-and-contract-guard.md @@ -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. diff --git a/docs/srs/index.md b/docs/srs/index.md index a344fd7..145ff2f 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -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) diff --git a/tests/application/test_sell_pricing.py b/tests/application/test_sell_pricing.py new file mode 100644 index 0000000..10f314c --- /dev/null +++ b/tests/application/test_sell_pricing.py @@ -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 diff --git a/tests/domain/slot/test_service.py b/tests/domain/slot/test_service.py index 2577ad0..91b012c 100644 --- a/tests/domain/slot/test_service.py +++ b/tests/domain/slot/test_service.py @@ -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, diff --git a/trex_core/adapters/persistence/postgresql.py b/trex_core/adapters/persistence/postgresql.py index b783d0d..364f03c 100644 --- a/trex_core/adapters/persistence/postgresql.py +++ b/trex_core/adapters/persistence/postgresql.py @@ -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], diff --git a/trex_core/adapters/persistence/sql/006_rename_entry_price_to_limit_price.sql b/trex_core/adapters/persistence/sql/006_rename_entry_price_to_limit_price.sql new file mode 100644 index 0000000..d5aad11 --- /dev/null +++ b/trex_core/adapters/persistence/sql/006_rename_entry_price_to_limit_price.sql @@ -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 $$; diff --git a/trex_core/application/execution.py b/trex_core/application/execution.py index 4d8d6cd..11e9512 100644 --- a/trex_core/application/execution.py +++ b/trex_core/application/execution.py @@ -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), diff --git a/trex_core/application/intents.py b/trex_core/application/intents.py index 95efeb3..b4a053c 100644 --- a/trex_core/application/intents.py +++ b/trex_core/application/intents.py @@ -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: diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index 55e512e..b4d6b92 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -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, diff --git a/trex_core/domain/slot/models.py b/trex_core/domain/slot/models.py index 450a03a..0e4b170 100644 --- a/trex_core/domain/slot/models.py +++ b/trex_core/domain/slot/models.py @@ -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", diff --git a/trex_core/domain/slot/service.py b/trex_core/domain/slot/service.py index 4ef4efc..fce948d 100644 --- a/trex_core/domain/slot/service.py +++ b/trex_core/domain/slot/service.py @@ -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, ) # ------------------------------------------------------------