feat(execution): 체결가 0/None 시 지정가 백필 가드 + WARN 로깅 (dcy-032/BAT-048)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
2026-06-21 13:34:31 +09:00
co-authored by Claude Opus 4.8
parent d182464b7a
commit f1b649cf2a
15 changed files with 2134 additions and 11 deletions
@@ -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 <slot_id>` → 슬롯 롤백 → 재진입.
- 이 케이스는 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는 인프라 작업으로 분리 권고 | 사장님 | 미확정 |
@@ -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 교차 영향 사안 정책).
@@ -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 계약 변경 필요 여부가 나오면 즉시 중단·팀장 보고.
@@ -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). |
+1
View File
@@ -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/) |
@@ -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)
+1
View File
@@ -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 |
+50 -2
View File
@@ -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 '%<broker_order_id>%'"
```
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 <slot_id>`로 슬롯을 롤백 후 재진입 매수.
- 미체결이라면: `trex-core cancel <slot_id>`로 주문 취소 → 슬롯 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 <slot_id>` 백스톱; pending stale WARN 확인 |
| 미체결 `pending_*` 고착 | 취소 감지 누락/timeout 미설정/fill_price_unresolvable | `trex-core cancel <slot_id>` 백스톱; pending stale WARN + `fill_price_unresolvable` WARN 확인(§5) |
| `avg_price_backfill` WARN | KIS avg_price 순간 레이스(None/0 반환) | 슬롯 평단·원장 price는 지정가로 보정됨 — §5 점검 절차 참고 |
### DB 직접 조회
```bash
+2 -1
View File
@@ -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 회귀 없음).
@@ -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에 반영 필요
@@ -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 구체화 필요
@@ -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: 없음
+8 -1
View File
@@ -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①).
@@ -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}"
+71 -7
View File
@@ -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),