From 4746f74aaea7349deedd72224acb24c6833bc58a Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Mon, 8 Jun 2026 14:11:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(broker):=20dcy-013=20KIS=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=EC=B7=A8=EC=86=8C=20=EA=B0=90=EC=A7=80=20+=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EC=B2=B4=EA=B2=B0=20=ED=9A=8C=EA=B3=84=20?= =?UTF-8?q?+=20stale=20=EC=9B=8C=EC=B9=98=EB=8F=85=20(BAT-017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KIS 계좌에서 직접 매도 주문을 취소하면 poll_fill 이 cncl_yn 을 파싱하지 않아 슬롯이 pending_sell 에 영구히 갇히고 죽은 주문을 무한 폴링하던 버그(issue #9) 정정. 거버넌스: 전부 change-req (신규 REQ 없음, dcy-013 OQ-3). - REQ-012: KIS poll_fill cncl_yn 파싱 → 외부 취소 감지. FillStatus.cancelled /is_partial_cancel 추가. 체결 0 취소→cancelled, 부분 체결 후 취소→partial+cancelled - REQ-013: 집행 _on_partial_cancel — 체결분 원장 1회 + qty 차감(매도 잔여/매수 체결분) + holding 복귀 (OQ-1 A안). 전량 취소는 기존 롤백 경로 재사용 - REQ-018: 부분 체결분 원장 1회 기록 (_record_ledger 재사용) - REQ-031: 잔여 포지션 정합 연계 (slot.qty ↔ 실 잔고, 과매도 방지) - REQ-014: pending stale 워치독 Alert-only (TREX_PENDING_STALE_THRESHOLD, WARN+카운터, 상태 미변경) — 취소 감지 누락 backstop - REQ-015: interface-contract/runtime-flows 정합 전량 취소는 holding 자동 복구(limit_price clear→재매도 시장가, dcy-012 연계). KIS cncl_yn 실 규약은 운영 검증(미지 값 보수 처리 + 워치독 backstop). 검증: 단위 294 passed/32 skipped, 인프라 게이트(PG+RabbitMQ) 326 passed, ruff clean. 신규 test_kis_cancel(httpx stub) + test_execution_cancel(programmable broker). BAT-017 release-candidate. Co-Authored-By: Claude Opus 4.8 --- .../design.md | 49 ++++ .../implementation.md | 39 +++ .../index.md | 36 +++ .../planning.md | 51 ++++ .../verification.md | 67 ++++++ docs/batches/index.md | 1 + ..._kis-external-cancel-pending-sell-stuck.md | 224 ++++++++++++++++++ docs/discovery/index.md | 3 +- docs/interface-contract.md | 9 +- docs/runtime-flows.md | 6 +- ...015_broker-execution-baseline-alignment.md | 38 +++ ...order-handoff-durability-reconciliation.md | 39 +++ ...031_position-reconciliation-and-warning.md | 38 +++ .../Integration/req-012_kis-broker-adapter.md | 39 +++ .../req-013_order-execution-loop.md | 39 +++ .../req-018_fill-to-ledger-recording.md | 39 +++ docs/srs/index.md | 2 +- tests/adapters/broker/test_kis_cancel.py | 79 ++++++ tests/application/test_execution_cancel.py | 152 ++++++++++++ trex_core/adapters/broker/kis.py | 26 ++ trex_core/application/broker_port.py | 11 + trex_core/application/execution.py | 74 +++++- trex_core/runtime/config.py | 7 + trex_core/runtime/daemon.py | 1 + 24 files changed, 1063 insertions(+), 6 deletions(-) create mode 100644 docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/design.md create mode 100644 docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/implementation.md create mode 100644 docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/index.md create mode 100644 docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/planning.md create mode 100644 docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/verification.md create mode 100644 docs/discovery/dcy-013_20260608_kis-external-cancel-pending-sell-stuck.md create mode 100644 tests/adapters/broker/test_kis_cancel.py create mode 100644 tests/application/test_execution_cancel.py diff --git a/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/design.md b/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/design.md new file mode 100644 index 0000000..f478034 --- /dev/null +++ b/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/design.md @@ -0,0 +1,49 @@ +# BAT-017 Design + +- Status: confirmed +- Batch: BAT-017 + +## 설계 목표 +- KIS 외부 취소를 종료 상태로 정확히 매핑해 pending_sell 영구 갇힘(issue #9)을 자동 복구한다. +- 부분 체결 후 취소를 정확 회계(과매도·이중기록 방지)하고, 미지 원인 갇힘을 Alert-only 워치독으로 가시화한다. + +## 1. FillStatus 계약 확장 (REQ-012) +- `FillStatus.cancelled: bool = False` 추가. `is_partial_cancel` 프로퍼티 = `cancelled and filled_qty > 0`. +- 매핑 규칙: + - 체결 0 + 외부 취소 → `state="cancelled"`(기존 `is_terminal_reject` True). + - 부분 체결(0= threshold` 면 `r.stale_pending += 1` + WARN. **상태 불변**. +- `slot.updated_at` 출처: mark_dispatched/transit 가 갱신 → "마지막 쓰기 이후 경과"가 곧 "pending 체류 시간" 근사. +- config: `DaemonConfig.pending_stale_threshold_sec`(env `TREX_PENDING_STALE_THRESHOLD`, default 0). daemon 이 ExecutionService 에 주입. + +## 5. 포지션 정합 연계 (REQ-031) +- `_on_partial_cancel` 이 slot.qty 를 잔여 held 와 일치시켜 reconcile_positions 의 입력 정합을 유지. reconcile 코드 자체 변경 없음(연계 change-req). + +## 6. baseline 문서 (REQ-015) +- interface-contract: FillStatus.cancelled/is_partial_cancel + state별 집행 처리 + KIS cncl_yn 매핑. +- runtime-flows: poll 단계 외부 취소(전량→롤백 / 부분→회계) + Alert-only stale 워치독 + limit_price clear 재매도. + +## 자동 복구 성립 근거 (A-2) +- `is_terminal_reject` 가 이미 `cancelled` 포함 + `_rollback` 이 pending_sell→holding 수행 → KIS 어댑터가 cancelled 만 정확히 반환하면 application 경로 재사용으로 복구(전량 취소 케이스는 코드 추가 없이 성립). + +## 테스트 설계 +- `tests/adapters/broker/test_kis_cancel.py`(httpx stub): full cancel→cancelled, zero-fill non-cancel→accepted, partial+cancel→is_partial_cancel(execution_id 확인), full fill 회귀. +- `tests/application/test_execution_cancel.py`(programmable broker): 전량 취소→holding 복귀+clear, 부분 취소→원장 1건+qty 차감+cost basis 유지, stale 워치독 WARN(상태 불변), 비활성 default. diff --git a/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/implementation.md b/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/implementation.md new file mode 100644 index 0000000..1acf6fa --- /dev/null +++ b/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/implementation.md @@ -0,0 +1,39 @@ +# BAT-017 Implementation + +- Status: completed +- Batch: BAT-017 + +## 구현 요약 +- dcy-013 KIS 외부 취소 감지(issue #9) + 부분 체결 회계 + pending stale 워치독(Alert-only)을 전부 change-req 로 구현(신규 REQ 없음). + +## 변경 내역 + +### REQ-012 — KIS 취소 감지 + FillStatus 확장 +- `trex_core/application/broker_port.py`: `FillStatus.cancelled: bool = False` + `is_partial_cancel`(=`cancelled and filled_qty > 0`). +- `trex_core/adapters/broker/kis.py::poll_fill`: `cncl_yn` 파싱(`=="Y"`, 미지 값 보수적 비취소). 체결 0+취소 → `state="cancelled"`; 부분 체결+취소 → `state="partially_filled"` + `cancelled=True` + execution_id/fee 합성. 전량 체결 경로 불변. + +### REQ-013/018 — 집행 부분 체결 회계 +- `trex_core/application/execution.py::_poll`: `is_filled` → `is_partial_cancel` → `is_terminal_reject` → else(대기+stale) 분기. +- `_on_partial_cancel`: 매도 `PENDING_SELL→HOLDING qty=잔여`(cost basis 유지), 매수 `PENDING_BUY→HOLDING qty=체결분 avg_price=체결가`; `_record_ledger`(REQ-018 재사용)로 체결분 원장 1회 + clear_dispatch + event. `r.filled += 1`. +- 전량 취소(`cancelled`, 체결 0)는 기존 `is_terminal_reject` → `_rollback`(holding 복귀 + clear_dispatch) 재사용. + +### REQ-014 — pending stale 워치독 (Alert-only) +- `execution.py`: `ExecutionService(stale_pending_threshold_sec=...)`(<=0/None 비활성), `_check_stale_pending`(age=now-updated_at, 초과 시 WARN + `r.stale_pending += 1`, 상태 불변), `ExecutionResult.stale_pending`. +- `trex_core/runtime/config.py`: `DaemonConfig.pending_stale_threshold_sec`(env `TREX_PENDING_STALE_THRESHOLD`, default 0). +- `trex_core/runtime/daemon.py`: ExecutionService 에 threshold 주입. + +### REQ-031 — 포지션 정합 연계 +- `_on_partial_cancel` 이 slot.qty 를 잔여 held 와 일치시켜 정합 입력 유지(과매도 방지). reconcile_positions 코드 변경 없음. + +### REQ-015 — baseline 문서 +- `docs/interface-contract.md`: FillStatus.cancelled/is_partial_cancel + state별 집행 처리 + KIS cncl_yn 매핑. Last Updated → BAT-017. +- `docs/runtime-flows.md`: poll 단계 외부 취소(전량→롤백 / 부분→회계) + Alert-only stale 워치독. Last Updated → BAT-017. + +## 테스트 +- 신규 `tests/adapters/broker/test_kis_cancel.py`(httpx stub): 4건 — full cancel→cancelled, zero-fill non-cancel→accepted, partial+cancel→is_partial_cancel(execution_id), full fill 회귀. +- 신규 `tests/application/test_execution_cancel.py`(programmable broker): 4건 — 전량 취소→holding 복귀+clear, 부분 취소→sell 원장 1건+qty 6 잔여+cost basis 1000 유지, stale 워치독 WARN(상태 PENDING_SELL 불변), 비활성 default 무경고. + +## 검증 실행 +- 단위: `TREX_BROKER=paper .venv/bin/python -m pytest -q` → `294 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` → `326 passed`. +- `ruff check trex_core tests` → All checks passed. diff --git a/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/index.md b/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/index.md new file mode 100644 index 0000000..b7832f9 --- /dev/null +++ b/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/index.md @@ -0,0 +1,36 @@ +# BAT-017: KIS 외부 취소 감지 + 부분 체결 회계 + pending stale 워치독 (issue #9) + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-013 +- Included REQ: REQ-012 (CHG-20260608-01), REQ-013 (CHG-20260608-02), REQ-018 (CHG-20260608-02), REQ-031 (CHG-20260608-01), REQ-014 (CHG-20260608-01), REQ-015 (CHG-20260608-02) + +## Summary +- dcy-013(KIS 외부 주문 취소 시 슬롯이 pending_sell 에 영구 갇힘 — cncl_yn 미파싱, issue #9)을 단일 standard batch 로 delivery 한다. 거버넌스는 dcy-013 OQ-3 결정대로 **전부 change-req, 신규 REQ 없음**(워치독 임계 env 는 REQ-014 에 흡수). +- 핵심: KIS `poll_fill` 이 `inquire-daily-ccld` 의 `cncl_yn="Y"` 를 파싱해 외부 취소를 감지한다. 체결 0 취소 → `cancelled`(기존 롤백 경로로 holding 자동 복구), 부분 체결 후 취소 → `is_partial_cancel`(체결분 원장 1회 + qty 차감 + 잔여 holding 복귀, OQ-1 A안). 보강책으로 pending 주문 stale 워치독(OQ-2 Alert-only: WARN+카운터, 상태 미변경). + +## Scope +- In Scope: + - KIS `poll_fill` 의 `cncl_yn` 파싱 + `cancelled`/partial-cancel 매핑, `FillStatus.cancelled`/`is_partial_cancel` (REQ-012) + - 집행 루프 부분 체결 회계 `_on_partial_cancel`(체결분 원장 + qty 차감 + holding 복귀), 전량 취소 롤백 재사용 (REQ-013) + - 부분 체결분 원장 1회 기록(`_record_ledger` 재사용) (REQ-018) + - 잔여 포지션 정합 연계(슬롯 qty ↔ 실 잔고, 과매도 방지) (REQ-031) + - pending stale 워치독 Alert-only + `TREX_PENDING_STALE_THRESHOLD` env (REQ-014) + - baseline 문서(interface-contract/runtime-flows) 정합 (REQ-015) +- Out of Scope: + - buy 경로·paper 브로커 동작 변경(paper 는 이미 취소 정상 처리) + - 매도 가격 정책(dcy-012/BAT-016 에서 처리 — 본 주기는 취소 *감지*만) + - 워치독 auto-remediation(타임아웃 시 자동 롤백/취소) — 정상 slow fill·지정가 대기 상호작용·KIS 취소 능력 필요로 후속 분리 + - 과거 갇힘 슬롯의 일괄 보정(운영 절차) + +## 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-013). OQ-1(A안 정확 회계)·OQ-2(워치독 Alert-only)·OQ-3(전부 change-req) Answered. +- 자동 회복 성립 근거: `FillStatus.is_terminal_reject` 가 이미 `cancelled` 를 포함하고 `_rollback` 이 `pending_sell→holding` 을 수행(A-2) → KIS 어댑터가 취소만 정확히 반환하면 application 경로 재사용으로 복구. +- dcy-012(BAT-016) 가정 충족: holding 진입 시 `limit_price` clear → 재매도 시장가(A-1). +- R-1(KIS 취소 필드 규약): `cncl_yn` 가정. 실 응답 확인은 운영/검증 단계 — 미지 값 보수 처리 + stale 워치독(FR-3) backstop. diff --git a/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/planning.md b/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/planning.md new file mode 100644 index 0000000..2653a6b --- /dev/null +++ b/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/planning.md @@ -0,0 +1,51 @@ +# BAT-017 Planning + +- Status: confirmed +- Batch: BAT-017 +- Profile: standard + +## Included REQ +- REQ-012 (Integration, change-req): KIS `poll_fill` `cncl_yn` 파싱 + `cancelled`/partial-cancel 매핑, `FillStatus.cancelled`/`is_partial_cancel`. +- REQ-013 (Integration, change-req): 집행 루프 `_on_partial_cancel`(체결분 원장 + qty 차감 + holding 복귀), 전량 취소 롤백 재사용. +- REQ-018 (Integration, change-req): 부분 체결분 원장 1회 기록(`_record_ledger` 재사용). +- REQ-031 (Exception, change-req): 잔여 포지션 정합 연계(슬롯 qty ↔ 실 잔고). +- REQ-014 (Exception, change-req): pending stale 워치독 Alert-only + `TREX_PENDING_STALE_THRESHOLD`. +- REQ-015 (Documentation, change-req): interface-contract/runtime-flows 정합. + +## Delivery Plan +1. REQ-012: `broker_port.py` FillStatus.cancelled + is_partial_cancel; `kis.py::poll_fill` cncl_yn 파싱(full cancel/partial cancel 매핑 + execution_id). +2. REQ-013/018: `execution.py::_poll` is_partial_cancel 분기 + `_on_partial_cancel`(원장 1회 + qty 차감 + holding). 전량 취소는 기존 is_terminal_reject 롤백. +3. REQ-014: `_check_stale_pending` + ExecutionResult.stale_pending + ExecutionService threshold; `config.py` `pending_stale_threshold_sec`/env; `daemon.py` 배선. +4. REQ-031: 부분 회계가 잔여 qty 정합 유지(연계, reconcile 코드 무변경) 확인. +5. REQ-015: interface-contract/runtime-flows 정합. +6. 신규 테스트(test_kis_cancel, test_execution_cancel) + 전체 회귀 + ruff + 인프라 게이트. + +## Dependencies +- Internal: + - dcy-003/BAT-004 broker execution(poll/rollback/REQ-012/013/014) — 변경 대상. + - dcy-004/BAT-006 fill→ledger(REQ-018) — 부분 체결분 기록. + - dcy-005/BAT-008 reconcile_positions(REQ-031) — 잔여 정합 연계. + - dcy-012/BAT-016 — holding 진입 시 limit_price clear(A-1, 재매도 시장가). +- External: + - KIS `inquire-daily-ccld` cncl_yn 필드(R-1, 실 응답 확인은 운영/검증). + +## Design Gate +- Design Required Before Implementation: yes +- Reason: (a) 부분 체결 후 취소 회계(A안)는 FillStatus 계약 확장 + 집행 분기 + 원장 기록 + 슬롯 qty/cost basis 정합까지 교차 — 매도/매수 각각의 qty·avg_price 처리 규칙을 설계에서 못박지 않으면 과매도/이중기록(R-3) 위험. (b) 전량 취소 자동 복구가 기존 is_terminal_reject 롤백 재사용으로 성립하는지(A-2) 설계에서 확인. (c) stale 워치독은 Alert-only 경계(상태 미변경)·임계 env·타임스탬프 출처(slot.updated_at)를 설계에서 고정. → design.md 동일 턴 scaffold. + +## Milestones +1. M1 — REQ-012 FillStatus.cancelled + KIS cncl_yn 매핑, test_kis_cancel green +2. M2 — REQ-013/018 부분 체결 회계 + 전량 취소 롤백, test_execution_cancel green +3. M3 — REQ-014 stale 워치독 + config/daemon 배선 +4. M4 — REQ-031 잔여 정합 확인 + REQ-015 문서 정합 +5. M5 — 전체 회귀 + ruff + 인프라 게이트 + +## Risks +- R-1 (High): KIS 실제 취소 표현(cncl_yn 필드/값)이 가정과 다르면 취소를 여전히 accepted 로 오판. 대응: 미지 값 보수 처리 + stale 워치독(FR-3) backstop. 실 응답 확인은 운영/검증 단계. +- R-2 (Medium): 외부 취소→롤백→재매도 반복 시 safety-sell 무한 재매도. 대응: dcy-010(REQ-052) per-slot 쿨다운 확인, 필요 시 cancelled 도 쿨다운 트리거 포함(후속). +- R-3 (Medium): 부분 체결 후 단순 롤백 시 과매도. 대응: A안(체결분 원장 + qty 차감 + 잔여 holding)으로 구조적 차단, cost basis 유지. + +## Notes +- 1차 게이트: 전체 회귀 green. +- KIS 어댑터 단위는 httpx stub(fixture)로 cncl_yn 매핑 검증; 집행 회계는 programmable broker 로 FillStatus 주입 검증. +- 실 KIS 외부 취소 e2e 는 실계좌 세션 필요로 비차단 이월(운영 검증). diff --git a/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/verification.md b/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/verification.md new file mode 100644 index 0000000..868e550 --- /dev/null +++ b/docs/batches/bat-017_20260608_external-cancel-detection-stale-watchdog/verification.md @@ -0,0 +1,67 @@ +# BAT-017 Verification + +- Status: verified +- Batch: BAT-017 + +## Acceptance Mapping + +### REQ-012 (KIS 취소 감지, change-req) +| AC | Evidence | +|----|----------| +| cncl_yn → cancelled (체결 0) | tests/adapters/broker/test_kis_cancel.py::test_full_external_cancel_maps_to_cancelled | +| 비취소 zero-fill → accepted | ::test_zero_fill_not_cancelled_stays_accepted | +| 부분 체결 + 취소 → is_partial_cancel + execution_id | ::test_partial_fill_then_cancel_flags_partial_cancel | +| 전량 체결 회귀 | ::test_full_fill_unaffected_by_cancel_flag | + +### REQ-013 (집행 부분 체결 회계, change-req) +| AC | Evidence | +|----|----------| +| 전량 외부 취소 → holding 복귀 + clear (영구 갇힘 0) | tests/application/test_execution_cancel.py::test_full_external_cancel_returns_slot_to_holding | +| 부분 취소 → qty 차감 + holding 잔여 + cost basis 유지 | ::test_partial_fill_then_cancel_records_and_keeps_remainder | +| 기존 filled/reject 회귀 | tests/application/test_execution.py 전체 | + +### REQ-018 (부분 체결분 원장 1회, change-req) +| AC | Evidence | +|----|----------| +| 부분 취소 시 sell 원장 1건(qty=체결분, price=체결가) | test_execution_cancel.py::test_partial_fill_then_cancel_records_and_keeps_remainder | + +### REQ-031 (포지션 정합 연계, change-req) +| AC | Evidence | +|----|----------| +| 부분 취소 후 slot.qty = 잔여 held (과매도 0) | test_execution_cancel.py::test_partial_fill_then_cancel_records_and_keeps_remainder (qty==6) | +| reconcile 로직 불변 회귀 | tests/application/test_reconciliation.py 전체 | + +### REQ-014 (pending stale 워치독 Alert-only, change-req) +| AC | Evidence | +|----|----------| +| 임계 초과 pending → WARN + 카운터, 상태 미변경 | test_execution_cancel.py::test_stale_pending_watchdog_warns_without_state_change | +| 비활성(default) → 무경고 | ::test_stale_watchdog_disabled_by_default | + +### REQ-015 (baseline 문서, change-req) +| AC | Evidence | +|----|----------| +| interface-contract/runtime-flows 정합 | 문서 diff(BAT-017) | + +## Evidence +- Test: + - 단위 전체 회귀: `294 passed / 32 skipped`(신규 KIS 취소 4 + 집행 취소/워치독 4 포함; `::test_sandbox_token_and_submit` deselect). + - 인프라 게이트(PG trex_core_test + RabbitMQ): `326 passed`. + - `ruff check trex_core tests` → All checks passed. +- Logs: `294 passed, 32 skipped, 1 deselected` / `326 passed, 1 deselected ~15.9s`. + +## 성공 기준(dcy-013) 대응 +- S-1(외부 취소 → cancelled 감지 → holding 복귀, 영구 갇힘 0): test_full_external_cancel_maps_to_cancelled + test_full_external_cancel_returns_slot_to_holding. +- S-2(죽은 주문 추가 폴링 없음): 취소 감지 후 clear_dispatch 로 broker_order_id 비워져 list_dispatched_pending 에서 제외(폴링 중단) — test_full_external_cancel_returns_slot_to_holding(broker_order_id is None). +- S-3(부분 체결분 원장 1회 + 잔여 정합, 과매도/이중기록 0): test_partial_fill_then_cancel_records_and_keeps_remainder(sell 1건 + qty 6 + cost basis 1000). +- S-4(임계 초과 pending stale 가시화, 상태 미변경): test_stale_pending_watchdog_warns_without_state_change. + +## Carried-Over / 비차단 +- R-1(KIS `cncl_yn` 실제 필드/값 규약): `cncl_yn="Y"` 가정으로 구현, 실 응답 확인은 운영/검증 단계. 미지 값 보수 처리 + stale 워치독(FR-3) backstop 으로 무한 갇힘 가시화 안전망 확보. +- 실 KIS 외부 취소 full-stack e2e: 실계좌 세션 필요로 이월(운영 검증, 비차단). +- R-2(외부 취소→재매도 반복 루프): dcy-010 per-slot 쿨다운과 연계, cancelled 쿨다운 트리거 포함은 후속. +- 워치독 auto-remediation: 후속 분리(OQ-2 Alert-only 결정). + +## Result +- Ready for Release: true +- Blocking Issues: + - 없음. 전량 취소 자동 복구·부분 체결 회계·stale 워치독 모두 단위 green(326 passed 회귀). LIVE sandbox 토큰 deselect 는 `.env` 환경 의존(본 batch 무관). KIS cncl_yn 실 규약은 운영 검증 비차단(보수 처리 + 워치독 backstop). diff --git a/docs/batches/index.md b/docs/batches/index.md index 6e509d1..f277ae6 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -24,3 +24,4 @@ | 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/) | +| BAT-017 | standard | release-candidate | REQ-012 (CHG-20260608-01), REQ-013 (CHG-20260608-02), REQ-018 (CHG-20260608-02), REQ-031 (CHG-20260608-01), REQ-014 (CHG-20260608-01), REQ-015 (CHG-20260608-02) | dcy-013 | [bat-017](./bat-017_20260608_external-cancel-detection-stale-watchdog/) | diff --git a/docs/discovery/dcy-013_20260608_kis-external-cancel-pending-sell-stuck.md b/docs/discovery/dcy-013_20260608_kis-external-cancel-pending-sell-stuck.md new file mode 100644 index 0000000..16eb527 --- /dev/null +++ b/docs/discovery/dcy-013_20260608_kis-external-cancel-pending-sell-stuck.md @@ -0,0 +1,224 @@ +# 0. 문서 상태 +이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. + +- 상태: confirmed +- 문서 ID: dcy-013_20260608_kis-external-cancel-pending-sell-stuck +- 이슈명: KIS 외부 주문 취소 시 슬롯이 pending_sell 에 영구히 갇힘 — cncl_yn 미파싱 +- 작성 시각(KST): 2026-06-08 13:29 KST +- 마지막 갱신 시각(KST): 2026-06-08 16:30 KST +- 대체됨: 없음 +- 후속 Discovery 참조: 없음 +- 생성된 REQ 참조: REQ-012/013/018/031/014/015 (전부 change-req, 신규 REQ 없음 — OQ-3). REQ-013/018/015=CHG-20260608-02, REQ-012/031/014=CHG-20260608-01. 워치독 임계 env 는 REQ-014 에 흡수. delivery: BAT-017 + +# 1. 계획 상태 요약 +현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. + +- 연계 문서 현황: + - 관련 점수 파일: 미생성 + - 관련 TODO/메모: GitHub issue #9 (fourblocks2020/trex-core), 관련 issue #10(dcy-012, 매도 가격 버그) +- 해석: + - 실제 KIS `poll_fill` 이 일별주문체결조회의 취소여부(`cncl_yn`)를 파싱하지 않아, 계좌에서 직접(trex-core 외부) 매도 주문을 취소하면 슬롯이 `pending_sell` 에 영구히 갇히고 데몬이 죽은 주문을 매 tick 무한 폴링하는 버그를 정식 SDLC 주기로 착수한다. + - 이번 Discovery에서는 KIS 어댑터의 취소 감지 갭을 메우는 핵심 수정과, pending 주문의 stale 처리(워치독/타임아웃) 보강 범위를 판정한다. 이미 Implemented·released(REL-001/003) 된 집행/브로커 경로(dcy-003) 변경이므로, 거버넌스는 OQ-3 결정에 따라 기존 REQ(REQ-012/013/018/031/014/015) `change-req` 로 처리하고 신규 REQ 는 원칙적으로 생성하지 않는다(워치독 임계 env 는 조건부). + +# 2. 요구사항 판정 결과 +이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. + +- 판정: 신규 +- 근거 + - 기존 Discovery(dcy-001~dcy-012) 중 KIS 외부 취소 감지(`cncl_yn` 파싱)나 pending 주문 stale 워치독을 다룬 문서는 없다. dcy-003(broker execution+outbox)은 집행 루프·롤백 경로를 도입했고, dcy-012(issue #10)는 매도 *가격* 버그를 다뤘으나 `poll_fill` 의 취소 *감지* 갭은 명시적으로 범위 밖(후속 이슈)으로 분리했다. + - 변경이 이미 Implemented·released 된 KIS 어댑터(`poll_fill`)와 집행 reconciliation 의미를 바꾸므로, 기존 Discovery 미세 조정이 아니라 독립 주기로 추적하는 것이 명확하다. + +# 3. 문제점의 요약 +문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. + +- 현재 상태 + - 취소를 종료 상태로 다루는 설계는 이미 존재한다 — `trex_core/application/broker_port.py` 의 `FillStatus.is_terminal_reject`(broker_port.py:63~64)는 `rejected`·`cancelled` 를 모두 포함하고, `_poll`(execution.py:113~)은 `is_terminal_reject` 면 `_rollback`(execution.py:198~)을 호출해 `PENDING_SELL → HOLDING` 으로 되돌리고 `clear_dispatch` 로 `broker_order_id` 를 비운다(execution.py:207~210). + - 그러나 실제 KIS `poll_fill`(`trex_core/adapters/broker/kis.py:163~208`)은 취소여부(`cncl_yn`)를 파싱하지 않는다. 계좌에서 직접 취소하면 일별주문체결조회(`inquire-daily-ccld`)에 주문 row 는 남지만 체결수량(`tot_ccld_qty`)이 0이므로, `if ccld_qty <= 0: return FillStatus(state="accepted", ...)`(kis.py:191~192)로 빠져 **`accepted`(접수됨)** 로 판정된다. `_poll` 은 `accepted` 를 "다음 tick 대기"로 처리해 슬롯이 영구히 `pending_sell` 에 머문다. + - 부분 체결 후 취소(`0 < ccld_qty < ord_qty`)도 같은 문제다 — `partially_filled`(kis.py:207~)로 판정되어 역시 "다음 tick 대기"로 처리되어 갇힌다. + - paper 브로커는 취소를 올바르게 처리한다 — `trex_core/adapters/broker/paper.py:74~75` 에서 `o.cancelled` 면 `FillStatus(state="cancelled")` 를 반환한다. 즉 갭은 **실제 KIS 어댑터에만** 존재한다. + - baseline 문서(`docs/project-structure.md`, `docs/runtime-flows.md`)는 존재하며 최신이다. 집행 폴/롤백은 기존 runtime flow 에 포함돼 있고, 이번 변경은 KIS 어댑터의 취소 감지 단계를 메운다. +- 기대 상태 + - KIS `poll_fill` 이 외부 취소를 `FillStatus(state="cancelled")` 로 매핑하면, 기존 롤백 경로가 동작해 슬롯이 `HOLDING` 으로 복귀하고 `broker_order_id` 가 비워져 자동 회복된다. (dcy-012 구현 가정 — HOLDING 진입 시 `limit_price` 가 clear 되어 재매도는 시장가로 나가 체결성이 보장된다.) + - 죽은 주문에 대한 무한 1초 폴링이 멈추고, 운영자 수동 개입 없이 자동 복구된다. 보강책으로 pending 주문이 비정상적으로 오래 머무는 경우 stale 처리(워치독/타임아웃)가 안전망이 된다. +- 이해관계자 + - 문서 작성자(AI agent) — Discovery/REQ/Batch 산출물 작성 + - 리뷰어/승인자 — KIS 어댑터·reconciliation 변경 승인 + - 운영 담당자 — 외부 취소·갇힘 상황의 자동 복구로 수동 개입 부담 감소 + - 전략 모듈 — 슬롯이 자동 복구되어 일관된 상태를 관측 +- 제약사항 + - 이미 released(REL-001/003) 된 KIS 어댑터/집행 경로 변경이므로 ad-hoc 수정이 아니라 정식 SDLC(change-req 또는 신규 REQ) 경유가 필요하다. + - KIS `inquire-daily-ccld` 응답의 실제 `cncl_yn`(또는 동등) 필드명·값 규약을 실데이터로 확인해야 정확히 매핑할 수 있다. + - 부분 체결 후 취소는 체결분 원장 귀속과 잔여 포지션 정합을 함께 고려해야 한다(단순 롤백으로 과매도 위험). + +## 주요 사용자 시나리오 +사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. + +- 운영자가 KIS HTS/MTS 에서 trex-core 가 낸 매도 주문을 직접 취소한다 → core 가 다음 tick 에 취소를 감지해 슬롯을 `HOLDING` 으로 복귀시키고, 필요 시 재매도(시장가)한다. +- 부분 체결 후 운영자가 잔량을 취소한다 → 체결분은 원장에 기록되고 잔여 포지션만 정합 처리되어 과매도가 발생하지 않는다. +- 어떤 이유로 pending 주문이 비정상적으로 오래 머문다 → 워치독/타임아웃이 stale 로 감지해 reconciliation 또는 경고를 발생시킨다. + +# 4. 이번에 정의할 변경 +이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. + +- KIS `poll_fill` 이 `inquire-daily-ccld` 응답의 취소여부(`cncl_yn` 등)를 파싱해, 취소된 주문(체결 0 또는 부분 체결 후 취소)을 `FillStatus(state="cancelled")` 로 매핑한다 — 기존 `is_terminal_reject` → `_rollback` 경로가 동작하도록. +- 부분 체결 후 취소(OQ-1 A안): 체결분 원장 기록 + 슬롯 qty 차감 + 잔여 HOLDING 복귀의 부분 체결 회계를 도입한다(과매도/원장 누락 방지). +- (OQ-2 — Alert-only) pending 주문 stale 워치독을 도입한다 — 임계 초과 시 WARN/메트릭 가시화만, 상태 미변경(auto-remediation 은 후속). +- 인터페이스/런타임 baseline 문서에 취소 감지·복구 흐름을 반영한다. + +## 영향 범위 +변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. + +- 코드 영향: + - `trex_core/adapters/broker/kis.py` — `poll_fill`(163~208) 에 `cncl_yn` 파싱 + `state="cancelled"` 매핑(핵심 갭) + - `trex_core/application/broker_port.py` — `FillStatus` 계약(필요 시 부분 체결+취소 표현 보강) + - `trex_core/application/execution.py` — 완전 취소(체결 0)는 기존 `_poll`/`_rollback` 경로 자동 재사용. 부분 체결+취소(OQ-1 A안)는 체결분 원장 기록 + 슬롯 qty 차감 + 잔여 HOLDING 복귀의 부분 체결 회계 경로 신규(`_on_filled`/`_record_ledger` 가 전량 체결만 다루던 것을 부분에도 확장) + - `trex_core/domain/slot/` — 슬롯 qty 부분 차감 후 HOLDING 복귀 전이(잔여 수량 갱신) + - (워치독 채택 시) pending stale 감지 컴포넌트 — 집행 reconciliation 경로 +- 문서 영향: + - `docs/interface-contract.md` — KIS poll/fill 상태 매핑(취소 포함) 반영 + - `docs/runtime-flows.md` — 외부 취소 감지 → 롤백 → 재매도/회복 흐름 반영 +- 운영 영향: + - 외부 취소·갇힘의 자동 복구로 수동 개입·불필요 폴링 감소 + - (워치독) stale 임계·알람 운영 파라미터 추가 가능 + +# 5. 요구사항 목록 +실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. + +## 기능 요구사항 +- FR-1: KIS `poll_fill` 은 `inquire-daily-ccld` 응답의 취소여부를 파싱해, 외부 취소된 주문(체결 0)을 `FillStatus(state="cancelled")` 로 매핑한다. 이로써 기존 `is_terminal_reject → _rollback` 경로가 동작해 슬롯이 `HOLDING` 으로 자동 복귀한다. +- FR-2: 부분 체결 후 취소(`0 < ccld_qty < ord_qty`)를 감지해 (OQ-1 A안) ① 체결분(`ccld_qty`)을 원장에 1회 기록, ② 슬롯 qty 를 `ccld_qty` 만큼 차감, ③ 잔여 qty 로 `HOLDING` 복귀해 잔여분만 재매도 가능하게 한다. `avg_price` 는 유지하여 잔여 포지션 정합을 보장하고 과매도/원장 누락을 방지한다. +- FR-3: (OQ-2 — Alert-only) pending 주문이 configurable 임계 시간을 초과해 머물면 stale 로 감지해 WARN 로그·메트릭으로 가시화한다. 슬롯/주문 상태는 변경하지 않는다(자동 롤백/취소는 후속). cncl_yn 매핑 오류(R-1) 등 미지 원인의 무한 갇힘을 silent 가 아니라 운영자에게 노출하는 안전망이다. +- FR-4: `docs/interface-contract.md` 와 `docs/runtime-flows.md` 에 취소 감지·복구(및 워치독) 흐름을 반영한다. + +## 비기능 요구사항 +성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. + +- NFR-1: 자동 회복성 — 외부 취소된 슬롯이 운영자 개입 없이 다음 tick 내에 `HOLDING` 으로 복구되어 `pending_sell` 영구 갇힘이 발생하지 않아야 한다. +- NFR-2: 자원 효율 — 죽은(취소된) 주문에 대한 무한 폴링이 중단되어 불필요한 KIS API 호출이 발생하지 않아야 한다. +- NFR-3: 정합성 — 부분 체결 후 취소에서 체결분이 원장에 정확히 귀속되고 잔여 포지션이 실제 잔고와 일치해야 한다(과매도/이중매도 금지). + +## 범위 경계 +이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. + +- In Scope + - KIS `poll_fill` 의 `cncl_yn` 파싱 + `cancelled` 매핑(핵심 갭) + - 부분 체결 후 취소 처리(OQ-1 A안: 체결분 원장 기록 + qty 차감 + 잔여 HOLDING 복귀) + - pending stale 워치독(OQ-2 Alert-only: WARN/메트릭 가시화) + - 인터페이스/런타임 문서 갱신 +- Out of Scope + - buy 경로·paper 브로커 동작 변경(paper 는 이미 정상) + - 매도 가격 정책(dcy-012 에서 처리 — 본 주기는 취소 *감지*만) + - 워치독 auto-remediation(타임아웃 시 자동 롤백/취소) — 정상 slow fill·지정가 대기 상호작용·KIS 취소 능력 필요로 후속 분리 + +# 6. 리스크/가정 목록 +불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. + +## 리스크 +- R-1 (High): KIS `inquire-daily-ccld` 의 실제 취소 표현(필드명/값)이 문서 가정과 다르면 취소를 여전히 `accepted` 로 오판해 갇힘이 재현된다. + - 완화 방안: 실 응답 샘플(또는 KIS 명세)로 `cncl_yn` 등 필드를 확정하고, 미지 응답은 `unknown` 으로 보수 처리하되 OQ-2 의 Alert-only 워치독으로 무한 갇힘을 가시화하는 안전망을 둔다. +- R-2 (Medium): 외부 취소 → 롤백 → 재매도가 반복되면(운영자가 계속 취소) safety-sell 이 무한 재매도 루프를 돌 수 있다. + - 완화 방안: dcy-010(REQ-052) 의 per-slot 재시도 쿨다운/영속 거부 경고가 재매도 빈도를 제어하는지 확인하고, 필요 시 cancelled 도 쿨다운 트리거에 포함한다. +- R-3 (Medium, OQ-1 A안으로 완화): 부분 체결 후 취소를 단순 롤백하면 이미 팔린 수량을 다시 팔아 과매도가 날 수 있다. + - 완화 방안: OQ-1 A안(체결분 원장 기록 + 슬롯 qty 차감 + 잔여 HOLDING 복귀)으로 잔여분만 재매도되어 과매도가 구조적으로 차단된다. 부분 체결 회계 정확성은 REQ-018(원장)·REQ-031(포지션 정합) 검증과 연계해 보장한다. + +## 가정 +아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. + +- A-1: dcy-012 구현이 완료되어 슬롯 가격 필드가 `limit_price` 로 통일되고 HOLDING 진입 시 clear 되므로, 취소 후 롤백된 슬롯의 재매도는 시장가로 나가 체결성이 보장된다. +- A-2: `FillStatus.is_terminal_reject` 가 이미 `cancelled` 를 포함하고 `_rollback` 이 `PENDING_SELL → HOLDING` 을 수행하므로, KIS 어댑터가 `cancelled` 만 정확히 반환하면 application 경로 변경 없이 회복이 성립한다. +- A-3: paper 브로커는 이미 취소를 올바르게 처리하므로 본 변경은 KIS 어댑터(+필요 시 부분취소/워치독)에 국한된다. + +## 오픈 질문 +답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. + +- OQ-1: 부분 체결 후 취소 처리 정책 — 체결분을 원장에 기록하고 잔여 수량만 정합(예: HOLDING 잔량 갱신) 처리하는가, 아니면 다른 규칙을 쓰는가? + - 상태: Answered (2026-06-08, 사용자 결정 — A안: 한 번에 올바르게(정확 회계)) + - 결정: 부분 체결 후 취소 감지 시 ① 체결분(`ccld_qty`)을 원장에 1회 기록, ② 슬롯 qty 를 `ccld_qty` 만큼 차감, ③ 잔여 qty 로 `HOLDING` 복귀(재매도는 잔여분만)한다. `avg_price`(주당 원가)는 그대로 유지되어 잔여 포지션도 정합하며, 과매도·원장 누락을 동시에 방지한다. 단순 롤백(C)·격리 후속(B)은 미채택. + - 반영 위치: FR-2, 4. 변경 항목, 영향 범위, R-3 +- OQ-2: pending stale 워치독/타임아웃을 이번 주기에 포함하는가, 후속으로 분리하는가? 포함 시 임계 기준은? + - 상태: Answered (2026-06-08, 사용자 결정 — 포함, Alert-only) + - 결정: pending 주문이 임계 시간을 초과해 머물면 stale 로 감지해 WARN 로그·메트릭으로 **가시화만** 하고 슬롯/주문 상태는 변경하지 않는다(Alert-only). cncl_yn 매핑이 틀려도(R-1) 무한 갇힘이 silent 가 아니라 운영자에게 노출되는 안전망. 타임아웃 시 자동 롤백/취소(auto-remediation)는 정상 slow fill·지정가 대기와의 상호작용 및 KIS 주문 취소 능력이 필요해 후속으로 분리. 임계는 configurable(시장가 매도는 짧게, 지정가 매도는 관대하게 또는 단일 보수적 임계). + - 반영 위치: FR-3, 범위 경계, R-1 완화 +- OQ-3: 거버넌스 경로 — 기존 REQ(REQ-012 KIS 어댑터, REQ-014 reconciliation 등)의 change-req 로 처리할지, 신규 REQ 로 추적할지? + - 상태: Answered (2026-06-08, 사용자 결정 — change-req 우선) + - 결정: 기존 Implemented REQ 본문 수정(change-req)으로 처리한다. change-req 대상 — REQ-012(KIS 어댑터: `cncl_yn` 파싱 + `cancelled` 매핑), REQ-013(주문 집행 루프: 부분 체결 회계 — qty 차감 + 잔여 HOLDING 복귀), REQ-018(체결→원장 기록: 부분 체결분 1회 기록), REQ-031(포지션 정합: 잔여 포지션 정합 연계), REQ-014(주문 핸드오프 durability + reconciliation: pending stale 워치독 Alert-only), REQ-015(브로커/집행 baseline 문서 정합: interface-contract/runtime-flows 갱신). 원칙적으로 신규 REQ 는 생성하지 않는다 — 단 워치독 임계 env 설정이 별도 추적을 요하면 REQ 정제에서 소형 config 신규 REQ 를 둘 수 있다(조건부). + - 반영 위치: 1. 거버넌스 해석, R-1 완화 방안, 8. 확인 체크 + +### 오픈 질문 처리 기준 +- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. +- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. +- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. +- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. + +# 7. 초기 성공 기준 +이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. + +- S-1: 외부 취소된 매도 주문이 다음 tick 에 `cancelled` 로 감지되어 슬롯이 `HOLDING` 으로 복귀하고 `broker_order_id` 가 비워진다(영구 갇힘 0건). +- S-2: 취소된(죽은) 주문에 대한 추가 KIS 폴링이 발생하지 않는다. +- S-3: 부분 체결 후 취소 시 체결분이 원장에 1회 기록되고 잔여 포지션이 실제 잔고와 일치한다(과매도/이중기록 0건). +- S-4: pending 주문이 임계 시간을 초과하면 워치독이 WARN/메트릭으로 가시화한다(미지 원인 무한 갇힘이 silent 로 남지 않음). 슬롯/주문 상태는 변경하지 않는다. + +## 측정 방식 및 데이터 출처 +각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. + +- S-1 측정 방식: KIS 어댑터 단위 테스트(취소 응답 fixture → `cancelled`) + 집행 통합 테스트(롤백→HOLDING) 통과 / 데이터 출처: `poll_fill` 테스트, 슬롯 상태 스냅샷, `inquire-daily-ccld` 응답 fixture +- S-2 측정 방식: 취소 감지 후 해당 slot 의 poll 호출 중단 확인 / 데이터 출처: 폴링 호출 로그/카운터 +- S-3 측정 방식: 부분 체결+취소 시나리오 테스트에서 원장 1건·잔여 수량 정합 확인 / 데이터 출처: ledger_outbox/포지션 정합(REQ-031) 검증, 슬롯 qty +- S-4 측정 방식: 임계 초과 pending 주입 테스트에서 WARN/메트릭 발생 및 상태 미변경 확인 / 데이터 출처: 워치독 테스트, 데몬 로그/메트릭, 슬롯 상태 스냅샷 + +# 8. REQ로 넘기기 전 확인 체크 +REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. + +- [x] 범위(In Scope/Out of Scope) 확정 + - 기준: 부분취소 정책(OQ-1 A안)·워치독 포함 여부(OQ-2 Alert-only)·거버넌스(OQ-3) 모두 확정됨 +- [x] 성공 지표의 측정 방식/데이터 출처 확정 + - 기준: S-1~S-4 의 테스트/검증 출처가 실제 실행 가능한 형태로 지정됨 +- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 + - 기준: R-1(취소 필드 규약 확인)의 change-req 대상(REQ-012/013/018/031/014/015)이 OQ-3 으로 확정됨. 해소 실무 책임자 배정·DATA(취소 필드 확인)는 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-013_20260608_kis-external-cancel-pending-sell-stuck.md +- 참조 문서: + - GitHub issue #9 (fourblocks2020/trex-core) — KIS 외부 취소 시 pending_sell 갇힘 + - docs/discovery/dcy-003_20260604_broker-execution-and-outbox.md — 집행 폴/롤백 경로 도입 근거 + - docs/discovery/dcy-012_20260608_sell-order-pricing-entry-price-bug.md — 매도 가격 버그(같은 pending_sell 갇힘 증상의 다른 원인, A-1) + - docs/discovery/dcy-010_20260607_safety-sell-persistent-rejection-backoff.md — per-slot 재시도 쿨다운(재매도 루프 완화, R-2) + - trex_core/adapters/broker/kis.py:163-208 — `poll_fill` 취소 미파싱 갭 + +# 10. 사용자 결정 필요 항목 요약 +사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. + +## DECIDE +- (없음 — OQ-1/OQ-2 확정으로 해소) + +> 결정 완료(2026-06-08): OQ-1 부분 체결 후 취소 = A안(체결분 원장 기록 + 슬롯 qty 차감 + 잔여 HOLDING 복귀, 정확 회계) / OQ-2 워치독 = 포함(Alert-only: WARN/메트릭 가시화, auto-remediation 후속). 결정 항목에서 제외됨. + +## CONFIRM +- 없음 — 거버넌스 경로는 OQ-3 으로 확정됨(REQ-012/013/018/031/014/015 change-req, 신규 REQ 원칙적 없음). + +## DATA +- (R-1 연계, REQ/delivery 단계 확인) KIS `inquire-daily-ccld` 응답의 실제 취소 표현(필드명 `cncl_yn` 등·값 규약). 구현/검증 단계에서 실 응답·KIS 명세로 확정할 데이터이며, 미지 응답은 `unknown` 보수 처리 + 워치독 가시화로 안전망이 있어 Discovery confirm 을 차단하지 않는다. + +# 11. Discovery Freeze +REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. + +## 섹션 참조 +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 +- Handoff Decision: 승인 +- Handoff Rationale: confirm-discovery 완료. 오픈 질문 OQ-1~OQ-3 모두 Answered (부분 체결 후 취소=A안 정확 회계 / 워치독=포함 Alert-only / 거버넌스=REQ-012/013/018/031/014/015 change-req, 신규 REQ 원칙적 없음). C 유형 플레이스홀더 0개, 저장소 사실관계 일치. DATA(KIS 취소 필드)는 REQ/delivery 단계 확인 비차단. REQ 초안 작성 인계 가능. +- Ready for REQ Drafting: true +- Confirmed By: Justin Ko +- Confirmed At (KST): 2026-06-08 14:10 KST diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 37c922a..09bae38 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -17,4 +17,5 @@ | 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 | \ No newline at end of file +| 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 | +| dcy-013_20260608_kis-external-cancel-pending-sell-stuck | 20260608 | KIS 외부 주문 취소 시 슬롯이 pending_sell 에 영구히 갇힘 — cncl_yn 미파싱 (issue #9) | [Discovery](./dcy-013_20260608_kis-external-cancel-pending-sell-stuck.md) | confirmed | \ No newline at end of file diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 4a8e4a3..bde1f98 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-016) +- Last Updated (KST): 2026-06-08 (BAT-017) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -401,6 +401,13 @@ resume_kill_switch 요청·응답 (REQ-046): - `execution_id :: str?` :: 결정적 합성(브로커별 규칙). filled 상태에서 필수. - `fee :: float` :: BrokerPort `calculate_fee` 결과(기본 0). - `tax :: float` :: 기본 0(정밀 모델 후속). + - `cancelled :: bool` :: 외부 취소 감지(dcy-013/REQ-012, issue #9). 기본 False. +- `state` 값과 집행 처리(dcy-013): + - `accepted` / `partially_filled`(취소 아님) :: 다음 tick 대기. (FR-3) `TREX_PENDING_STALE_THRESHOLD`(초, 0=비활성) 초과 시 Alert-only WARN+카운터(상태 미변경). + - `filled` :: 전량 체결 → `pending_buy→holding` / `pending_sell→closed` + 원장 1건. + - `cancelled`(체결 0 외부 취소) :: `is_terminal_reject` → 롤백(`pending_sell→holding` / `pending_buy→idle`), `clear_dispatch` → 자동 재매도 가능. + - `partially_filled` + `cancelled=True`(부분 체결 후 외부 취소) :: `is_partial_cancel` → 체결분 원장 1회 기록 + 슬롯 qty 차감(매도: 잔여 held / 매수: 체결분만) + `holding` 복귀. 매도는 cost basis(`avg_price`) 유지. +- KIS 매핑: `inquire-daily-ccld` 응답 row 의 `cncl_yn="Y"` 를 취소로 해석한다(미지/부재 값은 보수적으로 비취소 처리, stale 워치독이 backstop). ## Interface: `MessagingTransport` (Protocol, BAT-007) diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index ca52d14..197d042 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-016) +- Last Updated (KST): 2026-06-08 (BAT-017) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -189,10 +189,12 @@ 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`. + - 외부 취소 감지(dcy-013/REQ-012/013, issue #9): 계좌에서 직접 취소된 주문을 KIS `poll_fill` 이 `cncl_yn="Y"` 로 파싱한다. **체결 0 취소** → `FillStatus(state="cancelled")` → 기존 롤백 경로(`pending_sell→holding`, `clear_dispatch`)로 자동 복구(영구 갇힘 제거). **부분 체결 후 취소**(`is_partial_cancel`) → 체결분 원장 1회 기록 + 슬롯 qty 차감(매도: 잔여 held, cost basis 유지 / 매수: 체결분만) + `holding` 복귀(OQ-1 A안, 과매도·이중기록 방지). + - pending stale 워치독(dcy-013/REQ-014 FR-3, Alert-only): `accepted`/`partially_filled`(취소 아님)로 다음 tick 대기 중인 슬롯이 `TREX_PENDING_STALE_THRESHOLD`(초, 0=비활성)를 초과해 머물면 WARN 로그 + `ExecutionResult.stale_pending` 카운터로 가시화한다. **상태는 변경하지 않는다**(auto-remediation 후속) — 취소 감지 누락(R-1) 등 미지 원인의 무한 갇힘이 silent 로 남지 않게 하는 안전망. 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. +- 슬롯 `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. 외부 취소 후 holding 복귀에서도 clear 되어 재매도는 시장가. - 어댑터: paper(결정적 async 더블) / KIS(모의투자 검증, 실계좌 게이팅). ## Flow: `체결 → ledger outbox` (BAT-006) 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 fab7208..41664b4 100644 --- a/docs/srs/Documentation/req-015_broker-execution-baseline-alignment.md +++ b/docs/srs/Documentation/req-015_broker-execution-baseline-alignment.md @@ -185,3 +185,41 @@ #### Validation Plan - BAT-016: 코드-문서 diff 대조(S-4). + +### CHG-20260608-02 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-013) baseline 문서를 외부 취소 감지·부분 체결 회계·pending stale 워치독에 맞춰 정합: `docs/interface-contract.md`(FillStatus.cancelled/is_partial_cancel + state별 집행 처리 + KIS cncl_yn 매핑), `docs/runtime-flows.md`(poll 단계 외부 취소 → 롤백/부분 회계 + Alert-only stale 워치독). BAT-017 정합 완료 → Implemented 유지. + +#### Intent + +- 코드(kis/broker_port/execution/config) 변경과 baseline 문서를 일치(S 기준)시켜 운영자 참조 정합 유지. + +#### Acceptance Criteria Delta + +- Updated: + - interface-contract/runtime-flows 의 취소 감지·부분 회계·stale 워치독 기술 정합. + +#### Impacted Area Delta + +- Docs: docs/interface-contract.md, docs/runtime-flows.md + +#### Delivery Trace Delta + +- Batches: BAT-017 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no (문서 정합) +- Existing Implementation Invalidated: no +- Status Recommendation: Implemented (change-req → BAT-017 정합) +- Follow-up Action: confirm-req-implemented(BAT-017). + +#### Validation Plan + +- BAT-017: 코드-문서 diff 대조. diff --git a/docs/srs/Exception/req-014_order-handoff-durability-reconciliation.md b/docs/srs/Exception/req-014_order-handoff-durability-reconciliation.md index 205bd0d..eead15d 100644 --- a/docs/srs/Exception/req-014_order-handoff-durability-reconciliation.md +++ b/docs/srs/Exception/req-014_order-handoff-durability-reconciliation.md @@ -148,3 +148,42 @@ #### Validation Plan - BAT-004 verification evidence 재참조로 충분. + +### CHG-20260608-01 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-013/issue #9, OQ-2 Alert-only) pending 주문 stale 워치독 추가: dispatched-pending 슬롯이 `TREX_PENDING_STALE_THRESHOLD`(초, 0=비활성) 초과로 머물면 WARN 로그 + `ExecutionResult.stale_pending` 카운터로 가시화한다. **슬롯/주문 상태는 변경하지 않는다**(auto-remediation 후속). 취소 감지 누락(R-1) 등 미지 원인 무한 갇힘이 silent 로 남지 않게 하는 reconciliation 안전망. BAT-017 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- 자동 취소 감지(REQ-012)가 놓치는 케이스까지 포함해, 비정상적으로 오래 pending 인 주문을 운영자에게 노출하는 durability 안전망 제공. + +#### Acceptance Criteria Delta + +- Added: + - pending stale 임계 초과 시 WARN+카운터 가시화(상태 미변경, Alert-only). 임계 env `TREX_PENDING_STALE_THRESHOLD`(0=비활성). + +#### Impacted Area Delta + +- Modules: `trex_core/application/execution.py`(`_check_stale_pending`, ExecutionService threshold), `trex_core/runtime/config.py`(`pending_stale_threshold_sec`/env), `trex_core/runtime/daemon.py`(배선) +- Tests: tests/application/test_execution_cancel.py(stale WARN + 비활성) + +#### Delivery Trace Delta + +- Batches: BAT-017 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-017 에서 완료) +- Existing Implementation Invalidated: no (관측만 추가, 기존 reconcile/poll 동작 불변) +- Status Recommendation: Implemented (change-req → BAT-017 재검증) +- Follow-up Action: confirm-req-implemented(BAT-017). + +#### Validation Plan + +- BAT-017: 임계 초과 pending 주입 → WARN+카운터, 상태 미변경 / 비활성(default) → 무경고 단위. diff --git a/docs/srs/Exception/req-031_position-reconciliation-and-warning.md b/docs/srs/Exception/req-031_position-reconciliation-and-warning.md index 489f2ff..68f722c 100644 --- a/docs/srs/Exception/req-031_position-reconciliation-and-warning.md +++ b/docs/srs/Exception/req-031_position-reconciliation-and-warning.md @@ -133,3 +133,41 @@ - AC-1: tests/application/test_reconciliation.py::test_mismatch_raises_warning_and_masks_asset. - AC-2: ::test_subsequent_match_auto_clears_warning. - AC-3: ::test_paper_broker_returns_deterministic_balance. + +### CHG-20260608-01 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-013/issue #9) 부분 체결 후 외부 취소 처리(REQ-013)가 슬롯 qty 를 체결분만큼 차감하고 잔여를 holding 으로 복귀시켜, 슬롯 잔여 수량이 실 브로커 잔고와 정합하도록 보장한다(과매도 방지). 포지션 정합 검증 로직 자체(`reconcile_positions`)는 코드 변경 없음 — 본 change-req 는 정합 불변식이 유지됨을 명시하는 연계 기록. BAT-017 검증 완료 → Implemented 유지. + +#### Intent + +- 부분 체결+취소에서 slot.qty ↔ 실제 held 수량 정합을 보장해 REQ-031 의 포지션 정합 불변식이 깨지지 않게 한다. + +#### Acceptance Criteria Delta + +- (변경 없음 — reconcile 로직 불변. 부분 취소 회계가 정합 입력을 정확히 유지) + +#### Impacted Area Delta + +- Modules: (없음 — REQ-013 `_on_partial_cancel` 이 잔여 qty 를 정확히 갱신) +- Tests: tests/application/test_execution_cancel.py(잔여 qty=6 정합) + +#### Delivery Trace Delta + +- Batches: BAT-017 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no (reconcile 코드 불변, 회계 정확성은 REQ-013 테스트로 커버) +- Existing Implementation Invalidated: no +- Status Recommendation: Implemented (연계 change-req) +- Follow-up Action: confirm-req-implemented(BAT-017). + +#### Validation Plan + +- BAT-017: 부분 취소 후 slot.qty 가 잔여 held 와 일치(과매도 0). diff --git a/docs/srs/Integration/req-012_kis-broker-adapter.md b/docs/srs/Integration/req-012_kis-broker-adapter.md index 7dbfd8d..38baa4a 100644 --- a/docs/srs/Integration/req-012_kis-broker-adapter.md +++ b/docs/srs/Integration/req-012_kis-broker-adapter.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-013/issue #9) KIS `poll_fill` 이 `inquire-daily-ccld` 응답의 `cncl_yn="Y"` 를 파싱해 외부 취소를 감지한다. 체결 0 취소 → `FillStatus(state="cancelled")`, 부분 체결 후 취소 → `state="partially_filled" + cancelled=True`(execution_id/fee 운반). 미지/부재 값은 보수적 비취소. BAT-017 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- 계좌 직접 취소가 `accepted` 로 오판되어 슬롯이 pending_sell 에 영구 갇히던 갭(issue #9) 제거 — KIS 어댑터가 취소를 종료 상태로 정확히 매핑. + +#### Acceptance Criteria Delta + +- Added: + - poll_fill 이 cncl_yn 을 파싱: 체결 0 취소 → cancelled, 부분 체결 후 취소 → partially_filled+cancelled. + +#### Impacted Area Delta + +- Modules: `trex_core/adapters/broker/kis.py`(poll_fill), `trex_core/application/broker_port.py`(FillStatus.cancelled/is_partial_cancel) +- Tests: tests/adapters/broker/test_kis_cancel.py + +#### Delivery Trace Delta + +- Batches: BAT-017 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-017 에서 완료) +- Existing Implementation Invalidated: yes (취소를 accepted 로 오판하던 poll_fill) +- Status Recommendation: Implemented (change-req → BAT-017 재검증) +- Follow-up Action: confirm-req-implemented(BAT-017). + +#### Validation Plan + +- BAT-017: 취소 fixture(full/partial/non-cancel/full-fill) → FillStatus 매핑 단위. diff --git a/docs/srs/Integration/req-013_order-execution-loop.md b/docs/srs/Integration/req-013_order-execution-loop.md index 8a3cf53..445ea2a 100644 --- a/docs/srs/Integration/req-013_order-execution-loop.md +++ b/docs/srs/Integration/req-013_order-execution-loop.md @@ -189,3 +189,42 @@ #### Validation Plan - BAT-016: 지정가 매수 슬롯 + 가격 미전달 매도 → 시장가(entry_price 미사용 증명), + 가격 전달 매도 → 그 지정가, 시장가 매수 슬롯 회귀. + +### CHG-20260608-02 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-013/issue #9) 집행 루프에 외부 취소 처리 추가: 전량 취소(`is_terminal_reject`)는 기존 롤백 경로 재사용, 부분 체결 후 취소(`is_partial_cancel`)는 신규 `_on_partial_cancel` — 체결분 원장 1회 기록 + 슬롯 qty 차감(매도 잔여 held / 매수 체결분) + holding 복귀(OQ-1 A안). BAT-017 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- 부분 체결 후 취소에서 과매도·이중기록 없이 잔여 포지션만 재매도 가능하게(NFR-3). 전량 취소는 자동 holding 복귀. + +#### Acceptance Criteria Delta + +- Added: + - `_poll` 이 is_partial_cancel 분기로 부분 체결 회계 수행(원장 1회 + qty 차감 + holding 복귀). 전량 취소는 롤백. + +#### Impacted Area Delta + +- Modules: `trex_core/application/execution.py`(`_poll`, `_on_partial_cancel`, ExecutionResult.stale_pending) +- Tests: tests/application/test_execution_cancel.py + +#### Delivery Trace Delta + +- Batches: BAT-017 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-017 에서 완료) +- Existing Implementation Invalidated: no (신규 분기 추가, 기존 filled/reject 경로 불변) +- Status Recommendation: Implemented (change-req → BAT-017 재검증) +- Follow-up Action: confirm-req-implemented(BAT-017). + +#### Validation Plan + +- BAT-017: 전량 취소 → holding 복귀, 부분 취소 → 원장 1건 + qty 차감 + cost basis 유지 단위. diff --git a/docs/srs/Integration/req-018_fill-to-ledger-recording.md b/docs/srs/Integration/req-018_fill-to-ledger-recording.md index 89c09d0..b00de48 100644 --- a/docs/srs/Integration/req-018_fill-to-ledger-recording.md +++ b/docs/srs/Integration/req-018_fill-to-ledger-recording.md @@ -189,3 +189,42 @@ #### Validation Plan - BAT-015: ledger skip WARN 로그 단위(caplog) + 회귀. + +### CHG-20260608-02 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-013/issue #9) 부분 체결 후 외부 취소 시 체결분(`ccld_qty`)을 원장에 **1회** 기록한다. `_on_partial_cancel` 이 `_record_ledger`(기존)를 재사용해 partial fill 의 execution_id/fee/qty 를 정확히 운반. BAT-017 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- 전량 체결만 다루던 원장 기록을 부분 체결+취소에도 확장해 체결분 누락/이중기록을 방지(정확 회계, OQ-1 A안). + +#### Acceptance Criteria Delta + +- Updated: + - AC-1/2 의 fill→원장 기록을 부분 체결 후 취소(체결분 1회)에도 적용. + +#### Impacted Area Delta + +- Modules: `trex_core/application/execution.py`(`_on_partial_cancel` → `_record_ledger` 재사용) +- Tests: tests/application/test_execution_cancel.py::test_partial_fill_then_cancel_records_and_keeps_remainder + +#### Delivery Trace Delta + +- Batches: BAT-017 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-017 에서 완료) +- Existing Implementation Invalidated: no (기록 경로 재사용, 호출 지점만 확장) +- Status Recommendation: Implemented (change-req → BAT-017 재검증) +- Follow-up Action: confirm-req-implemented(BAT-017). + +#### Validation Plan + +- BAT-017: 부분 취소 시 sell 원장 1건(qty=체결분, price=체결가) 단위. diff --git a/docs/srs/index.md b/docs/srs/index.md index 145ff2f..6bc813a 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -139,8 +139,8 @@ ## Recent Change Log Summary +- 2026-06-08 :: REQ-012/013/018/031/014/015 change-req (dcy-013, BAT-017): KIS 외부 주문 취소 시 슬롯이 pending_sell 에 영구 갇히던 버그(issue #9) 정정. KIS `poll_fill` 이 `cncl_yn` 파싱해 외부 취소 감지 — 체결 0 취소→`cancelled`(롤백 자동 복구), 부분 체결 후 취소→`is_partial_cancel`(체결분 원장 1회+qty 차감+holding 복귀, OQ-1 A안)(REQ-012/013/018), 잔여 포지션 정합 연계(REQ-031), pending stale 워치독 Alert-only(`TREX_PENDING_STALE_THRESHOLD`, WARN+카운터, 상태 미변경)(REQ-014), baseline 2문서 정합(REQ-015). 전부 change-req(신규 REQ 없음, OQ-3). 단위 294 passed, 인프라 게이트 326 passed, ruff clean. (change-req/confirm-req-implemented) - 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) diff --git a/tests/adapters/broker/test_kis_cancel.py b/tests/adapters/broker/test_kis_cancel.py new file mode 100644 index 0000000..28e0948 --- /dev/null +++ b/tests/adapters/broker/test_kis_cancel.py @@ -0,0 +1,79 @@ +"""KIS poll_fill external-cancel detection (dcy-013 / REQ-012, issue #9). + +inquire-daily-ccld marks a cancelled order with cncl_yn="Y". These tests stub +the HTTP layer (token pre-seeded, httpx.get faked) and assert the FillStatus +mapping for full cancel / partial-fill-then-cancel / not-cancelled. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from trex_core.adapters.broker import kis as kis_mod +from trex_core.adapters.broker.kis import KISBroker + +ODNO = "0000123456" + + +def _broker(): + b = KISBroker( + "12345678-01", + app_key="k", app_secret="s", + base_url="https://openapivts.koreainvestment.com:29443", # mock endpoint + ) + b._token = "tok" + b._token_exp = datetime.now(tz=timezone.utc) + timedelta(hours=1) + return b + + +class _Resp: + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + +def _fake_get(payload): + def _get(*a, **k): + return _Resp(payload) + return _get + + +def _row(**kw): + base = {"odno": ODNO, "ord_qty": "10", "tot_ccld_qty": "0", + "avg_prvs": "0", "cncl_yn": "N", "ord_dt": "20260608", "ord_gno_brno": "06010"} + base.update(kw) + return {"output1": [base]} + + +def test_full_external_cancel_maps_to_cancelled(monkeypatch): + monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(_row(tot_ccld_qty="0", cncl_yn="Y"))) + fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930") + assert fs.state == "cancelled" and fs.is_terminal_reject and not fs.is_partial_cancel + + +def test_zero_fill_not_cancelled_stays_accepted(monkeypatch): + monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(_row(tot_ccld_qty="0", cncl_yn="N"))) + fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930") + assert fs.state == "accepted" and not fs.is_terminal_reject + + +def test_partial_fill_then_cancel_flags_partial_cancel(monkeypatch): + monkeypatch.setattr( + kis_mod.httpx, "get", + _fake_get(_row(ord_qty="10", tot_ccld_qty="4", avg_prvs="70000", cncl_yn="Y")), + ) + fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930") + assert fs.state == "partially_filled" + assert fs.cancelled and fs.is_partial_cancel + assert fs.filled_qty == 4 and fs.avg_price == 70000.0 + assert fs.execution_id == "kis-daily-ccld:20260608:06010:0000123456:4" + + +def test_full_fill_unaffected_by_cancel_flag(monkeypatch): + monkeypatch.setattr( + kis_mod.httpx, "get", + _fake_get(_row(ord_qty="10", tot_ccld_qty="10", avg_prvs="70000", cncl_yn="N")), + ) + fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930") + assert fs.is_filled and fs.filled_qty == 10 and not fs.is_partial_cancel diff --git a/tests/application/test_execution_cancel.py b/tests/application/test_execution_cancel.py new file mode 100644 index 0000000..a9eb55b --- /dev/null +++ b/tests/application/test_execution_cancel.py @@ -0,0 +1,152 @@ +"""External-cancel + stale-watchdog execution tests (dcy-013 / issue #9). + +A programmable broker lets each poll return a chosen FillStatus so we can drive +full external cancel (-> rollback to holding) and partial-fill-then-cancel +(-> ledger record + qty decrement + holding remainder), plus the Alert-only +pending stale watchdog. +""" +from __future__ import annotations + +import logging +from dataclasses import replace +from datetime import date, datetime, timedelta, timezone + +from trex_core.adapters.ledger.in_memory import InMemoryLedger +from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore +from trex_core.application.broker_port import FillStatus, OrderAck +from trex_core.application.execution import ExecutionService +from trex_core.application.intents import BuyIntent, IntentService, SellIntent +from trex_core.application.ledger_outbox import InMemoryLedgerOutbox, LedgerWriter +from trex_core.domain.slot import SlotService, SlotState +from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository + +DAY = date(2026, 6, 4) + + +class ProgrammableBroker: + """Broker double whose poll_fill response is chosen per call by `responder`.""" + + def __init__(self): + self._seq = 0 + # default: full fill (used to drive a buy to holding) + self.responder = lambda side: FillStatus( + state="filled", filled_qty=10, avg_price=1000.0, + execution_id="exec-full", fee=1.0, tax=0.0, + ) + self.polls = 0 + + def name(self): + return "stub" + + def submit_order(self, req): + self._seq += 1 + return OrderAck(accepted=True, broker_order_id=f"o{self._seq}") + + def poll_fill(self, broker_order_id, *, side, symbol): + self.polls += 1 + return self.responder(side) + + def cancel_order(self, broker_order_id, *, side, symbol, qty): + return True + + def get_balance(self): + return [] + + def get_current_price(self, symbol): + return None + + +def _wire(broker, *, stale_sec=None): + repo = InMemorySlotRepository() + svc = SlotService(repo) + intents = IntentService(svc, repo, InMemoryIntentStore()) + outbox = InMemoryLedgerOutbox() + ledger = InMemoryLedger() + execution = ExecutionService( + svc, repo, broker, ledger_outbox=outbox, + stale_pending_threshold_sec=stale_sec, + ) + writer = LedgerWriter(outbox, ledger) + svc.preallocate_daily_slots(DAY, 5) + return repo, svc, intents, execution, ledger, writer + + +def _buy_to_holding(intents, execution): + sid = intents.submit_buy(BuyIntent( + intent_id="b1", trading_date=DAY, symbol="005930", qty=10, + entry_price=1000, portfolio_id="PF-1", + )).slot_id + execution.run_once() # buy dispatched + full fill -> holding + return sid + + +def test_full_external_cancel_returns_slot_to_holding(): + broker = ProgrammableBroker() + repo, svc, intents, execution, ledger, writer = _wire(broker) + sid = _buy_to_holding(intents, execution) + assert repo.fetch_slot(sid).status is SlotState.HOLDING + + intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid)) + broker.responder = lambda side: FillStatus(state="cancelled") # external cancel + r = execution.run_once() # dispatch sell + poll(cancelled) -> rollback + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.HOLDING # recovered, not stuck pending_sell + assert slot.broker_order_id is None # dispatch cleared -> re-sellable + assert r.rolled_back == 1 + + +def test_partial_fill_then_cancel_records_and_keeps_remainder(): + broker = ProgrammableBroker() + repo, svc, intents, execution, ledger, writer = _wire(broker) + sid = _buy_to_holding(intents, execution) + writer.run_once() # drain the buy ledger item + + intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid)) + # 4 of 10 sold, then externally cancelled + broker.responder = lambda side: FillStatus( + state="partially_filled", filled_qty=4, avg_price=1100.0, + execution_id="exec-partial", fee=0.5, tax=0.0, cancelled=True, + ) + r = execution.run_once() + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.HOLDING # remainder stays held + assert slot.qty == 6 # 10 - 4 sold + assert slot.avg_price == 1000.0 # cost basis preserved (not sell price) + assert slot.broker_order_id is None + assert r.filled == 1 and r.ledger_enqueued == 1 + + writer.run_once() + sells = [t for t in ledger.trades() if t.side == "sell"] + assert len(sells) == 1 and sells[0].qty == 4 and sells[0].price == 1100.0 + + +def test_stale_pending_watchdog_warns_without_state_change(caplog): + broker = ProgrammableBroker() + repo, svc, intents, execution, ledger, writer = _wire(broker, stale_sec=60) + sid = _buy_to_holding(intents, execution) + intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid)) + # order sits accepted (never fills) + broker.responder = lambda side: FillStatus(state="accepted") + execution.run_once() # dispatch sell, poll -> accepted (fresh, not stale yet) + # backdate the dispatched slot's updated_at beyond the threshold + s = repo.fetch_slot(sid) + repo._slots[sid] = replace(s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=120)) + + with caplog.at_level(logging.WARNING): + r = execution.run_once() + assert r.stale_pending == 1 + assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL # state unchanged + assert any("pending order stale" in rec.message for rec in caplog.records) + + +def test_stale_watchdog_disabled_by_default(): + broker = ProgrammableBroker() + repo, svc, intents, execution, ledger, writer = _wire(broker) # no threshold + sid = _buy_to_holding(intents, execution) + intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid)) + broker.responder = lambda side: FillStatus(state="accepted") + execution.run_once() + s = repo.fetch_slot(sid) + repo._slots[sid] = replace(s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=9999)) + r = execution.run_once() + assert r.stale_pending == 0 # disabled => never warns diff --git a/trex_core/adapters/broker/kis.py b/trex_core/adapters/broker/kis.py index 0831494..c56f197 100644 --- a/trex_core/adapters/broker/kis.py +++ b/trex_core/adapters/broker/kis.py @@ -188,7 +188,15 @@ class KISBroker: ord_qty = _safe_int(row.get("ord_qty")) or 0 ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0 avg = _safe_int(row.get("avg_prvs")) or _safe_int(row.get("ccld_prvs")) + # dcy-013 / REQ-012 (issue #9): external cancel detection. KIS + # inquire-daily-ccld marks a cancelled order with cncl_yn="Y". Without + # this, a zero-fill cancel looked like "accepted" and the slot was stuck + # in pending_sell forever. Unknown/absent values are treated as not + # cancelled (conservative — the stale watchdog is the backstop, R-1). + cancelled = str(row.get("cncl_yn") or "").strip().upper() == "Y" if ccld_qty <= 0: + if cancelled: + return FillStatus(state="cancelled", raw=row) # full external cancel return FillStatus(state="accepted", raw=row) if ccld_qty >= ord_qty and ord_qty > 0: # Deterministic execution_id (dcy-004 OQ-3, orbit kis.py rule): @@ -204,6 +212,24 @@ class KISBroker: avg_price=fill_price or None, execution_id=execution_id, fee=fee, tax=0.0, raw=row, ) + # 0 < ccld_qty < ord_qty + if cancelled: + # Partial fill then external cancel (dcy-013 FR-2): carry a + # deterministic execution_id + fee so the filled portion is recorded + # once, and flag cancelled so execution returns the remainder to + # holding instead of waiting for a fill that will never come. + ord_dt = str(row.get("ord_dt") or today) + ord_gno_brno = str(row.get("ord_gno_brno") or "") + row_odno = str(row.get("odno") or odno) + execution_id = f"kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{row_odno}:{ccld_qty}" + fill_price = float(avg) if avg else 0.0 + fee = calculate_fee(side, ccld_qty * fill_price) + return FillStatus( + state="partially_filled", filled_qty=ccld_qty, + avg_price=fill_price or None, + execution_id=execution_id, fee=fee, tax=0.0, + cancelled=True, raw=row, + ) return FillStatus(state="partially_filled", filled_qty=ccld_qty, avg_price=float(avg) if avg else None, raw=row) diff --git a/trex_core/application/broker_port.py b/trex_core/application/broker_port.py index b4f9d4e..6a32aed 100644 --- a/trex_core/application/broker_port.py +++ b/trex_core/application/broker_port.py @@ -53,6 +53,12 @@ class FillStatus: execution_id: Optional[str] = None fee: float = 0.0 tax: float = 0.0 + # dcy-013 / REQ-012 (issue #9): an external cancel was detected. When the + # order had a partial fill before the cancel, state stays "partially_filled" + # and this flag is set so execution records the filled portion and returns + # the remainder to holding (is_partial_cancel). A zero-fill external cancel + # is mapped to state="cancelled" (is_terminal_reject) instead. + cancelled: bool = False raw: dict[str, Any] = field(default_factory=dict) @property @@ -63,6 +69,11 @@ class FillStatus: def is_terminal_reject(self) -> bool: return self.state in {"rejected", "cancelled"} + @property + def is_partial_cancel(self) -> bool: + """A partial fill that was then externally cancelled (dcy-013 FR-2).""" + return self.cancelled and self.filled_qty > 0 + class BrokerPort(Protocol): """Single broker session boundary (invariant B).""" diff --git a/trex_core/application/execution.py b/trex_core/application/execution.py index 11e9512..5568823 100644 --- a/trex_core/application/execution.py +++ b/trex_core/application/execution.py @@ -46,6 +46,7 @@ class ExecutionResult: rolled_back: int = 0 ledger_enqueued: int = 0 # REQ-019: ledger work items appended this tick ledger_skipped: int = 0 # REQ-018 AC-4: fills without portfolio_id + stale_pending: int = 0 # dcy-013 / REQ-014 FR-3: pending orders over threshold class ExecutionService: @@ -56,12 +57,22 @@ class ExecutionService: broker: BrokerPort, ledger_outbox: Optional[LedgerOutbox] = None, event_emitter: Optional[SlotEventEmitter] = None, + stale_pending_threshold_sec: Optional[int] = None, ) -> None: self._svc = slot_service self._repo = repo self._broker = broker self._ledger_outbox = ledger_outbox self._event_emitter = event_emitter + # dcy-013 / REQ-014 (issue #9): Alert-only pending-order stale watchdog. + # <=0 or None disables it. When set, a dispatched-pending slot whose + # last update is older than the threshold is surfaced (WARN + counter) + # without changing its state — the backstop for a cancel-detection miss. + self._stale_threshold_sec = ( + stale_pending_threshold_sec + if stale_pending_threshold_sec and stale_pending_threshold_sec > 0 + else None + ) # ------------------------------------------------------------ def run_once(self) -> ExecutionResult: @@ -111,16 +122,25 @@ class ExecutionService: r.rolled_back += 1 def _poll(self, r: ExecutionResult) -> None: + now = datetime.now(tz=timezone.utc) for slot in self._repo.list_dispatched_pending(): side = "buy" if slot.status is SlotState.PENDING_BUY else "sell" fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "") if fs.is_filled: self._on_filled(slot, fs, side, r) r.filled += 1 + elif fs.is_partial_cancel: + # dcy-013 / REQ-013 FR-2: partial fill then external cancel. + self._on_partial_cancel(slot, fs, side, r) + r.filled += 1 elif fs.is_terminal_reject: + # rejected or full external cancel (dcy-013 FR-1) -> rollback. self._rollback(slot, reason=f"order_{fs.state}") r.rolled_back += 1 - # accepted / partially_filled -> leave for next tick + else: + # accepted / partially_filled (not cancelled) -> leave for next + # tick, but surface if it has been stuck too long (FR-3). + self._check_stale_pending(slot, now, r) # ------------------------------------------------------------ def _on_filled(self, slot, fs, side: str, r: ExecutionResult) -> None: @@ -149,6 +169,58 @@ class ExecutionService: self._emit_event(event_type, updated) self._repo.clear_dispatch(slot.slot_id) # done with this order + def _on_partial_cancel(self, slot, fs, side: str, r: ExecutionResult) -> None: + """Partial fill then external cancel (dcy-013 / REQ-013 FR-2, OQ-1 A). + + Record the filled portion to the ledger ONCE, decrement the held qty, + and return the slot to holding with the remainder — so the remaining + position can be re-sold and no over-sell / double-record occurs (NFR-3). + avg_price (cost basis) is preserved on a sell; a buy keeps only the + portion that actually filled. + """ + broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch + filled = float(fs.filled_qty) + try: + if slot.status is SlotState.PENDING_BUY: + # Only the filled portion is held; avg_price = fill price. + updated = self._svc.transit_slot( + slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, + reason="buy_partial_cancel", actor="execution_loop", + avg_price=fs.avg_price, qty=filled, + ) + event_type = "slot_filled_buy" + else: # PENDING_SELL: remainder stays held, cost basis unchanged. + remaining = max(0.0, float(slot.qty or 0) - filled) + updated = self._svc.transit_slot( + slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING, + reason="sell_partial_cancel", actor="execution_loop", + qty=remaining, + ) + event_type = "slot_filled_sell" + except SlotStateMismatchError: + return # slot moved concurrently; next tick reconciles + self._record_ledger(slot, fs, side, broker_order_id, r) + self._emit_event(event_type, updated) + self._repo.clear_dispatch(slot.slot_id) + + def _check_stale_pending(self, slot, now: datetime, r: ExecutionResult) -> None: + """Alert-only: surface a dispatched-pending slot stuck past the threshold. + + dcy-013 / REQ-014 FR-3 — state is NOT changed (auto-remediation is a + follow-up). This is the backstop for a cancel-detection miss (R-1). + """ + if self._stale_threshold_sec is None or slot.updated_at is None: + return + age = (now - slot.updated_at).total_seconds() + if age >= self._stale_threshold_sec: + r.stale_pending += 1 + _log.warning( + "pending order stale: slot=%s status=%s broker_order_id=%s " + "age_sec=%.0f threshold_sec=%d (no state change — ops attention)", + slot.slot_id, slot.status.value, slot.broker_order_id, + age, self._stale_threshold_sec, + ) + def _emit_event(self, event_type: str, slot) -> None: if self._event_emitter is None: return diff --git a/trex_core/runtime/config.py b/trex_core/runtime/config.py index 1fbc903..2ed8916 100644 --- a/trex_core/runtime/config.py +++ b/trex_core/runtime/config.py @@ -140,6 +140,10 @@ class DaemonConfig: snapshot_schedule: SnapshotScheduleConfig = SnapshotScheduleConfig() # REQ-041 (BAT-010): safety-sell feature config. safety_sell: SafetySellConfig = SafetySellConfig() + # dcy-013 / REQ-014 (BAT-017): Alert-only pending-order stale watchdog + # threshold in seconds. 0 disables (default). When >0, dispatched-pending + # slots older than this are surfaced (WARN + counter) without state change. + pending_stale_threshold_sec: int = 0 @staticmethod def from_env() -> "DaemonConfig": @@ -219,6 +223,9 @@ class DaemonConfig: rabbitmq=rabbitmq, snapshot_schedule=snapshot_schedule, safety_sell=safety_sell, + pending_stale_threshold_sec=int( + os.environ.get("TREX_PENDING_STALE_THRESHOLD", "0") + ), ) @staticmethod diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index 73e0843..56ea901 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -177,6 +177,7 @@ class Daemon: self.slot_service, self.slot_repo, self.broker, ledger_outbox=self.outbox, event_emitter=self._make_event_emitter(), + stale_pending_threshold_sec=config.pending_stale_threshold_sec, ) # BAT-011: safety-sell trigger service (forced_close / SL-TP / market-close).