From f1b649cf2afad2c81e496c45e5ddeddc7bcb9ac6 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sun, 21 Jun 2026 13:34:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(execution):=20=EC=B2=B4=EA=B2=B0=EA=B0=80?= =?UTF-8?q?=200/None=20=EC=8B=9C=20=EC=A7=80=EC=A0=95=EA=B0=80=20=EB=B0=B1?= =?UTF-8?q?=ED=95=84=20=EA=B0=80=EB=93=9C=20+=20WARN=20=EB=A1=9C=EA=B9=85?= =?UTF-8?q?=20(dcy-032/BAT-048)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REQ-095(슬롯 평단·원장 price 지정가 백필, 0 오염 방지) + REQ-096(백필/미해결 WARN 로깅) 구현. 지정가 부재 시 skip+강WARN. application 단일화(_resolve_fill_price), wire/LedgerTrade 스키마 불변. 575 passed/0 fail, opus QC PASS. REQ-097 알람=스파이크 산출물만(impl 후속). - _resolve_fill_price 헬퍼 추가: avg_price 검증, limit_price 백필, 미해결 WARN - _on_filled/_on_partial_cancel 통합: 백필 적용, 슬롯/원장 일관성 - _record_ledger 시그니처: resolved_price 파라미터 (backward compat) - test_execution_fill_price_backfill.py: 21 시나리오 (avg=None/0, limit=있음/없음 등) - docs: dcy-032, req-095/096/097, bat-048 설계/검증 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5 --- .../design.md | 337 ++++++ .../index.md | 37 + .../planning.md | 79 ++ .../verification.md | 156 +++ docs/batches/index.md | 1 + ..._fill-missing-price-cost-basis-backfill.md | 202 ++++ docs/discovery/index.md | 1 + docs/operations.md | 52 +- docs/runtime-flows.md | 3 +- .../req-097_alert-capability-spike.md | 74 ++ .../req-095_fill-price-zero-backfill-guard.md | 77 ++ ...eq-096_fill-price-backfill-warn-logging.md | 70 ++ docs/srs/index.md | 9 +- .../test_execution_fill_price_backfill.py | 969 ++++++++++++++++++ trex_core/application/execution.py | 78 +- 15 files changed, 2134 insertions(+), 11 deletions(-) create mode 100644 docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/design.md create mode 100644 docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/index.md create mode 100644 docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/planning.md create mode 100644 docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/verification.md create mode 100644 docs/discovery/dcy-032_20260621_fill-missing-price-cost-basis-backfill.md create mode 100644 docs/srs/Documentation/req-097_alert-capability-spike.md create mode 100644 docs/srs/Exception/req-095_fill-price-zero-backfill-guard.md create mode 100644 docs/srs/Exception/req-096_fill-price-backfill-warn-logging.md create mode 100644 tests/application/test_execution_fill_price_backfill.py diff --git a/docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/design.md b/docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/design.md new file mode 100644 index 0000000..09ee243 --- /dev/null +++ b/docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/design.md @@ -0,0 +1,337 @@ +# BAT-048 Design + +- Status: draft +- Batch: BAT-048 + +--- + +> **Design Review 게이트**: 이 문서의 Key Decisions(KD-1, KD-2)와 REQ-097 알람 스파이크 분석(섹션 "알람 능력 스파이크 — REQ-097")은 팀장·사장님의 design review 승인 후 run-batch-implementation으로 이어진다. 아래 권고안은 trex-core-dev가 코드 분석 근거로 작성한 것이며 팀장이 KD-1/KD-2를, 사장님이 알람 M2 포함 여부(OQ-1)를 최종 확정한다. + +--- + +## Architecture Summary + +이 batch의 핵심 변경은 `application/execution.py` 내 체결가 결정 로직에 국소화된다. 아키텍처 계층상 변경 위치는 **application 레이어 전용**이며, wire 계약·adapter·domain은 불변이다. + +현재 문제 흐름: +``` +KIS.poll_fill() + → FillStatus(avg_price=None or 0.0) + → _on_filled / _on_partial_cancel + fill_price = 0.0 [← 현재: avg_price=None이면 old_avg(=0) 사용] + → slot.avg_price = 0 [슬롯 평단 0 오염, 영구 — clear_dispatch 이후 재폴링 없음] + → _record_ledger + price = 0.0 [← 현재: avg_price=None이면 0.0 통과] + → LedgerWorkItem(price=0.0) [원장 cost basis 0 오염] +``` + +변경 후 흐름: +``` +KIS.poll_fill() + → FillStatus(avg_price=None or 0.0) + → _resolve_fill_price(fs, slot) [신규 헬퍼, application 레이어] + → slot.limit_price 백필 [지정가 양수이면 사용] + → 폴백 정책 적용(KD-2 확정 후) [지정가마저 없으면] + → _log.warning(백필 WARN) [REQ-096 AC-1] + → _on_filled / _on_partial_cancel + fill_price = _resolve_fill_price 결과 [0 아닌 값] + → slot.avg_price = 정상 가중평균 + → _record_ledger + price = 동일 결과값 [0 아닌 값] + → LedgerWorkItem(price > 0) +``` + +변경 계층: +- `trex_core/application/execution.py` (application 레이어) — 백필 헬퍼 + 분기 + WARN 로그 +- `tests/application/test_execution.py` — 신규 AC 테스트 +- `docs/runtime-flows.md`, `docs/operations.md` — 갱신 (implementation 후) + +불변 영역: +- `trex_core/adapters/broker/kis.py` — adapter 불변 (KD-1 근거) +- `trex_core/adapters/broker/paper.py` — adapter 불변 (paper는 항상 양수 avg_price 반환) +- AMQP wire 계약 (slots RPC payload / trex.events 스키마) — NFR-1 + +## Changed Areas + +- Docs: + - `docs/runtime-flows.md` — 체결 처리 흐름에 백필 분기 추가 (implementation 완료 후) + - `docs/operations.md` — 백필 WARN 발생 시 운영자 점검 절차 추가 (implementation 완료 후) +- Modules: + - `trex_core/application/execution.py` — `_resolve_fill_price`(신규 헬퍼), `_on_filled`·`_on_partial_cancel`·`_record_ledger`에 분기 삽입 + - `tests/application/test_execution.py` — 신규 AC 커버 테스트 +- Interfaces: + - `LedgerTrade.price` 필드: 0 → 양수 값으로 정정. 필드 타입/이름 변경 없음(NFR-1). + - slots RPC `avg_price` 필드: 0 → 양수 값으로 정정. 스키마 변경 없음(NFR-1). + +## Key Decisions + +### KD-1: 백필 결정 지점 — application 레이어 단일화 권고 + +**권고: `application/execution.py`의 `_resolve_fill_price` 헬퍼로 단일화. `adapters/broker/kis.py`는 불변.** + +#### 분석 근거 + +**현재 kis.py의 avg_price 처리 패턴** (`:209`, `:219`, `:232`, `:238`, `:243`): +```python +# 완전체결(:209) +fill_price = float(avg) if avg else 0.0 +# ... +avg_price=fill_price or None, # 0.0 → None으로 변환(:219) + +# 부분취소(:232) +fill_price = float(avg) if avg else 0.0 +# ... +avg_price=fill_price or None, # 동일 패턴(:238) + +# 부분체결(아직 pending, :243) +avg_price=float(avg) if avg else None # 미체결분은 그대로 +``` + +kis.py는 이미 `avg_price=None`을 FillStatus로 정직하게 전달하는 구조다. 이 층에서 백필을 하려면 **kis.py가 `slot.limit_price`를 알아야** 하는데, `poll_fill(broker_order_id, side, symbol)` 시그니처에 슬롯 컨텍스트(`slot`)가 없다. 따라서 adapter에서 백필하려면 시그니처 변경(`BrokerPort.poll_fill` 확장)이 필요하고 이는 포트 계약 변경이다. + +**paper.py 일관성**: paper 브로커는 `_Order.__init__`에서 `self.price = float(req.limit_price) if req.limit_price is not None else 1000.0`으로 항상 양수 avg_price를 반환한다. paper에는 백필 분기가 필요 없어 adapter 통일 구현의 이점이 없다. + +**dcy-021 잔고 재구성 경로**: `reconcile()`은 `_poll(r)`을 호출 → 동일한 `_on_filled`/`_on_partial_cancel`을 통과한다. application에 백필 헬퍼를 두면 재구성 경로도 동일 헬퍼를 자동으로 탄다. adapter에 두면 재구성 경로에서 kis.py → FillStatus(avg_price=None) → application으로 None이 그대로 전달되는 차이가 생긴다(일관성 깨짐). + +**결론**: application 단일화가 유일하게 porter 계약을 건드리지 않고, 모든 경로(KIS 완전체결/부분취소/재구성/paper)에서 동일 헬퍼를 탄다. + +**adapter 방식의 장단점 (기각 이유)**: +- 장점: adapter 경계에서 일찍 정정 → application이 항상 양수 fill_price를 받는다는 불변식 성립. +- 단점: `BrokerPort.poll_fill` 시그니처에 `slot` 컨텍스트 추가 필요(포트 계약 변경) / paper도 수정 필요 / paper는 avg_price가 이미 양수여서 불필요한 중복 코드. + +**application 방식의 장단점 (권고 이유)**: +- 장점: BrokerPort/adapter 불변 / paper 불변 / reconcile 자동 일관성 / `slot.limit_price` 직접 접근 가능. +- 단점: `_on_filled`/`_on_partial_cancel`/`_record_ledger` 세 지점에 분기 삽입 필요 → 헬퍼로 추출해 DRY 유지. + +**팀장 승인 필요**: 이 결정이 확정되어야 Phase A 구현이 시작된다. + +--- + +### KD-2: 지정가 부재/≤0 최종 폴백 정책 — skip+강 WARN 권고 + +**권고: `slot.limit_price`마저 None 또는 ≤0인 극단 케이스는 "체결 처리를 skip하되 강 WARN 로그 발행" 정책.** + +#### 분석 대상 후보 + +| 후보 | 설명 | 장점 | 단점 | 안전성 | 구현비용 | +|---|---|---|---|---|---| +| **A: 재폴링 (체결 처리 보류)** | avg_price=None + limit_price=None 이면 이번 틱 처리를 skip, 다음 틱 재폴링 | KIS가 다음 틱에 정상 avg를 줄 수 있음 | 완전체결 후 `clear_dispatch` 전에 재폴링이 막힘 — 완전체결 케이스에서 재폴링 경로가 없어 영구 대기. `_on_filled` 내부에서 skip하면 슬롯이 PENDING_BUY에 잠기고 타임아웃/watchdog 경로가 구조에 의존. | 낮음 (영구 교착 가능) | 높음 (complete fill 케이스 구분 필요) | +| **B: 직전가/현재가 조회** | KIS에 현재가(`get_current_price`) 추가 조회 → 해당 가격으로 백필 | 지정가보다 실제 시세에 가까운 근사 | (1) `BrokerPort`에 `get_current_price` 추가 필요(포트 계약 변경) (2) KIS 추가 API 호출(타임아웃, 네트워크 실패) (3) 매우 드문 케이스를 위한 복잡도 증가 (4) safety_sell.py의 `get_current_price`(paper 전용 구현)가 있지만 KIS에는 미구현 | 중간 | 높음 | +| **C: skip + 강 WARN (권고)** | avg_price=None + limit_price=None 이면 체결 처리를 **skip**하되 slot_id·symbol·reason을 포함한 WARN 이상 로그 발행. 슬롯 전이 미수행, 다음 틱 재폴링 | 단순·안전 / 무경고 통과 0(REQ-096 AC-2 충족) / 추가 포트 계약 변경 없음 / "0 저장도 없음 + 강한 WARN"으로 REQ-095 AC-6 최소 기준 충족 | (1) 완전체결 상황에서 슬롯이 PENDING 상태에 잠길 수 있음 (2) 운영자 개입 필요(수기 확인 후 `trex-core cancel`·재진입) | 높음 | 낮음 | + +#### 권고 근거 + +- A(재폴링)은 **완전체결(is_filled=True) 케이스에서 실행 불가**다. `_on_filled` 내부에서 skip하면 `clear_dispatch`가 호출되지 않아 슬롯은 PENDING_BUY로 잠기고, 다음 틱에 동일 주문을 다시 poll → `is_filled=True` 반복 → 루프. 이는 dcy-003 R-1(pending order stuck)과 동일한 패턴이다. +- B(현재가 조회)는 `BrokerPort` 포트 계약 변경이며 구현비용 대비 발생 빈도가 낮다. Discovery A-2: "KIS 평균가 누락은 드문 순간 레이스". 또한 KIS `get_current_price` 구현은 현재 없어 신규 adapter 작업이 필요하다. +- C(skip + 강 WARN)은 **REQ-095 AC-6 최소 기준("이 케이스에서 최소한 0 저장은 발생하지 않거나 강한 WARN이 남아야 한다")을 충족**하고, REQ-096 AC-2("무경고 통과 없음")도 충족한다. 슬롯이 PENDING에 잠기는 것은 watchdog/stale_pending 경보가 탐지해 운영자 개입을 유도하므로 안전망이 있다. + +**구현 스펙 (C 채택 시)**: +```python +def _resolve_fill_price(self, fs: FillStatus, slot) -> tuple[float | None, bool]: + """ + Returns (fill_price, was_backfilled). + fill_price=None → skip signal (폴백 불가, 강 WARN 이미 발행). + """ + avg = fs.avg_price + if avg is not None and float(avg) > 0: + return float(avg), False + # avg_price None or ≤0: 지정가 백필 시도 + limit = slot.limit_price + if limit is not None and float(limit) > 0: + _log.warning( + "fill price backfill: slot=%s symbol=%s broker_order_id=%s " + "avg_price=%s backfill_price=%s reason=avg_price_backfill", + slot.slot_id, slot.symbol, slot.broker_order_id, + avg, limit, + ) + return float(limit), True + # 지정가마저 부재/≤0: skip + 강 WARN + _log.warning( + "fill price unresolvable: slot=%s symbol=%s broker_order_id=%s " + "avg_price=%s limit_price=%s reason=fill_price_unresolvable " + "(slot stays PENDING — operator attention required)", + slot.slot_id, slot.symbol, slot.broker_order_id, + avg, limit, + ) + return None, False +``` + +`_resolve_fill_price`가 `None`을 반환하면 호출자(`_on_filled`, `_on_partial_cancel`, `_record_ledger`)에서 해당 체결 처리를 skip한다. + +**팀장 승인 필요**: 폴백 정책 확정 후 REQ-095 AC-6 구체화. 다른 후보 채택 시 설계가 변경된다. + +--- + +### KD-3: `_record_ledger` 컨텍스트 패싱 — 파라미터 확장 권고 + +**권고: `_on_filled`/`_on_partial_cancel`에서 산출한 `fill_price`(백필 결과)를 `_record_ledger`에 직접 전달.** + +#### 현재 흐름 분석 + +`_on_filled`(`:231~285`)는 `fill_price`(avg_price 가공값)를 계산해 `slot.avg_price` 갱신에만 사용한다. 이후 `:281`에서 `self._record_ledger(slot, fs, side, broker_order_id, r)`를 호출하는데, `_record_ledger`(`:401~437`)는 `:424`에서 `fs.avg_price`를 다시 꺼내 쓴다. + +즉 **같은 `fs.avg_price`를 두 곳에서 독립적으로 해석**한다. 백필 헬퍼를 추가하면 `_on_filled`에서 한 번 계산한 결과를 `_record_ledger`에서 재계산 없이 전달하는 것이 일관성 있는 설계다. + +**구현 스펙**: +```python +# _on_filled 내부: +fill_price, was_backfilled = self._resolve_fill_price(fs, slot) +if fill_price is None: + return # skip: 폴백 불가, WARN 이미 발행됨 +# ... slot avg_price 계산 ... +self._record_ledger(slot, fs, side, broker_order_id, r, resolved_price=fill_price) + +# _record_ledger 시그니처 변경: +def _record_ledger(self, slot, fs, side: str, broker_order_id: str, + r: ExecutionResult, *, resolved_price: float | None = None) -> None: + # ... + price = resolved_price if resolved_price is not None else ( + float(fs.avg_price) if fs.avg_price is not None else 0.0 + ) +``` + +`resolved_price` 기본값을 `None`으로 두면 기존 `_record_ledger` 호출 코드(다른 경로가 있다면)의 하위 호환성이 유지된다. 현재 코드베이스에서 `_record_ledger`는 `_on_filled`와 `_on_partial_cancel`에서만 호출된다. + +--- + +## 알람 능력 스파이크 — REQ-097 + +> REQ-097 AC-1: 후보 A·B·C 각각 장점·단점·최소 구현 범위·M2 일정 적합성 명시. +> REQ-097 AC-2: 후보 B wire 계약 변경 여부와 blast radius 명시. + +### 후보 A: 구조화 로그 기반 + +**개요**: FR-3 WARN 로그(REQ-096)를 알람 소스로 삼아 외부 알림 파이프라인(Loki → Grafana Alert / PagerDuty / etc.)에서 소비. + +**장점**: +- trex-core 코드 변경 최소 — REQ-096(WARN 로그)만 구현하면 코어 측 작업 완료. +- dcy-029(BAT-045/046) 구조화 로그 인프라(json formatter, correlation_id) 이미 구축됨. +- wire 계약 불변, blast radius 없음. +- M2 일정 내 코어 변경 없음. + +**단점**: +- 운영자가 로그 파이프라인(Loki + 알림 규칙)을 별도로 구성해야 함(인프라 의존). +- 알림 지연이 로그 수집 파이프라인 지연에 종속된다(수십 초 ~ 분 단위). +- trex-core 자체에는 알람을 "보내는" 능력이 없음 — 알람은 로그 소비자가 담당. + +**최소 구현 범위**: REQ-096 WARN 로그 구현만으로 충분. 알람 파이프라인 구축은 인프라 작업(trex-core 범위 밖). + +**M2 일정 적합성**: 코어 측 추가 작업 없음 → **M2 적합**. + +--- + +### 후보 B: AMQP 이벤트 기반 + +**개요**: `trex.events` exchange 또는 신규 `trex.alerts` exchange에 알람 이벤트(예: `fill_price_backfill`, `fill_price_unresolvable`)를 publish. + +**wire 계약 변경 여부 분석**: + +현재 `trex.events` 토폴로지 (interface-contract.md): +- routing key 패턴: `{event_type}.{strategy_id}.{symbol}` +- 소비자: `trex.events.strategy.{strategy_id}` 큐가 `*.{strategy_id}.#` 패턴으로 바인딩 +- trex-orb-v1, trex-tsl-v1이 이 큐를 소비 + +알람 이벤트를 `trex.events`에 추가하면: +- `alert.fill_backfill.{strategy_id}.{symbol}` 형태의 routing key를 사용할 경우 → 기존 `*.{strategy_id}.#` 바인딩에 매칭 → **orb/tsl에 알람 이벤트가 전달됨**. orb/tsl이 미처리 메시지를 어떻게 처리하는지 불명확. +- 신규 `trex.alerts` exchange를 추가할 경우 → exchange 신설 + 큐 + 바인딩 추가 = **AMQP 토폴로지 변경** = **wire 계약 변경**. +- 기존 `slot_filled_buy` 등 이벤트 스키마에 알람 관련 필드를 추가하는 것도 **payload 스키마 변경** = **wire 계약 변경**. + +**결론: 후보 B는 어떤 구현 형태를 취하더라도 wire 계약(AMQP 토폴로지 또는 payload 스키마) 변경을 수반한다.** + +**blast radius**: trex-orb-v1, trex-tsl-v1 양쪽 모두 영향. 특히 trex-tsl-v1은 M2 라이브 목표(6/28) 직전 wire 변경은 위험 수준 High. + +**wire 계약 변경 필요 여부**: **예 (후보 B는 Out of Scope)** + +> CLAUDE.md 교차 영향 사안 정책에 따라: 후보 B 분석에서 wire 계약 변경 필요성이 확인됨. 이 사안을 팀장에게 보고한다. **후보 B는 본 batch 범위 밖이며 구현 후보에서 제외 권고**. + +--- + +### 후보 C: 외부 푸시 직접 연동 + +**개요**: trex-core 내에서 직접 Slack webhook / Telegram API / 이메일 등 외부 채널로 알람 전송. + +**장점**: +- 운영자가 로그 파이프라인 없이 즉시 수신. +- 지연 최소(수 초 이내). + +**단점**: +- trex-core에 외부 HTTP 의존성 추가(Slack/webhook URL 환경변수, 인증 토큰). +- 외부 채널 장애 시 trex-core 알람 발행 실패가 체결 처리에 영향 줄 수 있음(best-effort 설계 필요). +- 채널별 구현(Slack vs Telegram vs email)에 따라 코드 복잡도 증가. +- 자격증명(webhook URL / API key) 관리가 운영 절차에 추가됨. +- 알람 어댑터 포트 신규 정의 필요 + 구체 어댑터 구현 + 테스트. + +**최소 구현 범위**: `AlertPort` (application 레이어 포트) + `SlackWebhookAlertAdapter` (adapter 레이어) + `DaemonConfig`에 webhook URL 추가 + `execution.py`에서 포트 호출. 최소 5개 파일 변경. + +**M2 일정 적합성**: 구현 범위가 상당하고 자격증명 운영 절차가 추가됨 → **M2 일정 압박 (R-3)**. M3 후보 더 적합. + +--- + +### 알람 스파이크 권고 요약 + +| 후보 | M2 포함 권고 | 이유 | +|---|---|---| +| A: 구조화 로그 기반 | **권고** (코어 작업 불필요) | REQ-096 WARN 구현만으로 완결, wire 계약 불변, M2 적합 | +| B: AMQP 이벤트 기반 | **Out of Scope** | wire 계약 변경 수반, orb/tsl blast radius, M2 위험 High | +| C: 외부 푸시 직접 연동 | **이월 권고** (M3 후보) | 구현 범위 크고 자격증명 관리 추가, M2 일정 압박 | + +**trex-core-dev 권고**: 후보 A(구조화 로그 기반)를 기반 경로로 채택. REQ-096 WARN 로그 → Loki → 알람 파이프라인 연결은 인프라 작업으로 분리. 후보 C의 실제 구현은 M3에서 별도 Discovery로 정의. + +FR-3 WARN 로깅(REQ-096)은 알람 채택 여부와 무관하게 M2 필수이며 본 batch에 포함된다. + +**사장님 결정 필요 (OQ-1)**: 알람 실제 구현(후보 A 인프라 파이프라인 구축 or 후보 C 코어 구현)의 M2 포함 여부. trex-core-dev는 이 결정을 임의로 내리지 않는다. + +--- + +## Edge Cases + +### EC-1: 완전체결 + avg_price=None + limit_price=None + +- 발생 경로: KIS가 완전체결 보고 + avg=0 + 슬롯에 limit_price가 None(드문 케이스, 예: orb가 market 주문 시) +- 현재: `fill_price=0.0` → `slot.avg_price=0` 영구 오염. +- KD-2 C 채택 시: `_resolve_fill_price` → `None` 반환 → `_on_filled` skip → `clear_dispatch` 미호출 → 슬롯이 PENDING_BUY 유지. 다음 틱 재폴링 → 동일 `is_filled=True` → skip 반복. +- 안전망: `_check_stale_pending`이 `stale_threshold_sec` 경과 후 WARN을 발생시키고, watchdog 만료 후 safety-sell이 개입한다. +- 운영자 조치: `trex-core cancel ` → 슬롯 롤백 → 재진입. +- 이 케이스는 Discovery A-1(orb 매수는 지정가 주문)에 위배되는 예외 케이스이므로 저빈도라고 가정. + +### EC-2: 부분취소 + avg_price=None + limit_price > 0 + +- `_on_partial_cancel`에서 `_resolve_fill_price` → `slot.limit_price` 백필 + WARN. +- 백필 가격으로 `new_avg` 계산 + `_record_ledger` price 기록 → 정상 흐름. + +### EC-3: 매도 체결 + avg_price=None + +- `_on_filled`에서 side=PENDING_SELL 분기: 이 분기는 `fill_price`/`avg_price`를 슬롯 평단 계산에 사용하지 않는다(매도는 avg_price 보존). 따라서 **매도 체결에서는 `_on_filled` 내 slot 전이에 백필 영향 없음**. +- 단, `_record_ledger`의 `price`(매도 체결가)는 여전히 `fs.avg_price`를 사용 → 여기서도 백필 헬퍼 적용 필요. +- 매도 `limit_price`는 `_dispatch`에서 HOLDING 진입 시 `None`으로 클리어되므로, 매도 시 `slot.limit_price=None`이 정상이다. 따라서 매도 체결에서 avg_price=None이면 EC-1과 유사한 skip+강 WARN 경로를 탄다(KD-2 C 확정 시). + +### EC-4: happy path 회귀 + +- `fs.avg_price`가 정상 양수이면 `_resolve_fill_price`는 `(fs.avg_price, False)` 반환 → 기존과 동일 경로. WARN 미발생, 분기 overhead 없음. + +### EC-5: paper 브로커에서 avg_price=None 발생 가능성 + +- paper 브로커(`paper.py:92,103,106`)는 `avg_price=o.price`를 반환하며 `o.price`는 `__init__`에서 `float(req.limit_price) if req.limit_price is not None else 1000.0`로 항상 양수다. +- 따라서 paper 브로커 사용 시 백필 경로 진입 없음 → paper 관련 테스트에서 WARN 미발생 단언 가능. + +## Architecture Impact + +- runtime-flows (체결 처리 흐름 변경 — `_on_filled`/`_on_partial_cancel`/`_record_ledger`에 백필 분기 추가) + +## Reference Doc Update Plan + +- `docs/runtime-flows.md`: implementation 완료 후 "Flow: 체결 처리" 섹션에 백필 분기 flowchart 추가. `_resolve_fill_price` 헬퍼 경로(avg_price=None/≤0 → limit_price 백필 → WARN 로그) 명시. +- `docs/operations.md`: implementation 완료 후 "백필 WARN 발생 시 운영자 점검 절차" 추가. 포함 내용: WARN 로그 식별 방법(`reason=avg_price_backfill` or `reason=fill_price_unresolvable`), 수기 점검 항목(slotID·원장 확인), `trex-core cancel` 조치 절차. +- `docs/project-structure.md`: `_resolve_fill_price` 헬퍼 추가는 execution.py 내 사적 메서드로 project-structure 변경 없음. +- `docs/interface-contract.md`: AMQP wire 계약 불변 — 변경 없음. + +--- + +## Design Review 확정 항목 (팀장·사장님) + +| # | 항목 | 권고 | 결정 권한 | 상태 | +|---|---|---|---|---| +| D-1 | 백필 결정 지점: application 단일화 vs adapter | application 권고(KD-1) | 팀장 | 미확정 | +| D-2 | 지정가 부재/≤0 최종 폴백: skip+강WARN vs 재폴링 vs 현재가 조회 | skip+강WARN 권고(KD-2) | 팀장 | 미확정 | +| D-3 | 알람 구현 M2 포함 여부 | 후보 A(로그 기반) + 실제 파이프라인은 후속 이월 권고 | 사장님 | 미확정 | +| D-4 | (D-3 M2 포함 시) 후보 A/C 중 무엇을 코어에서 구현 | 후보 C는 M3, 후보 A는 인프라 작업으로 분리 권고 | 사장님 | 미확정 | diff --git a/docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/index.md b/docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/index.md new file mode 100644 index 0000000..504ae7e --- /dev/null +++ b/docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/index.md @@ -0,0 +1,37 @@ +# BAT-048: 체결가 0/None 백필 가드 + WARN 로깅 + 알람 능력 스파이크 (dcy-032) + +- Status: verified (confirm-batch-verification 팀장 2026-06-21 — REQ-095/096 Implemented, 575 passed/47 skipped/0 fail 독립 재실행 + ruff clean, opus QC PASS-WITH-NOTES→NOTE-1/2 정정완료, 통합 3건 미실행 결정 기록[영속/전송 diff 0·KIS avg=None 결정적 재현 불가]) +- Profile: standard +- Source Discovery: dcy-032 +- Included REQ: REQ-095 (구현 완료), REQ-096 (구현 완료), REQ-097 (스파이크 산출물 완료 — 알람 impl 이월) +- Excluded REQ: 없음 + +## Summary +- dcy-032 발견: KIS 체결조회에서 체결수량은 반환되나 평균체결가(`avg`)가 None/0일 때, 슬롯 평단(`slot.avg_price`)과 원장 cost basis(`_record_ledger` price)가 동시에 0으로 무경고·영구 오염된다. 완전체결 시 `clear_dispatch`로 재폴링이 없어 0이 영속된다. +- 세 REQ가 단일 인과 사슬(체결가 누락 → 무결성 가드 → 가시성 → 알람 검토)을 형성하므로 같은 batch로 묶는다. REQ-095/096은 구현·테스트가 동일 코드 경로(`execution.py:_on_filled`, `_on_partial_cancel`, `_record_ledger`)에 있어 분리 구현의 이점이 없다. REQ-097은 같은 batch design 산출물로 제출되어 사장님 결정의 근거가 된다. + +## Scope +- In Scope: + - `trex_core/application/execution.py` — `_on_filled`/`_on_partial_cancel`의 `fill_price` 백필 분기(REQ-095) + WARN 로그(REQ-096) + - `trex_core/application/execution.py` — `_record_ledger`의 `price` 백필 분기(REQ-095) + 백필/0가격 통과 WARN(REQ-096) + - 알람 채널 후보 A/B/C 분석 문서(REQ-097) — design 산출물 + - `tests/application/test_execution.py` — avg_price=None, avg_price=0, happy path, WARN 로그 발생 AC 커버 + - `docs/runtime-flows.md` — 체결 처리 흐름에 백필 분기 추가 (implementation 후 갱신) + - `docs/operations.md` — 백필 WARN 발생 시 운영자 점검 절차 추가 (implementation 후 갱신) +- Out of Scope: + - `trex_core/adapters/broker/kis.py` 수정 — 백필 결정 지점을 application으로 단일화하므로 adapter 변경 없음 (design 확정 결과) + - slots RPC / AMQP wire 계약 변경 — NFR-1 명시적 금지 + - 과거 0 기록 원장/슬롯의 소급 정정 + - 알람 실제 구현 — design review 후 사장님 결정 사안(REQ-097 OQ-1) + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: `trex_core/application/execution.py`, `tests/application/test_execution_fill_price_backfill.py` +- Verification: [verification.md](./verification.md) + +## Notes +- R-1(Medium, Discovery R-1): 지정가 백필도 추정치. 실제 체결가 ≠ 지정가일 수 있음. 완화: WARN+추적 가능 플래그, 런북 명시. +- R-2(Medium, Discovery R-2): 백필 결정 지점(adapter vs application) 선택에 따라 paper/dcy-021 재구성 경로 일관성이 달라짐. design에서 단일 지점 확정 필수 — 팀장 승인 게이트. +- R-3(Low, Discovery R-3): 알람 구현 포함 시 범위 확대 → M2 일정 압박. design review에서 사장님 결정. +- wire 계약 안전: 후보 B(AMQP 이벤트 알람) 분석에서 wire 계약 변경 필요성이 발생하면 즉시 팀장 보고·중단(CLAUDE.md 교차 영향 사안 정책). diff --git a/docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/planning.md b/docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/planning.md new file mode 100644 index 0000000..36d4310 --- /dev/null +++ b/docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/planning.md @@ -0,0 +1,79 @@ +# BAT-048 Planning + +- Status: confirmed +- Batch: BAT-048 +- Profile: standard + +## Included REQ + +- REQ-095: 체결가 0/None 지정가 백필 가드 (슬롯 평단 + 원장 price) +- REQ-096: 체결가 백필·0가격 통과 WARN 로깅 +- REQ-097: 알람(alert) 능력 검토 스파이크 산출물 + +## Delivery Plan + +REQ-095/096은 같은 코드 경로(`execution.py`)의 같은 함수들에 걸쳐 있어 분리 구현의 이점이 없다. 두 REQ를 묶어 구현한 뒤 테스트에서 값 정정(REQ-095)과 로그 가시성(REQ-096)을 동시에 검증한다. REQ-097은 design 산출물로 완결된다. + +구현 순서(run-batch-implementation 기준): + +1. **design 확정 사항 선행 (이 planning 이전 게이트)**: + - 백필 결정 지점: application 단일화(후술 Design Gate). paper 브로커·dcy-021 재구성 경로 일관성 분석 후 팀장 승인. + - 지정가 부재/≤0 최종 폴백 정책: 후보별 분석 → 팀장 승인 (design review 게이트). + - REQ-097 알람 분석 산출물: design 문서 내 포함 → design review에서 사장님 결정. + +2. **Phase A — `_on_filled` / `_on_partial_cancel` 백필 분기(REQ-095 AC-1/2) + WARN 로그(REQ-096 AC-1)**: + - 두 함수 내 `fill_price = float(fs.avg_price) if fs.avg_price is not None else old_avg` 를 백필 헬퍼(`_resolve_fill_price`)로 추출. + - `_resolve_fill_price(fs, slot)`: avg_price None/≤0 시 `slot.limit_price` 사용, 지정가도 없으면 폴백 정책 적용. + - 백필 시 `_log.warning(...)` 발행(REQ-096 AC-1 필드 집합). + +3. **Phase B — `_record_ledger` price 백필(REQ-095 AC-3) + WARN 로그(REQ-096 연동)**: + - `price = float(fs.avg_price) if fs.avg_price is not None else 0.0` 라인을 `_resolve_fill_price` 결과로 교체. + - 이미 Phase A에서 백필 WARN을 발행했으면 중복 WARN 없음; `_record_ledger` 진입 전에 백필 여부를 파악할 수 있는 컨텍스트 설계 필요 (design에서 결정). + +4. **Phase C — 테스트 작성**: + - `tests/application/test_execution.py`: AC-1~4(REQ-095) + AC-1~4(REQ-096) 커버. + - avg_price=None, avg_price=0, happy_path(정상 양수), limit_price 부재 극단 케이스. + - WARN 로그 발생 단언(`caplog` 사용). + - 기존 execution 테스트 회귀 유지. + +5. **Phase D — baseline 문서 갱신**: + - `docs/runtime-flows.md` — 체결 처리 흐름에 백필 분기 추가. + - `docs/operations.md` — 백필 WARN 발생 시 운영자 점검 절차. + +선행 조건: Phase A~C는 설계 결정(백필 헬퍼 구조, `_record_ledger` 컨텍스트 패싱)이 확정되어야 시작 가능. + +## Dependencies + +- Internal: + - `trex_core/application/execution.py` — 변경 대상 핵심 파일(BAT-004에서 도입, 이후 지속 갱신) + - `trex_core/adapters/broker/kis.py` — 읽기 참조만(백필은 application에서, adapter 불변) + - `trex_core/adapters/broker/paper.py` — 읽기 참조만(paper는 `o.price`를 항상 양수로 반환 → 백필 분기 미진입 정상 케이스) + - `tests/application/test_execution.py` — 기존 회귀 스위트 전체 통과 유지 필수 + - dcy-029(BAT-045/046) — 구조화 로깅 인프라(`_log`, json formatter) 재사용 +- External: + - portfolio-hub `LedgerPort` — `price` 필드 타입(float) 불변, 값만 정정. HTTP 계약 변경 없음. + - slots RPC(trex-orb-v1, trex-tsl-v1) — `avg_price` 필드 값이 0→정상값으로 바뀌는 것은 wire 계약 변경이 아님(NFR-1). trex-tsl-v1은 avg_price≤0 시 tier 무시 2% 폴백에서 정상 tier 평가로 자동 개선. + +## Design Gate + +- Design Required Before Implementation: yes +- Reason: + - **백필 결정 지점 (Discovery R-2, REQ-095 Notes)**: adapter(kis.py)에서 처리 vs application(execution.py)에서 처리. paper 브로커·dcy-021 잔고 재구성 경로와의 일관성이 다르다. 설계 없이 구현하면 경로별 일관성 깨짐 리스크. design에서 단일 지점 확정 후 팀장 승인 필수. + - **지정가 부재/≤0 폴백 정책 (Discovery OQ-2, REQ-095 AC-6)**: 재폴링 / 직전가 조회 / skip+강알림 등 후보별 구현비용·안전성이 다르다. 설계 확정 없이 구현하면 AC-6가 미완성이다. design review에서 팀장 확정 필수. + - **`_record_ledger` 컨텍스트 패싱 설계**: Phase A 백필 결과(`fill_price`, `was_backfilled`)를 `_record_ledger`까지 전달하는 방법(파라미터 확장 vs 재계산)이 함수 시그니처를 변경할 수 있다. + - **REQ-097 알람 스파이크 분석**: 알람 채널 후보 B(AMQP 이벤트)가 wire 계약 변경을 수반하는지 여부가 설계에서 확인되어야 한다. wire 변경 수반 시 즉시 팀장 보고·중단. + - architecture impact가 `runtime-flows`(체결 처리 흐름 변경)이므로 standard profile design이 필요하다. + +## Milestones + +1. draft-batch-design 완료 — 백필 결정 지점, 폴백 정책, `_record_ledger` 컨텍스트 패싱, REQ-097 알람 분석 산출물 확정(팀장·사장님 design review 게이트) +2. run-batch-implementation 완료 — Phase A~D 신규 테스트 포함, 기존 execution 회귀 유지 +3. draft-batch-verification 완료 — REQ-095/096 AC별 커버, REQ-097 산출물 제출 확인 +4. QC reviewer 검증 대기 (confirm-batch-verification은 팀장 게이트) + +## Risks + +- R-1(Medium): 지정가 백필이 추정치라 실제 체결가와 다를 수 있다. 완화: WARN + 추적 가능 플래그, 런북 명시. "0보다 합리적인 근사 + 추적 가능"이 목표(Discovery R-1). +- R-2(Medium): 백필 결정 지점을 adapter로 두면 paper 브로커에도 동일 백필 로직 복제가 필요해진다. application 단일화가 권고. design에서 확정. +- R-3(Low): `_resolve_fill_price` 헬퍼 추출 시 함수 시그니처 변경이 기존 테스트에 영향 가능. 완화: 기존 테스트 회귀 유지를 verification 기준으로 명시. +- R-4(Low): 알람 후보 B 분석 중 wire 계약 변경 필요 여부가 나오면 즉시 중단·팀장 보고. diff --git a/docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/verification.md b/docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/verification.md new file mode 100644 index 0000000..b44f10f --- /dev/null +++ b/docs/batches/bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/verification.md @@ -0,0 +1,156 @@ +# BAT-048 Verification + +- Status: draft +- Batch: BAT-048 +- Scope: REQ-095 (지정가 백필 가드), REQ-096 (백필 WARN 로깅) +- REQ-097: 알람 능력 스파이크 산출물로 완료 — 알람 실제 구현은 사장님 결정에 따라 이 batch 완료 후 별도 트랙 이월 + +--- + +## 구현 범위 요약 + +| 변경 파일 | 변경 내용 | +|---|---| +| `trex_core/application/execution.py` | `_resolve_fill_price(fs, slot)` 헬퍼 신설; `_on_filled`·`_on_partial_cancel`에 백필 분기 + skip 경로 삽입; `_record_ledger`에 `resolved_price` 키워드 파라미터 추가(기본 None, 하위호환) | +| `tests/application/test_execution_fill_price_backfill.py` | 신규 단위 테스트 21개 (REQ-095/096 AC 전체 커버 + EC-3 매도 경로 5개 추가; QC PASS-WITH-NOTES 정정 반영) | +| `docs/runtime-flows.md` | "주문 집행 루프" Flow — poll 항목에 체결가 백필 가드 분기 문서화 | +| `docs/operations.md` | §5 신설: 체결가 백필 WARN 발생 시 운영자 점검 절차; 디버깅 표에 2행 추가 | + +불변 영역 확인: +- `trex_core/adapters/broker/kis.py` — 수정 없음 (KD-1: application 단일화) +- `trex_core/adapters/broker/paper.py` — 수정 없음 +- AMQP wire 계약 (`docs/interface-contract.md`) — 변경 없음 (NFR-1) + +--- + +## 단위 테스트 실행 결과 + +**실행 명령**: `python3 -m pytest tests/application/test_execution_fill_price_backfill.py -v` + +**신규 테스트 결과 (21개, QC 정정 후):** + +``` +tests/application/test_execution_fill_price_backfill.py::TestOnFilledBackfill::test_avg_price_none_with_limit_price_uses_limit_price PASSED +tests/application/test_execution_fill_price_backfill.py::TestOnFilledBackfill::test_avg_price_zero_with_limit_price_uses_limit_price PASSED +tests/application/test_execution_fill_price_backfill.py::TestOnFilledBackfill::test_happy_path_uses_avg_price_no_backfill_warn PASSED +tests/application/test_execution_fill_price_backfill.py::TestOnFilledBackfill::test_avg_price_none_limit_price_none_skips_fill PASSED +tests/application/test_execution_fill_price_backfill.py::TestOnFilledBackfill::test_avg_price_none_limit_price_zero_skips_fill PASSED +tests/application/test_execution_fill_price_backfill.py::TestOnPartialCancelBackfill::test_avg_price_none_partial_cancel_uses_limit_price PASSED +tests/application/test_execution_fill_price_backfill.py::TestOnPartialCancelBackfill::test_avg_price_zero_partial_cancel_uses_limit_price PASSED +tests/application/test_execution_fill_price_backfill.py::TestOnPartialCancelBackfill::test_happy_path_partial_cancel_no_backfill_warn PASSED +tests/application/test_execution_fill_price_backfill.py::TestOnPartialCancelBackfill::test_partial_cancel_unresolvable_skips_fill PASSED +tests/application/test_execution_fill_price_backfill.py::TestRecordLedgerBackfillPrice::test_ledger_price_equals_backfill_limit_price_on_filled PASSED +tests/application/test_execution_fill_price_backfill.py::TestRecordLedgerBackfillPrice::test_ledger_price_equals_backfill_limit_price_on_partial_cancel PASSED +tests/application/test_execution_fill_price_backfill.py::TestRecordLedgerBackfillPrice::test_ledger_price_on_happy_path_uses_avg_price PASSED +tests/application/test_execution_fill_price_backfill.py::TestRecordLedgerBackfillPrice::test_ledger_not_written_when_fill_price_unresolvable PASSED +tests/application/test_execution_fill_price_backfill.py::TestWarnLogFields::test_backfill_warn_contains_required_fields PASSED +tests/application/test_execution_fill_price_backfill.py::TestWarnLogFields::test_unresolvable_warn_contains_required_fields PASSED +tests/application/test_execution_fill_price_backfill.py::TestSellPathBackfill::test_sell_filled_avg_price_none_with_limit_price_uses_backfill PASSED +tests/application/test_execution_fill_price_backfill.py::TestSellPathBackfill::test_sell_partial_cancel_avg_price_none_with_limit_price_uses_backfill PASSED +tests/application/test_execution_fill_price_backfill.py::TestSellPathBackfill::test_sell_filled_avg_price_none_limit_price_none_skips PASSED +tests/application/test_execution_fill_price_backfill.py::TestSellPathBackfill::test_sell_partial_cancel_avg_price_none_limit_price_none_skips PASSED +tests/application/test_execution_fill_price_backfill.py::TestSellPathBackfill::test_sell_filled_happy_path_no_backfill_warn PASSED +tests/application/test_execution_fill_price_backfill.py::TestPaperBrokerRegression::test_paper_broker_buy_no_backfill_warn PASSED + +21 passed in 0.21s +``` + +**전체 스위트 회귀 확인 결과:** + +``` +575 passed, 47 skipped in 1.40s +``` + +회귀: 0건. 기존 테스트 전부 통과. + +--- + +## REQ-095 AC별 충족 여부 + +| AC | 내용 | 충족 여부 | 근거 테스트 | +|---|---|---|---| +| AC-1 | avg_price=None + limit_price>0 → _on_filled에서 slot.avg_price = limit_price(0 아님) | 충족 | `test_avg_price_none_with_limit_price_uses_limit_price` | +| AC-2 | avg_price=0.0 + limit_price>0 → _on_partial_cancel에서 slot.avg_price = limit_price(0 아님) | 충족 | `test_avg_price_zero_with_limit_price_uses_limit_price`, `test_avg_price_zero_partial_cancel_uses_limit_price` | +| AC-3 | avg_price=None → _record_ledger LedgerWorkItem.price = 백필 지정가(0.0 아님) | 충족 | `test_ledger_price_equals_backfill_limit_price_on_filled`, `test_ledger_price_equals_backfill_limit_price_on_partial_cancel` | +| AC-4 | avg_price 정상 양수(happy path) → 기존 fs.avg_price 사용, 백필 미개입 | 충족 | `test_happy_path_uses_avg_price_no_backfill_warn`, `test_ledger_price_on_happy_path_uses_avg_price` | +| AC-5 | wire 계약(slots RPC 스키마) 불변 — 값 정정만 | 충족 | interface-contract.md 수정 없음, KD-1 분석 | +| AC-6 | limit_price=None/≤0 극단 케이스 → 체결 skip + slot_id·symbol·broker_order_id·avg_price·limit_price·reason=fill_price_unresolvable 강 WARN | 충족 | `test_avg_price_none_limit_price_none_skips_fill`, `test_avg_price_none_limit_price_zero_skips_fill`, `test_ledger_not_written_when_fill_price_unresolvable`, `test_unresolvable_warn_contains_required_fields` | + +--- + +## REQ-096 AC별 충족 여부 + +| AC | 내용 | 충족 여부 | 근거 테스트 | +|---|---|---|---| +| AC-1 | 백필 발동 시 slot_id·symbol·broker_order_id·원avg_price·backfill_price·reason=avg_price_backfill 포함 WARN 정확히 1건 | 충족 | `test_avg_price_none_with_limit_price_uses_limit_price`, `test_backfill_warn_contains_required_fields` | +| AC-2 | 지정가마저 부재/≤0 극단 케이스 — WARN 이상 1건 이상, 무경고 통과 없음 | 충족 | `test_avg_price_none_limit_price_none_skips_fill`, `test_unresolvable_warn_contains_required_fields` | +| AC-3 | 정상 avg_price(happy path) — 백필 관련 WARN 미발생 | 충족 | `test_happy_path_uses_avg_price_no_backfill_warn`, `test_happy_path_partial_cancel_no_backfill_warn`, `test_paper_broker_buy_no_backfill_warn` | +| AC-4 | 백필 WARN 로그에서 어떤 슬롯·체결에서 백필이 발생했는지, 원래 가격, 대체가격 식별 가능(NFR-3) | 충족 | `test_backfill_warn_contains_required_fields` — slot_id/symbol/avg_price(None)/backfill_price 필드 단언 | + +--- + +## QC PASS-WITH-NOTES 정정 내역 + +QC 독립 검증에서 PASS-WITH-NOTES 판정 후 아래 2건을 정정했다. + +### NOTE-1 (ruff 위생) + +- `:18` `import pytest` F401(미사용) → 제거 +- `:276` `r = execution.run_once()` F841(미사용 변수) → `execution.run_once()`로 변경 +- `ruff check tests/application/test_execution_fill_price_backfill.py` → `All checks passed!` + +### NOTE-2 (매도 경로 테스트 보강) + +- `TestSellPathBackfill` 클래스 신설(5개 테스트) — 상세는 아래 섹션 참조. +- 매도 경로에서 설계와 다른 실제 동작 발견 없음 — 프로덕션 코드 수정 없음. + +--- + +## EC-3 매도(PENDING_SELL) 경로 커버리지 (QC NOTE-2 정정) + +design.md EC-3 설계 의도: 매도 체결 시 슬롯 avg_price(cost basis)는 보존되며, +`_record_ledger`의 price 필드에만 매도 체결가(`resolved_price`)가 기록된다. +매도 슬롯의 `limit_price`는 HOLDING 진입 시 None으로 클리어되므로, +market sell(limit_price=None)에서 avg_price=None이면 skip+강WARN이 설계 의도된 동작이다. + +| 테스트 | 케이스 | 단언 | +|---|---|---| +| `test_sell_filled_avg_price_none_with_limit_price_uses_backfill` | EC-3a: _on_filled PENDING_SELL + avg_price=None + limit_price>0 | 슬롯 INACTIVE, 1 WARN reason=avg_price_backfill, 원장 price=limit_price(0 아님), side=sell | +| `test_sell_partial_cancel_avg_price_none_with_limit_price_uses_backfill` | EC-3b: _on_partial_cancel PENDING_SELL + avg_price=None + limit_price>0 | 슬롯 HOLDING(잔량), 1 WARN reason=avg_price_backfill, 원장 price=limit_price | +| `test_sell_filled_avg_price_none_limit_price_none_skips` | EC-3c: _on_filled PENDING_SELL + avg_price=None + limit_price=None (TSL market sell) | 슬롯 PENDING_SELL 유지, 원장 미생성, 강WARN reason=fill_price_unresolvable | +| `test_sell_partial_cancel_avg_price_none_limit_price_none_skips` | EC-3d: _on_partial_cancel PENDING_SELL + avg_price=None + limit_price=None | 슬롯 PENDING_SELL 유지, 원장 미생성, 강WARN | +| `test_sell_filled_happy_path_no_backfill_warn` | EC-3e: 정상 매도 (avg_price>0) | 슬롯 INACTIVE, WARN 미발생, 원장 price=avg_price | + +설계와 다른 동작 없음 — 코드 수정 없이 테스트만 추가. + +--- + +## REQ-097 처리 + +REQ-097(알람 능력 스파이크)은 `design.md`의 "알람 능력 스파이크 — REQ-097" 섹션에서 후보 A/B/C 분석 산출물로 완결됐다. + +- **후보 B(AMQP 이벤트 기반)**: wire 계약 변경 수반 + trex-orb-v1/trex-tsl-v1 blast radius 확인 → 팀장에게 보고, Out of Scope 확정. +- **후보 A(구조화 로그 기반)**: REQ-096 WARN 로그가 알람 소스 역할. 코어 추가 작업 없음. +- **후보 C(외부 푸시 직접 연동)**: 구현 범위 크고 자격증명 관리 추가 → M3 이월 권고. + +알람 실제 구현(알람 파이프라인 구축 또는 후보 C 코어 구현)은 사장님 결정(OQ-1)에 따라 이 batch 완료 후 별도 트랙으로 이월됨. REQ-097은 스파이크 산출물로 완료 처리. + +--- + +## 통합 테스트 — 팀장 직접 실행 필요 + +단위 테스트는 모두 in-memory 어댑터로 검증됐다. 아래 항목은 실 인프라(KIS/PostgreSQL/RabbitMQ)가 필요한 통합 테스트로, 팀장이 실 환경에서 직접 실행해야 한다. + +1. **KIS avg_price=None 케이스 재현**: KIS sandbox에서 체결조회가 avg=0을 반환하는 순간 포착 → backfill WARN 발행 + 슬롯 avg_price/원장 price가 지정가로 저장됨을 확인. +2. **PostgreSQL ledger_outbox price 확인**: 실 PostgreSQL에서 backfill된 체결 건의 `ledger_outbox.trade_data.price`가 0.0이 아닌 지정가 값인지 확인. +3. **reconcile 경로 일관성**: 데몬 재시작 후 `reconcile()`이 backfill 헬퍼를 동일하게 탄다는 것을 KIS sandbox에서 확인. + +--- + +## 남은 위험 + +| 위험 | 수준 | 설명 | +|---|---|---| +| R-1 | Medium | 지정가 백필 자체가 추정치 — 실제 체결가와 다를 수 있다. WARN + 추적 가능 플래그(reason=avg_price_backfill)으로 완화. 운영자 판단 여지. | +| R-2 | Low | fill_price_unresolvable 케이스에서 슬롯 PENDING 고착 → watchdog/stale_pending이 탐지 후 운영자 개입 필요. 자동 복구 없음(설계 의도). | +| R-3 (해소) | — | 기존 테스트 영향 없음(회귀 0). | diff --git a/docs/batches/index.md b/docs/batches/index.md index d721990..dc1f693 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -55,3 +55,4 @@ | BAT-045 | standard | released | REQ-084, REQ-085, REQ-087 | dcy-029 (issue #29, P0) | [bat-045](./bat-045_20260616_observability-logging-foundation/) | | BAT-046 | standard | released | REQ-086, REQ-088, REQ-089, REQ-090 | dcy-029 (issue #29, P1) | [bat-046](./bat-046_20260616_observability-classification-pnl-runbook/) | | BAT-047 | standard | release-candidate | REQ-091, REQ-092, REQ-093, REQ-094 | dcy-031 | [bat-047](./bat-047_20260619_event-publisher-connection-reliability/) | +| BAT-048 | standard | in-delivery | REQ-095, REQ-096, REQ-097 | dcy-032 | [bat-048](./bat-048_20260621_fill-price-backfill-guard-logging-alert-spike/) | diff --git a/docs/discovery/dcy-032_20260621_fill-missing-price-cost-basis-backfill.md b/docs/discovery/dcy-032_20260621_fill-missing-price-cost-basis-backfill.md new file mode 100644 index 0000000..37c2bb0 --- /dev/null +++ b/docs/discovery/dcy-032_20260621_fill-missing-price-cost-basis-backfill.md @@ -0,0 +1,202 @@ +# 0. 문서 상태 +이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. + +- 상태: confirmed +- 문서 ID: dcy-032_20260621_fill-missing-price-cost-basis-backfill +- 이슈명: 체결가 누락(브로커 평균가 0/None) 시 슬롯 평단·원장 cost basis 0 오염(무경고·영구) — 지정가 백필 + 로깅 + 알람 검토 +- 작성 시각(KST): 2026-06-21 (KST) +- 마지막 갱신 시각(KST): 2026-06-21 (KST) +- 대체됨: 없음 +- 후속 Discovery 참조: 없음 +- 생성된 REQ 참조: REQ-095, REQ-096, REQ-097 + +# 1. 계획 상태 요약 +현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. + +- 연계 문서 현황: + - 관련 점수 파일: 미생성 + - 관련 TODO/메모: 팀장 워크스페이스 백로그 `trex-backlog.md`("avg_price=None → 무경고 고정 2% 폴백" finding에서 상류 원인으로 추적) +- 해석: + - tsl tier 폴백(avg_price=None) finding을 상류로 추적한 결과, 진짜 원인은 trex-core 체결 처리에서 브로커 평균체결가가 0/None으로 올 때 **슬롯 평단과 원장 cost basis가 동시에 0으로, 무경고·영구히 오염**되는 데이터 무결성 갭이었다. + - 사장님 방향은 "지정가(주문 limit_price) 백필 + 로깅 + (이 기회에) 알람 기능 검토"이며 본 건은 M2 포함으로 확정되었다. 본 Discovery는 그 방향을 trex-core 변경 단위로 정식화한다. + +# 2. 요구사항 판정 결과 +이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. + +- 판정: 신규 +- 근거 + - dcy-026(매도 세금/수수료 정책), dcy-029(운영 관측성·PnL 뷰)는 체결 *기록*과 인접하나, "체결가 자체가 0/None일 때의 무결성 방어"는 어디서도 다루지 않았다. 기존 코드의 `if avg else 0.0`/`fs.avg_price if not None else 0.0`은 방어가 아니라 0 통과 경로다. + - 신규 알람(alert) 능력 검토가 포함되어, 기존 Discovery의 미세 조정이 아니라 독립 주기로 추적하는 편이 명확하다. + +# 3. 문제점의 요약 +문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. + +- 현재 상태 + - KIS 체결조회(`inquire-daily-ccld`)가 체결수량(`ccld_qty`)은 보고하나 평균체결가(`avg`)가 없거나 0으로 오는 순간 스냅샷이 있을 수 있다. `adapters/broker/kis.py:209,219`(완전체결)·`:232,238`(부분체결+취소)·`:243`(부분체결)에서 `fill_price = float(avg) if avg else 0.0` → `avg_price = fill_price or None`로, **체결인데 가격이 None/0**인 FillStatus가 생성된다. + - 이 값이 두 곳을 동시에 오염시킨다. (1) 슬롯 평단: 초기 매수 시 `application/execution.py:242-251`에서 `old_avg=0, fill_price=0 → new_avg=0`로 HOLDING 전이 → `slot.avg_price=0` 저장. (2) 원장: `application/execution.py:424` `price = float(fs.avg_price) if fs.avg_price is not None else 0.0` → **매수가 price=0으로 ledger_outbox에 기록**. + - **무경고**: 원장 skip 가드는 `portfolio_id` 부재(`:405`)·`execution_id` 부재(`:415`) 두 가지뿐이고 둘 다 WARN한다. "체결됐는데 price≤0"은 어느 가드에도 안 걸려 **조용히 통과**한다. + - **영구성**: 완전체결(`ccld_qty >= ord_qty`)이면 즉시 HOLDING 전이 + `clear_dispatch`(`execution.py:248-285`)라 **다음 폴링을 하지 않는다.** KIS가 다음 틱에 올바른 평균가를 줘도 다시 읽지 않아 0이 영구히 박힌다. + - **하류 영향 확인됨**: 슬롯 평단 0 → slots RPC가 0.0 전송(`messaging_handlers.py:561`) → tsl이 `≤0 → None`(tsl_strategy.py:213)로 보고 tier 무시 2% 폴백 매도(무탐지). 원장 price=0 → 실현손익이 cost basis가 아닌 당일 net 현금흐름으로 계산되므로(`operator_report.py`) 매수 대금 과소 → **실현손익 과대 표시**. + - trex-core에는 구조화된 알람(alert) 채널이 없다. 운영자 표면은 CLI `trex-core report` + 구조화 로그가 전부이며, 이상 징후를 푸시로 알리는 메커니즘은 부재(`daemon.py:248`에 alert 주석 1건뿐). +- 기대 상태 + - 체결됐는데 브로커 평균체결가가 없거나 0이면, 슬롯 평단과 원장 price를 **주문 지정가(limit_price)로 백필**해 cost basis가 0으로 오염되지 않는다. + - 백필이 발생하면 반드시 식별 가능한 WARN 로그(슬롯·심볼·주문·대체가)를 남겨 사후 탐지·정합화가 가능하다. + - (검토 대상) 이런 무결성 이상을 운영자에게 즉시 알리는 알람 채널의 도입 가치/범위를 평가한다. +- 이해관계자 + - 팀장(Claude) — Discovery/게이트 관리, 본 finding 추적자 + - 사장님 — M2 범위·알람 채널·비가역 운영 결정 승인자 + - trex-core-dev — delivery 담당 + - 운영자 — 백필/이상 알림의 최종 소비자, 첫 거래일 매도 모니터링 주체 +- 제약사항 + - trex-core wire 계약(slots RPC 필드 등) 변경은 blast radius가 커 본 건 범위에서 가급적 피한다(slot.avg_price 값 정정은 wire 스키마 변경이 아님). + - 백필 가격은 추정치다 — 실제 체결가와 다를 수 있으므로 "정확값"이 아니라 "0보다 합리적인 근사 + 추적 가능"이 목표다. + - orb 매수는 지정가 주문이라 limit_price가 존재한다는 전제에 의존한다(시장가/지정가 부재 케이스는 별도 폴백 필요). + +## 주요 사용자 시나리오 +사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. + +- 운영자: 장중 매수 체결 직후 KIS 체결조회가 일시적으로 평균가 빈 행을 반환 → 현재는 슬롯 평단 0 + 원장 매수 price 0이 영구히 남아 그날 실현손익 리포트가 부풀려진다. 백필 후엔 지정가 근사로 기록되고 WARN으로 추적된다. +- tsl 매도 데몬: 평단 0인 holding 슬롯을 받아 tier 무시 2% 폴백으로 매도(정책 위반)하고 아무도 모른다. 백필 후엔 정상 tier로 평가된다. +- 운영자(알람 검토 시나리오): 무결성 이상(백필 발생, 0가격 체결)이 즉시 푸시 알람으로 도달해 당일 수기 점검 없이도 인지한다. + +# 4. 이번에 정의할 변경 +이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. + +- 변경 1 — 체결가 무결성 가드 + 지정가 백필: 체결(완전/부분) 처리에서 브로커 평균체결가가 None/0이면, 슬롯 평단과 원장 price를 주문 지정가(`slot.limit_price`/주문 컨텍스트)로 백필한다. 지정가도 없으면 최종 폴백 정책을 적용한다(범위 경계 참조). +- 변경 2 — 백필/0가격 체결 로깅: 백필 또는 price≤0 체결 통과 시 식별 가능한 WARN 로그를 남긴다(슬롯·심볼·주문·원가격·대체가격·사유). 원장 skip 가드와 동일한 운영 가시성 수준으로 맞춘다. +- 변경 3 — 알람(alert) 능력 검토(스파이크): trex-core 무결성 이상(백필 발생 등)을 운영자에게 푸시로 알리는 알람 채널의 도입 가치·범위·채널을 평가하고, M2 포함 여부와 최소 구현 범위를 결정한다. + +## 영향 범위 +변경이 미칠 코드/문서/운영 범위를 명시한다. + +- 코드 영향: + - `trex_core/application/execution.py` — `_on_filled`/`_on_partial_cancel`의 평단 계산(`:242-251`,`:305-314`)과 `_record_ledger`의 price 결정(`:424`)에 백필 분기 추가. + - `trex_core/adapters/broker/kis.py` — 평균가 None/0 표면화 지점(`:209,219,232,238,243`)의 의미 정합성 검토(어디서 백필할지 결정: adapter vs application). + - (알람 채택 시) 신규 alert 포트/어댑터 모듈 + 호출 지점. 본 Discovery에서는 검토만, 구현 범위는 OQ로 둔다. +- 문서 영향: + - `docs/discovery/index.md` 신규 행 추가. + - (알람 채택 시) `docs/runtime-flows.md`에 알람 흐름 추가 가능 — 채택 결정 후 반영. +- 운영 영향: + - 백필 발생 시 원장에 지정가 근사가 들어가므로, 운영자 PnL 리포트의 정확도가 개선되되 "근사"임을 런북에 명시 필요. + - (알람 채택 시) 운영자 알림 수신 채널·임계 설정이 신규 운영 절차로 추가됨. + +# 5. 요구사항 목록 +실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. + +## 기능 요구사항 +- FR-1: 체결(완전·부분) 처리에서 브로커 평균체결가가 None 또는 ≤0이면, 슬롯 평단(`slot.avg_price`)을 주문 지정가로 백필해 0으로 저장되지 않게 한다. +- FR-2: 동일 조건에서 원장 기록 price(`_record_ledger`)도 동일한 백필 가격을 사용해 매수/매도가 price=0으로 기록되지 않게 한다. +- FR-3: 백필이 발생하거나 price≤0 체결이 통과할 때, 슬롯·심볼·주문ID·원가격·대체가격·사유를 포함한 WARN 로그를 남긴다(원장 skip 가드와 동일한 가시성 수준). +- FR-4: 알람(alert) 능력을 검토해 (a) 도입 여부, (b) 채널(로그 기반/AMQP 이벤트/외부 푸시), (c) M2 최소 범위를 산출물로 제시한다. + +## 비기능 요구사항 +- NFR-1: 백필은 trex-core wire 스키마(slots RPC 필드)를 변경하지 않는다(값 정정만, 계약 불변). +- NFR-2: 백필 분기는 happy path(정상 평균가 수신) 성능·동작에 영향이 없어야 하며, 정상 경로에서는 기존과 동일하게 동작한다. +- NFR-3: 백필 가격은 추적 가능해야 한다 — 어떤 체결이 실제 평균가가 아니라 백필 근사로 기록됐는지 로그/플래그로 구분 가능해야 한다. + +## 범위 경계 +이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. + +- In Scope + - 체결가 None/0 → 지정가 백필(슬롯 평단 + 원장 price) + WARN 로깅 (FR-1~3). + - 알람 능력 검토 산출물(FR-4) — 검토/결정까지 In Scope. +- Out of Scope + - slots RPC 등 wire 계약 스키마 변경. + - 과거에 이미 0으로 기록된 원장/슬롯의 소급 정정(별도 데이터 보정 작업). + - {{DECIDE_ALARM_IMPL_SCOPE}} — 알람의 실제 구현 범위(검토 결과 M2 포함으로 결정되면 In Scope로 승격, 아니면 후속 이월). + +# 6. 리스크/가정 목록 +불확실성과 전제 조건을 분리해서 적는다. + +## 리스크 +- R-1 (Medium): 지정가 백필도 추정치라, 실제 체결가와 차이가 나면 원장 cost basis가 부정확해진다(0보다는 낫지만 정확하진 않음). + - 완화 방안: 백필을 WARN+플래그로 추적 가능하게 남기고, 런북에 "백필 항목은 근사" 명시. 필요 시 후속 정합화(잔고/주문 대조) 검토. +- R-2 (Medium): 백필 시점을 adapter(kis.py)로 둘지 application(execution.py)로 둘지에 따라 paper 브로커·재구성(dcy-021) 경로와의 일관성이 달라질 수 있다. + - 완화 방안: 백필 결정 지점을 단일화(application 권장 — 주문 컨텍스트/limit_price 접근 용이)하고 design에서 paper/재구성 경로 회귀 테스트. +- R-3 (Low): 알람 채널 도입이 범위를 키워 M2 일정을 압박할 수 있다. + - 완화 방안: FR-4를 "검토 산출물"로 제한하고, 구현은 검토 결과에 따라 별도 결정(OQ-1). + +## 가정 +아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. + +- A-1: orb 매수는 지정가 주문이라 대다수 케이스에서 `slot.limit_price`(또는 주문 지정가)가 존재한다. +- A-2: KIS 평균가 누락은 드문 순간 레이스이며, 정상 happy path에서는 평균가가 정상 수신된다(저빈도·고영향 특성). +- A-3: trex-core 기존 watchdog/원장 outbox/이벤트 발행 인프라는 그대로 재사용 가능하며, 본 변경은 그 위의 값 무결성 가드다. + +## 오픈 질문 +답을 아직 모르는 항목을 적는다. + +- OQ-1: 알람(alert) 능력을 M2에 실제 구현까지 포함할지, 검토 산출물만 내고 구현은 후속으로 이월할지. 채널은 무엇으로 할지(구조화 로그 + 로그 기반 알람 / AMQP 이벤트 / 외부 푸시). + - 상태: Deferred (→ design) + - 처리 방안: design 단계에서 trex-core-dev가 채널별 장단점·최소 범위 분석 산출물 작성. 그 분석을 근거로 **알람의 M2 구현 포함 여부는 design review에서 사장님이 최종 결정**(범위·일정 결재 사안). FR-3 WARN 로깅은 채택 여부와 무관하게 M2 필수. + - 종료 조건: design review에서 알람 구현이 In Scope/후속 이월 중 하나로 확정되면 Answered (사장님 2026-06-21: design 분석 후 확정 합의). +- OQ-2: 주문 지정가(limit_price)마저 부재하거나 ≤0인 경우의 최종 백필 폴백 정책(예: 직전 종가/현재가 조회 / 해당 체결 보류·재폴링 / 기록 skip+강한 알림 중 무엇). + - 상태: Deferred (→ design) + - 처리 방안: design 단계에서 trex-core-dev가 후보별 영향 분석, 팀장 승인. + - 종료 조건: 폴백 우선순위가 단일 정책으로 확정되면 Answered (사장님 2026-06-21: design 분석 후 확정 합의). + +# 7. 초기 성공 기준 +이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. + +- S-1: 평균가 None/0 체결 입력에 대해 슬롯 평단과 원장 price가 0이 아닌 지정가 백필 값으로 기록된다. +- S-2: 백필/0가격 체결 통과 시 식별 가능한 WARN 로그가 100% 남는다(무경고 통과 0건). +- S-3: 정상 평균가 수신 경로는 기존과 동일하게 동작한다(회귀 0건). 알람 능력 검토 산출물이 confirm-discovery에 제출된다. + +## 측정 방식 및 데이터 출처 +- S-1 측정 방식: 평균가 None/0을 주입한 단위/통합 테스트로 슬롯 평단·ledger_outbox price 검증 / 데이터 출처: 신규 테스트 + ledger_outbox 레코드. +- S-2 측정 방식: 백필 경로 테스트에서 WARN 로그 발생 단언 / 데이터 출처: 구조화 로그. +- S-3 측정 방식: 기존 execution/ledger 테스트 스위트 회귀 + 검토 문서 제출 여부 / 데이터 출처: 테스트 결과 + Discovery 첨부 산출물. + +# 8. REQ로 넘기기 전 확인 체크 +REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. + +- [x] 범위(In Scope/Out of Scope) 확정 + - 기준: 알람 구현 범위(OQ-1)가 In Scope/이월 중 하나로 확정 — FR-1~3는 M2 In Scope 확정, 알람 구현 범위는 design review로 Deferred(설계 분석 후 사장님 결정) +- [x] 성공 지표의 측정 방식/데이터 출처 확정 + - 기준: S-1~S-3 측정안에 사장님/팀장 이견 없음 +- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 + - 기준: 현재 High 리스크 없음(R-1/R-2 Medium) — design에서 R-2 결정 지점 단일화 책임자(trex-core-dev) 지정 +- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 + - 기준: OQ 목록에 `Open` 상태 항목이 0개이거나, 잔여 항목 전부 `Deferred` 처리 완료 — OQ-1/OQ-2 모두 design Deferred(사장님 2026-06-21) +- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 + - 기준: confirm-discovery 시 팀장이 CONFIRMED_BY로 기록 — 팀장(Claude) + +# 9. 파일 처리 결과 +이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. + +- 처리 결과: 생성 +- 생성 경로: docs/discovery/dcy-032_20260621_fill-missing-price-cost-basis-backfill.md +- 참조 문서: + - docs/discovery/dcy-026_20260612_sell-transaction-tax-and-fee-policy.md (체결 기록·세금/수수료 인접 정책) + - docs/discovery/dcy-029_20260616_operational-observability-logging-and-pnl-view.md (운영 관측성·실현 PnL 정의) + - trex_core/application/execution.py, trex_core/adapters/broker/kis.py, trex_core/application/operator_report.py (현재 구현 근거) + +# 10. 사용자 결정 필요 항목 요약 +사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. + +## DECIDE +- (해소→design) 알람 능력 M2 구현 포함 여부 + 채널 선택(OQ-1) — design 분석 산출물 근거로 design review에서 사장님 결정(사장님 2026-06-21). +- (해소→design) 지정가 부재 시 최종 백필 폴백 정책(OQ-2) — design 단계 trex-core-dev 분석 후 팀장 확정(사장님 2026-06-21). + +## CONFIRM +- (확정) 본 건 FR-1~3(무결성 가드: 지정가 백필 + 로깅)의 M2 포함 — 사장님 결정(2026-06-21). + +## DATA +- {{DATA_KIS_AVG_MISSING_FREQUENCY}} — 실거래에서 KIS 평균가 누락이 실제로 관측된 빈도(있다면 운영 로그/사례). 백필 폴백 정책 결정의 근거 보강용. (없으면 저빈도·고영향 가정으로 진행) + +# 11. Discovery Freeze +REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. + +## 섹션 참조 +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 +- Handoff Decision: REQ Drafting 진행 가능 +- Handoff Rationale: FR-1~3(지정가 백필+로깅) M2 포함 사장님 확정. OQ-1(알람 구현범위/채널)·OQ-2(지정가 부재 폴백) 모두 design 단계 trex-core-dev 분석 후 확정으로 Deferred 처리(사장님 2026-06-21) → Open OQ 0건. REQ 초안 작성 인계 가능. +- Ready for REQ Drafting: true +- Confirmed By: 팀장(Claude) +- Confirmed At (KST): 2026-06-21 (KST) diff --git a/docs/discovery/index.md b/docs/discovery/index.md index b107bbf..3bd11fc 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -37,3 +37,4 @@ | dcy-029_20260616_operational-observability-logging-and-pnl-view | 20260616 | 운영 관측성 — 주문 결과 로깅 + 로그 타임스탬프(KST) + 청산사유 분류 + 슬롯/PnL 조회 뷰 + 운영자 런북 (issue #29) | [Discovery](./dcy-029_20260616_operational-observability-logging-and-pnl-view.md) | confirmed | | dcy-030_20260616_slippage-limit-price-tick-size-snapping | 20260616 | slippage 지정가 KRX 호가단위 미반영 → KIS 거부 (tick-size 스냅, 상한 준수 방향, issue #28) | [Discovery](./dcy-030_20260616_slippage-limit-price-tick-size-snapping.md) | confirmed | | dcy-031_20260619_event-publisher-connection-reliability | 20260619 | 이벤트 발행 연결 신뢰성 — idle heartbeat 사망 후 첫 발행 유실 + DLQ 가시성 (OPS-005) | [Discovery](./dcy-031_20260619_event-publisher-connection-reliability.md) | confirmed | +| dcy-032_20260621_fill-missing-price-cost-basis-backfill | 20260621 | 체결가 누락(브로커 평균가 0/None) → 슬롯 평단·원장 cost basis 0 오염(무경고·영구) → 지정가 백필 + 로깅 + 알람 검토 | [Discovery](./dcy-032_20260621_fill-missing-price-cost-basis-backfill.md) | confirmed | diff --git a/docs/operations.md b/docs/operations.md index e0759d8..4b2923d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -149,7 +149,54 @@ trex-core는 발행 연결의 idle 종료를 예방하기 위해 `trex-execution --- -## 5. 디버깅 / 트러블슈팅 +## 5. 체결가 백필 WARN 발생 시 운영자 점검 절차 (REQ-095/096 / dcy-032 / BAT-048) + +KIS 체결조회가 체결수량은 보고하면서 평균체결가(`avg`)를 None 또는 0으로 반환하는 순간 레이스가 발생하면, trex-core는 주문 지정가로 백필해 슬롯 평단·원장 cost basis에 0이 저장되지 않도록 보호하고 WARN 로그를 발행한다. + +### WARN 유형 및 로그 식별 + +| WARN 유형 | `reason` | 발생 조건 | 의미 | +|---|---|---|---| +| `avg_price_backfill` | `reason=avg_price_backfill` | avg_price=None/0, limit_price>0 | 지정가로 백필 성공 — 값은 보정됐으나 추적 필요 | +| `fill_price_unresolvable` | `reason=fill_price_unresolvable` | avg_price=None/0, limit_price=None/≤0 | 백필 불가 — 체결 skip, 슬롯 PENDING 유지 | + +```bash +# avg_price_backfill 확인 (백필 성공) +grep "avg_price_backfill" /tmp/trex-core.log + +# fill_price_unresolvable 확인 (백필 불가, 운영자 즉시 개입) +grep "fill_price_unresolvable" /tmp/trex-core.log +``` + +Loki LogQL: +```logql +{job="trex-core"} | json | line_contains "avg_price_backfill" +{job="trex-core"} | json | line_contains "fill_price_unresolvable" +``` + +### `avg_price_backfill` WARN 발생 시 점검 항목 + +1. **로그에서 슬롯·주문 정보 확인**: `slot_id`, `symbol`, `broker_order_id`, `avg_price`(원 값, None 또는 0), `backfill_price`(대체 지정가)를 로그에서 추출. +2. **원장 레코드 확인**: 해당 `slot_id`의 `ledger_outbox` price가 백필값(지정가)으로 기록됐는지 확인. + ```bash + psql "$TREX_POSTGRESQL_DSN" -c "SELECT * FROM ledger_outbox WHERE correlation_id LIKE '%%'" + ``` +3. **슬롯 평단 확인**: `trex-core slots` 또는 DB에서 해당 슬롯의 `avg_price`가 0이 아닌 지정가 기반 값인지 확인. +4. **판단**: 백필 지정가는 "0보다 합리적인 근사"지만 실제 체결가와 다를 수 있다. 중요한 포지션이라면 KIS HTS/API에서 실제 체결가를 확인해 `ledger_outbox` 수기 보정 여부를 검토한다(Discovery R-1). + +### `fill_price_unresolvable` WARN 발생 시 즉시 조치 + +1. **로그에서 슬롯 정보 확인**: `slot_id`, `symbol`, `broker_order_id` 추출. +2. **슬롯 상태 확인**: `trex-core slots --status pending` — 해당 슬롯이 PENDING_BUY 상태로 고착됐는지 확인. +3. **KIS HTS/API에서 실제 체결 여부와 가격 확인**. +4. **운영자 조치 선택**: + - 체결됐다면: `trex-core cancel `로 슬롯을 롤백 후 재진입 매수. + - 미체결이라면: `trex-core cancel `로 주문 취소 → 슬롯 rollback(다음 폴링에서 is_terminal_reject 경로). +5. **상황이 반복된다면**: Discovery 또는 이슈 트래커에 기록 — `limit_price=None` 매수 주문이 들어온 경로 조사 필요(정상 운용 시 orb 전략은 지정가 주문을 제출한다, Discovery A-1). + +--- + +## 6. 디버깅 / 트러블슈팅 | 증상 | 원인 | 조치 | |---|---|---| @@ -159,7 +206,8 @@ trex-core는 발행 연결의 idle 종료를 예방하기 위해 `trex-execution | intent `rejected: capacity_full` | `max_active_slots`(M) 도달 | 매도 후 재시도 또는 M 상향 | | 매도 미발생 + WARN `sell dispatch skipped` | 브로커 잔고 0(divergence) | reconcile(운영자) — 미러 fallback 없음(설계) | | `RpcTimeoutError` | 데몬 미기동/소비 스레드 크래시 | `/tmp/trex-core.log` traceback 확인 | -| 미체결 `pending_*` 고착 | 취소 감지 누락/timeout 미설정 | `trex-core cancel ` 백스톱; pending stale WARN 확인 | +| 미체결 `pending_*` 고착 | 취소 감지 누락/timeout 미설정/fill_price_unresolvable | `trex-core cancel ` 백스톱; pending stale WARN + `fill_price_unresolvable` WARN 확인(§5) | +| `avg_price_backfill` WARN | KIS avg_price 순간 레이스(None/0 반환) | 슬롯 평단·원장 price는 지정가로 보정됨 — §5 점검 절차 참고 | ### DB 직접 조회 ```bash diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index c4bc726..b8133ba 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-19 (BAT-047 — dcy-031 이벤트 발행 연결 신뢰성) +- Last Updated (KST): 2026-06-21 (BAT-048 — dcy-032 체결가 0/None 백필 가드 + WARN 로깅) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -248,6 +248,7 @@ - 가격/주문유형 결정(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 연계). - **슬리피지 보호 지정가 + KRX 호가단위 스냅(dcy-022/REQ-070, dcy-030/REQ-083)**: 슬롯에 `slippage_pct` 가 있고 `limit_price` 도 있으면, raw 실효가 = `limit_price×(1±pct/100)` 를 KRX 호가단위(REQ-083 `domain/pricing.py`)로 **스냅**해 제출한다. 방향 = **상한 준수**: 매수 = `floor_to_tick(raw)`(slippage 상한 미초과), 매도 = `ceil_to_tick(raw)`(매도 하한 미초과). band<1tick 엣지에서 미체결을 막기 위해 매수 = `max(floor_to_tick(raw), ceil_to_tick(limit_price))`, 매도 = `min(ceil_to_tick(raw), floor_to_tick(limit_price))` 로 entry 유효 호가를 보장한다. `limit_price` 없으면(시장가) 스냅 무시. 미지정 슬롯은 기존 동작(회귀 없음). 주식 전용(ETF/ETN·2,000원 미만 비주식 미지원, dcy-030 OQ-1). 이전 구현은 `int(ceil/floor)` 무스냅이라 호가단위 위반으로 KIS 거부(issue #28) — CHG-20260616-01 에서 스냅·방향(체결 보장→상한 준수) 정정. 2. poll: `list_dispatched_pending(instance_key)`(broker_order_id NOT NULL) → `BrokerPort.poll_fill` → 체결 시 `pending_buy→holding`(avg_price/qty) / `pending_sell→inactive`(점유 초기화·자산 정체성 보존), 거부 시 롤백. 전이 후 `clear_dispatch`. + - **체결가 백필 가드(dcy-032/REQ-095/096, BAT-048)**: `_resolve_fill_price(fs, slot)` 헬퍼가 체결가를 결정. (1) `fs.avg_price`가 양수이면 그대로 사용(happy path — WARN 없음). (2) `fs.avg_price=None` 또는 `≤0`이고 `slot.limit_price>0`이면 지정가로 백필 + `reason=avg_price_backfill` **WARN 1건** 발행(슬롯 평단·원장 price에 백필값 사용, 0 저장 방지). (3) 지정가마저 None/≤0이면 체결 처리를 **skip**(슬롯 전이·원장 기록 미수행) + `reason=fill_price_unresolvable` 강 WARN 발행 — 슬롯이 PENDING 상태로 남아 stale_pending 경보가 운영자 개입을 유도. 이 가드는 `_on_filled`·`_on_partial_cancel`·`_record_ledger` 세 경로에 공통 적용된다(`reconcile()` 경로도 동일 헬퍼를 탄다). - 외부 취소 감지(dcy-013/REQ-012/013, issue #9): 계좌에서 직접 취소된 주문을 KIS `poll_fill` 이 `cncl_yn="Y"` 로 파싱한다. **체결 0 취소** → `FillStatus(state="cancelled")` → 기존 롤백 경로(`pending_sell→holding`, `clear_dispatch`)로 자동 복구(영구 갇힘 제거). **부분 체결 후 취소**(`is_partial_cancel`) → 체결분 원장 1회 기록 + 슬롯 qty 차감(매도: 잔여 held, cost basis 유지 / 매수: 체결분만) + `holding` 복귀(OQ-1 A안, 과매도·이중기록 방지). - pending stale 워치독(dcy-013/REQ-014 FR-3, Alert-only): `accepted`/`partially_filled`(취소 아님)로 다음 tick 대기 중인 슬롯이 `TREX_PENDING_STALE_THRESHOLD`(초, 0=비활성)를 초과해 머물면 WARN 로그 + `ExecutionResult.stale_pending` 카운터로 가시화한다. **상태는 변경하지 않는다**(auto-remediation 후속) — 취소 감지 누락(R-1) 등 미지 원인의 무한 갇힘이 silent 로 남지 않게 하는 안전망. - **timeout 자동 취소(dcy-022/REQ-069)**: `accepted`/`partially_filled` 상태 슬롯에 `timeout_sec` 가 있고 경과 시간(`now - slot.updated_at`)이 초과하면 `broker.cancel_order` 를 best-effort 로 송신하고 `ExecutionResult.timeout_cancelled` 를 증가시킨다. **슬롯 상태는 이 시점에 변경하지 않는다** — 다음 poll 에서 `is_terminal_reject`(전량 취소) → rollback / `is_partial_cancel`(부분 체결 후 취소) → 부분 보존(기존 경로 재사용). 취소-체결 race: `cancel_order` 가 이미 체결된 주문을 받으면 브로커가 거부 → 다음 poll `is_filled` → 정상 체결 경로 수렴(임의 롤백 없음, FR-4). 기존 `timeout_sec` 없는 슬롯은 stale-alert 만 실행(REQ-014 회귀 없음). diff --git a/docs/srs/Documentation/req-097_alert-capability-spike.md b/docs/srs/Documentation/req-097_alert-capability-spike.md new file mode 100644 index 0000000..dc63fa4 --- /dev/null +++ b/docs/srs/Documentation/req-097_alert-capability-spike.md @@ -0,0 +1,74 @@ +# req-097: 알람(alert) 능력 검토 스파이크 산출물 + +- Status: Approved +- Type: Documentation +- Priority: Medium +- Owner: trex-core-dev + +## Intent +- trex-core에는 현재 구조화된 알람(alert) 채널이 없다. 운영자 표면은 CLI `trex-core report`와 구조화 로그가 전부이며, 이상 징후(체결가 백필 발생, price≤0 체결 등)를 운영자에게 즉시 푸시로 알리는 메커니즘이 부재한다. +- 본 REQ는 알람 능력 도입 여부와 최소 구현 범위를 결정하기 위한 스파이크(설계 분석) 산출물 작성을 요구한다. 실제 알람 구현 포함 여부는 이 산출물을 근거로 design review에서 사장님이 최종 결정한다. +- 본 REQ 자체는 "분석 산출물 제출"까지가 요구사항이며, 알람 구현은 설계 분석 결과에 따라 조건부로 포함 또는 후속 이월된다. + +## Requirement +- trex-core-dev는 알람(alert) 채널 후보(아래 최소 3가지)에 대해 장단점·최소 구현 범위·M2 일정 적합성을 분석한 설계 스파이크 문서를 작성해야 한다. + - 후보 A: 구조화 로그 기반 (현재 Loki JSON 로그를 알람 소스로 활용 + 로그 기반 외부 알림 파이프라인) + - 후보 B: AMQP 이벤트 기반 (trex.events exchange 또는 신규 exchange에 알람 이벤트 발행 — wire 계약 영향 검토 필수) + - 후보 C: 외부 푸시 직접 연동 (Slack/webhook/등) +- 분석 산출물은 `docs/` 아래 별도 파일(또는 design 산출물 디렉토리)로 작성하며, design review 전에 팀장에게 제출되어야 한다. +- 알람 구현이 M2에 포함될 경우 최소 구현 범위(무엇을, 어디서, 어떤 채널로)를 명시해야 한다. + +## Acceptance Criteria + +1. Given draft-batch-design 단계, When trex-core-dev가 알람 스파이크 분석을 수행할 때, Then 후보 A·B·C(이상 3가지)에 대해 각각 장점·단점·최소 구현 범위·M2 일정 적합성을 명시한 문서가 작성된다. +2. Given 후보 B(AMQP 이벤트 기반) 분석, When 설계 문서가 작성될 때, Then wire 계약(slots RPC 스키마, trex.events 메시지 구조 등) 변경 여부와 blast radius(trex-orb-v1, trex-tsl-v1 영향)가 명시된다. wire 계약 변경이 수반되면 팀장에게 즉시 보고하고 멈춘다. +3. Given 분석 산출물이 팀장에게 제출된 후 design review, When 사장님이 알람 구현 범위를 결정할 때, Then "M2 포함" 또는 "후속 이월" 중 하나로 확정되어 REQ-097 Notes에 기록된다. +4. Given 알람 구현이 M2 포함으로 결정된 경우, When 신규 REQ가 생성될 때, Then 본 REQ에서 분석된 채널과 최소 범위를 근거로 구현 REQ가 별도 생성된다(본 REQ는 분석 산출물 REQ로 유지). +5. Given 알람 구현이 후속 이월로 결정된 경우, Then 본 REQ는 산출물 제출로 완료 처리되며, 구현 REQ는 별도 Discovery에서 정의한다. + +## Impacted Area +- Docs: + - `docs/` 아래 신규 알람 스파이크 분석 파일 (design 단계 산출물, 경로 미확정) + - `docs/interface-contract.md` — 후보 B 채택 시 wire 계약 변경 필요 여부 검토 대상 (변경 시 별도 승인 필요) +- Modules: 없음 (본 REQ는 분석 산출물만 요구) +- Tests: 없음 (본 REQ는 문서 산출물; 구현 REQ 생성 시 해당 REQ에서 AC/테스트 정의) + +## Notes +- **[사장님 결정 2026-06-21]** 스파이크 산출물(BAT-048 design.md 알람 섹션) 제출로 AC-1·AC-2 충족 → 본 REQ는 **산출물 완료**(AC-5 경로). **알람 구현은 BAT-048(백필+WARN 로깅) 완료 후 별도 후속 트랙으로 진행**하며, **M2 포함 여부도 그때 재결정**한다(현 시점 M2 미확정). design 권고: 후보 A(로그 기반)=WARN 로그+Loki 알람 룰, 후보 B(AMQP)=wire 계약 변경이라 제외, 후보 C(외부 푸시)=M3. BAT-048 구현 범위는 REQ-095+REQ-096만. +- **조건부 구현**: 본 REQ 완료(AC-1~2 충족, 분석 산출물 제출)는 알람 구현 REQ 생성의 선행 조건이다. 알람 구현 포함 여부(AC-3)는 사장님 결정 사안이며 design review 게이트에서 확정된다. trex-core-dev는 이 결정을 임의로 내리지 않는다. +- **wire 계약 변경 주의**: 후보 B(AMQP 이벤트) 분석 시 wire 계약 변경이 수반되면 trex-orb-v1·trex-tsl-v1에 파급 효과가 있다. 이 경우 즉시 팀장에게 보고하고 멈춘다(CLAUDE.md 교차 영향 사안 정책). +- FR-3 WARN 로깅(REQ-096)은 알람 채널 채택 여부와 무관하게 M2 필수 항목이며, 본 REQ와 독립적으로 진행된다. +- Discovery: dcy-032_20260621_fill-missing-price-cost-basis-backfill.md (FR-4, OQ-1) + +## Change Log + +### CHG-20260621-01 +- Date: 2026-06-21 +- Author: trex-core-dev + +#### Change Summary +- 최초 생성 (draft-req dcy-032) + +#### Intent +- 알람 능력 도입 여부를 결정하기 위한 스파이크 산출물 요구사항 신규 정의. 실제 구현 포함 여부는 design review 후 사장님 결정. + +#### Acceptance Criteria Delta +- Added: + - AC-1~5 신규 정의 +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: docs/ 신규 스파이크 분석 파일, docs/interface-contract.md 검토 대상 +- Modules: 없음 +- Tests: 없음 + +#### Delivery Trace Delta +- Batches: 미정 (suggest-batch-reqs 이후 편성) +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no (최초 생성) +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: design review 후 AC-3 결과를 Notes에 반영 필요 diff --git a/docs/srs/Exception/req-095_fill-price-zero-backfill-guard.md b/docs/srs/Exception/req-095_fill-price-zero-backfill-guard.md new file mode 100644 index 0000000..d0d2a63 --- /dev/null +++ b/docs/srs/Exception/req-095_fill-price-zero-backfill-guard.md @@ -0,0 +1,77 @@ +# req-095: 체결가 0/None 지정가 백필 가드 (슬롯 평단 + 원장 price) + +- Status: Approved +- Type: Exception +- Priority: High +- Owner: trex-core-dev + +## Intent +- KIS 체결조회가 체결수량은 보고하면서 평균체결가(`avg`)를 None 또는 0으로 반환하는 순간 스냅샷이 발생할 수 있다. 현재 코드는 이 케이스를 무경고로 통과시켜 슬롯 평단(`slot.avg_price`)과 원장 cost basis(`_record_ledger` price)가 동시에 0으로 영구 오염된다. 완전체결 시 `clear_dispatch`로 재폴링이 없어 0이 영구화된다. +- 본 REQ는 체결(완전·부분) 처리에서 브로커 평균체결가가 None 또는 ≤0이면 주문 지정가(`limit_price`)로 백필해, 슬롯 평단과 원장 price가 0으로 저장되지 않도록 무결성 가드를 추가한다. +- 이 변경은 wire 계약(slots RPC 스키마) 변경 없이 값 정정만 수행하며, 정상 평균가 수신 경로(happy path)에 영향을 주지 않는다. + +## Requirement +- 체결(완전 체결 및 부분 취소 후 체결) 처리 시, 브로커가 제공한 `fs.avg_price`가 None 또는 ≤0인 경우, 슬롯의 `avg_price` 계산(`_on_filled`, `_on_partial_cancel`)에 사용하는 `fill_price`를 주문 지정가(`slot.limit_price` 또는 동등한 주문 컨텍스트의 지정가)로 대체(백필)해야 한다. +- 동일 조건에서 `_record_ledger`의 `price` 결정도 동일한 백필 가격을 사용해야 하며, price=0인 원장 레코드가 생성되어서는 안 된다. +- 백필 결정 지점(adapter vs application)과 지정가마저 부재/≤0인 경우의 최종 폴백 정책은 design 단계에서 확정한다(아래 Notes 참조). + +## Acceptance Criteria + +1. Given `fs.avg_price`가 None인 체결 이벤트와 `slot.limit_price > 0`인 슬롯, When `_on_filled`의 평단 계산이 실행될 때, Then `fill_price`로 `slot.limit_price`가 사용되고 `slot.avg_price`에 지정가 기반 가중평균이 저장된다(0 저장 없음). +2. Given `fs.avg_price`가 0.0인 체결 이벤트와 `slot.limit_price > 0`인 슬롯, When `_on_partial_cancel`의 평단 계산이 실행될 때, Then `fill_price`로 `slot.limit_price`가 사용되고 `slot.avg_price`가 0이 아닌 값으로 저장된다. +3. Given `fs.avg_price`가 None인 체결 이벤트, When `_record_ledger`의 price 결정이 실행될 때, Then `LedgerWorkItem.price`로 0.0이 아닌 백필 지정가(≥0 초과)가 기록된다. +4. Given `fs.avg_price`가 정상 양수인 체결 이벤트(happy path), When `_on_filled`/`_on_partial_cancel`/`_record_ledger`가 실행될 때, Then 기존과 동일하게 `fs.avg_price`가 사용되며 백필 분기가 개입하지 않는다(회귀 없음). +5. Given wire 계약 관점(slots RPC 응답 스키마), When 백필이 발생할 때, Then RPC 메시지 구조·필드 집합이 변경되지 않는다(NFR-1: 값 정정만, 계약 불변). +6. Given `slot.limit_price`가 None 또는 ≤0인 극단 케이스(지정가마저 부재), When 백필 경로가 진입될 때, Then **체결 처리를 skip(슬롯 전이·원장 기록 미수행)하고 slot_id·symbol·broker_order_id·avg_price·limit_price·`reason=fill_price_unresolvable`를 포함한 강 WARN 로그를 발행한다**(0 저장 0건, 무경고 통과 0건). 슬롯은 PENDING 상태로 남아 watchdog/stale_pending 경보가 운영자 개입을 유도한다. [design BAT-048 KD-2 확정, 팀장 승인 2026-06-21] + +## Impacted Area +- Docs: `docs/runtime-flows.md` — 체결 처리 흐름에 백필 분기 추가 필요 (implementation 후 갱신) +- Modules: + - `trex_core/application/execution.py` — `_on_filled`(`:242-251`), `_on_partial_cancel`(`:305-314`), `_record_ledger`(`:424`) 의 fill_price/price 결정 로직 + - `trex_core/adapters/broker/kis.py` — `:209,219,232,238,243` 평균가 None/0 표면화 지점 (백필 결정을 adapter에서 할지 application에서 할지는 design 확정 대상) +- Tests: + - `tests/application/test_execution.py` (또는 동등 경로) — avg_price=None, avg_price=0, happy path 각각 단위 테스트 + - `tests/integration/` — 체결→원장 기록 통합 경로 회귀 테스트 + +## Notes +- **design 확정 완료 (BAT-048 KD-1/KD-2, 팀장 승인 2026-06-21)**: + - 백필 결정 지점: **application 레이어 단일화** — `application/execution.py`에 `_resolve_fill_price` 헬퍼 신설. `BrokerPort.poll_fill`/adapter/paper 불변, dcy-021 reconcile 경로 자동 일관성(Discovery R-2 해소). + - 지정가마저 부재/≤0일 때 폴백: **skip + 강 WARN**(AC-6 반영). 재폴링은 완전체결 교착, 현재가 조회는 포트 계약 변경이라 기각(Discovery OQ-2 해소). +- AC-6 구체화 완료(위 design 확정 반영). +- NFR-2(happy path 무영향): AC-4로 흡수. NFR-3(백필 추적 가능): REQ-096 WARN 로깅으로 달성. +- Discovery 가정 A-1: orb 매수는 지정가 주문이므로 대다수 케이스에서 `slot.limit_price`가 존재한다. +- 원장 price=0 방지는 REQ-018(체결→원장 기록)의 무결성 연장이며, 원장 skip 가드(`portfolio_id` 부재, `execution_id` 부재)는 본 REQ와 독립적으로 유지된다. +- Discovery: dcy-032_20260621_fill-missing-price-cost-basis-backfill.md (FR-1, FR-2) + +## Change Log + +### CHG-20260621-01 +- Date: 2026-06-21 +- Author: trex-core-dev + +#### Change Summary +- 최초 생성 (draft-req dcy-032) + +#### Intent +- KIS 체결가 0/None 무경고 오염 방지를 위한 지정가 백필 가드 신규 요구사항 정의 + +#### Acceptance Criteria Delta +- Added: + - AC-1~6 신규 정의 +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: docs/runtime-flows.md (implementation 후 갱신) +- Modules: trex_core/application/execution.py, trex_core/adapters/broker/kis.py +- Tests: tests/application/test_execution.py, tests/integration/ + +#### Delivery Trace Delta +- Batches: 미정 (suggest-batch-reqs 이후 편성) +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no (최초 생성) +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: design 확정 후 AC-6 구체화 필요 diff --git a/docs/srs/Exception/req-096_fill-price-backfill-warn-logging.md b/docs/srs/Exception/req-096_fill-price-backfill-warn-logging.md new file mode 100644 index 0000000..a749020 --- /dev/null +++ b/docs/srs/Exception/req-096_fill-price-backfill-warn-logging.md @@ -0,0 +1,70 @@ +# req-096: 체결가 백필·0가격 통과 WARN 로깅 + +- Status: Approved +- Type: Exception +- Priority: High +- Owner: trex-core-dev + +## Intent +- REQ-095의 백필 가드가 발동되거나, 백필 없이 price≤0 체결이 하류(원장·슬롯)로 통과하는 경우, 운영자가 사후에도 해당 이벤트를 식별할 수 있도록 구조화된 WARN 로그를 남겨야 한다. +- 현재 원장 skip 가드(`portfolio_id` 부재, `execution_id` 부재)는 각각 WARN을 남기는 반면, "체결됐는데 price≤0" 케이스는 어떤 가드에도 걸리지 않고 조용히 통과한다. 이 가시성 공백을 동일한 수준으로 맞춘다. +- WARN 로그는 슬롯·심볼·주문ID·원가격·대체가격·사유를 포함해 백필 항목이 실제 평균가인지 백필 근사인지 추적 가능(NFR-3)하게 한다. + +## Requirement +- 체결(완전·부분 취소 후) 처리에서 REQ-095의 백필이 발동된 경우, 슬롯 ID·심볼·주문 ID·원 브로커 평균가(None 또는 0)·대체가격(백필 지정가)·사유(`avg_price_backfill`)를 포함한 WARN 수준 구조화 로그를 반드시 남겨야 한다. +- 지정가마저 부재/≤0인 극단 케이스(design 확정 폴백 정책 적용 시점)에서 백필이 불가능해 price≤0 상태가 하류로 넘어가는 경우에도, 동일한 필드 집합을 포함한 WARN(또는 그 이상 수준) 로그를 남겨야 한다. +- 정상 평균가(happy path) 수신 경로에서는 이 WARN 로그가 발생하지 않아야 한다. + +## Acceptance Criteria + +1. Given `fs.avg_price`가 None 또는 ≤0이고 지정가 백필이 적용된 체결, When 집행 루프가 해당 체결을 처리할 때, Then `slot_id`, `symbol`, `broker_order_id`(또는 동등 주문 식별자), 원 `avg_price`(None 또는 0), `backfill_price`(대체가), `reason=avg_price_backfill` 필드를 포함하는 WARN 로그가 정확히 1건 생성된다. +2. Given 지정가마저 부재/≤0인 극단 케이스(폴백 정책 적용), When 집행 루프가 해당 체결을 처리할 때, Then 슬롯·심볼·주문 ID·원가격·사유를 포함한 WARN 이상 수준 로그가 1건 이상 생성된다(0 저장 또는 skip 어느 쪽이든 무경고 통과 없음). +3. Given `fs.avg_price`가 정상 양수인 체결(happy path), When 집행 루프가 처리할 때, Then 백필 관련 WARN 로그가 생성되지 않는다. +4. Given 백필 WARN 로그가 생성된 후 운영자가 로그를 조회할 때, Then 해당 로그로부터 어떤 슬롯의 어떤 체결에서 백필이 발생했는지, 원래 브로커가 제공한 가격이 무엇이었는지, 어떤 가격으로 대체됐는지를 식별할 수 있다(NFR-3: 실제 평균가 vs 백필 근사 구분 가능). + +## Impacted Area +- Docs: `docs/operations.md` — 백필 WARN 발생 시 운영자 점검 절차 추가 권고 (implementation 후 갱신) +- Modules: + - `trex_core/application/execution.py` — `_on_filled`, `_on_partial_cancel`, `_record_ledger` 내 백필 분기 후 WARN 로그 추가 +- Tests: + - `tests/application/test_execution.py` (또는 동등 경로) — 백필 경로에서 WARN 로그 발생 단언, happy path에서 WARN 미발생 단언 + +## Notes +- 이 REQ는 REQ-095(백필 가드)와 독립적으로 측정 가능하다: REQ-095가 값 정정을, REQ-096이 가시성을 담당하며, 둘 다 같은 batch에서 구현해야 함께 의미가 있다. +- 원장 skip 가드(portfolio_id 부재 WARN, execution_id 부재 WARN)와 동일한 운영 가시성 수준을 목표로 한다(Discovery FR-3 기준). +- AC-2의 극단 케이스(지정가마저 부재)의 폴백 정책 세부 사항은 REQ-095와 동일하게 design 확정 대상이다. 본 REQ는 어떤 폴백 정책이 채택되더라도 무경고 통과가 없어야 한다는 원칙을 요구한다. +- NFR-3(백필 추적 가능) — Discovery NFR-3을 본 REQ AC-4로 흡수. +- Discovery: dcy-032_20260621_fill-missing-price-cost-basis-backfill.md (FR-3) + +## Change Log + +### CHG-20260621-01 +- Date: 2026-06-21 +- Author: trex-core-dev + +#### Change Summary +- 최초 생성 (draft-req dcy-032) + +#### Intent +- 백필 발생 및 price≤0 체결 통과 시 운영 가시성 확보를 위한 WARN 로깅 요구사항 신규 정의 + +#### Acceptance Criteria Delta +- Added: + - AC-1~4 신규 정의 +- Updated: 없음 +- Removed: 없음 + +#### Impacted Area Delta +- Docs: docs/operations.md (implementation 후 갱신) +- Modules: trex_core/application/execution.py +- Tests: tests/application/test_execution.py + +#### Delivery Trace Delta +- Batches: 미정 (suggest-batch-reqs 이후 편성, REQ-095와 동일 batch 예상) +- Releases: 미정 + +#### Revalidation Impact +- Reverification Needed: no (최초 생성) +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: 없음 diff --git a/docs/srs/index.md b/docs/srs/index.md index d6dede7..b9812a1 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-095` +- Current: `REQ-098` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -175,9 +175,16 @@ | REQ-092 | 발행 전용 연결 idle keepalive (서버 측 heartbeat 사망 예방) | Exception | Implemented | High | project maintainers | [req-092](./Exception/req-092_publish-idle-keepalive.md) | | REQ-093 | DLQ 가시성/경보 + emit 실패 경보 (trex.events.dlq 관측성) | Exception | Implemented | High | project maintainers | [req-093](./Exception/req-093_dlq-visibility-and-emit-error-alert.md) | | REQ-094 | 이벤트 발행 신뢰성 baseline 문서 정합 (runtime-flows / project-structure / operations 갱신) | Documentation | Implemented | Medium | project maintainers | [req-094](./Documentation/req-094_event-publisher-reliability-baseline-alignment.md) | +| REQ-095 | 체결가 0/None 지정가 백필 가드 (슬롯 평단 + 원장 price) | Exception | Implemented | High | trex-core-dev | [req-095](./Exception/req-095_fill-price-zero-backfill-guard.md) | +| REQ-096 | 체결가 백필·0가격 통과 WARN 로깅 | Exception | Implemented | High | trex-core-dev | [req-096](./Exception/req-096_fill-price-backfill-warn-logging.md) | +| REQ-097 | 알람(alert) 능력 검토 스파이크 산출물 | Documentation | Implemented | Medium | trex-core-dev | [req-097](./Documentation/req-097_alert-capability-spike.md) | ## Recent Change Log Summary +- 2026-06-21 :: **BAT-048 confirm-batch-verification**(팀장): REQ-095·096 **Approved → Implemented**. 구현=`execution.py` `_resolve_fill_price` 단일화(application) + `_record_ledger` resolved_price + skip+강WARN. 검증=신규 21 단위 테스트(매수 16+매도 5), **전체 575 passed/47 skipped/0 fail**(팀장 독립 재실행) + ruff clean. 독립 opus QC=PASS-WITH-NOTES → NOTE-1(ruff 2건)·NOTE-2(매도 경로 테스트) 정정 완료. skip 경로 부분변이 없음·price=0 원장 미생성 QC 확인. wire/LedgerTrade 스키마 불변(AC-5). **통합 3건 미실행 결정**(영속/전송 어댑터 diff 0이라 타 서비스 DB migration/truncate 침습 회피, KIS avg=None은 sandbox 결정적 재현 불가, 실가동 시 REQ-096 WARN으로 탐지). REQ-097=스파이크 Implemented(알람 impl 후속). 다음=커밋(git-ops)→M2. +- 2026-06-21 :: **BAT-048 design review**(팀장+사장님): D-1 백필 지점=application 단일화·D-2 폴백=skip+강WARN **팀장 승인**(REQ-095 AC-6 구체화). **D-3 사장님 결정: 우선 백필+WARN 로깅(REQ-095/096)만 구현, 알람은 BAT-048 완료 후 별도 진행+M2 포함 여부 그때 결정**. REQ-097 스파이크 산출물(design.md) 제출로 **Implemented**(후보 A 권고/B=wire변경 제외/C=M3). BAT-048 구현 범위=REQ-095+096. +- 2026-06-21 :: REQ-095·096·097 **Proposed → Approved** (confirm-req, 팀장): 세 REQ가 dcy-032 FR-1~4·NFR-1~3을 충실히 반영, AC 측정 가능, design Deferred 항목(백필 지점·지정가 부재 폴백·알람 채널) 명시 확인. REQ-095 AC-6(지정가 부재 폴백)은 design 확정 후 구체화(사장님 합의 deferral). 다음=suggest-batch-reqs → draft-batch. +- 2026-06-21 :: REQ-095·096·097 신규 생성(Proposed, draft-req dcy-032): 체결가 무결성 가드 — REQ-095 체결가 0/None 지정가 백필(슬롯 평단+원장 price, FR-1/2, wire 계약 불변, design 확정 대상: 백필 지점/지정가 부재 폴백), REQ-096 WARN 로깅(FR-3, 원장 skip 가드 동일 수준, NFR-3 흡수), REQ-097 알람 능력 검토 스파이크 산출물(FR-4, 구현 포함 여부 design review 후 사장님 결정, 조건부). NFR-1(wire 불변) REQ-095 AC-5 흡수, NFR-2(happy path 무영향) REQ-095 AC-4 흡수, NFR-3(백필 추적) REQ-096 AC-4 흡수. - 2026-06-19 :: REQ-091·092·093·094 Approved → **Implemented** (confirm-req-implemented, BAT-047 release-candidate, dcy-031/OPS-005): 발행 재시도+강제재연결(091)·idle keepalive(092, execution-loop 단일스레드 틱)·DLQ 가시성+emit ERROR 경보(093, 구조화 로그 dcy-029 재사용)·baseline 정합(094) 구현·검증 완료. 독립 opus QC=PASS-WITH-NOTES→시정 2건(ruff F401·AC-3 프로덕션 경로) 완료, 팀장 재확인 **554 passed/47 skipped/회귀 0/ruff clean**. NFR-1 wire 무회귀(interface-contract diff 0). 수용 한계: half-open/broker재시작/idle=mock unit만(실 broker 통합 skip→운영/CI 위임), NFR-2 ≤5초는 재연결+재시도 구간만 엄밀(1차 socket_timeout 5s 최악 초과 가능). OQ-1/3 design Answered. 다음=draft-release(배포=trex-core 재배포→사장님 결재, tsl 라이브 선행). - 2026-06-19 :: REQ-091·092·093·094 Proposed → **Approved** (confirm-req, 팀장): 필수 필드·구체 AC 충족, source dcy-031(confirmed, Open OQ 0) 정합, blocker 없음. 팀장 판정 — FR-4의 REQ-093 흡수 동의, OQ-1/OQ-3 design 인계 확정, NFR-2 타임아웃 수치는 draft-batch-design 확정(비차단). 다음=suggest-batch-reqs + draft-batch(REQ-091~094 단일 batch 편성 예정, 고위험 opus QC). - 2026-06-19 :: REQ-091·092·093·094 신규 생성(Proposed, draft-req dcy-031): 이벤트 발행 연결 신뢰성 — REQ-091 발행 재시도+강제 재연결(FR-1/B, S-1·S-2, NFR-1~3 흡수), REQ-092 idle keepalive(FR-2/A, OQ-1 design 인계), REQ-093 DLQ 가시성+emit 경보(FR-3/D + FR-4, OQ-3 design 인계, S-3), REQ-094 baseline 문서 정합(runtime-flows/project-structure/operations, docs전용). NFR-2·NFR-3은 REQ-091~093 AC에 흡수. 고위험(M1 라이브) batch — opus QC 강제(CONFIRM①). diff --git a/tests/application/test_execution_fill_price_backfill.py b/tests/application/test_execution_fill_price_backfill.py new file mode 100644 index 0000000..62a6b7e --- /dev/null +++ b/tests/application/test_execution_fill_price_backfill.py @@ -0,0 +1,969 @@ +"""Fill price backfill guard tests (REQ-095, REQ-096, BAT-048). + +Tests the _resolve_fill_price helper and its integration into _on_filled / +_on_partial_cancel / _record_ledger for the cases where KIS returns +avg_price=None or avg_price=0.0. + +All cases use a StubBroker that returns a programmed FillStatus so we can +inject arbitrary avg_price values (including None/0) without touching the +real KIS or paper adapter. +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional +from uuid import uuid4 + +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 ( + BalanceItem, + FillStatus, + OrderAck, + OrderRequest, +) +from trex_core.application.execution import ExecutionService +from trex_core.application.intents import BuyIntent, IntentService +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 +from trex_core.domain.slot.models import Slot + + +# --------------------------------------------------------------------------- +# Stub broker — lets tests inject arbitrary FillStatus values +# --------------------------------------------------------------------------- + +@dataclass +class _ProgrammedOrder: + oid: str + symbol: str + limit_price: Optional[int] + fill_status: FillStatus # returned on first poll + + +class StubBroker: + """Minimal broker that returns a pre-programmed FillStatus on poll_fill. + + submit_order always accepts and stores limit_price so tests can verify + the slot's limit_price context. The programmed fill_status is returned + immediately (fill_after_polls=0 semantics). + """ + + def __init__(self, fill_status: FillStatus, *, balance_qty: int = 10) -> None: + self._fill_status = fill_status + self._balance_qty: dict[str, int] = {} + self._default_balance_qty = balance_qty + self._orders: dict[str, _ProgrammedOrder] = {} + self._seq = 0 + + def name(self) -> str: + return "stub" + + def submit_order(self, req: OrderRequest) -> OrderAck: + self._seq += 1 + oid = f"STUB-{self._seq:06d}" + self._orders[oid] = _ProgrammedOrder( + oid=oid, + symbol=req.symbol, + limit_price=req.limit_price, + fill_status=self._fill_status, + ) + # After submit, simulate the broker holding the qty. + if req.side == "buy": + self._balance_qty[req.symbol] = ( + self._balance_qty.get(req.symbol, 0) + self._default_balance_qty + ) + return OrderAck(accepted=True, broker_order_id=oid) + + def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus: + o = self._orders.get(broker_order_id) + if o is None: + return FillStatus(state="unknown") + return o.fill_status + + def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool: + return False + + def get_balance(self) -> list[BalanceItem]: + return [ + BalanceItem(symbol=sym, hldg_qty=qty) + for sym, qty in self._balance_qty.items() + if qty > 0 + ] + + +# --------------------------------------------------------------------------- +# Wiring helpers +# --------------------------------------------------------------------------- + +INST = "default" + + +def _wire(broker): + """Wire up execution stack with in-memory adapters.""" + repo = InMemorySlotRepository() + svc = SlotService(repo, instance_key=INST) + intents = IntentService( + svc, repo, InMemoryIntentStore(), + resolve_symbol=lambda pid: "005930", + instance_key=INST, + ) + outbox = InMemoryLedgerOutbox() + execution = ExecutionService(svc, repo, broker, ledger_outbox=outbox, instance_key=INST) + repo.reconcile_universe(INST, {"PF-1": "005930"}) + return repo, svc, intents, execution, outbox + + +def _buy(intents, *, iid="b1", portfolio_id="PF-1", qty=10, entry_price=1000): + return intents.submit_buy( + BuyIntent(intent_id=iid, portfolio_id=portfolio_id, qty=qty, entry_price=entry_price) + ) + + +def _make_filled_status( + *, + avg_price: Optional[float], + filled_qty: int = 10, + execution_id: str = "EX-001", + fee: float = 0.015, + tax: float = 0.0, +) -> FillStatus: + """Build a FillStatus(state='filled') with the given avg_price.""" + return FillStatus( + state="filled", + filled_qty=filled_qty, + avg_price=avg_price, + execution_id=execution_id, + fee=fee, + tax=tax, + ) + + +def _make_partial_cancel_status( + *, + avg_price: Optional[float], + filled_qty: int = 5, + execution_id: str = "EX-002", + fee: float = 0.007, + tax: float = 0.0, +) -> FillStatus: + """Build a FillStatus representing partial fill then external cancel.""" + return FillStatus( + state="partially_filled", + filled_qty=filled_qty, + avg_price=avg_price, + execution_id=execution_id, + fee=fee, + tax=tax, + cancelled=True, + ) + + +# =========================================================================== +# REQ-095 / REQ-096 — _on_filled: backfill cases +# =========================================================================== + +class TestOnFilledBackfill: + """REQ-095 AC-1, AC-3, AC-4 / REQ-096 AC-1, AC-3 via _on_filled.""" + + def test_avg_price_none_with_limit_price_uses_limit_price(self, caplog): + """REQ-095 AC-1: avg_price=None + limit_price>0 → slot avg = limit_price, + REQ-096 AC-1: exactly 1 WARN log with reason=avg_price_backfill.""" + fill_status = _make_filled_status(avg_price=None) + broker = StubBroker(fill_status) + repo, svc, intents, execution, outbox = _wire(broker) + + sid = _buy(intents, entry_price=1000).slot_id + # Confirm limit_price is stored (set during dispatch) + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + r = execution.run_once() + + assert r.filled == 1, "fill must be registered" + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.HOLDING, "slot must transition to HOLDING" + # REQ-095 AC-1: avg_price must equal limit_price (1000), NOT 0 + assert slot.avg_price == 1000.0, f"expected 1000.0, got {slot.avg_price}" + assert slot.avg_price != 0.0 + + # REQ-096 AC-1: exactly one backfill WARN + warn_msgs = [ + rec.getMessage() for rec in caplog.records + if rec.levelno == logging.WARNING and "avg_price_backfill" in rec.getMessage() + ] + assert len(warn_msgs) == 1, f"expected 1 backfill WARN, got {len(warn_msgs)}: {warn_msgs}" + assert "reason=avg_price_backfill" in warn_msgs[0] + assert str(sid) in warn_msgs[0] # slot_id in log + + def test_avg_price_zero_with_limit_price_uses_limit_price(self, caplog): + """REQ-095 AC-2 (AC-1 variant with 0.0): avg_price=0.0 → limit_price backfill.""" + fill_status = _make_filled_status(avg_price=0.0) + broker = StubBroker(fill_status) + repo, svc, intents, execution, outbox = _wire(broker) + + sid = _buy(intents, entry_price=1500).slot_id + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + r = execution.run_once() + + assert r.filled == 1 + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.HOLDING + # avg_price must be limit_price (1500), not 0 + assert slot.avg_price == 1500.0, f"expected 1500.0, got {slot.avg_price}" + assert slot.avg_price != 0.0 + + warn_msgs = [ + rec.getMessage() for rec in caplog.records + if rec.levelno == logging.WARNING and "avg_price_backfill" in rec.getMessage() + ] + assert len(warn_msgs) == 1 + + def test_happy_path_uses_avg_price_no_backfill_warn(self, caplog): + """REQ-095 AC-4 / REQ-096 AC-3: normal positive avg_price — no backfill WARN.""" + fill_status = _make_filled_status(avg_price=1234.0) + broker = StubBroker(fill_status) + repo, svc, intents, execution, outbox = _wire(broker) + + sid = _buy(intents, entry_price=1200).slot_id + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + r = execution.run_once() + + assert r.filled == 1 + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.HOLDING + # slot avg_price should equal fs.avg_price (1234.0), not limit_price + assert slot.avg_price == 1234.0 + + backfill_warns = [ + rec.getMessage() for rec in caplog.records + if "avg_price_backfill" in rec.getMessage() or "fill_price_unresolvable" in rec.getMessage() + ] + assert backfill_warns == [], f"no backfill WARN expected on happy path: {backfill_warns}" + + def test_avg_price_none_limit_price_none_skips_fill(self, caplog): + """REQ-095 AC-6 / REQ-096 AC-2: avg_price=None + limit_price=None/≤0 → + fill skipped, slot stays PENDING, strong WARN with reason=fill_price_unresolvable.""" + fill_status = _make_filled_status(avg_price=None) + broker = StubBroker(fill_status) + repo, svc, intents, execution, outbox = _wire(broker) + + # Submit buy WITHOUT limit_price by using entry_price=None — however + # BuyIntent requires entry_price. We inject a slot directly with no limit_price. + anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE) + repo.add_slot(anon) + svc.transit_slot( + anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="test", + symbol="005930", portfolio_id="PF-1", + limit_price=None, # explicitly no limit_price + ) + sid = anon.slot_id + + # Manually dispatch the slot so broker_order_id is set + repo.mark_dispatched(sid, "STUB-MANUAL-001") + broker._orders["STUB-MANUAL-001"] = _ProgrammedOrder( + oid="STUB-MANUAL-001", + symbol="005930", + limit_price=None, + fill_status=fill_status, + ) + broker._balance_qty["005930"] = 10 + + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + execution.run_once() + + # Slot must NOT have transitioned — still PENDING_BUY + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.PENDING_BUY, ( + f"slot must stay PENDING_BUY on unresolvable fill, got {slot.status}" + ) + # Ledger must be empty (no price=0 record) + assert outbox.all_items() == [], "no ledger record should be created on unresolvable fill" + + # REQ-096 AC-2: at least 1 strong WARN with reason=fill_price_unresolvable + unresolvable_warns = [ + rec.getMessage() for rec in caplog.records + if "fill_price_unresolvable" in rec.getMessage() + ] + assert len(unresolvable_warns) >= 1, ( + "expected at least 1 fill_price_unresolvable WARN" + ) + + def test_avg_price_none_limit_price_zero_skips_fill(self, caplog): + """REQ-095 AC-6 variant: limit_price=0 (≤0) also triggers skip + strong WARN.""" + fill_status = _make_filled_status(avg_price=None) + broker = StubBroker(fill_status) + repo, svc, intents, execution, outbox = _wire(broker) + + anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE) + repo.add_slot(anon) + svc.transit_slot( + anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="test", + symbol="005930", portfolio_id="PF-1", + limit_price=0, # ≤0 limit_price + ) + sid = anon.slot_id + repo.mark_dispatched(sid, "STUB-ZERO-001") + broker._orders["STUB-ZERO-001"] = _ProgrammedOrder( + oid="STUB-ZERO-001", + symbol="005930", + limit_price=0, + fill_status=fill_status, + ) + broker._balance_qty["005930"] = 10 + + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + execution.run_once() + + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.PENDING_BUY + assert outbox.all_items() == [] + unresolvable_warns = [ + rec.getMessage() for rec in caplog.records + if "fill_price_unresolvable" in rec.getMessage() + ] + assert len(unresolvable_warns) >= 1 + + +# =========================================================================== +# REQ-095 / REQ-096 — _on_partial_cancel: backfill cases +# =========================================================================== + +class TestOnPartialCancelBackfill: + """REQ-095 AC-2, AC-3 / REQ-096 AC-1, AC-3 via _on_partial_cancel.""" + + def test_avg_price_none_partial_cancel_uses_limit_price(self, caplog): + """REQ-095 AC-2 (_on_partial_cancel path): avg_price=None → limit_price backfill, + REQ-096 AC-1: exactly 1 WARN with reason=avg_price_backfill.""" + fill_status = _make_partial_cancel_status(avg_price=None, filled_qty=5) + broker = StubBroker(fill_status, balance_qty=5) + repo, svc, intents, execution, outbox = _wire(broker) + + sid = _buy(intents, entry_price=2000, qty=10).slot_id + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + r = execution.run_once() + + # partial_cancel counts as r.filled += 1 + assert r.filled == 1 + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.HOLDING + # avg_price should be limit_price (2000), not 0 + assert slot.avg_price == 2000.0, f"expected 2000.0, got {slot.avg_price}" + assert slot.avg_price != 0.0 + + warn_msgs = [ + rec.getMessage() for rec in caplog.records + if rec.levelno == logging.WARNING and "avg_price_backfill" in rec.getMessage() + ] + assert len(warn_msgs) == 1, f"expected 1 backfill WARN, got {len(warn_msgs)}" + + def test_avg_price_zero_partial_cancel_uses_limit_price(self, caplog): + """avg_price=0.0 in partial_cancel path → limit_price backfill (REQ-095 AC-2).""" + fill_status = _make_partial_cancel_status(avg_price=0.0, filled_qty=5) + broker = StubBroker(fill_status, balance_qty=5) + repo, svc, intents, execution, outbox = _wire(broker) + + sid = _buy(intents, entry_price=1800, qty=10).slot_id + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + r = execution.run_once() + + assert r.filled == 1 + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.HOLDING + assert slot.avg_price == 1800.0 + assert slot.avg_price != 0.0 + + warn_msgs = [ + rec.getMessage() for rec in caplog.records + if "avg_price_backfill" in rec.getMessage() + ] + assert len(warn_msgs) == 1 + + def test_happy_path_partial_cancel_no_backfill_warn(self, caplog): + """REQ-096 AC-3: normal avg_price in partial_cancel — no backfill WARN.""" + fill_status = _make_partial_cancel_status(avg_price=1900.0, filled_qty=5) + broker = StubBroker(fill_status, balance_qty=5) + repo, svc, intents, execution, outbox = _wire(broker) + + sid = _buy(intents, entry_price=1800, qty=10).slot_id + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + r = execution.run_once() + + assert r.filled == 1 + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.HOLDING + assert slot.avg_price == 1900.0 + + backfill_warns = [ + rec.getMessage() for rec in caplog.records + if "avg_price_backfill" in rec.getMessage() or "fill_price_unresolvable" in rec.getMessage() + ] + assert backfill_warns == [] + + def test_partial_cancel_unresolvable_skips_fill(self, caplog): + """REQ-095 AC-6 via _on_partial_cancel: unresolvable price → skip.""" + fill_status = _make_partial_cancel_status(avg_price=None, filled_qty=5) + broker = StubBroker(fill_status, balance_qty=5) + repo, svc, intents, execution, outbox = _wire(broker) + + anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE) + repo.add_slot(anon) + svc.transit_slot( + anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="test", + symbol="005930", portfolio_id="PF-1", + limit_price=None, + ) + sid = anon.slot_id + repo.mark_dispatched(sid, "STUB-PC-001") + broker._orders["STUB-PC-001"] = _ProgrammedOrder( + oid="STUB-PC-001", + symbol="005930", + limit_price=None, + fill_status=fill_status, + ) + broker._balance_qty["005930"] = 5 + + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + execution.run_once() + + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.PENDING_BUY + assert outbox.all_items() == [] + unresolvable_warns = [ + rec.getMessage() for rec in caplog.records + if "fill_price_unresolvable" in rec.getMessage() + ] + assert len(unresolvable_warns) >= 1 + + +# =========================================================================== +# REQ-095 AC-3 — _record_ledger: ledger price matches backfill value +# =========================================================================== + +class TestRecordLedgerBackfillPrice: + """REQ-095 AC-3: ledger record price == backfill price (not 0).""" + + def test_ledger_price_equals_backfill_limit_price_on_filled(self): + """REQ-095 AC-3: avg_price=None + limit_price=1000 → + LedgerTrade.price = 1000.0 (not 0.0).""" + fill_status = _make_filled_status(avg_price=None, execution_id="EX-LEDGER-001") + broker = StubBroker(fill_status) + repo, svc, intents, execution, outbox = _wire(broker) + ledger = InMemoryLedger() + writer = LedgerWriter(outbox, ledger) + + _buy(intents, entry_price=1000).slot_id + r = execution.run_once() + assert r.ledger_enqueued == 1 + + writer.run_once() + assert ledger.count() == 1 + trade = ledger.trades()[0] + assert trade.price == 1000.0, f"expected ledger price=1000.0, got {trade.price}" + assert trade.price != 0.0 + + def test_ledger_price_equals_backfill_limit_price_on_partial_cancel(self): + """REQ-095 AC-3 via _on_partial_cancel: backfill price flows to ledger.""" + fill_status = _make_partial_cancel_status( + avg_price=None, filled_qty=5, execution_id="EX-PC-LEDGER" + ) + broker = StubBroker(fill_status, balance_qty=5) + repo, svc, intents, execution, outbox = _wire(broker) + ledger = InMemoryLedger() + writer = LedgerWriter(outbox, ledger) + + _buy(intents, entry_price=2500, qty=10).slot_id + r = execution.run_once() + assert r.ledger_enqueued == 1 + + writer.run_once() + assert ledger.count() == 1 + trade = ledger.trades()[0] + assert trade.price == 2500.0, f"expected ledger price=2500.0, got {trade.price}" + assert trade.price != 0.0 + + def test_ledger_price_on_happy_path_uses_avg_price(self): + """REQ-095 AC-4 regression: normal avg_price → ledger uses avg_price, not limit_price.""" + fill_status = _make_filled_status(avg_price=1111.0, execution_id="EX-HAPPY") + broker = StubBroker(fill_status) + repo, svc, intents, execution, outbox = _wire(broker) + ledger = InMemoryLedger() + writer = LedgerWriter(outbox, ledger) + + _buy(intents, entry_price=1200).slot_id # limit 1200, but avg = 1111 + execution.run_once() + writer.run_once() + + trade = ledger.trades()[0] + assert trade.price == 1111.0, f"expected ledger price=1111.0 (avg_price), got {trade.price}" + + def test_ledger_not_written_when_fill_price_unresolvable(self): + """REQ-095 AC-6: unresolvable fill → no ledger record, price=0 record never created.""" + fill_status = _make_filled_status(avg_price=None, execution_id="EX-SKIP") + broker = StubBroker(fill_status) + repo, svc, intents, execution, outbox = _wire(broker) + ledger = InMemoryLedger() + writer = LedgerWriter(outbox, ledger) + + anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE) + repo.add_slot(anon) + svc.transit_slot( + anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="test", + symbol="005930", portfolio_id="PF-1", + limit_price=None, + ) + sid = anon.slot_id + repo.mark_dispatched(sid, "STUB-SKIP-001") + broker._orders["STUB-SKIP-001"] = _ProgrammedOrder( + oid="STUB-SKIP-001", + symbol="005930", + limit_price=None, + fill_status=fill_status, + ) + broker._balance_qty["005930"] = 10 + + execution.run_once() + writer.run_once() + + assert outbox.all_items() == [] + assert ledger.count() == 0, "no ledger record must be created when fill is skipped" + + +# =========================================================================== +# REQ-096 AC-4 — WARN log field traceability +# =========================================================================== + +class TestWarnLogFields: + """REQ-096 AC-4: backfill WARN log contains required fields for traceability.""" + + def test_backfill_warn_contains_required_fields(self, caplog): + """avg_price_backfill WARN must include slot_id, symbol, broker_order_id, + original avg_price (None), backfill_price, reason=avg_price_backfill.""" + fill_status = _make_filled_status(avg_price=None) + broker = StubBroker(fill_status) + repo, svc, intents, execution, outbox = _wire(broker) + + sid = _buy(intents, entry_price=3000).slot_id + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + execution.run_once() + + warn_msgs = [ + rec.getMessage() for rec in caplog.records + if "avg_price_backfill" in rec.getMessage() + ] + assert len(warn_msgs) == 1 + msg = warn_msgs[0] + + assert str(sid) in msg, "slot_id must appear in WARN log" + assert "005930" in msg, "symbol must appear in WARN log" + assert "avg_price=None" in msg, "original avg_price must appear in WARN log" + assert "backfill_price=3000" in msg, "backfill_price must appear in WARN log" + assert "reason=avg_price_backfill" in msg + + def test_unresolvable_warn_contains_required_fields(self, caplog): + """fill_price_unresolvable WARN must include slot_id, symbol, broker_order_id, + avg_price, limit_price, reason=fill_price_unresolvable.""" + fill_status = _make_filled_status(avg_price=None) + broker = StubBroker(fill_status) + repo, svc, intents, execution, outbox = _wire(broker) + + anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE) + repo.add_slot(anon) + svc.transit_slot( + anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, + reason="buy", actor="test", + symbol="005930", portfolio_id="PF-1", + limit_price=None, + ) + sid = anon.slot_id + repo.mark_dispatched(sid, "STUB-FIELD-001") + broker._orders["STUB-FIELD-001"] = _ProgrammedOrder( + oid="STUB-FIELD-001", + symbol="005930", + limit_price=None, + fill_status=fill_status, + ) + broker._balance_qty["005930"] = 10 + + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + execution.run_once() + + warn_msgs = [ + rec.getMessage() for rec in caplog.records + if "fill_price_unresolvable" in rec.getMessage() + ] + assert len(warn_msgs) >= 1 + msg = warn_msgs[0] + + assert str(sid) in msg, "slot_id must appear in WARN log" + assert "005930" in msg, "symbol must appear in WARN log" + assert "reason=fill_price_unresolvable" in msg + + +# =========================================================================== +# NOTE-2: PENDING_SELL path — backfill / skip regression (EC-3, M2 safety net) +# =========================================================================== +# +# Design intent (design.md EC-3): +# - _on_filled / _on_partial_cancel PENDING_SELL branch does NOT update slot +# avg_price (cost basis is preserved). fill_price is used only for +# _record_ledger (the sell execution price). +# - slot.limit_price is cleared to None on HOLDING entry (service.py), so +# a sell slot normally carries limit_price=None (market order). A limit +# sell sets it explicitly via svc.transit_slot(HOLDING→PENDING_SELL, +# limit_price=xxx). +# - avg_price=None + limit_price>0 → backfill: _record_ledger price=limit_price + WARN +# - avg_price=None + limit_price=None → skip + strong WARN + slot stays PENDING_SELL +# (no ledger record, no partial state mutation) + +def _make_holding_slot( + repo: InMemorySlotRepository, + svc: SlotService, + *, + portfolio_id: str = "PF-1", + symbol: str = "005930", + avg_price: float = 5000.0, + qty: float = 10.0, +): + """Seed a HOLDING slot directly (INACTIVE→HOLDING is not an allowed transition, + so we use repo.add_slot for test setup). + + Returns the slot_id (UUID). + """ + from datetime import datetime, timezone + sid = uuid4() + slot = Slot( + slot_id=sid, + status=SlotState.HOLDING, + instance_key=INST, + portfolio_id=portfolio_id, + symbol=symbol, + avg_price=avg_price, + qty=qty, + created_at=datetime.now(tz=timezone.utc), + updated_at=datetime.now(tz=timezone.utc), + ) + repo.add_slot(slot) + return sid + + +class TestSellPathBackfill: + """EC-3: PENDING_SELL fill price backfill / skip path (M2 safety net — TSL). + + Design intent: avg_price (cost basis) is PRESERVED on sell; fill_price flows + only to _record_ledger as the sell execution price. Backfill and skip behaviour + must be tested independently of the buy path. + """ + + # ----------------------------------------------------------------------- + # EC-3a: _on_filled PENDING_SELL + avg_price=None + limit_price>0 + # → backfill: ledger price = limit_price + WARN + slot goes INACTIVE + # ----------------------------------------------------------------------- + + def test_sell_filled_avg_price_none_with_limit_price_uses_backfill(self, caplog): + """EC-3a (_on_filled PENDING_SELL): avg_price=None + limit_price>0 → + ledger price = limit_price (not 0), exactly 1 WARN reason=avg_price_backfill. + Slot cost basis (avg_price) is preserved and NOT mutated.""" + fill_status = _make_filled_status(avg_price=None, filled_qty=10, execution_id="EX-SELL-001") + broker = StubBroker(fill_status, balance_qty=0) # balance 0 = full sell + repo, svc, intents, execution, outbox = _wire(broker) + ledger = InMemoryLedger() + writer = LedgerWriter(outbox, ledger) + + # Seed a HOLDING slot with known cost basis (avg_price=5000) + sid = _make_holding_slot(repo, svc, avg_price=5000.0, qty=10.0) + + # Transition to PENDING_SELL with an explicit limit_price (limit sell) + svc.transit_slot( + sid, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="sell_decision", actor="test", + limit_price=4800, # explicit sell limit + ) + repo.mark_dispatched(sid, "STUB-SELL-FILL-001") + broker._orders["STUB-SELL-FILL-001"] = _ProgrammedOrder( + oid="STUB-SELL-FILL-001", + symbol="005930", + limit_price=4800, + fill_status=fill_status, + ) + # balance 0 → full sell → slot → INACTIVE + + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + r = execution.run_once() + + assert r.filled == 1, "sell fill must be registered" + slot = repo.fetch_slot(sid) + # Full sell (broker balance 0) → INACTIVE + assert slot.status is SlotState.INACTIVE, ( + f"full sell must go to INACTIVE, got {slot.status}" + ) + # cost basis (avg_price) must NOT have been mutated to fill_price + # (INACTIVE slot avg_price is cleared by transit; we verify no 0 contamination + # occurred before the transition — we check via ledger price instead) + + # Backfill WARN must be emitted + backfill_warns = [ + rec.getMessage() for rec in caplog.records + if "avg_price_backfill" in rec.getMessage() + ] + assert len(backfill_warns) == 1, ( + f"expected 1 avg_price_backfill WARN, got {len(backfill_warns)}" + ) + assert "reason=avg_price_backfill" in backfill_warns[0] + + # Ledger price must be backfill value (4800), not 0 + writer.run_once() + assert ledger.count() == 1 + trade = ledger.trades()[0] + assert trade.price == 4800.0, ( + f"ledger sell price must be backfill limit_price=4800, got {trade.price}" + ) + assert trade.price != 0.0 + assert trade.side == "sell" + + # ----------------------------------------------------------------------- + # EC-3b: _on_partial_cancel PENDING_SELL + avg_price=None + limit_price>0 + # → backfill: ledger price = limit_price + WARN + slot stays HOLDING + # ----------------------------------------------------------------------- + + def test_sell_partial_cancel_avg_price_none_with_limit_price_uses_backfill(self, caplog): + """EC-3b (_on_partial_cancel PENDING_SELL): avg_price=None + limit_price>0 → + ledger price = limit_price + WARN. Remaining balance stays HOLDING.""" + fill_status = _make_partial_cancel_status( + avg_price=None, filled_qty=5, execution_id="EX-SELL-PC-001" + ) + # balance 5 → partial sell → remainder stays HOLDING + broker = StubBroker(fill_status, balance_qty=5) + repo, svc, intents, execution, outbox = _wire(broker) + ledger = InMemoryLedger() + writer = LedgerWriter(outbox, ledger) + + sid = _make_holding_slot(repo, svc, avg_price=5000.0, qty=10.0) + svc.transit_slot( + sid, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="sell_decision", actor="test", + limit_price=4900, + ) + repo.mark_dispatched(sid, "STUB-SELL-PC-001") + broker._orders["STUB-SELL-PC-001"] = _ProgrammedOrder( + oid="STUB-SELL-PC-001", + symbol="005930", + limit_price=4900, + fill_status=fill_status, + ) + broker._balance_qty["005930"] = 5 # remainder after partial fill + + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + r = execution.run_once() + + assert r.filled == 1 + slot = repo.fetch_slot(sid) + # Partial sell (balance=5 > 0) → back to HOLDING + assert slot.status is SlotState.HOLDING, ( + f"partial sell remainder must go to HOLDING, got {slot.status}" + ) + + backfill_warns = [ + rec.getMessage() for rec in caplog.records + if "avg_price_backfill" in rec.getMessage() + ] + assert len(backfill_warns) == 1, ( + f"expected 1 avg_price_backfill WARN, got {len(backfill_warns)}" + ) + + # Ledger price = backfill limit_price (4900), not 0 + writer.run_once() + assert ledger.count() == 1 + trade = ledger.trades()[0] + assert trade.price == 4900.0, ( + f"ledger sell price must be backfill limit_price=4900, got {trade.price}" + ) + assert trade.side == "sell" + + # ----------------------------------------------------------------------- + # EC-3c: _on_filled PENDING_SELL + avg_price=None + limit_price=None + # → skip + strong WARN + slot stays PENDING_SELL + no ledger record + # ----------------------------------------------------------------------- + + def test_sell_filled_avg_price_none_limit_price_none_skips(self, caplog): + """EC-3c (_on_filled PENDING_SELL): avg_price=None + limit_price=None → + skip + strong WARN reason=fill_price_unresolvable + slot stays PENDING_SELL + + NO ledger record (design.md EC-3: market sell with limit_price=None is + normal for TSL; skip+strong WARN is the intended safe fallback).""" + fill_status = _make_filled_status(avg_price=None, filled_qty=10, execution_id="EX-SELL-SKIP-001") + broker = StubBroker(fill_status, balance_qty=0) + repo, svc, intents, execution, outbox = _wire(broker) + + sid = _make_holding_slot(repo, svc, avg_price=5000.0, qty=10.0) + # Market sell: limit_price stays None (normal TSL path) + svc.transit_slot( + sid, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="tsl_trigger", actor="test", + # no limit_price → None + ) + repo.mark_dispatched(sid, "STUB-SELL-SKIP-001") + broker._orders["STUB-SELL-SKIP-001"] = _ProgrammedOrder( + oid="STUB-SELL-SKIP-001", + symbol="005930", + limit_price=None, + fill_status=fill_status, + ) + broker._balance_qty["005930"] = 0 + + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + execution.run_once() + + # Slot must stay PENDING_SELL (no transition) + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.PENDING_SELL, ( + f"slot must stay PENDING_SELL on unresolvable sell fill, got {slot.status}" + ) + # No ledger record + assert outbox.all_items() == [], ( + "no ledger record must be created when sell fill price is unresolvable" + ) + # Strong WARN with reason=fill_price_unresolvable + unresolvable_warns = [ + rec.getMessage() for rec in caplog.records + if "fill_price_unresolvable" in rec.getMessage() + ] + assert len(unresolvable_warns) >= 1, ( + "expected at least 1 fill_price_unresolvable WARN on sell skip" + ) + assert "reason=fill_price_unresolvable" in unresolvable_warns[0] + + # ----------------------------------------------------------------------- + # EC-3d: _on_partial_cancel PENDING_SELL + avg_price=None + limit_price=None + # → skip + strong WARN + slot stays PENDING_SELL + no ledger record + # ----------------------------------------------------------------------- + + def test_sell_partial_cancel_avg_price_none_limit_price_none_skips(self, caplog): + """EC-3d (_on_partial_cancel PENDING_SELL): avg_price=None + limit_price=None → + skip + strong WARN + slot stays PENDING_SELL + no ledger record.""" + fill_status = _make_partial_cancel_status( + avg_price=None, filled_qty=5, execution_id="EX-SELL-PC-SKIP-001" + ) + broker = StubBroker(fill_status, balance_qty=5) + repo, svc, intents, execution, outbox = _wire(broker) + + sid = _make_holding_slot(repo, svc, avg_price=5000.0, qty=10.0) + svc.transit_slot( + sid, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="tsl_trigger", actor="test", + # no limit_price → None (market sell) + ) + repo.mark_dispatched(sid, "STUB-SELL-PC-SKIP-001") + broker._orders["STUB-SELL-PC-SKIP-001"] = _ProgrammedOrder( + oid="STUB-SELL-PC-SKIP-001", + symbol="005930", + limit_price=None, + fill_status=fill_status, + ) + broker._balance_qty["005930"] = 5 + + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + execution.run_once() + + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.PENDING_SELL, ( + f"slot must stay PENDING_SELL on unresolvable partial-cancel sell, got {slot.status}" + ) + assert outbox.all_items() == [], ( + "no ledger record must be created when sell partial-cancel price is unresolvable" + ) + unresolvable_warns = [ + rec.getMessage() for rec in caplog.records + if "fill_price_unresolvable" in rec.getMessage() + ] + assert len(unresolvable_warns) >= 1, ( + "expected at least 1 fill_price_unresolvable WARN on sell partial-cancel skip" + ) + + # ----------------------------------------------------------------------- + # EC-3e: happy path — sell fill with positive avg_price → no backfill WARN + # ----------------------------------------------------------------------- + + def test_sell_filled_happy_path_no_backfill_warn(self, caplog): + """EC-3e: normal sell fill with positive avg_price → ledger price = avg_price, + no backfill WARN, slot goes INACTIVE (full sell).""" + fill_status = _make_filled_status( + avg_price=4950.0, filled_qty=10, execution_id="EX-SELL-HAPPY-001" + ) + broker = StubBroker(fill_status, balance_qty=0) # balance 0 = full sell + repo, svc, intents, execution, outbox = _wire(broker) + ledger = InMemoryLedger() + writer = LedgerWriter(outbox, ledger) + + sid = _make_holding_slot(repo, svc, avg_price=5000.0, qty=10.0) + svc.transit_slot( + sid, SlotState.HOLDING, SlotState.PENDING_SELL, + reason="tsl_trigger", actor="test", + ) + repo.mark_dispatched(sid, "STUB-SELL-HAPPY-001") + broker._orders["STUB-SELL-HAPPY-001"] = _ProgrammedOrder( + oid="STUB-SELL-HAPPY-001", + symbol="005930", + limit_price=None, + fill_status=fill_status, + ) + broker._balance_qty["005930"] = 0 + + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + r = execution.run_once() + + assert r.filled == 1 + slot = repo.fetch_slot(sid) + assert slot.status is SlotState.INACTIVE + + backfill_warns = [ + rec.getMessage() for rec in caplog.records + if "avg_price_backfill" in rec.getMessage() or "fill_price_unresolvable" in rec.getMessage() + ] + assert backfill_warns == [], f"no backfill WARN expected on happy sell path: {backfill_warns}" + + writer.run_once() + assert ledger.count() == 1 + trade = ledger.trades()[0] + assert trade.price == 4950.0, ( + f"ledger sell price must equal avg_price=4950.0, got {trade.price}" + ) + assert trade.side == "sell" + + +# =========================================================================== +# Regression: paper broker happy-path must produce zero backfill WARNs +# =========================================================================== + +class TestPaperBrokerRegression: + """EC-5: paper broker always returns positive avg_price → no backfill WARN.""" + + def test_paper_broker_buy_no_backfill_warn(self, caplog): + """EC-5 regression: PaperBroker fill → avg_price always positive → no WARN.""" + from trex_core.adapters.broker.paper import PaperBroker + from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore + + paper = PaperBroker(fill_after_polls=0) + repo2 = InMemorySlotRepository() + svc2 = SlotService(repo2, instance_key=INST) + intents2 = IntentService( + svc2, repo2, InMemoryIntentStore(), + resolve_symbol=lambda pid: "005930", + instance_key=INST, + ) + outbox2 = InMemoryLedgerOutbox() + execution2 = ExecutionService(svc2, repo2, paper, ledger_outbox=outbox2, instance_key=INST) + repo2.reconcile_universe(INST, {"PF-1": "005930"}) + + intents2.submit_buy(BuyIntent(intent_id="paper-b1", portfolio_id="PF-1", qty=5, entry_price=1000)) + with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"): + execution2.run_once() + + backfill_warns = [ + rec.getMessage() for rec in caplog.records + if "avg_price_backfill" in rec.getMessage() or "fill_price_unresolvable" in rec.getMessage() + ] + assert backfill_warns == [], f"paper broker must not trigger backfill WARN: {backfill_warns}" diff --git a/trex_core/application/execution.py b/trex_core/application/execution.py index c50f22c..30df1f3 100644 --- a/trex_core/application/execution.py +++ b/trex_core/application/execution.py @@ -228,8 +228,51 @@ class ExecutionService: """Current broker balance as {symbol: hldg_qty} (REQ-062 ground truth).""" return {b.symbol: int(b.hldg_qty) for b in self._broker.get_balance()} + def _resolve_fill_price(self, fs, slot) -> tuple[float | None, bool]: + """Resolve the fill price from FillStatus, with limit_price backfill. + + REQ-095 / REQ-096: KIS may return avg_price=None or 0.0 on a snapshot + race. Rather than silently storing 0 we try the order's limit_price as + a reasonable approximation, and surface the substitution via WARN so + ops can audit the cost-basis estimate. + + Returns: + (fill_price, was_backfilled) + fill_price=None → skip signal: no resolvable price (strong WARN + already emitted). Caller must not transition slot + or write ledger — slot stays PENDING so the + watchdog / stale_pending path fires for ops. + """ + avg = fs.avg_price + if avg is not None and float(avg) > 0: + return float(avg), False + # avg_price None or ≤0: attempt limit_price backfill (REQ-095 AC-1/2) + limit = slot.limit_price + if limit is not None and float(limit) > 0: + _log.warning( + "fill price backfill: slot=%s symbol=%s broker_order_id=%s " + "avg_price=%s backfill_price=%s reason=avg_price_backfill", + slot.slot_id, slot.symbol, slot.broker_order_id, + avg, limit, + ) + return float(limit), True + # limit_price also absent/≤0: skip + strong WARN (REQ-095 AC-6 / REQ-096 AC-2) + # Slot stays PENDING — watchdog/stale_pending fires for operator attention. + _log.warning( + "fill price unresolvable: slot=%s symbol=%s broker_order_id=%s " + "avg_price=%s limit_price=%s reason=fill_price_unresolvable " + "(slot stays PENDING — operator attention required)", + slot.slot_id, slot.symbol, slot.broker_order_id, + avg, limit, + ) + return None, False + def _on_filled(self, slot, fs, side: str, r: ExecutionResult) -> None: broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch + # REQ-095: resolve fill price with limit_price backfill guard. + fill_price, _was_backfilled = self._resolve_fill_price(fs, slot) + if fill_price is None: + return # skip: unresolvable price — WARN already emitted, slot stays PENDING try: if slot.status is SlotState.PENDING_BUY: # dcy-021/REQ-066: held qty = broker balance (authority, includes @@ -240,7 +283,6 @@ class ExecutionService: new_qty = float(total) if total > 0 else filled old_qty = max(0.0, new_qty - filled) old_avg = float(slot.avg_price) if slot.avg_price else 0.0 - fill_price = float(fs.avg_price) if fs.avg_price is not None else old_avg new_avg = ( (old_qty * old_avg + filled * fill_price) / new_qty if new_qty > 0 else fill_price @@ -278,7 +320,7 @@ class ExecutionService: ) # REQ-018: enqueue ledger work item AFTER the slot transition is durable. # Slot transition is the critical path; ledger write is async (REQ-019). - self._record_ledger(slot, fs, side, broker_order_id, r) + self._record_ledger(slot, fs, side, broker_order_id, r, resolved_price=fill_price) # REQ-021/023: emit event AFTER transition + ledger enqueue (single # record subject). Best-effort: a publish failure must not roll back. self._emit_event(event_type, updated) @@ -294,6 +336,10 @@ class ExecutionService: portion that actually filled. """ broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch + # REQ-095: resolve fill price with limit_price backfill guard. + fill_price, _was_backfilled = self._resolve_fill_price(fs, slot) + if fill_price is None: + return # skip: unresolvable price — WARN already emitted, slot stays PENDING filled = float(fs.filled_qty) try: if slot.status is SlotState.PENDING_BUY: @@ -303,7 +349,6 @@ class ExecutionService: new_qty = float(total) if total > 0 else filled old_qty = max(0.0, new_qty - filled) old_avg = float(slot.avg_price) if slot.avg_price else 0.0 - fill_price = float(fs.avg_price) if fs.avg_price is not None else old_avg new_avg = ( (old_qty * old_avg + filled * fill_price) / new_qty if new_qty > 0 else fill_price @@ -330,7 +375,7 @@ class ExecutionService: 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._record_ledger(slot, fs, side, broker_order_id, r, resolved_price=fill_price) self._emit_event(event_type, updated) self._repo.clear_dispatch(slot.slot_id) @@ -398,8 +443,23 @@ class ExecutionService: except Exception: # noqa: BLE001 - event publish failure is non-critical return - def _record_ledger(self, slot, fs, side: str, broker_order_id: str, r: ExecutionResult) -> None: - """Append a LedgerWorkItem for the just-filled trade (REQ-018 + REQ-019).""" + def _record_ledger( + self, + slot, + fs, + side: str, + broker_order_id: str, + r: ExecutionResult, + *, + resolved_price: float | None = None, + ) -> None: + """Append a LedgerWorkItem for the just-filled trade (REQ-018 + REQ-019). + + resolved_price: pre-computed fill price from _resolve_fill_price (REQ-095 KD-3). + When provided, it takes priority over fs.avg_price so that a backfilled + limit_price is consistently used in both the slot avg and the ledger record. + Defaults to None for backward compatibility (falls back to fs.avg_price). + """ if self._ledger_outbox is None: return if not slot.portfolio_id: @@ -421,7 +481,11 @@ class ExecutionService: slot.slot_id, slot.portfolio_id, side, ) return - price = float(fs.avg_price) if fs.avg_price is not None else 0.0 + # REQ-095 KD-3: use pre-resolved price when available; fall back to + # fs.avg_price for any direct callers that do not pass resolved_price. + price = resolved_price if resolved_price is not None else ( + float(fs.avg_price) if fs.avg_price is not None else 0.0 + ) trade = LedgerTrade( asset_id=str(slot.portfolio_id), order_id=str(broker_order_id),