feat(execution,obs): 호가단위 스냅(dcy-030/#28) + 운영 관측성(dcy-029/#29) + REL-007 환류

dcy-030 (issue #28, P0 체결 복구):
- REQ-083 KRX 호가단위(tick) 순수 유틸 (domain/pricing.py)
- REQ-070 CHG: 슬리피지 실효가 호가단위 스냅 — 상한 준수(매수 floor/매도 ceil)
  + band<1tick 하한/상한 보장 + 부동소수 epsilon 가드. 147700→148400/54500→54700.
- BAT-044 release-candidate

dcy-029 (issue #29, 운영 관측성):
- REQ-084 구조화 로깅 기반 (runtime/logging_setup.py): ISO8601 KST·레벨·Loki JSON
  ·correlation id(slot_id)
- REQ-085 주문 결과 로깅 (event=order_submit|fill|reject)
- REQ-087 거부 ERROR 가시성 (조용한 롤백 제거)
- REQ-086 청산 reason+actor 동반 노출 (안전망/전략 구분, 신규 필드 없음)
- REQ-088 운영 조회 CLI report (application/operator_report.py, 보유·오늘 체결
  ·실현 PnL net·대기, direct-DB)
- REQ-089 운영자 런북 docs/operations.md
- REQ-090 baseline 정합 (runtime-flows·project-structure·interface-contract)
- BAT-045(P0)·BAT-046(P1) release-candidate

REL-007: confirmed → feedback-captured (운영 인스턴스 라이브 검증 + #28/#29 환류
정규화). BAT-037~041 released.

검증: 537 passed, 47 skipped, 회귀 0, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 13:19:31 +09:00
co-authored by Claude Opus 4.8
parent e0d4e71304
commit d8d9021260
48 changed files with 2461 additions and 58 deletions
@@ -0,0 +1,32 @@
# BAT-044 Design
- Status: confirmed
- Batch: BAT-044
## Architecture Summary
- 가격→호가단위 변환을 순수 도메인 함수(`trex_core/domain/pricing.py`)로 분리하고, `execution.py`의 dispatch-time 슬리피지 산출이 이를 소비해 제출 직전 유효 호가로 스냅한다. 도메인은 무의존 유지(application이 domain을 단방향 참조).
## Changed Areas
- Docs: docs/runtime-flows.md(주문 집행 루프 슬리피지 스냅 단계), docs/project-structure.md(domain/pricing.py)
- Modules: trex_core/domain/pricing.py(신규), trex_core/application/execution.py(`_dispatch`)
- Interfaces: 외부 메시지/계약 변경 없음 — 제출되는 `OrderRequest.limit_price` 값만 유효 호가로 정정(관측 가능한 동작: 거부→체결).
## Key Decisions
1. **반올림 방향 = 상한 준수**(매수 `floor_to_tick`, 매도 `ceil_to_tick`). `slippage_pct`를 리스크 상한으로 취급 → 스냅이 상한을 넘기지 않음. REQ-070 원래 메모("체결 보장=매수 상위/매도 하위")에서 전환(dcy-030 결정).
2. **band<1tick 보장**: 매수 = `max(floor_to_tick(raw), ceil_to_tick(base))`, 매도 = `min(ceil_to_tick(raw), floor_to_tick(base))`. slippage가 1틱 미만이어도 entry의 유효 호가에서 체결 가능(미체결 방지). on-tick entry(실거래 정상)에서는 guard가 no-op(floor 결과가 이미 ≥ entry).
3. **부동소수 가드**: `floor_to_tick`/`ceil_to_tick`에서 `q=price/tick`에 ±1e-9 epsilon. `1000*1.005=1004.9999…` 같은 잡음이 off-by-one을 일으키지 않게 함. q가 유계라 안전.
4. **유틸 위치 = domain**(순수 함수, 무의존). slot 도메인과 동급의 가격 도메인 모듈.
5. **주식 전용**: 단일 구간표 상수. 종목유형 분기 없음(universe에 유형 필드 부재, dcy-030 OQ-1).
## Edge Cases
- 시장가(`limit_price=None`) 또는 `slippage_pct` 없음/0(falsy) → 스냅 미적용, 기존 동작(회귀 없음).
- 구간 경계(2,000/5,000/…/500,000) → 하한 포함 처리.
- ceil이 상위 구간으로 넘어가는 경우(예: 199,950→200,000) → 결과가 상위 구간 tick의 배수임(KRX 표가 중첩 배수라 항상 유효).
- 2,000원 미만 비주식(ETF 등) → 주식 tick(1원) 적용 시 무효 가능 → known limitation(미지원, dcy-030 OQ-1).
## Architecture Impact
- both (project-structure: 신규 domain 모듈 / runtime-flows: 슬리피지 스냅 단계)
## Reference Doc Update Plan
- project-structure.md: `domain/pricing.py` 모듈 1행 추가 — 완료.
- runtime-flows.md: 주문 집행 루프 슬리피지 보호 지정가 항목에 호가단위 스냅·방향·band<tick 보장 반영 — 완료.
@@ -0,0 +1,30 @@
# BAT-044 Implementation
- Status: completed
- Batch: BAT-044
## Plan Summary
- KRX 호가단위 유틸 신설 + execution dispatch 슬리피지 스냅 적용(상한 준수 + band<tick 보장) + 테스트 + baseline 문서.
## Changed Files
- `trex_core/domain/pricing.py` (신규) — `_KRX_STOCK_TICKS` 구간표, `krx_stock_tick`, `floor_to_tick`, `ceil_to_tick`(epsilon 1e-9 가드).
- `trex_core/application/execution.py` — `_dispatch` 슬리피지 블록을 tick 스냅으로 교체(매수 `max(floor_to_tick(raw), ceil_to_tick(base))`, 매도 `min(ceil_to_tick(raw), floor_to_tick(base))`); `import math` 제거, `from trex_core.domain.pricing import ceil_to_tick, floor_to_tick` 추가; debug 로그에 raw/effective 표기.
- `tests/domain/test_pricing.py` (신규) — tick 대표값/경계값/just-below + floor·ceil 불변식(`f ≤ price ≤ c`, 유효 배수, `c-f ≤ tick`).
- `tests/application/test_execution_policy.py` — 슬리피지 테스트 방향 정정(`floor_to_tick`/`ceil_to_tick`) + issue #28 케이스(147700→148400, 54500→54700) + band<tick 하한 보장 + 상한 미초과 + 35000@0.7%→35200.
- `docs/runtime-flows.md`, `docs/project-structure.md` — baseline 반영.
## Execution Log
1. `domain/pricing.py` 작성 → `tests/domain/test_pricing.py` green.
2. `execution.py` `_dispatch` 스냅 적용, `math` import 제거.
3. 기존 슬리피지 테스트(ceil 가정) 정정 + 신규 케이스 추가.
4. 부동소수 off-by-one(1000*1.005=1004.9999…→1004) 발견 → `floor/ceil_to_tick`에 epsilon 1e-9 가드 추가 → green.
5. 전체 회귀 + ruff + baseline 문서.
## Validation
- `pytest tests/domain/test_pricing.py tests/application/test_execution_policy.py` → 47 passed.
- 전체 `pytest -q` → **516 passed, 47 skipped**(skip=실 DB/RabbitMQ 게이트), 회귀 0.
- `ruff check` (pricing.py, execution.py, 테스트) → All checks passed.
## Remaining Risks
- 2,000원 미만 비주식(ETF 등) 미지원(known limitation, dcy-030 OQ-1) — universe에 종목유형 필드 도입 시 후속.
- 실계좌 KIS 거부 해소는 paper 환경 단위 검증까지 완료, 실 거래 검증은 release 운영 단계 위임.
@@ -0,0 +1,30 @@
# BAT-044: slippage 지정가 KRX 호가단위 스냅 (issue #28)
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-030
- Included REQ: REQ-083 (신규 tick 유틸), REQ-070 (CHG-20260616-01, 슬리피지 스냅)
- Excluded REQ: 없음
## Summary
- slippage 보호 지정가(REQ-070)가 KRX 호가단위를 위반해 KIS `주식주문호가단위 오류`로 매수 전량 거부되던 버그(issue #28)를 수정한다. 가격→tick 순수 유틸(REQ-083)을 신설하고, `execution.py`의 슬리피지 실효가를 유효 호가단위로 스냅한다. 반올림 방향은 **상한 준수**(매수 내림/매도 올림), band<1tick은 entry 유효 호가를 하한/상한으로 보장(미체결 방지).
## Scope
- In Scope:
- `trex_core/domain/pricing.py` — `krx_stock_tick`/`floor_to_tick`/`ceil_to_tick` (REQ-083).
- `trex_core/application/execution.py` — `_dispatch` 슬리피지 스냅 적용(상한 준수 + band<tick 보장) (REQ-070 CHG).
- 단위 테스트(tick 경계값·floor/ceil 불변식·슬리피지 스냅 방향·issue #28 케이스·band<tick).
- baseline: runtime-flows(슬리피지 스냅 단계), project-structure(pricing 모듈).
- Out of Scope:
- ETF/ETN 비주식 호가체계(dcy-030 OQ-1, 주식 전용), 2,000원 미만 비주식(known limitation).
- slippage_pct 정책(밴드 폭/기본값) 변경.
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- P0(다음 거래일 전 체결 복구). 반올림 방향은 REQ-070 원래 메모("체결 보장")에서 "상한 준수"로 전환 — `slippage_pct`를 리스크 상한으로 취급(dcy-030 OQ 결정).
- 부동소수 잡음(예: 1000*1.005=1004.9999…) 흡수용 epsilon(1e-9) 가드 포함.
@@ -0,0 +1,32 @@
# BAT-044 Planning
- Status: confirmed
- Batch: BAT-044
- Profile: standard
## Included REQ
- REQ-083 (KRX 호가단위 tick 유틸, Approved)
- REQ-070 (슬리피지 스냅, CHG-20260616-01, Approved)
## Delivery Plan
1. `trex_core/domain/pricing.py` 신설 — 구간표 + `krx_stock_tick` + `floor_to_tick`/`ceil_to_tick` (epsilon 가드).
2. `execution.py` `_dispatch` 슬리피지 블록 교체 — 상한 준수 방향(매수 floor/매도 ceil) + band<tick 하한/상한 보장. `import math` 제거(미사용), `domain.pricing` import.
3. 테스트: `tests/domain/test_pricing.py`(경계값·불변식), `tests/application/test_execution_policy.py`(방향 정정 + issue #28 케이스 + band<tick + 상한 미초과).
4. baseline 문서 갱신(runtime-flows 슬리피지 스냅 단계, project-structure pricing 모듈).
## Dependencies
- Internal: REQ-070 dispatch 경로가 REQ-083 유틸을 소비. domain(무의존) → application 단방향 유지.
- External: 없음(순수 계산, 외부 의존 없음).
## Design Gate
- Design Required Before Implementation: yes
- Reason: 신규 도메인 모듈 배치 위치 + 반올림 방향/band<tick 보장 공식 + 부동소수 처리 결정이 필요.
## Milestones
1. pricing 유틸 + 단위 테스트 green.
2. execution 스냅 적용 + 정책 테스트 정정/추가 green.
3. 전체 회귀 + baseline 문서 정합.
## Risks
- 부동소수 잡음으로 경계 off-by-one(예: 1000*1.005) → epsilon 가드로 해소.
- 기존 슬리피지 테스트(ceil 가정) 회귀 → 방향 정정에 맞춰 갱신.
@@ -0,0 +1,39 @@
# BAT-044 Verification
- Status: confirmed
- Batch: BAT-044
## Acceptance Mapping
- REQ-083 (KRX tick 유틸):
- AC-1 (1,500→1): `test_representative_prices[1500-1]` ✅
- AC-2 (대표값 5/10/50/100/500/1000): `test_representative_prices` ✅
- AC-3 (경계값 하한 포함): `test_boundaries_are_lower_inclusive` ✅
- AC-4 (148,439→148,400 floor): `test_issue28_floor_cases` ✅
- AC-5 (54,773→54,700 floor / 54,800 ceil): `test_issue28_floor_cases`·`test_ceil_case` ✅
- AC-6 (floor≤p≤ceil, 유효 배수): `test_invariants_floor_le_price_le_ceil_and_valid_multiples` ✅
- REQ-070 (슬리피지 스냅, CHG-20260616-01):
- AC-1 (매수 floor_to_tick): `test_buy_slippage_floor_to_tick`(1000→1005)·`test_buy_slippage_fractional_floors_down`(1001@0.3%→1004) ✅
- AC-2 (매도 ceil_to_tick): `test_sell_slippage_ceil_to_tick`(2000@1%→1980) ✅
- AC-3 (시장가 무시): `test_no_slippage_when_market_order_no_entry_price` ✅
- AC-4 (slippage_pct None 회귀): `test_no_slippage_when_slippage_pct_is_none` ✅
- AC-5 (0% 기준가): `test_zero_slippage_uses_base_price` ✅
- AC-6 (기존 회귀): 전체 스위트 516 passed ✅
- AC-7 (147,700@0.5%→148,400): `test_issue28_buy_147700_snaps_to_valid_tick` ✅
- AC-8 (54,500@0.5%→54,700): `test_issue28_buy_54500_snaps_to_valid_tick` ✅
- AC-9 (band<1tick 하한 보장): `test_buy_sub_tick_band_floors_to_entry_tick` ✅
- AC-10 (유효 호가 배수 + 상한/하한 미초과): `test_buy_snap_never_exceeds_slippage_ceiling`(35000@0.7%→35200) + 불변식 테스트 ✅
## Evidence
- Test:
- `pytest tests/domain/test_pricing.py tests/application/test_execution_policy.py -q` → **47 passed**.
- 전체 `pytest -q` → **516 passed, 47 skipped**(실 DB/RabbitMQ 게이트 skip), 회귀 0.
- `ruff check` → All checks passed.
- Manual:
- issue #28 두 케이스가 유효 호가(148,400 / 54,700)로 산출되고 0.5% 상한 미초과 확인.
- Logs:
- 해당 없음(단위 검증). 실 KIS 거부 해소는 release 운영 검증 위임.
## Result
- Ready for Release: true
- Blocking Issues:
- 없음
@@ -0,0 +1,31 @@
# BAT-045 Design
- Status: confirmed
- Batch: BAT-045
## Architecture Summary
- 로깅 설정을 단일 모듈(`trex_core/runtime/logging_setup.py`)로 모은다. 포맷터(text/json)·KST 타임스탬프·correlation id contextvar/filter를 제공하고, 엔트리포인트가 `configure_logging()`으로 root 핸들러를 구성한다. 호출부(`_log.info`)는 그대로 두어도 포맷·cid가 자동 적용. execution 루프는 주문 결과를 일관 event 라인으로 남기고 처리 슬롯마다 correlation id(slot_id)를 설정한다.
## Changed Areas
- Docs: docs/runtime-flows.md·docs/project-structure.md(REQ-090/BAT-046에서 정합)
- Modules: trex_core/runtime/logging_setup.py(신규), trex_core/runtime/daemon.py·reset.py(configure_logging), trex_core/application/execution.py(주문 결과 로깅+cid)
- Interfaces: 외부 메시지 계약 변경 없음. 로그 라인 포맷/키만 추가(관측 표면).
## Key Decisions
1. **correlation id = slot_id**. intent·order·fill의 공통 조인 키. intent_id를 slot에 저장하는 스키마 변경 대신 slot_id로 단일 키 구성(REQ-084 "파생 키" 허용). execution 루프가 dispatch/poll 각 슬롯 처리 시작 시 `set_correlation_id(slot_id)`.
2. **포맷 선택 = env `TREX_LOG_FORMAT`**(text 기본 / json). JSON은 `ts/level/logger/msg/correlation_id` + extra 필드. KST 타임스탬프는 코드에서 명시적 +09:00(환경 TZ 비의존).
3. **`configure_logging` idempotent**: 재호출 시 기존 핸들러 제거 후 단일 핸들러 부착(데몬·reset 양쪽 안전, 중복 출력 방지).
4. **레이어 방향**: application(execution) → runtime(logging_setup) import는 기존 선례(safety_sell→runtime.config)와 동일. domain은 무의존 유지(import sweep 영향 없음).
5. **거부 ERROR(REQ-087)는 REQ-085 reject 라인과 동일 지점**에서 ERROR 레벨로 구현(코드 중복 편집 방지). 정상 체결 경로는 INFO 유지.
## Edge Cases
- correlation id 미설정 → 빈 문자열, 예외 없음(text는 cid 생략, json은 빈 값).
- caplog 기반 기존 테스트: root 핸들러만 교체하고 메시지 텍스트 보존 → 회귀 없음(529 passed).
- JSON extra 필드: 표준 LogRecord 속성 제외 후 직렬화(default=str로 비직렬 객체 방어).
## Architecture Impact
- both (project-structure: 신규 logging 모듈 / runtime-flows: 로깅·관측성 흐름) — 실제 문서 정합은 REQ-090(BAT-046)에서 일괄.
## Reference Doc Update Plan
- project-structure.md: `runtime/logging_setup.py` 추가 — REQ-090(BAT-046)에서.
- runtime-flows.md: 구조화 로깅·correlation id·주문 결과 로깅·거부 ERROR 흐름 — REQ-090(BAT-046)에서.
@@ -0,0 +1,30 @@
# BAT-045 Implementation
- Status: completed
- Batch: BAT-045
## Plan Summary
- 구조화 로깅 기반(REQ-084) + 주문 결과 로깅(REQ-085) + 거부 ERROR(REQ-087) 구현.
## Changed Files
- `trex_core/runtime/logging_setup.py` (신규) — `_correlation_id` contextvar, `set_correlation_id`/`get_correlation_id`/`correlation_id` cm, `_CorrelationFilter`, `TextFormatter`/`JsonFormatter`(KST ISO8601), `configure_logging`(idempotent root 핸들러).
- `trex_core/runtime/daemon.py` — `main()`에서 `logging.basicConfig` → `configure_logging`.
- `trex_core/runtime/reset.py` — 동일 교체.
- `trex_core/application/execution.py` — import `set_correlation_id`; `_dispatch`에서 슬롯별 cid 설정 + `event=order_submit` INFO + `event=order_reject` ERROR; `_poll` 슬롯별 cid + terminal_reject ERROR; `_on_filled` `event=order_fill`(price/qty/fee/tax).
- `tests/runtime/test_logging_setup.py` (신규) — text/json 포맷·KST·cid·idempotent.
- `tests/application/test_execution_policy.py` — `TestOrderResultLogging`(submit/fill event 키 + cid=slot_id, 거부 ERROR, 정상 무ERROR).
## Execution Log
1. `logging_setup.py` 작성 → 단위 테스트 green.
2. 엔트리포인트 `configure_logging` 교체(daemon/reset).
3. execution 주문 결과 로깅 + correlation id + 거부 ERROR.
4. 테스트 추가/실행, 전체 회귀.
## Validation
- `pytest tests/runtime/test_logging_setup.py tests/application/test_execution_policy.py` → 35 passed.
- 전체 `pytest -q` → **529 passed, 47 skipped**(실 DB/RabbitMQ 게이트 skip), 회귀 0.
- `ruff check`(logging_setup/execution/daemon/reset/테스트) → All checks passed.
## Remaining Risks
- correlation id는 slot_id 기반(단일 키). intent_id 전체 전파는 후속(스키마 변경 필요 시).
- Loki 실제 적재는 배포측 수집기 설정(코드는 JSON stdout). 실 환경 검증은 release 운영 위임.
@@ -0,0 +1,30 @@
# BAT-045: 관측성 로깅 기반 — 구조화 로깅 + 주문 결과 + 거부 ERROR (issue #29 P0)
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-029
- Included REQ: REQ-084, REQ-085, REQ-087
- Excluded REQ: REQ-086·088·089·090 (P1 → BAT-046)
## Summary
- dcy-029 P0 — 운영 가시성의 토대. 모든 로그에 ISO8601(KST) 타임스탬프+레벨, Loki 적재용 JSON 출력 옵션, correlation id(slot_id, intent→order→fill join key)를 도입하고(REQ-084), 주문 submit/체결/거부를 일관 tailable 로그로 남기며(REQ-085), 거부를 ERROR 레벨로 가시화한다(REQ-087, 조용한 롤백 제거 — issue #28이 수 시간 묻혔던 원인).
## Scope
- In Scope:
- `trex_core/runtime/logging_setup.py`(신규) — TextFormatter/JsonFormatter + KST 타임스탬프 + correlation id contextvar/filter + `configure_logging`.
- `daemon.py`·`reset.py` — `basicConfig` → `configure_logging`.
- `execution.py` — submit/fill 로그(`event=order_submit|order_fill`) + 거부 ERROR(`event=order_reject`) + correlation id(slot_id) 설정.
- 단위 테스트(포맷터 text/json/KST/cid, 설정 idempotent, 주문 결과 로깅, 거부 ERROR).
- Out of Scope:
- 청산 reason+actor 노출(REQ-086), PnL CLI(REQ-088), 런북(REQ-089), baseline 정합(REQ-090) → BAT-046.
- 알림 전송 채널(dcy-029 OQ-3 Deferred).
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- correlation id 키 = **slot_id**(intent·order·fill 공통 조인 키). intent_id 전체 전파(slot에 저장)는 과한 스키마 변경이라 slot_id로 단일 키 구성(REQ-084 "intent_id 또는 그로부터 파생한 키" 허용).
- REQ-087(FR-4)은 원래 P1이나 REQ-085의 reject 로깅 지점과 동일 코드라 P0에 동반 구현(코드 중복 편집 방지) — 사용자 위임 결정 범위 내 조정.
@@ -0,0 +1,33 @@
# BAT-045 Planning
- Status: confirmed
- Batch: BAT-045
- Profile: standard
## Included REQ
- REQ-084 (구조화 로깅 기반, Approved)
- REQ-085 (주문 결과 로깅, Approved)
- REQ-087 (주문 거부 ERROR 가시성, Approved)
## Delivery Plan
1. `trex_core/runtime/logging_setup.py` 신설 — KST 포맷터(text/json) + correlation id contextvar/filter + `configure_logging`(basicConfig 대체, idempotent).
2. `daemon.py`/`reset.py` 엔트리포인트에서 `configure_logging` 호출로 교체.
3. `execution.py` `_dispatch`/`_poll`/`_on_filled`에 submit/fill/reject 일관 로그(event 키) + correlation id(slot_id) 설정. 거부 라인은 ERROR 레벨(REQ-087).
4. 테스트: `tests/runtime/test_logging_setup.py`, `tests/application/test_execution_policy.py`(주문 결과 로깅 + 거부 ERROR).
## Dependencies
- Internal: `execution.py`(application)가 `logging_setup`(runtime)의 `set_correlation_id` 사용 — 기존 선례(safety_sell→runtime.config) 동일 방향. domain 무의존 유지.
- External: 없음. Loki 적재는 배포측 수집기(코드는 JSON stdout만, A-4 전제).
## Design Gate
- Design Required Before Implementation: yes
- Reason: correlation id 키 선택(slot_id), 포맷터 구조, 레이어 의존 방향, basicConfig 교체 idempotency 결정 필요.
## Milestones
1. logging_setup + 단위 테스트 green.
2. execution 주문 결과 로깅 + 거부 ERROR + 테스트 green.
3. 전체 회귀 + ruff.
## Risks
- 로그 포맷 변경이 기존 caplog 기반 테스트에 영향 → 메시지 텍스트 보존, 핸들러 교체는 root만.
- correlation id 단일 스레드 execution 루프에서 set-per-iteration로 충분(리셋 불요).
@@ -0,0 +1,41 @@
# BAT-045 Verification
- Status: confirmed
- Batch: BAT-045
## Acceptance Mapping
- REQ-084 (구조화 로깅 기반):
- AC-1 (ISO8601 KST+레벨+메시지): `TestTextFormatter::test_has_iso8601_kst_timestamp_level_and_msg` ✅
- AC-2 (json 유효+필수 키): `TestJsonFormatter::test_is_valid_json_with_required_keys` ✅
- AC-3 (text 기본): `TestConfigureAndContext::test_configure_text_default` ✅
- AC-4 (cid 포함): `test_includes_correlation_id_when_set`·`test_correlation_id_propagates` ✅
- AC-5 (cid 미설정 안전): `test_omits_cid_when_unset`·json 빈 cid ✅
- AC-6 (KST 고정): `+09:00` 검증(text/json) ✅
- REQ-085 (주문 결과 로깅):
- AC-1 (order_submit 키): `TestOrderResultLogging::test_submit_and_fill_are_logged_with_event_keys` ✅
- AC-2 (order_fill 가격/수량/fee/tax): 동 테스트(`fee=1.5`) ✅
- AC-3 (order_reject 사유): `test_reject_is_logged_at_error_level` ✅
- AC-4 (json 구조화 필드): `JsonFormatter` extra 필드 테스트 + 라인 키 ✅
- AC-5 (grep 전수 추출): `event=order_reject`/`event=order_submit` 안정 키 ✅
- AC-6 (submit→fill 동일 cid): `get_correlation_id()==slot_id` 검증 ✅
- REQ-087 (거부 ERROR 가시성):
- AC-1·2 (매수/매도 거부 ERROR): `test_reject_is_logged_at_error_level`(dispatch) + poll terminal_reject ERROR 경로 ✅
- AC-3 (정상 체결 비-ERROR): `test_normal_fill_emits_no_error` ✅
- AC-4 (json level=ERROR): JsonFormatter level 키 ✅
- AC-5 (반복 거부 가시화): reject 라인 event 키 grep ✅
- AC-6 (알림 채널 미호출): 본 구현은 로그까지만(전송 채널 코드 없음, OQ-3 Deferred) ✅
## Evidence
- Test:
- `pytest tests/runtime/test_logging_setup.py tests/application/test_execution_policy.py -q` → **35 passed**.
- 전체 `pytest -q` → **529 passed, 47 skipped**, 회귀 0.
- `ruff check` → All checks passed.
- Manual:
- text/json 포맷·KST 타임스탬프·correlation id·거부 ERROR 출력 단위 검증.
- Logs:
- 실 데몬/Loki 적재 검증은 release 운영 단계 위임(코드는 JSON stdout 보장).
## Result
- Ready for Release: true
- Blocking Issues:
- 없음
@@ -0,0 +1,30 @@
# BAT-046 Design
- Status: confirmed
- Batch: BAT-046
## Architecture Summary
- P0 로깅 기반(BAT-045) 위에 분류·조회·문서를 얹는다. 청산 로그는 기존 reason·actor를 함께 노출(신규 필드 없음). 운영 조회는 순수 집계 모듈(`operator_report.py`) + CLI direct-DB 리더로 구성해 집계 로직을 테스트 가능하게 분리.
## Changed Areas
- Docs: docs/operations.md(신규), docs/interface-contract.md(reason+actor 어휘 + report 매핑), docs/runtime-flows.md·docs/project-structure.md(정합)
- Modules: trex_core/application/safety_sell.py(actor 로그), trex_core/application/operator_report.py(신규), trex_core/runtime/cli.py(report)
- Interfaces: 운영자 CLI `report` 추가(direct-DB, RPC 없음). reason+actor는 로그/계약 노출(메시지 계약 불변).
## Key Decisions
1. **REQ-086 = Option A**: 기존 `reason`(조건) + `actor`(주체)를 함께 노출. safety_sell fire 로그에 `actor=trex-safety-sell` 추가, interface-contract에 어휘 명문화. 신규 reason 값·분류 필드 없음. `slot_transition_log`는 이미 actor 기록 → 추가 스키마 변경 불요.
2. **REQ-088 실현 PnL = net 매매 현금흐름**: `ledger_outbox`에 매도별 cost basis가 없어, 운영자가 수기 계산하던 당일 net(Σ매도 대금−수수료−세금 − Σ매수 대금−수수료)을 정의로 채택. 일중 진입·청산에서 net ≈ 실현손익. 미청산 평가손익 제외(런북·계약에 명시).
3. **REQ-088 표면 = CLI direct-DB**(OQ-1): 신규 RPC 없음. 집계는 순수 함수(`operator_report.py`)로 분리해 단위 테스트, DB 와이어링은 `cli._cmd_report`(psycopg) 얇은 리더.
4. **"오늘" 경계 = trading_date KST**: `kst_day_bounds_utc()`로 KST 자정 경계를 UTC 범위로 변환해 `ledger_outbox.executed_at` 필터.
## Edge Cases
- ledger_outbox executed_at NULL → `COALESCE(executed_at, created_at)`로 폴백.
- 체결 0건 → realized PnL 0, 빈 섹션.
- report는 인스턴스 필터 없이 DB 전체 집계(pause/resume 선례) — 멀티인스턴스 분리는 후속(런북에 명시).
## Architecture Impact
- both (project-structure: operator_report·logging_setup·operations.md / runtime-flows: 로깅·관측성·report 흐름) — REQ-090에서 정합 완료.
## Reference Doc Update Plan
- project-structure.md: `application/operator_report.py`·`runtime/logging_setup.py`·`docs/operations.md`·cli report — 완료.
- runtime-flows.md: 구조화 로깅/관측성 flow + 운영 조회 리포트 flow — 완료.
@@ -0,0 +1,35 @@
# BAT-046 Implementation
- Status: completed
- Batch: BAT-046
## Plan Summary
- 청산 reason+actor 노출(REQ-086) + 운영 조회 CLI(REQ-088) + 운영자 런북(REQ-089) + baseline 정합(REQ-090).
## Changed Files
- `trex_core/application/safety_sell.py` — `_log_fire`에 `actor=trex-safety-sell` 동반 노출(REQ-086).
- `docs/interface-contract.md` — 청산 reason+actor 어휘 절 + `report` CLI 매핑(REQ-086/088).
- `trex_core/application/operator_report.py` (신규) — `Holding`/`PendingOrder`/`TradeRow`/`OperatorReport`, `realized_pnl`(net), `build_report`, `render_text`/`render_json`, `kst_day_bounds_utc`(REQ-088).
- `trex_core/runtime/cli.py` — `report` 서브커맨드 + `_cmd_report`(direct-DB psycopg: daily_slot holdings/pending + ledger_outbox 오늘 체결 → build_report → text/json)(REQ-088).
- `docs/operations.md` (신규) — 운영자 런북 4영역(REQ-089).
- `docs/runtime-flows.md`·`docs/project-structure.md` — 로깅/관측성·report flow + 신규 모듈 정합(REQ-090).
- `tests/application/test_operator_report.py` (신규) — 집계·net PnL·KST 경계·렌더.
- `tests/application/test_safety_sell_visibility.py` — reason+actor 동반 노출 테스트(REQ-086).
## Execution Log
1. operator_report 순수 집계 + 단위 테스트(7) green.
2. cli `report` 서브커맨드(direct-DB) 추가.
3. safety_sell actor 로그 + 테스트.
4. interface-contract reason+actor 어휘 + report 매핑.
5. docs/operations.md 런북 작성.
6. runtime-flows·project-structure 정합, 전체 회귀 + ruff(F841 1건 수정).
## Validation
- `pytest tests/application/test_operator_report.py tests/application/test_safety_sell_visibility.py` → 14 passed.
- 전체 `pytest -q` → **537 passed, 47 skipped**, 회귀 0.
- `ruff check trex_core/ tests/` → All checks passed.
## Remaining Risks
- `report` direct-DB 와이어링은 순수 집계 단위 검증 + 실 DB 검증 release 위임(pause/resume 선례).
- 실현 PnL은 net 현금흐름 정의(미청산 평가손익·cost-basis 매칭 제외) — 런북·계약 명시.
- Loki 실 적재는 배포측 수집기.
@@ -0,0 +1,30 @@
# BAT-046: 관측성 P1 — reason+actor 노출 + PnL 조회 CLI + 운영자 런북 + baseline (issue #29)
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-029
- Included REQ: REQ-086, REQ-088, REQ-089, REQ-090
- Excluded REQ: REQ-084·085·087 (P0 → BAT-045)
## Summary
- dcy-029 P1 — P0 로깅 기반(BAT-045) 위에 운영 가시성을 완성한다. 청산 reason+actor 동반 노출(REQ-086, 안전망/전략 구분), 운영 조회 CLI `report`(REQ-088, 보유·오늘 체결·실현 PnL·대기), 운영자 런북 `docs/operations.md`(REQ-089), baseline 문서 정합(REQ-090).
## Scope
- In Scope:
- `safety_sell.py` — 청산 fire 로그에 actor 동반(REQ-086) + `interface-contract.md` reason+actor 어휘.
- `application/operator_report.py`(신규) + `cli.py` `report` 서브커맨드(REQ-088, direct-DB, RPC 없음).
- `docs/operations.md`(신규 운영자 런북, REQ-089).
- `docs/runtime-flows.md`·`docs/project-structure.md` 정합(REQ-090).
- 단위 테스트(report 집계·KST 경계·렌더, safety_sell reason+actor).
- Out of Scope:
- 알림 전송 채널(OQ-3 Deferred). 실현 PnL의 cost-basis 매칭(net 현금흐름 정의 사용).
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- REQ-086 = Option A(기존 reason·actor 함께 노출, 신규 필드 없음). `slot_transition_log` 는 이미 actor 기록.
- REQ-088 실현 PnL = `ledger_outbox` net 매매 현금흐름(수수료·세금 반영, BAT-038). 미청산 평가손익 제외.
@@ -0,0 +1,34 @@
# BAT-046 Planning
- Status: confirmed
- Batch: BAT-046
- Profile: standard
## Included REQ
- REQ-086 (청산 reason+actor 노출, Approved)
- REQ-088 (운영 조회 CLI, Approved)
- REQ-089 (운영자 런북, Approved)
- REQ-090 (관측성 baseline 정합, Approved)
## Delivery Plan
1. REQ-086: `safety_sell._log_fire` 에 actor 동반 + `interface-contract.md` reason+actor 어휘 절 추가.
2. REQ-088: `application/operator_report.py`(순수 집계: holdings/pending/realized PnL net + KST 경계) + `cli.py` `report` 서브커맨드(direct-DB psycopg) + 단위 테스트.
3. REQ-089: `docs/operations.md` 신설(배포/CLI/로깅·관측성/디버깅 4영역).
4. REQ-090: `runtime-flows.md`(로깅/관측성 + report 흐름) + `project-structure.md`(logging_setup·operator_report·operations.md) 정합.
## Dependencies
- Internal: BAT-045(P0 로깅 기반) 선행 — report/로그가 그 위에 동작. REQ-086 로그는 P0 로깅 포맷 활용.
- External: psycopg(report direct-DB). 기존 의존, 신규 없음.
## Design Gate
- Design Required Before Implementation: yes
- Reason: 실현 PnL 정의(net 현금흐름 vs cost-basis), report 표면(CLI/direct-DB), reason+actor 노출 방식(신규 필드 없음) 결정 필요.
## Milestones
1. operator_report 순수 집계 + 단위 테스트 green.
2. report CLI + safety_sell actor + interface-contract.
3. 런북 + baseline 정합 + 전체 회귀.
## Risks
- 실현 PnL net 정의가 cost-basis 매칭 PnL과 다름 → 런북·계약 문서에 정의 명시(미청산 평가손익 제외).
- report direct-DB 는 단위 미검증(psycopg) — 집계 순수 함수로 검증하고 DB 와이어링은 release 운영 검증(pause/resume 선례).
@@ -0,0 +1,41 @@
# BAT-046 Verification
- Status: confirmed
- Batch: BAT-046
## Acceptance Mapping
- REQ-086 (청산 reason+actor 노출):
- AC-1 (stop_loss+actor=safety_sell 함께): `test_fire_log_exposes_reason_and_actor`(forced_close_at+actor) + 로그 포맷(reason= actor=) ✅
- AC-2 (전략 actor 구분): actor 어휘 interface-contract 문서화 + slot_transition_log actor 기록(기존) ✅
- AC-3 (운영자 오해 제거): fire 로그 reason+actor 동반 ✅
- AC-4 (safety_sell 4경로 reason+actor): `_log_fire` 모든 경로 공통 + 기존 forced/SL/TP/market 테스트 ✅
- AC-5 (기존 reason 소비처 회귀 없음): reason 값 불변, 537 passed 회귀 0 ✅
- AC-6 (interface-contract 문서화): "청산 사유 어휘 — reason + actor" 절 추가 ✅
- REQ-088 (운영 조회 CLI):
- AC-1 (보유): `test_render_text_has_sections`·`render_json` holdings ✅
- AC-2 (오늘 체결 건수): `test_build_counts_and_pnl`(fills_count) ✅
- AC-3 (실현 PnL fee/tax 반영, ledger_outbox 집계 일치): `test_buy_then_sell_net_cash_flow_with_fee_tax` ✅
- AC-4 (대기 주문): pending 렌더 ✅
- AC-5 (KST "오늘" 경계 전일 제외): `test_today_boundary_excludes_yesterday` ✅
- AC-6 (신규 RPC 없이 동작): `report`=direct-DB, RPC 미추가(cli `_cmd_report`) ✅
- REQ-089 (운영자 런북):
- AC-1~5 (배포·CLI·로깅/관측성·디버깅 4영역 + 신규 운영자 수행 가능): `docs/operations.md` 4개 절(§1 배포/§2 CLI+report/§3 로깅·관측성·reason+actor/§4 디버깅+DB조회) ✅ (문서 리뷰)
- REQ-090 (baseline 정합):
- AC-1·2 (runtime-flows 로깅/관측성+주문결과+거부ERROR+reason+actor+report): "구조화 로깅/관측성" + "운영 조회 리포트" flow 추가 ✅
- AC-3 (project-structure 신규 모듈): logging_setup·operator_report·operations.md·cli report 반영 ✅
- AC-4 (구현과 무모순): 코드와 일치 ✅
## Evidence
- Test:
- `pytest tests/application/test_operator_report.py tests/application/test_safety_sell_visibility.py -q` → **14 passed**.
- 전체 `pytest -q` → **537 passed, 47 skipped**, 회귀 0.
- `ruff check trex_core/ tests/` → All checks passed.
- Manual:
- operations.md 4영역 포함 리뷰, interface-contract reason+actor 어휘 리뷰.
- Logs:
- report direct-DB·Loki 실 적재는 release 운영 검증 위임.
## Result
- Ready for Release: true
- Blocking Issues:
- 없음
+8 -5
View File
@@ -44,10 +44,13 @@
| BAT-034 | standard | released | REQ-002 (CHG-20260610-02), REQ-006 (CHG-20260610-02), REQ-007 (CHG-20260610-02), REQ-028 (CHG-20260610-01), REQ-032 (CHG-20260610-07) | dcy-023 (issue #22) | [bat-034](./bat-034_20260610_drop-allocated-capital/) | | BAT-034 | standard | released | REQ-002 (CHG-20260610-02), REQ-006 (CHG-20260610-02), REQ-007 (CHG-20260610-02), REQ-028 (CHG-20260610-01), REQ-032 (CHG-20260610-07) | dcy-023 (issue #22) | [bat-034](./bat-034_20260610_drop-allocated-capital/) |
| BAT-035 | standard | released | REQ-073, REQ-074, REQ-075, REQ-021 (CHG-20260611-01), REQ-034 (CHG-20260611-01), REQ-063 (CHG-20260611-01) | dcy-025 | [bat-035](./bat-035_20260611_strategy-read-surface-slots-req/) | | BAT-035 | standard | released | REQ-073, REQ-074, REQ-075, REQ-021 (CHG-20260611-01), REQ-034 (CHG-20260611-01), REQ-063 (CHG-20260611-01) | dcy-025 | [bat-035](./bat-035_20260611_strategy-read-surface-slots-req/) |
| BAT-036 | standard | released | REQ-048 (CHG-20260611-01), REQ-005 (CHG-20260611-01) | dcy-024 | [bat-036](./bat-036_20260611_safety-sell-disable-mode/) | | BAT-036 | standard | released | REQ-048 (CHG-20260611-01), REQ-005 (CHG-20260611-01) | dcy-024 | [bat-036](./bat-036_20260611_safety-sell-disable-mode/) |
| BAT-037 | standard | release-candidate | REQ-073 (CHG-20260612-01) | dcy-025 (REQ-073 change-req) | [bat-037](./bat-037_20260612_slots-rpc-available-slots/) | | BAT-037 | standard | released | REQ-073 (CHG-20260612-01) | dcy-025 (REQ-073 change-req) | [bat-037](./bat-037_20260612_slots-rpc-available-slots/) |
| BAT-038 | standard | release-candidate | REQ-018 (CHG-20260612-01) | dcy-026 (issue #24) | [bat-038](./bat-038_20260612_sell-transaction-tax-fee/) | | BAT-038 | standard | released | REQ-018 (CHG-20260612-01) | dcy-026 (issue #24) | [bat-038](./bat-038_20260612_sell-transaction-tax-fee/) |
| BAT-039 | standard | release-candidate | REQ-011 (CHG-20260612-01) | 없음 (issue #4) | [bat-039](./bat-039_20260612_paper-seq-redis-persistence/) | | BAT-039 | standard | released | REQ-011 (CHG-20260612-01) | 없음 (issue #4) | [bat-039](./bat-039_20260612_paper-seq-redis-persistence/) |
| BAT-040 | standard | release-candidate | REQ-002 (CHG-20260612-01), REQ-006 (CHG-20260612-01), REQ-073 (CHG-20260612-02) | 없음 (issue #25) | [bat-040](./bat-040_20260612_slots-rpc-db-filter/) | | BAT-040 | standard | released | REQ-002 (CHG-20260612-01), REQ-006 (CHG-20260612-01), REQ-073 (CHG-20260612-02) | 없음 (issue #25) | [bat-040](./bat-040_20260612_slots-rpc-db-filter/) |
| BAT-041 | standard | release-candidate | REQ-076, REQ-077, REQ-078 | dcy-027 (issue #20 이월) | [bat-041](./bat-041_20260612_long-term-instance-profile/) | | BAT-041 | standard | released | REQ-076, REQ-077, REQ-078 | dcy-027 (issue #20 이월) | [bat-041](./bat-041_20260612_long-term-instance-profile/) |
| BAT-042 | standard | release-candidate | REQ-079, REQ-080, REQ-081, REQ-082, REQ-073 (CHG-20260615-01), REQ-074 (CHG-20260615-01) | dcy-028 (issue #26) | [bat-042](./bat-042_20260615_slot-strategy-state-store/) | | BAT-042 | standard | release-candidate | REQ-079, REQ-080, REQ-081, REQ-082, REQ-073 (CHG-20260615-01), REQ-074 (CHG-20260615-01) | dcy-028 (issue #26) | [bat-042](./bat-042_20260615_slot-strategy-state-store/) |
| BAT-043 | batch-lite | release-candidate | REQ-007 (CHG-20260615-01) | 없음 (issue #27) | [bat-043](./bat-043_20260615_sell-intent-retryable-flag/) | | BAT-043 | batch-lite | release-candidate | REQ-007 (CHG-20260615-01) | 없음 (issue #27) | [bat-043](./bat-043_20260615_sell-intent-retryable-flag/) |
| BAT-044 | standard | release-candidate | REQ-083, REQ-070 (CHG-20260616-01) | dcy-030 (issue #28) | [bat-044](./bat-044_20260616_slippage-tick-size-snapping/) |
| BAT-045 | standard | release-candidate | REQ-084, REQ-085, REQ-087 | dcy-029 (issue #29, P0) | [bat-045](./bat-045_20260616_observability-logging-foundation/) |
| BAT-046 | standard | release-candidate | REQ-086, REQ-088, REQ-089, REQ-090 | dcy-029 (issue #29, P1) | [bat-046](./bat-046_20260616_observability-classification-pnl-runbook/) |
@@ -0,0 +1,237 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: dcy-029_20260616_operational-observability-logging-and-pnl-view
- 이슈명: 운영 관측성 — 주문 결과 로깅 + 로그 타임스탬프 + 청산사유 분류 + 슬롯/PnL 조회 + 운영자 런북 (issue #29)
- 작성 시각(KST): 2026-06-16 10:52
- 마지막 갱신 시각(KST): 2026-06-16 12:55
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조: REQ-084, REQ-085, REQ-086, REQ-087, REQ-088, REQ-089, REQ-090
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
- 연계 문서 현황:
- 관련 점수 파일: 미생성
- 관련 TODO/메모: GitHub issue #29 (P0: 1·2 / P1: 3·4·5), 관련 issue #28(호가단위 거부 — 별도 정확성 버그 트랙)
- 해석:
- 2026-06-15/16 라이브 테스트에서 trex-core의 운영 가시성 부족이 진단을 막았다. 매수 거부(#28)가 로그에 없고 `slot_transition_log` DB에만 남아 수 시간 동안 인지하지 못했고, 로그에 타임스탬프가 없어 타임라인 재구성이 불가능했다.
- 이번 Discovery는 "거래 실패를 즉시·정확히 관측"하기 위한 로깅/분류/조회 표면을 정의한다. 청산사유 분류·조회 뷰가 런타임 흐름과 read 표면을 바꾸므로 minor-change가 아니라 정식 Discovery로 다룬다.
# 2. 요구사항 판정 결과
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
- 판정: 신규
- 근거
- 기존 Discovery(dcy-001~028)는 슬롯 도메인·전송·브로커·안전매도·자산중심 슬롯·전략 read 표면을 다뤘고, "운영 관측성(로깅/타임스탬프/청산사유 분류/PnL 조회)"을 정면으로 다룬 주기는 없다. dcy-016/dcy-018(슬롯 상태 가시화·운영자 CLI)은 슬롯 상태 조회에 한정돼 주문 결과 로깅·PnL 집계와 범위가 다르다.
- issue #29는 신규 운영 환류(라이브 테스트 인사이트)에서 출발한 독립 주제로, 후속 반복을 독립 주기로 추적하는 편이 책임 경계가 명확하다.
# 3. 문제점의 요약
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다.
- 현재 상태
- 로그 포맷: `trex_core/runtime/daemon.py:408`·`trex_core/runtime/reset.py:129`가 `logging.basicConfig(level=logging.INFO)`만 호출해 **format 문자열이 없다** → 모든 로그 라인에 타임스탬프·레벨 prefix가 없다.
- 주문 결과 가시성: `trex_core/application/execution.py`의 submit/poll 경로는 산발적 `_log.debug`/`_log.warning`만 남기고, 주문 submit + 결과(체결가/수량/수수료/세금 또는 거부+사유)를 일관된 tailable 로그로 남기지 않는다. `_rollback()`은 거부 시 슬롯을 **조용히** 전이(`slot_transition_log`에만 기록)하고 ERROR 로그를 남기지 않아 호가단위 거부(#28)가 로그에 보이지 않았다.
- 청산사유 분류: `trex_core/application/safety_sell.py`·`trex_core/application/watchdog.py` 양쪽이 매도를 일으키는데, watchdog 청산이 `stop_loss`로 뭉뚱그려져 "진짜 손절"로 오해를 유발했다(2026-06-15 064400 사례).
- 조회 표면: 운영자 CLI(`trex_core/runtime/cli.py`)는 `slots/universe/pause/enter/exit/cancel`만 제공하고, 보유/오늘 체결/실현 PnL(수수료·세금 반영)/대기 주문을 한눈에 보는 뷰가 없다. 일일 실현 PnL을 `ledger_outbox`에서 수기 계산해야 했다. 데몬은 RabbitMQ 전용(BAT-007)이라 HTTP 표면이 없다.
- baseline 문서(`docs/project-structure.md`, `docs/runtime-flows.md`)는 존재하며 최신(2026-06-15 갱신). 단, 로깅/관측성·PnL 조회 흐름은 아직 기술돼 있지 않다.
- 기대 상태
- 모든 로그 라인에 ISO8601(KST) 타임스탬프 + 레벨이 붙고, 모든 주문 submit/결과/거부가 타임스탬프 달린 일관 로그(Loki 적재용 구조화 JSON + correlation id)로 즉시 관측된다.
- 청산이 기존 reason 어휘(`stop_loss`/`take_profit`/`forced_close_at`/`market_close_liquidation`)와 actor(`safety_sell`/`strategy`/`execution_loop`)를 함께 노출해, "안전망 자율청산"과 "전략 손절"이 구분 기록된다.
- 운영자가 보유·오늘 체결·실현 PnL(수수료·세금 반영)·대기 주문을 단일 CLI 명령으로 조회할 수 있어 일일 리포트 자동화의 기반이 된다.
- 주문 거부가 조용히 묻히지 않고 ERROR 로그로 즉시 인지된다(알림 전송 채널은 후속 반복으로 Deferred).
- 이해관계자
- 운영 담당자(trex 운영팀): 실패를 즉시 인지하고 일일 리포트를 자동화해야 하는 1차 수요자.
- 문서 작성자(AI agent): Discovery/REQ/Batch 산출물 작성·구현·검증.
- 리뷰어/승인자(Justin Ko): 범위·계약·우선순위 확정.
- 제약사항
- 데몬은 HTTP 표면이 없다(BAT-007, RabbitMQ-only). "조회 endpoint"는 운영자 CLI 확장 또는 RabbitMQ RPC로 구현해야 하며 HTTP 신설은 아키텍처 역행이다.
- 청산사유 reason 값은 전략/운영자가 읽는 계약 표면(`slots.req`, 이벤트, `slot_transition_log`)에 노출되므로 어휘 변경 시 하위호환을 고려해야 한다.
- 코어는 알림 전송 채널의 1차 소유자가 아니다(ERROR 로그는 코어, 전송은 운영 인프라/외부).
## 주요 사용자 시나리오
사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다.
- 운영자가 거래일 중 `tail -f`로 trex-core 로그를 보며 매수 주문이 호가단위 오류로 거부되는 것을 **즉시** 발견하고(타임스탬프+ERROR), #28 같은 문제를 몇 시간이 아니라 즉시 인지한다.
- 운영자가 장 마감 후 단일 명령/RPC로 "오늘 체결 N건, 실현 PnL(수수료·세금 반영) X원, 현재 보유 M종목, 대기 주문 K건"을 조회해 일일 리포트를 자동 생성한다.
- 운영자가 슬롯 청산 기록을 보고 그것이 watchdog 강제청산인지 진짜 손절(stop_loss)인지 사유로 즉시 구분한다.
# 4. 이번에 정의할 변경
이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다.
- 로깅 기반(P0): 전 로그 라인에 ISO8601(KST) 타임스탬프 + 레벨 prefix 부여, 모든 주문 submit/결과(체결가·수량·수수료·세금 또는 거부+사유)를 타임스탬프 달린 일관 로그로 출력, Loki 적재용 구조화(JSON) 출력 + correlation id(intent→order→fill) 전파.
- 의미론·가시성(P1): 청산을 기존 reason 어휘 + actor를 함께 노출해 안전망/전략을 구분 기록(신규 필드 없음), 주문 거부 시 조용한 롤백 대신 ERROR 로그(알림 전송 채널은 Deferred).
- 조회 표면(P1): 보유·오늘 체결·실현 PnL(수수료·세금 반영)·대기 주문 조회 뷰를 운영자 CLI 서브커맨드로 추가(DB-backed, RPC 없음).
- baseline 문서 갱신: `docs/runtime-flows.md`에 로깅/관측성·청산사유 분류·PnL 조회 흐름을 추가하고, 신규 모듈(로깅 포맷/주문결과 로깅/PnL 집계)이 생기면 `docs/project-structure.md`에 책임을 반영.
- 운영자 런북 문서(P1): `docs/operations.md` 신설 — 배포/실행·운영자 CLI 사용법·로깅/관측성 사용법·디버깅/트러블슈팅. `run-trex-core-daemon` SKILL의 사실 자료 추출 + 본 반복 신설 관측성 기능 사용법 통합.
## 영향 범위
변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다.
- 코드 영향:
- `trex_core/runtime/daemon.py`·`trex_core/runtime/reset.py`: 로깅 포맷(타임스탬프+레벨, KST) 설정.
- `trex_core/application/execution.py`: 주문 submit/결과/거부 일관 로깅, `_rollback()` 거부 시 ERROR 로그.
- `trex_core/application/safety_sell.py`·`trex_core/application/watchdog.py`(+ `trex_core/domain/slot/service.py` reason 전파): 청산사유 분류.
- 신규/확장: 주문결과 로깅 헬퍼, PnL 집계 read(`ledger_outbox`/체결 집계), 조회 CLI 서브커맨드(`trex_core/runtime/cli.py`).
- 구조화 출력(Loki JSON) + correlation id(intent→order→fill) 전파 (FR-7, In Scope).
- 문서 영향:
- `docs/runtime-flows.md`(로깅/관측성·PnL 조회 흐름 추가), `docs/project-structure.md`(신규 모듈 책임), `docs/interface-contract.md`(조회 CLI·청산사유 reason+actor 어휘·거부 로그 계약).
- 운영 영향:
- 로그 포맷 변경으로 기존 로그 파싱(있다면) 조정 필요. 조회 뷰/PnL 집계로 일일 리포트 자동화 가능. (알림 채널은 이번 범위 밖.)
# 5. 요구사항 목록
실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다.
## 기능 요구사항
- FR-1 (P0): 모든 로그 라인에 ISO8601 타임스탬프(KST) + 로그 레벨 prefix를 부여한다(현재 prefix 없음).
- FR-2 (P0): 모든 주문 submit과 그 결과(체결가·체결수량·수수료·세금, 또는 거부+사유)를 타임스탬프 달린 일관(tailable) 로그 라인으로 출력한다. 호가단위 같은 거부가 즉시 보여야 한다.
- FR-3 (P1): 청산 사유를 **두 축**으로 정확히 구분 노출한다(OQ-4 결정 = Option A: 신규 필드 없음). (1) 조건축 = 기존 `reason` 어휘 `{stop_loss, take_profit, forced_close_at, market_close_liquidation}`를 계약으로 고정, (2) 주체축 = 기존 `actor`(`safety_sell`=안전망 watchdog 소유 자율청산 vs `strategy`=전략 자율매도 vs `execution_loop`)를 reason과 **함께** 로그/`slot_transition_log`/조회 뷰에 노출. 현재 stop_loss/take_profit/forced_close_at는 모두 watchdog expired(전략 침묵→안전망 소유)에서만 발화하므로, actor를 같이 보여야 "안전망이 SL선에서 청산"과 "전략이 손절"을 구분할 수 있다(2026-06-15 064400 오해 원인). 신규 reason 값·신규 분류 필드는 추가하지 않는다.
- FR-4 (P1): 주문 거부 시 조용한 inactive/holding 롤백 대신 ERROR 레벨 로그를 남긴다(알림 전송 채널은 Deferred, OQ-3). 반복 거부를 즉시 인지할 수 있어야 한다.
- FR-5 (P1): 현재 보유·오늘 체결·오늘 실현 PnL(수수료·세금 반영)·대기 주문을 조회하는 운영 뷰를 **운영자 CLI 서브커맨드**로 제공한다(OQ-1 결정). 조회는 `ledger_outbox`/slots를 권위 출처로 한 DB-backed 시점 집계이며, RPC는 추가하지 않는다. 일일 리포트 자동화의 기반.
- FR-6: 이 변경이 런타임 흐름/구조를 바꾸므로 `docs/runtime-flows.md`(로깅/관측성·PnL 조회 흐름)와 필요 시 `docs/project-structure.md`(신규 모듈 책임)를 갱신한다.
- FR-7 (P0): 로그를 **Loki 적재용 구조화(JSON) 출력**으로 내보내고, correlation id(intent→order→fill)를 전파해 한 거래의 인텐트·주문·체결 로그를 단일 키로 묶는다(OQ-2 결정, 이번 범위 In Scope). FR-1·2·3·4의 로그가 이 구조화 스트림으로 흐른다.
- FR-8 (P1, Documentation): 운영자용 런북 문서(`docs/operations.md`)를 신설한다. 배포/실행(env 변수·`--config`·`--env-file`), 운영자 CLI 사용법(`pause/resume/slots/universe/enter/exit/cancel` + 본 반복 신설 PnL/조회 서브커맨드), 로깅·관측성 사용법(타임스탬프·주문결과 로그 grep·Loki/correlation id·청산 reason+actor 해석), 디버깅·트러블슈팅(거부 ERROR 확인, 미체결 진단, DB 상태 조회)을 포함한다. 기존 `.claude/skills/run-trex-core-daemon/SKILL.md`의 사실 자료를 사람용 문서로 추출·정리하고, FR-1~7로 추가되는 관측성 기능의 운영 사용법을 함께 기술한다.
## 비기능 요구사항
성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다.
- NFR-1 (운영성): 로그 타임스탬프는 KST 기준 ISO8601로 일관되며, 주문 결과 로그는 `grep`/`tail`로 거부·체결을 즉시 필터링할 수 있는 안정적 키(예: 종목·사유·intent_id)를 포함한다.
- NFR-2 (하위호환): 청산사유 노출 방식 개선(기존 reason 어휘 고정 + actor 동반 노출, 신규 필드 없음)과 조회 표면 추가는 기존 `slots.req`/이벤트/CLI 동작을 깨지 않는 additive 변경을 원칙으로 한다.
- NFR-3 (정확성): PnL 집계는 BAT-038(매도 증권거래세+수수료) 정책과 일치하는 수수료·세금 반영 값을 사용하며 `ledger_outbox`/체결 기록을 단일 출처로 한다.
## 범위 경계
이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다.
- In Scope
- FR-1·FR-2(P0 로깅 기반), FR-3·FR-4(P1 분류·거부 가시성), FR-5(P1 조회 뷰), FR-6(baseline 문서 갱신), FR-7(P0 Loki 구조화 출력 + correlation id), FR-8(P1 운영자 런북 `docs/operations.md`).
- FR-5 조회 표면 = 운영자 CLI 서브커맨드(DB-backed). RPC는 추가하지 않음(OQ-1 결정).
- FR-7 Loki 구조화(JSON) 출력 + correlation id(intent→order→fill) 전파를 이번 범위에 포함(OQ-2 결정).
- Out of Scope
- issue #28(호가단위 tick-size 스냅) — 별도 정확성 버그 트랙. 본 Discovery는 그 거부를 "관측"하는 범위만 다룬다.
- FR-5 조회용 RabbitMQ RPC 표면 — 이번 반복 제외(CLI로 충분, OQ-1 결정).
- 거부 시 알림 "전송 채널" 구현(claude-bus/외부 webhook 등) — Deferred. 이번 범위는 FR-4의 ERROR 로그까지만(OQ-3 결정). 후속 반복/이슈로 인계.
# 6. 리스크/가정 목록
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다.
## 리스크
- R-1 (Low): 청산사유 노출 방식 변경(FR-3)이 reason 계약을 바꿔 다운스트림 소비자(trex-tsl/trex-orb, 운영 리포트)에 영향을 줄 수 있다. (OQ-4=Option A로 신규 reason 값·필드 없음 → 리스크 Low로 하향)
- 완화 방안: 기존 reason 값 유지(어휘 고정만), actor는 이미 존재하는 필드를 함께 노출하는 additive 변경. `docs/interface-contract.md`에 reason+actor 어휘를 명문화, 표시 방식 변경은 issue 코멘트로 전략팀 통지.
- R-2 (Medium): 로그 포맷 변경(FR-1)이 기존 로그 수집/파싱 파이프라인(있다면)을 깨거나, KST 타임스탬프 도입이 컨테이너 TZ 설정에 의존해 불일치할 수 있다.
- 완화 방안: TZ는 코드에서 명시적으로 KST(Asia/Seoul) 변환, 환경 TZ 비의존. 포맷 변경 영향은 운영팀과 사전 합의.
- R-3 (Low): PnL 집계(FR-5)가 `ledger_outbox`/체결 기록의 부분 상태(미정산·재시도 중)와 수수료·세금 정책 차이로 수기 계산과 어긋날 수 있다.
- 완화 방안: BAT-038 정책과 동일 계산 지점 재사용, "오늘" 경계는 trading_date(KST) 기준, 집계 시점의 outbox 상태를 명시.
## 가정
아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다.
- A-1: 조회 표면은 HTTP가 아니라 운영자 CLI 서브커맨드로 구현한다(OQ-1 결정, 데몬 RabbitMQ-only 제약).
- A-2: P0(FR-1·2·7)와 P1(FR-3·4·5·8)은 별도 batch로 분리해 P0를 먼저 배포할 수 있다(CONFIRM 확인됨). FR-8 운영자 런북은 관측성 기능 사용법을 함께 문서화하므로 P1에 둔다(정확한 batch 분할은 suggest-batch-reqs/draft-batch에서 확정).
- A-3: PnL·수수료·세금 계산은 BAT-038(매도세+수수료) 정책을 단일 출처로 재사용한다.
- A-4: 배포 환경이 Loki 로그 수집을 사용한다(OQ-2 결정의 전제). 구조화(JSON) 출력은 Loki 미사용 환경에서도 stdout tail로 동작하므로 비파괴적이다.
## 오픈 질문
답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다.
- OQ-1: FR-5 조회 표면을 운영자 CLI 서브커맨드 확장으로 할지, 신규 RabbitMQ RPC로 할지, 둘 다 제공할지. (HTTP는 제외)
- 상태: Answered (2026-06-16, Justin Ko)
- 결정: **운영자 CLI 서브커맨드(DB-backed)** 로 제공. RPC는 추가하지 않음. → FR-5 및 §5 범위에 반영.
- 종료 조건: 조회 표면 형태가 In Scope에 단일 값으로 고정되면 Answered. (충족)
- OQ-2: 기능 6(Loki 구조화 JSON 출력 + correlation id intent→order→fill 전파)을 이번 범위에 포함할지, 후속으로 Deferred할지.
- 상태: Answered (2026-06-16, Justin Ko)
- 결정: **이번 범위 In Scope.** 구조화(JSON) 출력 + correlation id 전파를 FR-7로 신설. FR-1·2·3·4 로그가 이 스트림으로 흐름. 배포 환경이 Loki를 사용한다는 전제(A-4)로 진행.
- 종료 조건: 범위 포함/Deferred가 확정되면 Answered. (충족)
- OQ-3: FR-4 거부 알림에서 ERROR 로그 외 "알림 전송 채널"(claude-bus/외부 webhook 등)을 이번 범위에 포함할지.
- 상태: Answered (2026-06-16, Justin Ko)
- 결정: **Deferred.** 이번 범위는 FR-4의 ERROR 로그까지만. 알림 전송 채널은 후속 반복/이슈로 인계(처리 방안: 차기 Discovery 입력으로 등록). → §5 Out of Scope에 반영.
- 종료 조건: 알림 전송 범위가 확정되면 Answered. (Deferred로 종료)
- OQ-4: FR-3 청산사유 분류를 신규 reason 값 추가(`watchdog_liquidation` 등)로 할지, 기존 reason 필드 + 보조 분류 필드로 할지. (계약 하위호환 방식)
- 상태: Answered (2026-06-16, Justin Ko)
- 결정: **Option A — 기존 reason 어휘를 계약으로 고정 + 기존 actor와 함께 노출. 신규 reason 값·신규 분류 필드 없음.** 근거: 코드 확인 결과 reason(조건축)·actor(주체축)가 이미 존재하며, 오해 원인은 둘이 합쳐 노출되지 않은 것. 우산 라벨(`watchdog_liquidation`)로 뭉치면 SL/TP/forced 구체 정보가 손실되어 비채택. → FR-3에 반영.
- 종료 조건: 어휘/매핑 방식이 확정돼 FR-3에 반영되면 Answered. (충족)
### 오픈 질문 처리 기준
- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다.
- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다.
- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다.
- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다.
- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다.
- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다.
- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다.
# 7. 초기 성공 기준
이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다.
- S-1: 모든 로그 라인이 ISO8601(KST) 타임스탬프 + 레벨 prefix를 갖고, 주문 거부가 발생하면 ERROR 로그로 즉시 보인다(#28 유형 재현 시 로그만으로 인지 가능).
- S-2: 모든 주문 submit/결과(체결가·수량·수수료·세금 또는 거부+사유)가 단일 tailable 로그로 출력되어 `grep`으로 당일 체결/거부를 전수 추출할 수 있다.
- S-3: 청산 기록이 기존 reason 어휘 + actor를 함께 노출해, 안전망 자율청산(actor=safety_sell)과 전략 손절(actor=strategy)을 운영자가 구분할 수 있다(reason=stop_loss만으로 오해되지 않음).
- S-4: 운영자가 단일 CLI 호출로 보유·오늘 체결·실현 PnL(수수료·세금 반영)·대기 주문을 조회할 수 있고, 값이 `ledger_outbox` 수기 계산과 일치한다.
- S-5: `docs/operations.md`가 존재하고, 신규 운영자가 그 문서만으로 데몬 배포·실행, CLI로 상태/PnL 조회, 거부·미체결 디버깅을 수행할 수 있다(배포·CLI·로깅/관측성·트러블슈팅 4영역 모두 포함).
## 측정 방식 및 데이터 출처
각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다.
- S-1 측정 방식: 단위 테스트(로그 포맷터)+라이브 데몬 로그 라인 검사 / 데이터 출처: 데몬 stdout 로그, `execution.py` 거부 경로.
- S-2 측정 방식: 거부/체결 시나리오 구동 후 로그 grep 검증(단위+end-to-end) / 데이터 출처: 데몬 로그, paper 브로커 체결.
- S-3 측정 방식: safety_sell 각 경로(stop_loss/take_profit/forced_close_at/market_close_liquidation) + 전략 매도 단위 테스트로 기록 reason·actor 확인 / 데이터 출처: `slot_transition_log`, 이벤트 payload.
- S-4 측정 방식: 체결 후 조회 CLI 결과와 `ledger_outbox` 직접 집계 비교 / 데이터 출처: `ledger_outbox`, 체결 기록, BAT-038 수수료·세금 정책.
- S-5 측정 방식: `docs/operations.md` 4영역(배포·CLI·로깅/관측성·트러블슈팅) 포함 여부 리뷰 체크 / 데이터 출처: `docs/operations.md`, `run-trex-core-daemon` SKILL, 신설 CLI.
# 8. REQ로 넘기기 전 확인 체크
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
- [x] 범위(In Scope/Out of Scope) 확정
- 기준: OQ-1(조회 표면=CLI)·OQ-2(Loki/correlation In Scope)·OQ-3(알림 Deferred)·OQ-4(reason+actor) 결정으로 In/Out 경계의 플레이스홀더가 모두 해소됨 (충족)
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-1~S-4의 측정 방식과 데이터 출처가 테스트/로그/DB로 매핑됨 (충족)
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: 현재 High 리스크 없음. Medium(R-2 로그포맷)·Low(R-1 reason 계약, R-3 PnL 집계)의 처리 방안이 명시됨 (충족)
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ 목록에 `Open` 상태 항목이 0개. OQ-1·2·4 Answered, OQ-3 Deferred 처리 완료 (충족)
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 승인자(Justin Ko)가 confirm-discovery로 Freeze 확정 (confirm-discovery 단계에서 처리)
# 9. 파일 처리 결과
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
- 처리 결과: 생성
- 생성 경로: docs/discovery/dcy-029_20260616_operational-observability-logging-and-pnl-view.md
- 참조 문서:
- GitHub issue #29 (운영 관측성), issue #28 (호가단위 거부 — 관련 별도 트랙)
- docs/runtime-flows.md, docs/project-structure.md (baseline — 본 Discovery에서 갱신 대상)
- docs/interface-contract.md (조회 RPC/청산사유 어휘/거부 로그 계약 반영 대상)
- docs/operations.md (FR-8 신설 대상 운영자 런북), .claude/skills/run-trex-core-daemon/SKILL.md (런북 사실 자료 출처)
# 10. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
## DECIDE
- (해소됨) FR-5 조회 표면 = CLI(DB-backed), RPC 없음 — OQ-1 Answered
- (해소됨) Loki 구조화 출력 + correlation id 이번 범위 포함 — OQ-2 Answered (FR-7)
- (해소됨) 알림 전송 채널 = Deferred, ERROR 로그만 — OQ-3 Answered
- (해소됨) FR-3 청산사유 = 기존 reason 어휘 고정 + actor 함께 노출, 신규 필드 없음 — OQ-4 Answered (Option A)
## CONFIRM
- (확인됨, 2026-06-16, Justin Ko) P0(FR-1·2·7) batch 선행 → P1(FR-3·4·5·8) 후속 batch 분할 방향 동의. FR-8 운영자 런북은 P1(관측성 기능 사용법을 함께 문서화). 정확한 batch 경계는 suggest-batch-reqs에서 확정.
## DATA
- (해소됨) 배포 환경 Loki 사용 — A-4 전제로 진행 (OQ-2 결정에 반영)
# 11. Discovery Freeze
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
## 섹션 참조
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: 승인
- Handoff Rationale: OQ-1~4 결정 완료 + FR-8(운영자 런북 docs/operations.md) 추가 반영. 신규 FR은 추가 OQ 없는 P1 문서 산출물. 본문·범위·성공기준(S-5) 정합. REQ 초안 작성 진행 가능.
- Ready for REQ Drafting: true
- Confirmed By: Justin Ko
- Confirmed At (KST): 2026-06-16 12:20
@@ -0,0 +1,229 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: dcy-030_20260616_slippage-limit-price-tick-size-snapping
- 이슈명: slippage 지정가 KRX 호가단위 미반영 → KIS 거부 (tick-size 스냅, issue #28)
- 작성 시각(KST): 2026-06-16 11:18
- 마지막 갱신 시각(KST): 2026-06-16 12:35
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조: REQ-083 (신규, tick 유틸), REQ-070 (change-req CHG-20260616-01, 슬리피지 스냅)
# 1. 계획 상태 요약
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
- 연계 문서 현황:
- 관련 점수 파일: 미생성
- 관련 TODO/메모: GitHub issue #28 (P0, 다음 거래일 전 수정 필수), REQ-070(slippage 지정가 — 호가단위 반올림을 "후속 주기"로 연기해 둔 당사자), 관련 issue #29/dcy-029(이 거부를 "관측"하는 별도 트랙)
- 해석:
- ORB v1 라이브 테스트(2026-06-16)에서 slippage 밴드가 적용된 매수 한계가가 KRX 호가단위를 위반해 KIS `주식주문호가단위 오류`로 전량 거부됐다. slippage_pct>0인 매수는 산출 한계가가 거의 항상 호가단위를 벗어나 0체결이 된다.
- REQ-070은 호가단위 정밀 반올림을 의도적으로 후속 주기로 미뤘고(int ceil/floor만 구현), 그 미뤄둔 항목이 이제 P0 정확성 버그로 표면화했다. 이번 Discovery는 KRX 호가단위 스냅을 구현하되, **반올림 방향을 REQ-070의 원래 메모(체결 보장 = 매수 상위/매도 하위)와 달리 "상한 준수(매수 내림/매도 올림)"로 전환**한다(승인자 결정).
# 2. 요구사항 판정 결과
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
- 판정: 유사
- 근거
- 기존 REQ-070(slippage 지정가 집행)의 동작 결함/미완 항목이다. REQ-070 Requirement/Notes가 "KRX 정밀 호가단위 반올림은 후속 주기"로 명시 연기했으므로, 새 기능이라기보다 REQ-070을 완성·정정하는 change-req 성격이다.
- 다만 반올림 방향 정책 전환과 가격대별 호가단위 테이블 도입이라는 결정 요소가 있어, change-req 근거를 남기기 위해 Discovery로 먼저 문제·정책을 정의한다(기존 REQ 본문 수정은 change-req로 수행).
# 3. 문제점의 요약
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다.
- 현재 상태
- `trex_core/application/execution.py:147-158`(`_dispatch`)이 slippage 밴드를 `매수=int(ceil(limit*(1+pct/100)))`, `매도=int(floor(limit*(1-pct/100)))`로 산출하고 **KRX 호가단위로 스냅하지 않는다**. 예: `ceil(147700*1.005)=148439` → 호가단위(100원) 위반 → KIS 거부.
- REQ-070(Status: Implemented) Requirement는 "KRX 호가단위 반올림은 체결 보장 방향(매수=상위, 매도=하위)으로 적용"을 적어뒀으나 Notes에서 "정밀 호가단위 테이블은 후속 주기, 현재는 int(ceil/floor)로 충분"으로 **미구현 상태로 남겨 둔 것**이 확인된다. 즉 현재 코드는 호가단위 스냅이 전혀 없다.
- 영향: `slippage_pct>0`인 매수는 산출 한계가가 거의 항상 호가단위를 벗어나 **전량 거부**(연결·시세 정상이어도 0체결). 매도(floor)도 limit_price+slippage를 쓰는 경로면 동일 위험. ORB v1처럼 slippage_pct를 쓰는 모든 인스턴스 해당.
- 매도 자율청산 경로: `trex_core/application/safety_sell.py`는 SL/TP/forced/market-close 모두 limit_price 없이 **시장가**(grep상 limit 미사용)라 이 버그 영향 밖. 영향받는 매도는 **전략 SellIntent(지정가+slippage)** 경로로 한정된다.
- baseline 문서(`docs/runtime-flows.md`, `docs/project-structure.md`)는 존재하며 최신. runtime-flows에 slippage dispatch 흐름은 기술돼 있으나 호가단위 스냅 단계는 없다.
- 기대 상태
- slippage 산출 한계가가 항상 **유효한 KRX 호가단위 배수**로 제출되어 `주식주문호가단위 오류` 거부가 사라진다.
- 반올림 방향은 **상한 준수**: 매수는 호가단위로 **내림**(설정 slippage 상한을 넘지 않음), 매도는 호가단위로 **올림**(매도 하한을 넘지 않음). 즉 `slippage_pct`를 하드 리스크 상한으로 취급한다.
- 가격대별 호가단위 산출이 순수 함수(가격→tick)로 분리되어 단위테스트로 경계값까지 검증된다.
- 이해관계자
- 운영 담당자(trex 운영팀): 매수 체결 복구가 1차 수요. 다음 거래일 전 수정 필요.
- 전략팀(trex-orb-v1 등 slippage_pct 사용 인스턴스): 거부 없는 체결의 직접 수혜자.
- 문서 작성자(AI agent) / 승인자(Justin Ko): 반올림 방향·범위 확정.
- 제약사항
- KRX 주식 호가단위(2023.1.25~)는 가격 구간별로 다르다(아래 표). ETF/ETN 등은 호가체계가 다를 수 있다.
- `slippage_pct`는 리스크 상한이므로 스냅으로 상한을 넘기면 안 된다(반올림 방향 결정의 근거).
- 변경은 dispatch-time 변환만 — 슬롯의 `limit_price`(=entry_price) 원본은 덮어쓰지 않는다(REQ-070 설계 보존).
KRX 주식 호가단위 (2023.1.25~):
| 가격 구간(원) | 호가단위(원) |
| --- | --- |
| ~ 2,000 | 1 |
| 2,000 ~ 5,000 | 5 |
| 5,000 ~ 20,000 | 10 |
| 20,000 ~ 50,000 | 50 |
| 50,000 ~ 200,000 | 100 |
| 200,000 ~ 500,000 | 500 |
| 500,000 ~ | 1,000 |
## 주요 사용자 시나리오
사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다.
- 전략이 entry_price=147,700, slippage_pct=0.5%로 매수 인텐트를 내면, dispatch가 산출 한계가 148,439를 **148,400(호가단위 내림)** 으로 스냅해 유효 지정가로 제출하고 정상 체결된다(거부 없음).
- 전략이 entry_price=54,500, slippage_pct=0.5%로 매수하면 54,772.5 → **54,700(내림)** 으로 제출되어 0.5% 상한 안에서 체결된다.
- 전략이 지정가+slippage로 매도하면 산출 한계가가 **호가단위 올림**으로 스냅되어 매도 하한을 넘지 않는 유효 지정가로 제출된다.
# 4. 이번에 정의할 변경
이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다.
- KRX 호가단위 스냅 도입: slippage 산출 한계가를 가격대별 호가단위 유효 배수로 정규화. 방향 = **상한 준수(매수 내림 / 매도 올림)**.
- 호가단위 산출 유틸리티 신설: 가격 → tick 순수 함수(KRX 2023.1.25~ 구간표). 재사용·테스트 가능.
- REQ-070 정정: 호가단위 반올림 방향을 원래 메모의 "체결 보장(매수 상위/매도 하위)"에서 "**상한 준수(매수 하위/매도 상위)**"로 전환하고, "후속 주기"로 미뤄둔 정밀 호가단위 테이블을 이번에 구현으로 확정(change-req).
- baseline 문서 갱신: `docs/runtime-flows.md`의 slippage dispatch 흐름에 호가단위 스냅 단계 추가, 신규 tick 유틸 모듈이 생기면 `docs/project-structure.md`에 반영.
## 영향 범위
변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다.
- 코드 영향:
- `trex_core/application/execution.py`(`_dispatch`): slippage 산출 직후 호가단위 스냅 적용(매수 내림/매도 올림).
- 신규: 호가단위 유틸(가격→tick, tick 배수 내림/올림). 위치는 design에서 확정(예: `trex_core/domain/` 또는 `trex_core/application/` 순수 함수 모듈).
- 문서 영향:
- `docs/runtime-flows.md`(slippage dispatch + 호가단위 스냅 흐름), `docs/project-structure.md`(신규 tick 유틸 모듈 책임), REQ-070 본문/AC(change-req).
- 운영 영향:
- slippage_pct 사용 인스턴스의 매수 거부가 해소돼 체결 복구. 반올림 방향 변경으로 체결가가 entry에 더 가깝게(매수) 형성됨(상한 미초과). 배포 절차 변경 없음.
# 5. 요구사항 목록
실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다.
## 기능 요구사항
- FR-1: 가격 → KRX 호가단위(tick)를 산출하는 순수 함수를 제공한다(2023.1.25~ 구간표). 구간 경계값에서 정확한 tick을 반환한다.
- FR-2: slippage 산출 한계가를 호가단위 유효 배수로 스냅한다. 방향 = **상한 준수**: 매수 = 해당 tick의 **내림 배수**(≤ 산출 한계가, slippage 상한 미초과), 매도 = 해당 tick의 **올림 배수**(≥ 산출 한계가, 매도 하한 미초과).
- FR-2b: band < 1 tick 엣지에서 미체결을 막기 위해 **하한(매수)/상한(매도)을 보장**한다(OQ-2 결정). 매수 실효가 = `max(floor_to_tick(entry×(1+pct)), ceil_to_tick(entry))`, 매도 실효가 = `min(ceil_to_tick(entry×(1-pct)), floor_to_tick(entry))`. 즉 slippage가 1틱 이상이면 정상 반영, 1틱 미만이어도 최소 entry의 유효 호가는 보장한다. 이 보장값도 slippage 상한/하한을 넘지 않는다.
- FR-3: `limit_price=None`(시장가) 또는 `slippage_pct` 없음/0인 경로는 기존 동작을 보존한다(호가단위 스냅·회귀 없음). slippage가 적용된 경우에만 스냅한다.
- FR-4: REQ-070을 change-req로 정정한다 — 반올림 방향을 "상한 준수(매수 하위/매도 상위)"로 변경하고, 정밀 호가단위 테이블 구현을 확정한다. 변경 근거(상한 준수 > 체결 보장)를 Change Log에 남긴다.
- FR-5: 이 변경이 런타임 흐름/구조를 바꾸므로 `docs/runtime-flows.md`(slippage dispatch 호가단위 스냅)와 필요 시 `docs/project-structure.md`(신규 tick 유틸 모듈)를 갱신한다.
## 비기능 요구사항
성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다.
- NFR-1 (정확성): 호가단위 스냅 후 제출 지정가는 항상 유효한 KRX 호가단위 배수이며, `slippage_pct`가 정의한 가격 상한(매수)/하한(매도)을 넘지 않는다.
- NFR-2 (하위호환): slippage 미사용·시장가 경로의 기존 동작과 REQ-070 AC-3~6의 회귀 없음을 보장하는 additive 변경이다.
- NFR-3 (테스트 가능성): 가격→tick 함수와 스냅 방향을 각 가격 구간 경계값(2,000 / 5,000 / 20,000 / 50,000 / 200,000 / 500,000)에서 단위테스트로 검증한다.
## 범위 경계
이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다.
- In Scope
- FR-1·FR-2·FR-2b·FR-3·FR-4·FR-5. KRX **주식** 호가단위 스냅(상한 준수 방향 + band<tick 하한/상한 보장), tick 유틸, REQ-070 change-req, baseline 문서 갱신.
- Out of Scope
- issue #29/dcy-029(거부 관측성 — 별도 트랙).
- ETF/ETN 등 비주식 호가체계 지원 — 이번 반복 제외, **주식 전용**(OQ-1 결정). 근거: universe 항목(`PortfolioItem`)에 종목유형 필드가 없어 분기 불가하며, ETF 지원은 portfolio-hub 계약 변경을 수반. 주식 tick은 2,000원 이상 구간에서 모두 5의 배수라 ETF에도 유효 주문이 나옴. **알려진 한계: 2,000원 미만 ETF는 주식 tick(1원) 스냅 시 무효 가능 → 미지원**. ETF/ETN은 실제 편입 + 종목유형 필드 도입과 함께 후속 Discovery로 인계.
- slippage_pct 정책 자체(밴드 폭·기본값) 변경 — 본 Discovery는 호가단위 스냅과 반올림 방향만 다룬다.
# 6. 리스크/가정 목록
불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다.
## 리스크
- R-1 (Medium): 반올림 방향 전환(매수 내림)이 slippage 헤드룸을 한 틱 줄여 체결확률이 미세하게 낮아질 수 있다(특히 변동성 구간).
- 완화 방안: 매수 내림은 entry 기준 여전히 +slippage 범위 안의 상위 가격이라 헤드룸 대부분 유지. 거부(0체결) 해소 효과가 훨씬 크다. 필요 시 운영 데이터로 체결률 모니터링.
- R-2 (Low): slippage_pct가 한 틱보다 작은 경우(band < 1 tick) 매수 내림 스냅이 entry 자체 호가 또는 그 아래로 떨어져 미체결될 수 있다. (OQ-2=하한 보장 결정으로 완화 → Low)
- 완화 방안: FR-2b 하한/상한 보장 — 매수는 `ceil_to_tick(entry)`를 하한으로, 매도는 `floor_to_tick(entry)`를 상한으로 보장해 최소 entry 유효 호가에서 체결 가능. slippage 상한도 미초과.
- R-3 (Low): 가격대별 호가단위 표가 KRX 제도 변경 시 낡을 수 있다(2023.1.25 기준).
- 완화 방안: tick 표를 단일 유틸에 상수로 모아 변경 지점을 1곳으로, 출처·기준일을 주석/문서에 명시.
## 가정
아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다.
- A-1: 현재 universe는 KRX 주식 중심이고 종목유형 필드가 없어, 이번 반복은 주식 호가단위 표로 한정한다(OQ-1 결정).
- A-2: slippage가 적용되는 매도는 전략 SellIntent(지정가) 경로뿐이고, safety-sell 자율청산은 시장가라 영향 밖이다(코드 확인).
- A-3: 변경은 dispatch-time 변환만 — 슬롯 `limit_price` 원본은 보존한다(REQ-070 설계).
## 오픈 질문
답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다.
- OQ-1: ETF/ETN 등 비주식 호가체계 종목을 이번 범위에 포함할지(별도 tick 표 필요), 주식 전용으로 한정할지.
- 상태: Answered (2026-06-16, Justin Ko)
- 결정: **주식 전용.** 근거: universe `PortfolioItem`에 종목유형 필드 부재(코드 확인, `hub_read.py:114-120`) → 분기 불가. ETF 지원은 portfolio-hub 계약 변경 수반. 주식 tick은 2,000원 이상에서 5의 배수라 ETF에도 유효. 2,000원 미만 ETF는 미지원(known limitation). ETF/ETN은 후속 Discovery로 인계. → §5 Out of Scope에 반영.
- 종료 조건: 범위가 In/Out으로 고정되면 Answered. (충족)
- OQ-2: band < 1 tick 엣지(매수 내림 스냅이 entry 호가 이하로 떨어지는 경우)의 처리 정책.
- 상태: Answered (2026-06-16, Justin Ko)
- 결정: **하한/상한 보장.** 매수 = `max(floor_to_tick(entry×(1+pct)), ceil_to_tick(entry))`, 매도 = `min(ceil_to_tick(entry×(1-pct)), floor_to_tick(entry))`. slippage<1tick이어도 최소 entry 유효 호가 보장(미체결 방지), slippage 상한 미초과. → FR-2b로 반영.
- 종료 조건: 엣지 동작이 확정돼 FR-2(b)에 반영되면 Answered. (충족)
- OQ-3: issue #28 미해결 의문 — 2026-06-15 064400이 100,300에 정상 체결된 건이 slippage 미적용(시장가)이었는지.
- 상태: Deferred
- 처리 방안: delivery/verification 단계에서 해당 intent payload(slippage_pct 유무) 확인으로 검증. 본 Discovery confirm을 막지 않는 운영 데이터 검증 항목으로 인계.
- 종료 조건: intent payload로 시장가/지정가 여부가 확인되면 Answered.
### 오픈 질문 처리 기준
- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다.
- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다.
- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다.
- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다.
- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다.
- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다.
- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다.
# 7. 초기 성공 기준
이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다.
- S-1: issue #28의 두 케이스(147,700 / 54,500 @ 0.5% 매수)가 각각 **148,400 / 54,700**(호가단위 내림, 유효 호가, 상한 미초과)으로 산출된다.
- S-2: slippage 적용 매수/매도 산출 한계가가 모든 가격 구간에서 항상 유효한 KRX 호가단위 배수이고, 매수 ≤ 산출값 / 매도 ≥ 산출값(상한·하한 준수)이다.
- S-3: slippage 미사용·시장가·slippage_pct=0 경로가 기존 동작과 동일하다(REQ-070 AC-3~6 회귀 없음).
## 측정 방식 및 데이터 출처
각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다.
- S-1 측정 방식: 단위 테스트(두 케이스 명시 검증) / 데이터 출처: issue #28 케이스, `execution.py` `_dispatch`.
- S-2 측정 방식: 각 가격 구간 경계값 파라미터라이즈드 단위 테스트(tick 함수 + 스냅 방향) / 데이터 출처: KRX 호가단위 표, tick 유틸.
- S-3 측정 방식: REQ-070 기존 AC 회귀 테스트 통과 / 데이터 출처: `tests/application/test_execution.py`, REQ-070 AC-3~6.
# 8. REQ로 넘기기 전 확인 체크
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
- [x] 범위(In Scope/Out of Scope) 확정
- 기준: OQ-1(주식 전용)·OQ-2(하한 보장) 결정으로 In/Out 경계 플레이스홀더가 해소됨 (충족)
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: S-1~S-3의 측정 방식이 테스트/AC로 매핑됨 (충족)
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: 현재 High 리스크 없음. Medium(R-1 체결확률)·Low(R-2 band<tick, R-3 tick표 노후)의 처리 방안 명시 (충족)
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ-1·2 Answered, OQ-3 Deferred. `Open` 0개 (충족)
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: 승인자(Justin Ko)가 confirm-discovery로 Freeze 확정 (confirm-discovery 단계에서 처리)
# 9. 파일 처리 결과
이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다.
- 처리 결과: 생성
- 생성 경로: docs/discovery/dcy-030_20260616_slippage-limit-price-tick-size-snapping.md
- 참조 문서:
- GitHub issue #28 (호가단위 거부), issue #29/dcy-029 (관측성 — 관련 별도 트랙)
- docs/srs/Integration/req-070_slippage-limit-price-execution.md (정정 대상 REQ)
- docs/runtime-flows.md, docs/project-structure.md (baseline — 본 Discovery에서 갱신 대상)
# 10. 사용자 결정 필요 항목 요약
사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다.
## DECIDE
- (해소됨) 반올림 방향 = 상한 준수(매수 내림/매도 올림) — 승인자 결정, FR-2/§4에 반영
- (해소됨) 호가단위 범위 = 주식 전용, ETF/ETN Deferred — OQ-1 Answered
- (해소됨) band<1tick 엣지 = 하한/상한 보장(FR-2b) — OQ-2 Answered
## CONFIRM
- (확인됨, 2026-06-16, Justin Ko) REQ-070 반올림 방향을 원래 메모(체결 보장=매수 상위/매도 하위)에서 상한 준수(매수 하위/매도 상위)로 전환하는 change-req 방향 동의. 반올림 방향 (A) 결정에 내포됨.
## DATA
- (해소됨) universe 종목유형 필드 부재 확인(`hub_read.py`) → 주식 전제 — OQ-1 결정에 반영
# 11. Discovery Freeze
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다.
## 섹션 참조
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: 승인
- Handoff Rationale: 반올림 방향(상한 준수), OQ-1(주식 전용), OQ-2(하한 보장) 모두 결정 완료. REQ-070 change-req 방향 동의. 저장소 사실관계(execution.py 미스냅, REQ-070 연기, hub_read 유형필드 부재) 확인. REQ 초안 작성 진행 가능.
- Ready for REQ Drafting: true
- Confirmed By: Justin Ko
- Confirmed At (KST): 2026-06-16 11:30
+2
View File
@@ -34,3 +34,5 @@
| dcy-026_20260612_sell-transaction-tax-and-fee-policy | 20260612 | 매도 증권거래세 누락 + 수수료 정책 정정 — `tax=0.0` 하드코딩 제거 + 단일 정책 지점 (issue #24) | [Discovery](./dcy-026_20260612_sell-transaction-tax-and-fee-policy.md) | confirmed | | dcy-026_20260612_sell-transaction-tax-and-fee-policy | 20260612 | 매도 증권거래세 누락 + 수수료 정책 정정 — `tax=0.0` 하드코딩 제거 + 단일 정책 지점 (issue #24) | [Discovery](./dcy-026_20260612_sell-transaction-tax-and-fee-policy.md) | confirmed |
| dcy-027_20260612_long-term-strategy-slot-operation | 20260612 | 중장기 매매 전략용 슬롯 운영 도입 — 구현 위치 결정 + 멀티데이 리스크 규칙 + 운영 프로파일 (issue #20, dcy-020 Out-of-Scope 이월) | [Discovery](./dcy-027_20260612_long-term-strategy-slot-operation.md) | confirmed | | dcy-027_20260612_long-term-strategy-slot-operation | 20260612 | 중장기 매매 전략용 슬롯 운영 도입 — 구현 위치 결정 + 멀티데이 리스크 규칙 + 운영 프로파일 (issue #20, dcy-020 Out-of-Scope 이월) | [Discovery](./dcy-027_20260612_long-term-strategy-slot-operation.md) | confirmed |
| dcy-028_20260615_slot-strategy-state-store | 20260615 | Slot Strategy-State 저장소 — per-strategy opaque state store (read/write CAS/close 삭제, issue #26 C-1/C-2/C-3) | [Discovery](./dcy-028_20260615_slot-strategy-state-store.md) | confirmed | | dcy-028_20260615_slot-strategy-state-store | 20260615 | Slot Strategy-State 저장소 — per-strategy opaque state store (read/write CAS/close 삭제, issue #26 C-1/C-2/C-3) | [Discovery](./dcy-028_20260615_slot-strategy-state-store.md) | confirmed |
| 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 |
+10
View File
@@ -138,9 +138,19 @@
| `enter <portfolio_id> [--capital/--qty/--stop-loss/--take-profit/--forced-close-at ...]` | `trex.intent.req[.{instance}]` | buy intent(`portfolio_id` 필수, symbol 서버 resolve; `--forced-close-at <ISO8601>`→`forced_close_at`, REQ-032 CHG-20260609-01) | 안전장치 필수; `--forced-close-at` 형식은 제출 전 검증 | | `enter <portfolio_id> [--capital/--qty/--stop-loss/--take-profit/--forced-close-at ...]` | `trex.intent.req[.{instance}]` | buy intent(`portfolio_id` 필수, symbol 서버 resolve; `--forced-close-at <ISO8601>`→`forced_close_at`, REQ-032 CHG-20260609-01) | 안전장치 필수; `--forced-close-at` 형식은 제출 전 검증 |
| `exit <slot_id> [--price]` | `trex.intent.req[.{instance}]` | sell intent(`slot_id`, optional `limit_price`) | 가격 미지정 시 시장가 | | `exit <slot_id> [--price]` | `trex.intent.req[.{instance}]` | sell intent(`slot_id`, optional `limit_price`) | 가격 미지정 시 시장가 |
| `pause/resume/paused` | (RPC 아님) | — | direct-DB 유지(OQ-3) | | `pause/resume/paused` | (RPC 아님) | — | direct-DB 유지(OQ-3) |
| `report [--json]` | (RPC 아님) | — | direct-DB 시점 집계: 현재 보유 / 오늘(KST) 체결·실현 PnL(`ledger_outbox` net 매매 현금흐름, 수수료·세금 반영) / 대기 주문. 신규 RPC 없음(REQ-088/dcy-029 OQ-1) |
- enter/exit 안전장치(OQ-2): 확인 프롬프트 기본 ON, `--yes`(paper 생략), **live(`broker=kis`+allow_live)는 `--yes --live` 이중 ack**, `--dry-run` 미리보기. `--json` 은 비대화형이라 enter/exit 에 `--yes` 필수(미지정 시 자동 제출 없이 에러). 모든 명령 출력에 대상 인스턴스 표시, admission 응답(`status/slot_id/reason`)을 표시하며 rejected 면 비0 종료. - enter/exit 안전장치(OQ-2): 확인 프롬프트 기본 ON, `--yes`(paper 생략), **live(`broker=kis`+allow_live)는 `--yes --live` 이중 ack**, `--dry-run` 미리보기. `--json` 은 비대화형이라 enter/exit 에 `--yes` 필수(미지정 시 자동 제출 없이 에러). 모든 명령 출력에 대상 인스턴스 표시, admission 응답(`status/slot_id/reason`)을 표시하며 rejected 면 비0 종료.
### 청산 사유 어휘 — reason + actor (REQ-086 / dcy-029 FR-3)
슬롯 청산은 **두 축**으로 노출된다(신규 reason 값·필드 없음; 기존 `reason`·`actor`를 함께 본다):
- **조건축 `reason`** — 안전망(safety-sell) 자율청산 family: `stop_loss` · `take_profit` · `forced_close_at` · `market_close_liquidation`. 운영자 매도: `operator_exit`. 비상정지: `emergency_stop`. 정상 체결: `buy_filled`/`sell_filled`/`sell_filled_partial`.
- **주체축 `actor`** — `trex-safety-sell`(안전망 watchdog 소유 자율청산) · `execution_loop`(집행 루프 체결/롤백) · `operator`(운영자) · 전략(전략 SellIntent).
핵심: `stop_loss`/`take_profit`/`forced_close_at` 는 모두 **watchdog expired(전략 침묵 → 안전망 소유)** 에서만 발화하므로, `actor=trex-safety-sell` 가 동반될 때의 `reason=stop_loss` 는 "전략의 손절"이 아니라 **안전망 자율청산**이다. 로그(`safety-sell fired ... reason= actor=`), `slot_transition_log`, 운영 `report` 가 reason+actor 를 함께 노출해 오해(2026-06-15 064400)를 제거한다.
### `trex.control.req` 제어 RPC (BAT-012 / REQ-043, REQ-032) ### `trex.control.req` 제어 RPC (BAT-012 / REQ-043, REQ-032)
운영자 제어 채널. `action` discriminator 로 분기한다(REQ-043). 기존 `trex.pause.req`(REQ-032)의 개념 확장이며, 단일 제어 큐 + action 패턴으로 기능마다 큐/consumer 를 늘리지 않는다. 운영자 제어 채널. `action` discriminator 로 분기한다(REQ-043). 기존 `trex.pause.req`(REQ-032)의 개념 확장이며, 단일 제어 큐 + action 패턴으로 기능마다 큐/consumer 를 늘리지 않는다.
+124
View File
@@ -0,0 +1,124 @@
# trex-core 운영자 런북 (Operations Runbook)
> 대상: trex-core 데몬을 배포·운영·디버깅하는 운영자.
> 범위: 배포/실행, 운영자 CLI, 로깅/관측성, 디버깅/트러블슈팅.
> 출처: dcy-029(REQ-089) — `.claude/skills/run-trex-core-daemon/SKILL.md` 사실 자료 + 관측성 기능(REQ-084~088) 사용법 통합.
---
## 1. 배포 / 실행
trex-core 데몬은 **HTTP 표면이 없다**(BAT-007). 모든 전략-facing 트래픽은 RabbitMQ로 흐른다(스냅샷/슬롯 RPC, intent RPC, 슬롯 이벤트). 운영자 조작은 `trex-core` CLI.
### 필수 인프라
PostgreSQL + RabbitMQ가 떠 있어야 한다. (paper 브로커는 외부 증권사 불필요.)
### 환경 변수 (`config/.env`, gitignore)
| 변수 | 필수 | 설명 |
|---|---|---|
| `TREX_POSTGRESQL_DSN` | 예 | psycopg DSN |
| `TREX_RABBITMQ_URL` | 실사용 시 예 | 예 `amqp://admin:admin@localhost:5672/` (미설정 시 in-memory stub — 전략 모듈 도달 불가) |
| `TREX_BROKER` | 아니오 | `paper`(기본) 또는 `kis` |
| `TREX_PORTFOLIO_HUB_BASE_URL` | 아니오 | 설정 시 실제 portfolio-hub universe+ledger, 미설정 시 stub universe(pf-005930/pf-000660) |
| `TREX_LOG_FORMAT` | 아니오 | `text`(기본, 사람용) 또는 `json`(Loki 적재용) — §3 |
| `TREX_EXECUTION_INTERVAL_SEC` / `TREX_LEDGER_INTERVAL_SEC` | 아니오 | 루프 간격(기본 1.0) |
### 인스턴스 TOML
`--config <name>` (BAT-013 이후 필수)는 `config/<name>.toml`을 해석 — `instance_key`(큐 이름 `trex.intent.req.<key>` 등; `default`는 literal), `max_active_slots`(M cap).
### 기동 / 종료
```bash
# 기동 (로그 리다이렉트). 테스트는 trex_core_test DB로 — 라이브 DB 금지.
.venv/bin/trex-core-daemon --config <instance> &> /tmp/trex-core.log &
# 종료 (SIGTERM → 워커 join + RabbitMQ close, 마지막 로그 'Normal shutdown')
pkill -TERM -f trex-core-daemon
```
데몬은 TCP 포트를 열지 않는다. "ready" = 4개 워커 스레드 + RabbitMQ 토폴로지 선언 완료. `/tmp/trex-core.log`에 ERROR/traceback이 없으면 부팅 성공.
### 안전 수칙
- `config/.env`는 **라이브 KIS 키 + portfolio_hub 토큰 + 라이브 PostgreSQL DSN**을 담는다 — 커밋 금지, 라이브 DB에 파괴적 작업 금지.
- 테스트는 `TREX_BROKER=paper` + 파생 `trex_core_test` DSN.
---
## 2. 운영자 CLI
```bash
.venv/bin/trex-core <command> [options] # 콘솔 스크립트
```
| 명령 | 용도 | 경로 |
|---|---|---|
| `slots [--status all\|holding\|buyable\|pending\|error]` | 슬롯/포지션 목록 | RPC |
| `universe` | 오늘 universe 자산 | RPC |
| `enter <portfolio_id> [--qty/--entry-price/--stop-loss/--take-profit/--forced-close-at]` | 매수 intent | RPC |
| `exit <slot_id> [--price]` | 매도 intent | RPC |
| `cancel <slot_id>` | 대기 주문 취소(백스톱) | RPC |
| `pause/resume/paused` | portfolio 일시정지 | direct-DB |
| **`report [--json]`** | **보유·오늘 체결·실현 PnL·대기 주문** | **direct-DB (REQ-088)** |
- enter/exit 안전장치: 확인 프롬프트 기본 ON, `--yes`(paper 생략), live(`broker=kis`)는 `--yes --live` 이중 ack, `--dry-run` 미리보기. `--json`은 비대화형이라 enter/exit에 `--yes` 필수.
- 인스턴스 라우팅: `--config <toml>` 또는 `--instance <key>`, 미지정 시 `default`.
### `report` — 일일 운영 리포트 (REQ-088 / dcy-029)
```bash
.venv/bin/trex-core report # 사람용 표
.venv/bin/trex-core report --json # 자동화용 JSON
```
출력: **현재 보유**(symbol/qty/avg_price), **오늘(KST) 체결 건수**, **오늘 실현 PnL**(= `ledger_outbox` net 매매 현금흐름 = Σ매도(대금−수수료−세금) − Σ매수(대금+수수료), 수수료·세금 반영 BAT-038 정책), **대기 주문**.
- "오늘" 경계는 trading_date(KST 00:00~익일 00:00). 전일 체결은 제외.
- 일중 진입·청산 전략에서는 net ≈ 실현손익. 미청산 보유의 평가손익은 포함하지 않는다(실현분만).
---
## 3. 로깅 / 관측성 (REQ-084~087 / dcy-029)
### 포맷
- 모든 로그 라인: `<ISO8601 KST> <LEVEL> <logger> [cid=<slot_id>] <msg>`.
- `TREX_LOG_FORMAT=json` → Loki 적재용 한 줄 JSON(`ts`/`level`/`logger`/`msg`/`correlation_id` + 구조화 필드). Grafana/LogQL 대시보드용.
- **correlation id** = `slot_id` (intent→order→fill 공통 조인 키). 한 슬롯의 submit/fill/reject 로그가 같은 `cid`를 공유 → 한 거래 추적.
### 주문 결과 로그 (grep 키)
```bash
grep "event=order_submit" /tmp/trex-core.log # 제출 주문
grep "event=order_fill" /tmp/trex-core.log # 체결(fill_price/filled_qty/fee/tax)
grep "event=order_reject" /tmp/trex-core.log # 거부 — ERROR 레벨
```
- 거부는 **ERROR 레벨**로 즉시 보인다(조용한 롤백 제거). 호가단위 위반(issue #28) 같은 거부가 `event=order_reject ... reason=...`로 노출.
### 청산 사유 해석 — reason + actor (REQ-086)
- `safety-sell fired: ... reason=<r> actor=trex-safety-sell` → **안전망 자율청산**(전략 침묵으로 watchdog가 슬롯 소유). `reason=stop_loss`여도 이는 "전략 손절"이 아니라 안전망이 SL선에서 청산한 것.
- `actor=execution_loop` → 집행 루프 정상 체결/롤백. 전략 SellIntent 매도는 전략 actor.
- 즉 **reason(조건)과 actor(주체)를 함께** 보고 안전망/전략을 구분한다.
---
## 4. 디버깅 / 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
| 매수가 계속 거부 | (구) 슬리피지 한계가 호가단위 위반(issue #28) | dcy-030으로 수정됨(호가단위 스냅). `grep event=order_reject`로 사유 확인 |
| `slots returned: 0` | universe reconcile 미실행(hub down/boot race) | 스케줄러 로그 `slot reconcile:` 확인; hub 미설정 시 stub 2슬롯 |
| intent `rejected: unknown_slot` | slot_id/portfolio_id가 인스턴스 슬롯셋에 없음 | 먼저 `slots`로 조회 후 그 slot_id 사용 |
| 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 확인 |
### DB 직접 조회
```bash
# 슬롯 상태 분포
psql "$TREX_POSTGRESQL_DSN" -c "SELECT instance_key, status, COUNT(*) FROM daily_slot GROUP BY 1,2 ORDER BY 1,2"
# ledger outbox 상태
psql "$TREX_POSTGRESQL_DSN" -c "SELECT status, last_outcome, COUNT(*) FROM ledger_outbox GROUP BY 1,2"
# 슬롯 전이 이력(reason+actor)
psql "$TREX_POSTGRESQL_DSN" -c "SELECT slot_id, from_state, to_state, reason, actor, created_at FROM slot_transition_log ORDER BY created_at DESC LIMIT 20"
```
---
## 관련 문서
- `docs/interface-contract.md` — RabbitMQ 토폴로지 + 메시지 payload + reason/actor 어휘
- `docs/runtime-flows.md` — 흐름 다이어그램(스냅샷/intent RPC/이벤트/집행 루프/로깅)
- `.claude/skills/run-trex-core-daemon/SKILL.md` — AI 에이전트용 실행 레시피(이 런북의 사실 자료 출처)
+5 -2
View File
@@ -66,6 +66,7 @@
│ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존) │ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존)
│ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.py │ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.py
│ │ ├── repository.py(Protocol, instance 스코프) · service.py · in_memory_repository.py │ │ ├── repository.py(Protocol, instance 스코프) · service.py · in_memory_repository.py
│ ├── domain/pricing.py # KRX 호가단위(tick) 산출 순수 함수 (BAT-044 / REQ-083, dcy-030)
│ ├── application/ # 유스케이스 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008) │ ├── application/ # 유스케이스 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008)
│ │ ├── ports.py # IntentStore Protocol │ │ ├── ports.py # IntentStore Protocol
│ │ ├── intents.py # IntentService / BuyIntent / SellIntent / IntentDecision │ │ ├── intents.py # IntentService / BuyIntent / SellIntent / IntentDecision
@@ -80,7 +81,8 @@
│ │ ├── snapshot_scheduler.py # SnapshotScheduler — daily fetch + swap + universe 슬롯 reconcile(REQ-058) (BAT-008/028) │ │ ├── snapshot_scheduler.py # SnapshotScheduler — daily fetch + swap + universe 슬롯 reconcile(REQ-058) (BAT-008/028)
│ │ ├── watchdog.py # WatchdogStore — per-slot last_seen_at + expiry/grace 판정 (BAT-010 / REQ-034) │ │ ├── watchdog.py # WatchdogStore — per-slot last_seen_at + expiry/grace 판정 (BAT-010 / REQ-034)
│ │ ├── safety_sell.py # SafetySellService — forced_close / stop-loss·take-profit / 장 마감 트리거 (BAT-011 / REQ-037~039) │ │ ├── safety_sell.py # SafetySellService — forced_close / stop-loss·take-profit / 장 마감 트리거 (BAT-011 / REQ-037~039)
│ │ └── killswitch.py # KillSwitchService — 범위별 holding 즉시 청산 + day-scoped 제외 (BAT-012 / REQ-045) │ │ ├── killswitch.py # KillSwitchService — 범위별 holding 즉시 청산 + day-scoped 제외 (BAT-012 / REQ-045)
│ │ └── operator_report.py # 운영 조회 리포트 집계(보유·오늘 체결·실현 PnL·대기) 순수 함수 (BAT-046 / REQ-088, dcy-029)
│ ├── adapters/ # 인프라 어댑터 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008) │ ├── adapters/ # 인프라 어댑터 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008)
│ │ ├── broker/ # paper.py(결정적 async 더블 + 현재가 stub) · kis.py(KIS Open API + 잔고조회 + 현재가 polling) (BAT-004/BAT-008/BAT-011) │ │ ├── broker/ # paper.py(결정적 async 더블 + 현재가 stub) · kis.py(KIS Open API + 잔고조회 + 현재가 polling) (BAT-004/BAT-008/BAT-011)
│ │ ├── ledger/ # in_memory.py(stub) · portfolio_hub.py(HTTP 어댑터) (BAT-006) │ │ ├── ledger/ # in_memory.py(stub) · portfolio_hub.py(HTTP 어댑터) (BAT-006)
@@ -91,7 +93,8 @@
│ ├── config.py # DaemonConfig(+instance_key, max_active_slots) + KISConfig + PortfolioHubConfig + RabbitMQConfig + SnapshotScheduleConfig + SafetySellConfig + DaemonInstanceConfig.from_toml/from_config (env + per-instance TOML, BAT-013/028/030) │ ├── config.py # DaemonConfig(+instance_key, max_active_slots) + KISConfig + PortfolioHubConfig + RabbitMQConfig + SnapshotScheduleConfig + SafetySellConfig + DaemonInstanceConfig.from_toml/from_config (env + per-instance TOML, BAT-013/028/030)
│ ├── daemon.py # Daemon class: `--config` TOML 진입점(BAT-013) + setup(migrate/reconcile/declare-topology(instance_key)) + 7 worker threads(instance_key 큐 구독) + WatchdogStore + KillSwitchService + graceful shutdown │ ├── daemon.py # Daemon class: `--config` TOML 진입점(BAT-013) + setup(migrate/reconcile/declare-topology(instance_key)) + 7 worker threads(instance_key 큐 구독) + WatchdogStore + KillSwitchService + graceful shutdown
│ ├── reset.py # trex-core-reset: slot-core 테이블 wipe + 자산 중심 재초기화 (REQ-064, BAT-030) │ ├── reset.py # trex-core-reset: slot-core 테이블 wipe + 자산 중심 재초기화 (REQ-064, BAT-030)
│ └── cli.py # trex-core CLI: pause/resume/paused + universe/slots/enter/exit (BAT-008/020 / REQ-032) │ ├── logging_setup.py # 구조화 로깅(ISO8601 KST·레벨·Loki JSON·correlation id) + configure_logging (BAT-045 / REQ-084, dcy-029)
│ └── cli.py # trex-core CLI: pause/resume/paused + universe/slots/enter/exit/cancel + report(REQ-088) (BAT-008/020/046 / REQ-032)
└── tests/ └── tests/
├── domain/slot/ # test_state / test_service / test_contract ├── domain/slot/ # test_state / test_service / test_contract
├── domain/ # test_domain_import_sweep (NFR-1 가드, BAT-006/BAT-007) ├── domain/ # test_domain_import_sweep (NFR-1 가드, BAT-006/BAT-007)
+1 -1
View File
@@ -14,4 +14,4 @@
| REL-004 | app-service | feedback-captured | BAT-014, BAT-015, BAT-016, BAT-017, BAT-018, BAT-019, BAT-020, BAT-021, BAT-022, BAT-023 | dcy-010 (safety-sell 쿨다운) + dcy-011~017 (buy/sell 계약·KIS 취소·trading_date TZ·ledger 백오프 버그픽스 + 운영자 CLI + 워치독 가시화) + dcy-016 후속(슬롯 상태 가시화) + dcy-018 (ledger 현금/포지션 write-back, option D) — 통합 릴리즈 | [rel-004](rel-004_20260608_safety-cooldown-bugfix-hardening-cli.md) | | REL-004 | app-service | feedback-captured | BAT-014, BAT-015, BAT-016, BAT-017, BAT-018, BAT-019, BAT-020, BAT-021, BAT-022, BAT-023 | dcy-010 (safety-sell 쿨다운) + dcy-011~017 (buy/sell 계약·KIS 취소·trading_date TZ·ledger 백오프 버그픽스 + 운영자 CLI + 워치독 가시화) + dcy-016 후속(슬롯 상태 가시화) + dcy-018 (ledger 현금/포지션 write-back, option D) — 통합 릴리즈 | [rel-004](rel-004_20260608_safety-cooldown-bugfix-hardening-cli.md) |
| REL-005 | app-service | feedback-captured | BAT-024, BAT-025 | dcy-016 후속/issue #17 (operator enter --forced-close-at) + dcy-019/issue #18 (slot entry_count + closed 슬롯 재할당) — 통합 릴리즈 | [rel-005](rel-005_20260609_operator-forced-close-and-slot-reallocation.md) | | REL-005 | app-service | feedback-captured | BAT-024, BAT-025 | dcy-016 후속/issue #17 (operator enter --forced-close-at) + dcy-019/issue #18 (slot entry_count + closed 슬롯 재할당) — 통합 릴리즈 | [rel-005](rel-005_20260609_operator-forced-close-and-slot-reallocation.md) |
| REL-006 | app-service | feedback-captured | BAT-026, BAT-027, BAT-028, BAT-029, BAT-030, BAT-031, BAT-032, BAT-033, BAT-034, BAT-035, BAT-036 | dcy-020 (자산 중심 영구 슬롯 α~δ cutover) + dcy-021 (포지션 사이징/issue #1·#5) + dcy-022 (실행 정책/issue #21) + dcy-023 (allocated_capital 제거/issue #22) + dcy-024 (safety_sell_enabled/issue #15) + dcy-025 (slots.req 교체·REQ-028 Deprecated) + REQ-048·010 CHG (config-dir) + REQ-032 CHG (enter --qty/issue #23) — 통합 릴리즈 | [rel-006](rel-006_20260611_asset-centric-sizing-execution-policy.md) | | REL-006 | app-service | feedback-captured | BAT-026, BAT-027, BAT-028, BAT-029, BAT-030, BAT-031, BAT-032, BAT-033, BAT-034, BAT-035, BAT-036 | dcy-020 (자산 중심 영구 슬롯 α~δ cutover) + dcy-021 (포지션 사이징/issue #1·#5) + dcy-022 (실행 정책/issue #21) + dcy-023 (allocated_capital 제거/issue #22) + dcy-024 (safety_sell_enabled/issue #15) + dcy-025 (slots.req 교체·REQ-028 Deprecated) + REQ-048·010 CHG (config-dir) + REQ-032 CHG (enter --qty/issue #23) — 통합 릴리즈 | [rel-006](rel-006_20260611_asset-centric-sizing-execution-policy.md) |
| REL-007 | app-service | confirmed | BAT-037, BAT-038, BAT-039, BAT-040, BAT-041 | dcy-025 (REQ-073 CHG — available_slots hint) + dcy-026/issue #24 (매도세 정정) + issue #4 (paper seq Redis) + issue #25 (slots RPC DB 필터) + dcy-027/issue #20 (중장기 인스턴스 프로파일) — 통합 릴리즈 | [rel-007](rel-007_20260612_slots-rpc-fee-paper-longterm.md) | | REL-007 | app-service | feedback-captured | BAT-037, BAT-038, BAT-039, BAT-040, BAT-041 | dcy-025 (REQ-073 CHG — available_slots hint) + dcy-026/issue #24 (매도세 정정) + issue #4 (paper seq Redis) + issue #25 (slots RPC DB 필터) + dcy-027/issue #20 (중장기 인스턴스 프로파일) — 통합 릴리즈 | [rel-007](rel-007_20260612_slots-rpc-fee-paper-longterm.md) |
@@ -1,6 +1,6 @@
# REL-007: slots RPC 개선 + 매도세 정정 + paper seq 영속화 + 중장기 인스턴스 프로파일 통합 릴리즈 # REL-007: slots RPC 개선 + 매도세 정정 + paper seq 영속화 + 중장기 인스턴스 프로파일 통합 릴리즈
- Status: confirmed - Status: feedback-captured
- Profile: app-service - Profile: app-service
- Included Batch: BAT-037, BAT-038, BAT-039, BAT-040, BAT-041 - Included Batch: BAT-037, BAT-038, BAT-039, BAT-040, BAT-041
@@ -109,11 +109,16 @@
## Feedback Handoff ## Feedback Handoff
- Observation Summary: - Observation Summary:
- none - (2026-06-16 13:10 KST, 배포 에이전트) REL-007이 운영 인스턴스 `trex-admin-daily-trading`(실거래 KIS 91254)에 반영됨을 실환경 검증. 현 HEAD `1f59322`가 REL-007 커밋 `d30e915`를 조상 포함(`git merge-base --is-ancestor`=YES), 데몬 PID 1778 약 2일 무중단, DB 스키마 변경 없음.
- **BAT-038(매도세 정정) 라이브 결정적 검증**: 2026-06-15 064400 매도 체결(`ledger_outbox`) 4주@97,200=388,800 KRW → tax 777.60 = **20.0bps**(=`TREX_KRX_TAX_SELL_BPS`), fee 58.32 = **1.5bps**(=`TREX_KIS_FEE_BPS`). 수정 전 `tax=0.0` 자리에 세금 정확 반영 → 실현 PnL 과대계상 버그 해소 확정.
- BAT-037/040(slots RPC) 라이브 정상: `--json` 응답·`watchdog{expired,last_seen}` 구조·`available_slots` 힌트(orb-v1 소비)·`filter=inactive` 정상.
- 본 실거래 당일매매 인스턴스에 BAT-039(paper seq)·BAT-041(중장기)는 N/A(기능 무영향).
- Discovery Input: - Discovery Input:
- watchdog 소유권 진입 시 외부 알림(슬랙·이메일·웹훅) — dcy-027 OQ-3 후속 Discovery 명시 - watchdog 소유권 진입 시 외부 알림(슬랙·이메일·웹훅) — dcy-027 OQ-3 후속 Discovery 명시 (※ dcy-029 OQ-3 알림 전송 채널 Deferred와 합류 후보)
- 전략↔자산 할당 모델(다중 전략 per-instance, strategy 필드 복원) — dcy-025 Out of Scope 이월 - 전략↔자산 할당 모델(다중 전략 per-instance, strategy 필드 복원) — dcy-025 Out of Scope 이월
- **(2026-06-16 라이브 테스트 환류) 호가단위 주문 거부(P0)**: `slippage_pct` 한계가가 KRX 호가단위 미스냅으로 KIS 거부(issue #28) → **dcy-030 생성·confirmed, REQ-083/070(BAT-044) Implemented**로 처리 완료.
- **(2026-06-16 라이브 테스트 환류) 운영 관측성 부족**: 주문 거부가 로그에 없고 타임스탬프 부재로 진단 지연(issue #29) → **dcy-029 생성·confirmed, REQ-084~090(BAT-045/046) Implemented**로 처리 완료.
- REQ Input: - REQ Input:
- none - none (라이브 환류는 위 dcy-030/dcy-029로 정규화되어 REQ-083/070/084~090로 이미 처리)
- Change Request Input: - Change Request Input:
- none - none (REQ-070 호가단위 정정은 dcy-030 CHG-20260616-01로 처리 완료)
+18 -1
View File
@@ -236,7 +236,7 @@
1. dispatch: `list_undispatched_pending(instance_key)`(status pending_* AND `broker_order_id IS NULL`; 인스턴스 스코프, REQ-056) → `BrokerPort.submit_order` → `mark_dispatched(broker_order_id)`(멱등 가드). 거부 시 `pending_buy→inactive` / `pending_sell→holding` 롤백. 1. dispatch: `list_undispatched_pending(instance_key)`(status pending_* AND `broker_order_id IS NULL`; 인스턴스 스코프, REQ-056) → `BrokerPort.submit_order` → `mark_dispatched(broker_order_id)`(멱등 가드). 거부 시 `pending_buy→inactive` / `pending_sell→holding` 롤백.
- **매도 수량 = 브로커 잔고 정본**(dcy-020/REQ-062, D3): 매도 dispatch 는 `broker.get_balance()` 의 해당 symbol `hldg_qty` 로 수량을 정한다(슬롯 미러 `qty` fallback 없음). 브로커 0 이면 주문 미제출 + WARN + `sell_skipped_no_balance` 카운터(divergence 는 reconcile 경로로 표면화). skip 된 매도가 큐를 막지 않도록 리스트 기반 dispatch. - **매도 수량 = 브로커 잔고 정본**(dcy-020/REQ-062, D3): 매도 dispatch 는 `broker.get_balance()` 의 해당 symbol `hldg_qty` 로 수량을 정한다(슬롯 미러 `qty` fallback 없음). 브로커 0 이면 주문 미제출 + WARN + `sell_skipped_no_balance` 카운터(divergence 는 reconcile 경로로 표면화). skip 된 매도가 큐를 막지 않도록 리스트 기반 dispatch.
- 가격/주문유형 결정(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 연계). - 가격/주문유형 결정(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 연계).
- **슬리피지 보호 지정가(dcy-022/REQ-070)**: 슬롯에 `slippage_pct` 가 있고 `limit_price` 도 있으면, 실효 지정가 = `limit_price×(1+pct/100)` (매수, ceil) / `limit_price×(1-pct/100)` (매도, floor). `limit_price` 없으면(시장가) `slippage_pct` 무시. 미지정 슬롯은 기존 동작(회귀 없음). - **슬리피지 보호 지정가 + 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`. 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-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안, 과매도·이중기록 방지). - 외부 취소 감지(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 로 남지 않게 하는 안전망. - 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 로 남지 않게 하는 안전망.
@@ -284,6 +284,23 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:
5. 백오프 파라미터는 env 설정: `TREX_LEDGER_RETRY_BASE_SEC`(기본 1.0)/`TREX_LEDGER_RETRY_CAP_SEC`(기본 300)/`TREX_LEDGER_MAX_ATTEMPTS`(기본 8). 5. 백오프 파라미터는 env 설정: `TREX_LEDGER_RETRY_BASE_SEC`(기본 1.0)/`TREX_LEDGER_RETRY_CAP_SEC`(기본 300)/`TREX_LEDGER_MAX_ATTEMPTS`(기본 8).
6. portfolio-hub 장기 장애에도 outbox 가 적재되어 무유실. 재시작 시 PostgreSQL `ledger_outbox.status='pending'` 행을 그대로 이어 처리한다(터미널 `delivery_failed`/`conflict_blocked` 는 제외). 6. portfolio-hub 장기 장애에도 outbox 가 적재되어 무유실. 재시작 시 PostgreSQL `ledger_outbox.status='pending'` 행을 그대로 이어 처리한다(터미널 `delivery_failed`/`conflict_blocked` 는 제외).
## Flow: `구조화 로깅 / 관측성` (BAT-045·046 / dcy-029, REQ-084~088)
엔트리포인트(`daemon.py`/`reset.py`)가 `runtime/logging_setup.configure_logging()`으로 root 로깅을 단일 핸들러로 구성(`logging.basicConfig` 대체):
- **포맷**: 모든 라인 `<ISO8601 KST> <LEVEL> <logger> [cid=<id>] <msg>`. `TREX_LOG_FORMAT=json` 이면 Loki 적재용 한 줄 JSON(`ts/level/logger/msg/correlation_id` + extra). KST 는 코드에서 +09:00 고정(환경 TZ 비의존, REQ-084).
- **correlation id**: contextvar + 로깅 필터. 키 = `slot_id`(intent→order→fill 조인). execution 루프가 dispatch/poll 각 슬롯 처리 시작 시 `set_correlation_id(slot_id)` → 한 슬롯의 submit/fill/reject 로그가 같은 cid 공유(REQ-084).
- **주문 결과 로깅(REQ-085)**: 집행 루프가 `event=order_submit`(INFO, side/symbol/slot_id/qty/limit_price), `event=order_fill`(INFO, fill_price/filled_qty/fee/tax), `event=order_reject`를 안정 키로 남겨 `grep`/Loki 로 전수 추출. 슬롯 전이/원장 동작은 불변(additive).
- **거부 ERROR 가시성(REQ-087)**: dispatch/poll 거부 롤백 시 `event=order_reject`를 **ERROR 레벨**로 — 조용한 롤백 제거(issue #28 이 수 시간 묻혔던 원인). 정상 체결은 INFO. 알림 전송 채널은 Deferred(OQ-3).
- **청산 reason+actor(REQ-086)**: `safety-sell fired: ... reason=<r> actor=trex-safety-sell` 로 조건축(reason)과 주체축(actor)을 함께 노출. `stop_loss`/`take_profit`/`forced_close_at` 는 watchdog expired 에서만 발화하므로 actor 동반으로 "안전망 자율청산 vs 전략 손절"을 구분(신규 reason 값·필드 없음). `slot_transition_log` 는 이미 actor 기록.
## Flow: `운영 조회 리포트 (trex-core report)` (BAT-046 / dcy-029, REQ-088)
운영자 `trex-core report [--json]` — **direct-DB 시점 집계**(신규 RPC 없음, OQ-1):
1. `daily_slot` 에서 보유(status=holding: symbol/qty/avg_price) + 대기(pending_*) 조회.
2. `ledger_outbox` 에서 오늘(trading_date KST 경계 → UTC 범위) 체결 조회.
3. `application/operator_report.py` 순수 집계: 실현 PnL = Σ매도(qty·price − fee − tax) − Σ매수(qty·price + fee) (net 매매 현금흐름, 수수료·세금 반영 BAT-038 정책, 자산별 분해). 일중 진입·청산 전략에서 net ≈ 실현손익(미청산 평가손익 제외).
4. text 표 또는 `--json` 출력. 일일 리포트 자동화 기반.
## Shared Components ## Shared Components
- SlotRepository Protocol :: 저장소 추상화(자산 중심: list/count/reconcile 모두 instance_key 스코프). 구현체(InMemorySlotRepository, PostgreSQLSlotRepository)를 주입. - SlotRepository Protocol :: 저장소 추상화(자산 중심: list/count/reconcile 모두 instance_key 스코프). 구현체(InMemorySlotRepository, PostgreSQLSlotRepository)를 주입.
@@ -0,0 +1,54 @@
# req-089: 운영자 런북 — docs/operations.md 신설
- Status: Implemented
- Type: Documentation
- Priority: Medium
- Owner: project maintainers
## Intent
- trex-core에 사람 운영자용 문서(배포·디버깅·CLI 사용법)가 없다. 운영 런북 `docs/operations.md`를 신설해 신규 운영자가 배포·실행, 상태/PnL 조회, 거부·미체결 디버깅을 그 문서만으로 수행하게 한다. (dcy-029 FR-8)
## Requirement
- `docs/operations.md`를 신설하고 최소 4영역을 포함한다:
1. **배포/실행**: 필수 env(`TREX_POSTGRESQL_DSN`/`TREX_RABBITMQ_URL`/`TREX_BROKER` 등), `--config`/`--env-file`, 기동·종료, 테스트 DB 안전 절차.
2. **운영자 CLI 사용법**: `pause/resume/slots/universe/enter/exit/cancel` + 신설 조회/PnL 서브커맨드(REQ-088).
3. **로깅/관측성 사용법**: 타임스탬프·레벨, 주문 결과 로그 grep(`event=order_submit|fill|reject`), `TREX_LOG_FORMAT=json`/Loki·correlation id, 청산 reason+actor 해석(REQ-086).
4. **디버깅/트러블슈팅**: 거부 ERROR 확인, 미체결 진단, DB 상태 조회(daily_slot/ledger_outbox), 자주 겪는 증상→원인→조치.
- 기존 `.claude/skills/run-trex-core-daemon/SKILL.md`의 사실 자료(env 표·launch·troubleshooting)를 사람용 문서로 추출·정리하고, 본 반복(REQ-084~088) 관측성 기능 사용법을 반영한다.
## Acceptance Criteria
1. Given docs/operations.md, When 확인, Then 배포/실행 절차(env·config·기동·종료)가 포함돼 있다.
2. Given 운영자 CLI 절, When 확인, Then 기존 서브커맨드 + 신설 조회/PnL(REQ-088) 사용법이 있다.
3. Given 로깅/관측성 절, When 확인, Then 타임스탬프·주문결과 grep·JSON/Loki·correlation id·reason+actor 해석이 있다.
4. Given 디버깅 절, When 확인, Then 거부 ERROR 확인·미체결 진단·DB 조회 가이드가 있다.
5. Given 신규 운영자, When 런북만 참고, Then 데몬 배포·CLI 조회·거부 디버깅을 수행할 수 있다(4영역 모두 충족).
## Impacted Area
- Docs: docs/operations.md(신설), 참조: .claude/skills/run-trex-core-daemon/SKILL.md
- Modules: 없음(문서)
- Tests: 없음(문서 — verification은 4영역 포함 리뷰 체크)
## Notes
- 본 REQ는 REQ-084~088 구현 사용법을 문서화하므로 그 REQ들과 같은 batch(P1) 후반에 작성한다.
- 출처: dcy-029 FR-8.
## Change Log
### CHG-20260616-01
- Date: 2026-06-16
- Author: Justin Ko
#### Change Summary
- dcy-029 FR-8로부터 최초 생성(Proposed).
### CHG-20260616-02
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Proposed → **Approved** (confirm-req). 필수 필드·구체 AC 충족, source dcy-029(confirmed, Open OQ 0) 정합. dcy-029 P0/P1 batch 편성 예정.
### CHG-20260616-IMPL
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Approved → **Implemented** (confirm-req-implemented, BAT-046 release-candidate, dcy-029 P1). 근거: 단위/문서 AC 매핑(REQ-086 reason+actor 로그·interface-contract; REQ-088 operator_report 집계·net PnL·KST 경계·CLI report; REQ-089 docs/operations.md 4영역; REQ-090 runtime-flows·project-structure 정합). 14 passed(report+safety_sell), 전체 537 passed/47 skipped 회귀 0, ruff clean. report direct-DB·Loki 실 적재는 release 운영 검증 위임. Blocking Issues 없음.
@@ -0,0 +1,54 @@
# req-090: 관측성 baseline 문서 정합 — 로깅/관측성·PnL 조회 흐름
- Status: Implemented
- Type: Documentation
- Priority: Medium
- Owner: project maintainers
## Intent
- dcy-029가 런타임 로깅·청산 분류·PnL 조회 흐름을 바꾸므로 baseline 문서를 실제 구현과 정합시킨다. (dcy-029 FR-6)
## Requirement
- `docs/runtime-flows.md`에 다음 흐름을 추가/갱신한다:
- 구조화 로깅 기반(ISO8601 KST 타임스탬프·레벨·JSON/Loki·correlation id, REQ-084).
- 주문 결과 로깅(submit/fill/reject 일관 라인, REQ-085) 및 거부 ERROR 가시성(REQ-087).
- 청산 reason+actor 동반 노출(REQ-086).
- 운영자 조회 CLI(보유/오늘 체결/실현 PnL/대기 주문, REQ-088).
- `docs/project-structure.md`에 신규 로깅 모듈(REQ-084) 책임을 반영한다.
- baseline 문서는 전체 흐름을 복제하지 않고 구현된 제약·진입점만 정합 수준으로 기술한다.
## Acceptance Criteria
1. Given runtime-flows.md, When 갱신 후, Then 구조화 로깅·correlation id 흐름이 기술돼 있다.
2. Given runtime-flows.md, When 갱신 후, Then 주문 결과 로깅·거부 ERROR·청산 reason+actor·PnL 조회 CLI가 기술돼 있다.
3. Given project-structure.md, When 갱신 후, Then 신규 로깅 모듈이 모듈 트리에 반영돼 있다.
4. Given baseline 문서, When 확인, Then 실제 구현(REQ-084~088)과 모순이 없다.
## Impacted Area
- Docs: docs/runtime-flows.md, docs/project-structure.md
- Modules: 없음(문서)
- Tests: 없음(문서 — verification은 정합 리뷰)
## Notes
- 본 REQ는 P1 batch 구현 완료 후 정합한다(REQ-084~088 결과 반영).
- 출처: dcy-029 FR-6.
## Change Log
### CHG-20260616-01
- Date: 2026-06-16
- Author: Justin Ko
#### Change Summary
- dcy-029 FR-6로부터 최초 생성(Proposed).
### CHG-20260616-02
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Proposed → **Approved** (confirm-req). 필수 필드·구체 AC 충족, source dcy-029(confirmed, Open OQ 0) 정합. dcy-029 P0/P1 batch 편성 예정.
### CHG-20260616-IMPL
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Approved → **Implemented** (confirm-req-implemented, BAT-046 release-candidate, dcy-029 P1). 근거: 단위/문서 AC 매핑(REQ-086 reason+actor 로그·interface-contract; REQ-088 operator_report 집계·net PnL·KST 경계·CLI report; REQ-089 docs/operations.md 4영역; REQ-090 runtime-flows·project-structure 정합). 14 passed(report+safety_sell), 전체 537 passed/47 skipped 회귀 0, ruff clean. report direct-DB·Loki 실 적재는 release 운영 검증 위임. Blocking Issues 없음.
@@ -9,25 +9,30 @@
- `slippage_pct` 가 지정된 주문을 dispatch 할 때 `entry_price*(1±pct)` 실효 지정가로 제출해 과도한 불리 체결을 막는다. `entry_price` 없으면 무시(순수 시장가는 슬리피지 기준 없음). (dcy-022 FR-3) - `slippage_pct` 가 지정된 주문을 dispatch 할 때 `entry_price*(1±pct)` 실효 지정가로 제출해 과도한 불리 체결을 막는다. `entry_price` 없으면 무시(순수 시장가는 슬리피지 기준 없음). (dcy-022 FR-3)
## Requirement ## Requirement
- `_dispatch` 가 슬롯의 `slippage_pct` 를 읽어 실효 지정가를 산출한다: 매수 = `limit_price * (1 + slippage_pct/100)` (올림, ceil), 매도 = `limit_price * (1 - slippage_pct/100)` (내림, floor). `limit_price`(= `entry_price`)가 None 이면 `slippage_pct` 를 무시하고 시장가 주문 제출(기존 동작 보존). KRX 호가단위 반올림은 체결 보장 방향(매수=상위 단위, 매도=하위 단위)으로 적용한다(단, 테스트에서는 간단한 int() 사용 가능 — 정밀 호가단위 테이블은 후속). `slippage_pct` 없는 슬롯은 기존 `limit_price` 직접 사용(회귀 없음). - `_dispatch` 가 슬롯의 `slippage_pct` 를 읽어 실효 지정가를 산출한 뒤 **KRX 호가단위(REQ-083)로 스냅**한다. 방향은 **상한 준수**: 매수 = `floor_to_tick(limit_price * (1 + slippage_pct/100))` (내림 — slippage 상한 미초과), 매도 = `ceil_to_tick(limit_price * (1 - slippage_pct/100))` (올림 — 매도 하한 미초과). band<1tick 엣지에서 미체결을 막기 위해 하한/상한을 보장한다: 매수 실효가 = `max(floor_to_tick(...), ceil_to_tick(limit_price))`, 매도 실효가 = `min(ceil_to_tick(...), floor_to_tick(limit_price))`. `limit_price`(= `entry_price`)가 None 이면 `slippage_pct` 를 무시하고 시장가 주문 제출(기존 동작 보존, 스냅 없음). `slippage_pct` 없는 슬롯은 기존 `limit_price` 직접 사용(회귀 없음). 주식 전용 — ETF/ETN 비주식 호가체계는 범위 밖(dcy-030 OQ-1).
## Acceptance Criteria ## Acceptance Criteria
1. Given 슬롯에 `limit_price=10000`, `slippage_pct=1.0`, side=buy, When dispatch, Then OrderRequest.limit_price = `ceil(10000*1.01)` = 10100. 1. Given 슬롯에 `limit_price=10000`, `slippage_pct=1.0`, side=buy, When dispatch, Then OrderRequest.limit_price = `floor_to_tick(10000*1.01)` = `floor_to_tick(10100)` = 10100 (상한 준수 내림).
2. Given 슬롯에 `limit_price=10000`, `slippage_pct=1.0`, side=sell, When dispatch, Then OrderRequest.limit_price = `floor(10000*0.99)` = 9900. 2. Given 슬롯에 `limit_price=10000`, `slippage_pct=1.0`, side=sell, When dispatch, Then OrderRequest.limit_price = `ceil_to_tick(10000*0.99)` = `ceil_to_tick(9900)` = 9900 (하한 준수 올림).
3. Given `limit_price=None`(시장가), `slippage_pct=1.0`, When dispatch, Then OrderRequest.limit_price = None(시장가 — slippage 무시). 3. Given `limit_price=None`(시장가), `slippage_pct=1.0`, When dispatch, Then OrderRequest.limit_price = None(시장가 — slippage·스냅 무시).
4. Given `slippage_pct=None`, `limit_price=10000`, When dispatch, Then OrderRequest.limit_price = 10000(기존 동작, 회귀 없음). 4. Given `slippage_pct=None`, `limit_price=10000`, When dispatch, Then OrderRequest.limit_price = 10000(기존 동작, 회귀 없음).
5. Given `slippage_pct=0.0`, When dispatch, Then 실효 지정가 = limit_price(0% 슬리피지 = 기준가 그대로). 5. Given `slippage_pct=0.0`, When dispatch, Then 실효 지정가 = `floor_to_tick(limit_price)`(0% 슬리피지 = 기준가의 유효 호가).
6. Given 기존 매수/매도 슬롯(slippage_pct 없음), When dispatch, Then 기존 동작과 동일(전체 회귀 없음). 6. Given 기존 매수/매도 슬롯(slippage_pct 없음), When dispatch, Then 기존 동작과 동일(전체 회귀 없음).
7. Given `limit_price=147700`, `slippage_pct=0.5`, side=buy, When dispatch, Then `floor_to_tick(148438.5)` = 148400 (issue #28 케이스, 유효 호가·상한 미초과).
8. Given `limit_price=54500`, `slippage_pct=0.5`, side=buy, When dispatch, Then `floor_to_tick(54772.5)` = 54700 (issue #28 케이스).
9. Given band<1tick (예: `limit_price=147700`, `slippage_pct=0.01`, buy), When dispatch, Then 매수 실효가 = `max(floor_to_tick(147714.77), ceil_to_tick(147700))` ≥ entry 유효 호가 (미체결 방지), 그리고 slippage 상한 미초과.
10. Given 임의 매수/매도 + slippage, When dispatch, Then 결과는 유효 KRX 호가단위 배수이고 매수 ≤ `limit*(1+pct/100)` / 매도 ≥ `limit*(1-pct/100)`.
## Impacted Area ## Impacted Area
- Docs: docs/runtime-flows.md(slippage dispatch 흐름) - Docs: docs/runtime-flows.md(slippage dispatch + 호가단위 스냅 흐름), docs/project-structure.md(신규 tick 유틸 — REQ-083)
- Modules: trex_core/application/execution.py(`_dispatch` slippage 계산) - Modules: trex_core/application/execution.py(`_dispatch` slippage 계산 + 호가단위 스냅), REQ-083 tick 유틸 소비
- Tests: tests/application/test_execution.py(slippage dispatch 단위) - Tests: tests/application/test_execution.py(slippage dispatch + 스냅 방향·경계·band<tick 하한 보장 단위)
## Notes ## Notes
- `entry_price` = `limit_price` 로 슬롯에 저장되는 설계를 그대로 사용(REQ-013 CHG-20260608-01 선례). 슬리피지는 dispatch-time 변환만 — 슬롯의 `limit_price` 자체를 덮어쓰지 않는다. - `entry_price` = `limit_price` 로 슬롯에 저장되는 설계를 그대로 사용(REQ-013 CHG-20260608-01 선례). 슬리피지는 dispatch-time 변환만 — 슬롯의 `limit_price` 자체를 덮어쓰지 않는다.
- KRX 정밀 호가단위 반올림(500원 미만 → 1원, 500~2000 → 5원 등)은 후속 주기. 현재 구현은 int(math.ceil/floor) 로 충분(테스트 범위 내). - (CHG-20260616-01, dcy-030) KRX 정밀 호가단위 스냅을 REQ-083 유틸로 구현. 반올림 방향 = 상한 준수(매수 내림/매도 올림). 원래 메모의 "체결 보장(매수 상위/매도 하위)" 방향에서 전환 — `slippage_pct`를 리스크 상한으로 취급하기 때문(상한 1틱 초과 방지). band<1tick 하한/상한 보장으로 미체결 방지.
- 출처: dcy-022 FR-3, OQ-2. - 주식 전용. ETF/ETN 비주식 호가체계 및 2,000원 미만 비주식은 미지원(dcy-030 OQ-1, known limitation).
- 출처: dcy-022 FR-3, OQ-2 / dcy-030 FR-2·2b·3·4.
## Change Log ## Change Log
@@ -87,3 +92,52 @@
- Author: Justin Ko - Author: Justin Ko
- Type: status - Type: status
- Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-032(release-candidate) verification — 실행 정책(timeout_sec·slippage_pct) 계약·집행·운영자 cancel 백스톱 전 AC acceptance mapping 존재(신규 71 tests), 단위 425 passed 회귀 없음, baseline 2문서 수동 확인(M5). Blocking Issues 없음. - Summary: Approved → Implemented (confirm-req-implemented). 근거: BAT-032(release-candidate) verification — 실행 정책(timeout_sec·slippage_pct) 계약·집행·운영자 cancel 백스톱 전 AC acceptance mapping 존재(신규 71 tests), 단위 425 passed 회귀 없음, baseline 2문서 수동 확인(M5). Blocking Issues 없음.
### CHG-20260616-01
- Date: 2026-06-16
- Author: Justin Ko
#### Change Summary
- slippage 실효 지정가에 **KRX 호가단위 스냅**을 추가하고, 반올림 방향을 원래 메모의 "체결 보장(매수=상위/매도=하위)"에서 **"상한 준수(매수=하위/매도=상위)"** 로 전환한다. band<1tick 엣지에 하한(매수)/상한(매도) 보장을 추가한다. (dcy-030 FR-2/FR-2b/FR-3/FR-4)
#### Intent
- 현재 구현은 `int(ceil/floor)`만 적용하고 KRX 호가단위로 스냅하지 않아, slippage_pct>0인 매수 한계가가 호가단위를 위반해 KIS `주식주문호가단위 오류`로 전량 거부됨(issue #28). 호가단위 스냅으로 거부를 제거하되, `slippage_pct`를 리스크 상한으로 취급해 스냅이 상한을 넘기지 않도록 매수=내림/매도=올림으로 정정한다.
#### Acceptance Criteria Delta
- Updated:
- AC-1: 매수 = `floor_to_tick(limit*(1+pct/100))` (상한 준수 내림). 예: `limit=10000,pct=1.0` → `floor_to_tick(10100)=10100`.
- AC-2: 매도 = `ceil_to_tick(limit*(1-pct/100))` (하한 준수 올림). 예: `limit=10000,pct=1.0` → `ceil_to_tick(9900)=9900`.
- Added:
- AC-7: `limit=147700,pct=0.5,buy` → `floor_to_tick(148438.5)=148400` (issue #28 케이스).
- AC-8: `limit=54500,pct=0.5,buy` → `floor_to_tick(54772.5)=54700` (issue #28 케이스).
- AC-9 (band<1tick 하한 보장): 매수 실효가 = `max(floor_to_tick(limit*(1+pct/100)), ceil_to_tick(limit))`; 매도 실효가 = `min(ceil_to_tick(limit*(1-pct/100)), floor_to_tick(limit))`. slippage<1tick이어도 최소 entry 유효 호가 보장.
- AC-10: 스냅 결과는 항상 유효 호가단위 배수이고, 매수 ≤ `limit*(1+pct/100)` / 매도 ≥ `limit*(1-pct/100)` (상한·하한 미초과).
- Removed:
- (없음 — AC-3·4·5·6 시장가/무slippage/0% 회귀 기준 유지)
#### Impacted Area Delta
- Docs:
- docs/runtime-flows.md(주문 집행 슬리피지 dispatch에 호가단위 스냅 단계 추가), docs/project-structure.md(신규 tick 유틸 — REQ-083)
- Modules:
- trex_core/application/execution.py(`_dispatch` 스냅 적용), REQ-083 tick 유틸 소비
- Tests:
- tests/application/test_execution.py(슬리피지 스냅 방향·경계·band<tick 하한 보장), REQ-083 tick 단위 테스트
#### Delivery Trace Delta
- Batches: BAT-032(REQ-070 최초 구현) — 본 변경은 신규 batch(dcy-030)에서 재구현·재검증.
- Releases: REL-006(BAT-032 released) — 운영 영향(매수 거부 해소)은 dcy-030 batch 배포 시 신규 release로 추적.
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: yes (tick 스냅 부재 → 매수 거부; 반올림 방향도 변경)
- Status Recommendation: revert-to-approved
- Follow-up Action: dcy-030 batch에서 REQ-083 + REQ-070 재구현·검증 후 confirm-req-implemented로 재전환.
#### Validation Plan
- REQ-083 tick 유틸 단위 + execution.py 슬리피지 스냅 방향/경계/band<tick 하한 보장 단위 + 기존 AC-3~6 회귀.
### CHG-20260616-IMPL
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Approved → **Implemented** (confirm-req-implemented, BAT-044 release-candidate, dcy-030/issue #28). 근거: `execution.py` `_dispatch` 호가단위 스냅 구현(상한 준수 매수 floor/매도 ceil + band<tick 보장 + epsilon 가드, REQ-083 소비). AC-1·2·5·7·8·9·10 단위 매핑(issue #28 케이스 147700→148400/54500→54700, 35000@0.7%→35200, band<tick), AC-3·4·6 회귀. 47 passed(pricing+execution_policy), 전체 516 passed/47 skipped 회귀 0, ruff clean. Blocking Issues 없음.
@@ -0,0 +1,61 @@
# req-083: KRX 호가단위(tick) 산출 유틸 — 가격→tick 순수 함수 + 배수 내림/올림
- Status: Implemented
- Type: Integration
- Priority: High
- Owner: project maintainers
## Intent
- 가격대별 KRX 주식 호가단위(2023.1.25~)를 산출하는 순수 함수와 tick 배수 내림/올림 헬퍼를 제공해, slippage 보호 지정가 스냅(REQ-070)과 이후 호가단위가 필요한 지점이 단일 검증된 유틸을 재사용하게 한다. (dcy-030 FR-1)
## Requirement
- 가격(int/float, 원) → 해당 가격대의 KRX 주식 호가단위(tick, int)를 반환하는 순수 함수를 제공한다. 구간표(2023.1.25~):
- `< 2,000` → 1
- `2,000 ≤ p < 5,000` → 5
- `5,000 ≤ p < 20,000` → 10
- `20,000 ≤ p < 50,000` → 50
- `50,000 ≤ p < 200,000` → 100
- `200,000 ≤ p < 500,000` → 500
- `≥ 500,000` → 1,000
- 가격을 해당 tick의 **내림 배수**(`floor_to_tick`)와 **올림 배수**(`ceil_to_tick`)로 정규화하는 순수 헬퍼를 제공한다. 결과는 항상 tick의 정수배다.
- 구간 경계는 하한 포함/상한 미만(`[lo, hi)`)으로 처리한다(예: 정확히 50,000원은 100원 tick).
- 주식 전용이다. ETF/ETN 등 비주식 호가체계는 이번 범위 밖이며(dcy-030 OQ-1), 종목 유형 분기는 두지 않는다(known limitation: 2,000원 미만 비주식은 미지원).
## Acceptance Criteria
1. Given 가격 1,500, When tick 산출, Then 1.
2. Given 가격 3,000 / 12,000 / 35,000 / 147,700 / 300,000 / 800,000, When tick 산출, Then 각각 5 / 10 / 50 / 100 / 500 / 1,000.
3. Given 경계값 2,000 / 5,000 / 20,000 / 50,000 / 200,000 / 500,000, When tick 산출, Then 각각 5 / 10 / 50 / 100 / 500 / 1,000 (하한 포함).
4. Given 가격 148,439, When `floor_to_tick`, Then 148,400 (tick 100의 내림 배수).
5. Given 가격 54,773, When `floor_to_tick`, Then 54,700; When `ceil_to_tick`, Then 54,800.
6. Given 임의 가격 p, When `floor_to_tick(p)`/`ceil_to_tick(p)`, Then 결과는 tick(p)의 정수배이고 `floor ≤ p ≤ ceil`.
## Impacted Area
- Docs: docs/runtime-flows.md(주문 집행 슬리피지 스냅 단계 — REQ-070 change와 공동), docs/project-structure.md(신규 tick 유틸 모듈 책임)
- Modules: 신규 순수 모듈(후보: `trex_core/domain/pricing.py` 또는 `trex_core/application/tick_size.py` — design에서 확정), REQ-070 dispatch에서 소비
- Tests: 신규 단위 테스트(각 가격 구간 경계값 + floor/ceil 불변식)
## Notes
- 출처: dcy-030 FR-1, OQ-1(주식 전용).
- tick 표는 단일 상수 테이블로 모아 KRX 제도 변경 시 변경 지점을 1곳으로 한다(dcy-030 R-3).
- 모듈 배치는 순수 함수이므로 domain 계층 후보. 정확한 위치는 batch design에서 확정.
## Change Log
### CHG-20260616-01
- Date: 2026-06-16
- Author: Justin Ko
#### Change Summary
- dcy-030 FR-1로부터 최초 생성(Proposed).
### CHG-20260616-02
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Proposed → **Approved** (confirm-req). 필수 필드·구체 AC(경계값 6 + floor/ceil 불변식) 충족, source dcy-030(confirmed, Open OQ 0) 정합. REQ-070 change-req와 함께 dcy-030 단일 batch 편성 예정.
### CHG-20260616-03
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Approved → **Implemented** (confirm-req-implemented, BAT-044 release-candidate, dcy-030). 근거: `trex_core/domain/pricing.py` 구현 + `tests/domain/test_pricing.py` 전 AC 매핑(대표값/경계값/floor·ceil 불변식). 47 passed(pricing+execution), 전체 516 passed/47 skipped 회귀 0, ruff clean. Blocking Issues 없음.
@@ -0,0 +1,56 @@
# req-085: 주문 결과 로깅 — submit/체결/거부 일관 tailable 로그
- Status: Implemented
- Type: Integration
- Priority: High
- Owner: project maintainers
## Intent
- 주문 결과(체결/거부)가 산발적 로그·DB(`slot_transition_log`)에만 남아 호가단위 거부(#28)를 수 시간 못 봤다. 모든 주문 submit과 그 결과(체결가·수량·수수료·세금, 또는 거부+사유)를 타임스탬프 달린 **일관(tailable) 로그 라인**으로 출력해 `grep`/`tail`로 당일 체결·거부를 즉시 전수 추출할 수 있게 한다. (dcy-029 FR-2)
## Requirement
- 집행 루프(`execution.py`)의 주문 dispatch/poll 경로에서 다음 이벤트를 일관된 안정적 키를 가진 한 줄 로그로 남긴다:
- **submit**: side, symbol, slot_id, qty, limit_price(또는 시장가), order_type.
- **fill**: side, symbol, slot_id, 체결가, 체결수량, fee, tax.
- **reject**: side, symbol, slot_id, 거부 사유.
- 로그 라인은 안정적 키(예: `event=order_submit|order_fill|order_reject`, `symbol=`, `slot_id=`)를 포함해 grep 필터가 가능해야 한다. JSON 포맷(REQ-084) 시 동일 필드가 구조화 키로 나간다.
- correlation id(REQ-084)가 설정돼 있으면 submit/fill/reject 로그가 같은 id를 공유한다.
- 기존 슬롯 전이/원장 기록 동작은 변경하지 않는다(로그 추가만, additive).
## Acceptance Criteria
1. Given 매수 주문 dispatch, When submit, Then `event=order_submit` + side/symbol/slot_id/qty/limit_price를 포함한 한 줄 로그가 남는다.
2. Given 체결, When poll fill, Then `event=order_fill` + 체결가/체결수량/fee/tax를 포함한 로그가 남는다.
3. Given 거부, When poll/ dispatch reject, Then `event=order_reject` + 사유를 포함한 로그가 남는다.
4. Given `TREX_LOG_FORMAT=json`, When 위 이벤트, Then 해당 키들이 JSON 구조화 필드로 출력된다.
5. Given 일련의 체결/거부, When `grep "event=order_reject"`, Then 당일 거부 주문을 전수 추출할 수 있다.
6. Given correlation id 설정 컨텍스트, When submit→fill, Then 두 로그가 동일 correlation id를 가진다.
## Impacted Area
- Docs: docs/runtime-flows.md(주문 집행 루프 — 결과 로깅)
- Modules: trex_core/application/execution.py(`_dispatch`/`_poll`/`_on_filled`/`_rollback` 로깅), REQ-084 로깅 기반 소비
- Tests: tests/application/test_execution* (submit/fill/reject 로그 caplog 단위)
## Notes
- FR-4(거부 ERROR 레벨)는 REQ-087에서 다룬다. 본 REQ는 submit/fill/reject의 **일관 구조·가시성**(레벨 무관)에 집중하되, reject 로그 라인은 REQ-087과 동일 지점에서 협조한다.
- 출처: dcy-029 FR-2.
## Change Log
### CHG-20260616-01
- Date: 2026-06-16
- Author: Justin Ko
#### Change Summary
- dcy-029 FR-2로부터 최초 생성(Proposed).
### CHG-20260616-02
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Proposed → **Approved** (confirm-req). 필수 필드·구체 AC 충족, source dcy-029(confirmed, Open OQ 0) 정합. dcy-029 P0/P1 batch 편성 예정.
### CHG-20260616-IMPL
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Approved → **Implemented** (confirm-req-implemented, BAT-045 release-candidate, dcy-029 P0). 근거: 단위 AC 매핑(logging_setup text/json/KST/cid/idempotent, execution 주문 결과 submit/fill/reject + cid=slot_id + 거부 ERROR). 35 passed(logging+execution_policy), 전체 529 passed/47 skipped 회귀 0, ruff clean. 실 Loki 적재는 release 운영 검증 위임. Blocking Issues 없음.
@@ -0,0 +1,53 @@
# req-086: 청산 reason+actor 동반 노출 — 안전망/전략 구분 가시화
- Status: Implemented
- Type: Interface
- Priority: Medium
- Owner: project maintainers
## Intent
- watchdog 소유 안전망 청산이 `reason=stop_loss`로만 보여 "전략의 진짜 손절"로 오해됐다(2026-06-15 064400). 신규 필드 없이, 기존 `reason`(조건축)과 기존 `actor`(주체축)를 **함께** 노출해 "안전망 자율청산(actor=safety_sell)"과 "전략 손절(actor=strategy)"을 운영자가 구분하게 한다. (dcy-029 FR-3, OQ-4=Option A)
## Requirement
- 기존 `reason` 어휘 `{stop_loss, take_profit, forced_close_at, market_close_liquidation}`(safety_sell 발화)와 전략 SellIntent의 reason을 계약으로 고정한다(신규 reason 값·신규 분류 필드 없음).
- 슬롯 전이 로그/이벤트/조회 뷰에 `reason`과 `actor`(`safety_sell`/`strategy`/`execution_loop`)를 **함께** 노출한다. 특히 주문 결과 로깅(REQ-085)·조회 CLI(REQ-088)·청산 로그에 actor가 reason과 같이 보이도록 한다.
- `docs/interface-contract.md`에 reason+actor 어휘와 "안전망 자율청산 family vs 전략 매도" 구분을 명문화한다.
- 기존 `slots.req`/이벤트/전이 기록의 기존 동작은 깨지 않는다(additive 노출).
## Acceptance Criteria
1. Given safety_sell이 stop_loss로 발화, When 전이/로그, Then `reason=stop_loss` + `actor=safety_sell`이 함께 기록된다.
2. Given 전략 SellIntent 매도, When 전이/로그, Then `actor`가 전략 주체로 기록되어 안전망과 구분된다.
3. Given 청산 로그 라인, When 운영자가 확인, Then reason만으로 오해되지 않도록 actor가 동반 노출된다.
4. Given safety_sell 4경로(stop_loss/take_profit/forced_close_at/market_close_liquidation), When 발화, Then 각 reason이 actor=safety_sell과 함께 정확히 기록된다.
5. Given 기존 reason 소비처(`_SELL_REJECT_RETRYABLE` 등), When 변경 적용, Then 기존 동작 회귀 없음(reason 값 불변).
6. Given interface-contract.md, When 갱신 후, Then reason+actor 어휘와 구분 규칙이 문서화돼 있다.
## Impacted Area
- Docs: docs/interface-contract.md(reason+actor 어휘), docs/runtime-flows.md(청산 분류 노출)
- Modules: trex_core/application/safety_sell.py·execution.py(로그에 actor 동반), 조회 뷰(REQ-088)·주문결과 로깅(REQ-085)과 협조. `slot_transition_log`는 이미 actor 기록.
- Tests: safety_sell 각 경로 reason·actor 단위, 노출 형식 단위
## Notes
- 코드 확인: `slot_transition_log`는 이미 `actor`를 기록하고, safety_sell은 이미 구분된 reason으로 발화한다. 본 REQ는 둘을 **함께 노출**(로그/뷰/계약 문서)하는 가시화가 핵심이며 신규 필드·신규 reason 값은 만들지 않는다(dcy-029 OQ-4 Option A).
- 출처: dcy-029 FR-3, OQ-4.
## Change Log
### CHG-20260616-01
- Date: 2026-06-16
- Author: Justin Ko
#### Change Summary
- dcy-029 FR-3로부터 최초 생성(Proposed).
### CHG-20260616-02
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Proposed → **Approved** (confirm-req). 필수 필드·구체 AC 충족, source dcy-029(confirmed, Open OQ 0) 정합. dcy-029 P0/P1 batch 편성 예정.
### CHG-20260616-IMPL
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Approved → **Implemented** (confirm-req-implemented, BAT-046 release-candidate, dcy-029 P1). 근거: 단위/문서 AC 매핑(REQ-086 reason+actor 로그·interface-contract; REQ-088 operator_report 집계·net PnL·KST 경계·CLI report; REQ-089 docs/operations.md 4영역; REQ-090 runtime-flows·project-structure 정합). 14 passed(report+safety_sell), 전체 537 passed/47 skipped 회귀 0, ruff clean. report direct-DB·Loki 실 적재는 release 운영 검증 위임. Blocking Issues 없음.
@@ -0,0 +1,57 @@
# req-088: 운영 조회 CLI — 보유·오늘 체결·실현 PnL·대기 주문
- Status: Implemented
- Type: Interface
- Priority: High
- Owner: project maintainers
## Intent
- 일일 실현 PnL을 `ledger_outbox`에서 수기 계산해야 했다. 운영자가 단일 CLI 명령으로 현재 보유·오늘 체결·오늘 실현 PnL(수수료·세금 반영)·대기 주문을 조회해 일일 리포트 자동화의 기반을 만든다. (dcy-029 FR-5, OQ-1=CLI, RPC 없음)
## Requirement
- 운영자 CLI(`trex_core/runtime/cli.py`)에 신규 서브커맨드(예: `report` 또는 `pnl`)를 추가해 다음을 출력한다:
- **현재 보유**: holding 슬롯의 symbol/portfolio_id/qty/avg_price.
- **오늘 체결**: 당일(trading_date, KST 경계) 체결 건수/내역.
- **오늘 실현 PnL**: 수수료·세금 반영(BAT-038 정책과 동일 계산), `ledger_outbox`/체결 기록을 단일 권위 출처로 집계.
- **대기 주문**: pending_* 슬롯.
- 조회는 **DB-backed 시점 집계**다. 신규 RabbitMQ RPC는 추가하지 않는다(dcy-029 OQ-1). 데몬 미경유로 DB를 직접 읽거나 기존 read 경로를 재사용한다.
- 청산/체결 항목에 reason+actor(REQ-086)를 함께 보여 안전망/전략 구분이 되게 한다.
- 출력은 사람용 표/요약 + 가능하면 머신 파싱용(예: `--json`) 형태를 고려한다(자동화 기반).
## Acceptance Criteria
1. Given holding 슬롯 존재, When `report` 실행, Then 보유 symbol/qty/avg_price가 출력된다.
2. Given 당일 체결 N건, When 실행, Then 오늘 체결 건수/내역이 출력된다.
3. Given 당일 매도 체결, When 실행, Then 실현 PnL이 수수료·세금 반영 값으로 출력되고 `ledger_outbox` 직접 집계와 일치한다.
4. Given pending_* 슬롯, When 실행, Then 대기 주문이 출력된다.
5. Given "오늘" 경계, When trading_date(KST) 기준, Then 전일 체결은 오늘 집계에서 제외된다.
6. Given 신규 표면, When 실행, Then 신규 RabbitMQ RPC 없이 동작한다(CLI/DB-backed, OQ-1).
## Impacted Area
- Docs: docs/runtime-flows.md(운영자 CLI 조회 흐름), docs/operations.md(사용법 — REQ-089)
- Modules: trex_core/runtime/cli.py(신규 서브커맨드), PnL 집계 read(신규 헬퍼 — `ledger_outbox`/체결 집계), 기존 slot read 재사용
- Tests: 집계 함수 단위(수수료·세금·trading_date 경계), CLI 서브커맨드 단위
## Notes
- PnL 계산은 BAT-038(매도 증권거래세+수수료) 정책을 단일 출처로 재사용(dcy-029 NFR-3/A-3). 부분 정산·재시도 중 outbox 상태는 집계 시점 기준으로 명시.
- 출처: dcy-029 FR-5, OQ-1.
## Change Log
### CHG-20260616-01
- Date: 2026-06-16
- Author: Justin Ko
#### Change Summary
- dcy-029 FR-5로부터 최초 생성(Proposed).
### CHG-20260616-02
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Proposed → **Approved** (confirm-req). 필수 필드·구체 AC 충족, source dcy-029(confirmed, Open OQ 0) 정합. dcy-029 P0/P1 batch 편성 예정.
### CHG-20260616-IMPL
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Approved → **Implemented** (confirm-req-implemented, BAT-046 release-candidate, dcy-029 P1). 근거: 단위/문서 AC 매핑(REQ-086 reason+actor 로그·interface-contract; REQ-088 operator_report 집계·net PnL·KST 경계·CLI report; REQ-089 docs/operations.md 4영역; REQ-090 runtime-flows·project-structure 정합). 14 passed(report+safety_sell), 전체 537 passed/47 skipped 회귀 0, ruff clean. report direct-DB·Loki 실 적재는 release 운영 검증 위임. Blocking Issues 없음.
@@ -0,0 +1,54 @@
# req-084: 구조화 로깅 기반 — ISO8601(KST) 타임스탬프 + 레벨 + Loki JSON + correlation id
- Status: Implemented
- Type: Non-Functional
- Priority: High
- Owner: project maintainers
## Intent
- 모든 로그 라인에 시각·레벨이 없어 타임라인 재구성이 불가능했던 문제를 해결한다. ISO8601(KST) 타임스탬프 + 레벨 prefix를 전 로그에 부여하고, Loki 적재용 구조화(JSON) 출력 옵션과 correlation id(intent→order→fill) 전파를 도입해 한 거래의 로그를 단일 키로 묶는다. (dcy-029 FR-1, FR-7)
## Requirement
- 데몬 기동(및 reset CLI)의 로깅 설정을 단일 지점으로 모아, 모든 로그 라인에 **ISO8601 타임스탬프(KST, Asia/Seoul, 환경 TZ 비의존)** + **로그 레벨** prefix를 부여한다.
- 환경 변수(`TREX_LOG_FORMAT`, 기본 `text`)로 출력 포맷을 선택한다: `text`(사람용 한 줄) / `json`(Loki 적재용 구조화 — 최소 `ts`, `level`, `logger`, `msg`, 가능하면 `correlation_id`).
- **correlation id 전파**: contextvar 기반으로 현재 처리의 correlation id를 보관하고, 로깅 필터가 모든 레코드에 주입한다. intent 수락 시 intent_id(또는 그로부터 파생한 키)를 correlation id로 설정해, 같은 거래의 intent·order·fill 로그가 동일 id를 공유한다. id 미설정 시 빈 값/생략으로 안전하게 동작한다.
- 기존 로깅 호출부(`_log.info/...`)는 메시지를 바꾸지 않아도 새 포맷·correlation id가 자동 적용되어야 한다(호출부 광범위 수정 불요).
## Acceptance Criteria
1. Given 로깅 설정 적용 후 임의 로그 1줄, When 출력, Then ISO8601(KST) 타임스탬프 + 레벨 + 메시지를 포함한다.
2. Given `TREX_LOG_FORMAT=json`, When 로그 출력, Then 한 줄이 유효 JSON이며 `ts`/`level`/`logger`/`msg` 키를 포함한다.
3. Given `TREX_LOG_FORMAT` 미설정/`text`, When 로그 출력, Then 사람용 한 줄 포맷(타임스탬프+레벨+메시지)이다.
4. Given correlation id가 설정된 컨텍스트, When 로그 출력, Then 레코드(및 JSON `correlation_id`)에 그 id가 포함된다.
5. Given correlation id 미설정, When 로그 출력, Then 예외 없이 빈/생략 correlation id로 출력된다.
6. Given KST 타임스탬프, When 컨테이너 TZ가 UTC여도, Then 타임스탬프는 KST(+09:00) 기준이다.
## Impacted Area
- Docs: docs/runtime-flows.md(로깅/관측성 흐름 — REQ-090과 공동), docs/project-structure.md(신규 로깅 모듈)
- Modules: 신규 `trex_core/runtime/logging_setup.py`(포맷터+JSON+correlation id 필터·contextvar) 후보, `trex_core/runtime/daemon.py`·`trex_core/runtime/reset.py`(basicConfig 대체), intent 수락 경로(correlation id set)
- Tests: 로그 포맷터 단위(text/json/KST/correlation id), 설정 적용 단위
## Notes
- 기존 `logging.basicConfig(level=INFO)`(daemon.py·reset.py)는 format 미지정으로 타임스탬프·레벨 prefix가 없었다(dcy-029 현재상태).
- correlation id 전파 깊이(전 경로 완전 전파 vs 핵심 경로)는 batch design에서 확정. 최소 intent admission→order dispatch 로깅 연결을 보장한다.
- 출처: dcy-029 FR-1·FR-7, OQ-2(Loki In Scope), A-4(배포 Loki 사용 전제).
## Change Log
### CHG-20260616-01
- Date: 2026-06-16
- Author: Justin Ko
#### Change Summary
- dcy-029 FR-1·FR-7로부터 최초 생성(Proposed).
### CHG-20260616-02
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Proposed → **Approved** (confirm-req). 필수 필드·구체 AC 충족, source dcy-029(confirmed, Open OQ 0) 정합. dcy-029 P0/P1 batch 편성 예정.
### CHG-20260616-IMPL
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Approved → **Implemented** (confirm-req-implemented, BAT-045 release-candidate, dcy-029 P0). 근거: 단위 AC 매핑(logging_setup text/json/KST/cid/idempotent, execution 주문 결과 submit/fill/reject + cid=slot_id + 거부 ERROR). 35 passed(logging+execution_policy), 전체 529 passed/47 skipped 회귀 0, ruff clean. 실 Loki 적재는 release 운영 검증 위임. Blocking Issues 없음.
@@ -0,0 +1,53 @@
# req-087: 주문 거부 ERROR 가시성 — 조용한 롤백 제거
- Status: Implemented
- Type: Non-Functional
- Priority: High
- Owner: project maintainers
## Intent
- 주문 거부 시 `execution._rollback`이 슬롯을 조용히 전이(`slot_transition_log`에만 기록)해 호가단위 거부(#28)가 로그에 안 보였다. 거부를 **ERROR 레벨 로그**로 즉시 가시화해 반복 거부를 운영자가 즉시 인지하게 한다. (dcy-029 FR-4)
## Requirement
- 주문 거부로 인한 롤백(`pending_buy→inactive` / `pending_sell→holding`) 시 **ERROR 레벨** 로그를 남긴다. 라인은 side, symbol, slot_id, 거부 사유를 포함한다(REQ-085 order_reject 라인과 동일 지점·동일 키, 레벨만 ERROR).
- correlation id(REQ-084)가 있으면 함께 남긴다.
- 알림 전송 채널(claude-bus/외부 webhook 등)은 본 REQ 범위 밖이다(dcy-029 OQ-3 Deferred) — ERROR 로그까지만.
- 정상 체결/정상 전이 경로의 로그 레벨은 바꾸지 않는다(거부 경로만 ERROR).
## Acceptance Criteria
1. Given 매수 주문 거부, When 롤백(pending_buy→inactive), Then ERROR 레벨 로그(side/symbol/slot_id/사유)가 남는다.
2. Given 매도 주문 거부, When 롤백(pending_sell→holding), Then ERROR 레벨 로그가 남는다.
3. Given 정상 체결, When 전이, Then ERROR 로그가 남지 않는다(거부 경로만 ERROR).
4. Given `TREX_LOG_FORMAT=json`, When 거부, Then `level=ERROR` + 사유 키가 구조화 출력된다.
5. Given 반복 거부 N회, When grep `level=ERROR`/`event=order_reject`, Then N건 모두 가시화된다.
6. Given 알림 채널, When 거부, Then 본 반복에서는 전송 채널을 호출하지 않는다(Deferred, 로그까지만).
## Impacted Area
- Docs: docs/runtime-flows.md(주문 집행 거부 가시성)
- Modules: trex_core/application/execution.py(`_rollback`/거부 경로 ERROR 로그), REQ-084·085와 협조
- Tests: tests/application/test_execution* (거부 ERROR caplog 단위, 정상 경로 비-ERROR)
## Notes
- REQ-085(주문 결과 로깅)와 동일한 reject 지점에서 협조 — REQ-085는 일관 키, 본 REQ는 거부 라인의 ERROR 레벨 보장.
- 출처: dcy-029 FR-4, OQ-3(알림 Deferred).
## Change Log
### CHG-20260616-01
- Date: 2026-06-16
- Author: Justin Ko
#### Change Summary
- dcy-029 FR-4로부터 최초 생성(Proposed).
### CHG-20260616-02
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Proposed → **Approved** (confirm-req). 필수 필드·구체 AC 충족, source dcy-029(confirmed, Open OQ 0) 정합. dcy-029 P0/P1 batch 편성 예정.
### CHG-20260616-IMPL
- Date: 2026-06-16
- Author: Justin Ko
- Type: status
- Summary: Approved → **Implemented** (confirm-req-implemented, BAT-045 release-candidate, dcy-029 P0). 근거: 단위 AC 매핑(logging_setup text/json/KST/cid/idempotent, execution 주문 결과 submit/fill/reject + cid=slot_id + 거부 ERROR). 35 passed(logging+execution_policy), 전체 529 passed/47 skipped 회귀 0, ruff clean. 실 Loki 적재는 release 운영 검증 위임. Blocking Issues 없음.
+18 -1
View File
@@ -74,7 +74,7 @@
## Next Requirement ID ## Next Requirement ID
- Current: `REQ-083` - Current: `REQ-091`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register ## Requirement Register
@@ -163,9 +163,26 @@
| REQ-080 | trex.slots.strategy_state.set write RPC 계약 — occupancy_seq·version CAS | Interface | Implemented | High | project maintainers | [req-080](./Interface/req-080_strategy-state-set-rpc.md) | | REQ-080 | trex.slots.strategy_state.set write RPC 계약 — occupancy_seq·version CAS | Interface | Implemented | High | project maintainers | [req-080](./Interface/req-080_strategy-state-set-rpc.md) |
| REQ-081 | 슬롯 occupancy_seq 정합성 가드 + close 시 strategy_state 삭제 불변식 | Data | Implemented | High | project maintainers | [req-081](./Data/req-081_slot-occupancy-seq-guard-and-close-delete.md) | | REQ-081 | 슬롯 occupancy_seq 정합성 가드 + close 시 strategy_state 삭제 불변식 | Data | Implemented | High | project maintainers | [req-081](./Data/req-081_slot-occupancy-seq-guard-and-close-delete.md) |
| REQ-082 | strategy-state baseline 문서 정합 — read/write/삭제 흐름 + occupancy_seq 가드 | Documentation | Implemented | Medium | project maintainers | [req-082](./Documentation/req-082_strategy-state-baseline-alignment.md) | | REQ-082 | strategy-state baseline 문서 정합 — read/write/삭제 흐름 + occupancy_seq 가드 | Documentation | Implemented | Medium | project maintainers | [req-082](./Documentation/req-082_strategy-state-baseline-alignment.md) |
| REQ-083 | KRX 호가단위(tick) 산출 유틸 — 가격→tick + 배수 내림/올림 | Integration | Implemented | High | project maintainers | [req-083](./Integration/req-083_krx-tick-size-utility.md) |
| REQ-084 | 구조화 로깅 기반 — ISO8601(KST)·레벨·Loki JSON·correlation id | Non-Functional | Implemented | High | project maintainers | [req-084](./Non-Functional/req-084_structured-logging-foundation.md) |
| REQ-085 | 주문 결과 로깅 — submit/체결/거부 일관 tailable 로그 | Integration | Implemented | High | project maintainers | [req-085](./Integration/req-085_order-result-logging.md) |
| REQ-086 | 청산 reason+actor 동반 노출 — 안전망/전략 구분 가시화 | Interface | Implemented | Medium | project maintainers | [req-086](./Interface/req-086_liquidation-reason-actor-exposure.md) |
| REQ-087 | 주문 거부 ERROR 가시성 — 조용한 롤백 제거 | Non-Functional | Implemented | High | project maintainers | [req-087](./Non-Functional/req-087_order-rejection-error-visibility.md) |
| REQ-088 | 운영 조회 CLI — 보유·오늘 체결·실현 PnL·대기 주문 | Interface | Implemented | High | project maintainers | [req-088](./Interface/req-088_operator-pnl-query-cli.md) |
| REQ-089 | 운영자 런북 — docs/operations.md 신설 | Documentation | Implemented | Medium | project maintainers | [req-089](./Documentation/req-089_operator-runbook.md) |
| REQ-090 | 관측성 baseline 문서 정합 — 로깅/관측성·PnL 조회 흐름 | Documentation | Implemented | Medium | project maintainers | [req-090](./Documentation/req-090_observability-baseline-alignment.md) |
## Recent Change Log Summary ## Recent Change Log Summary
- 2026-06-16 :: REQ-086·088·089·090 Approved → **Implemented** (confirm-req-implemented, BAT-046 release-candidate, dcy-029 P1/issue #29): 청산 reason+actor 노출 + 운영 조회 CLI(report, 보유·오늘 체결·실현 PnL net·대기) + 운영자 런북 docs/operations.md + baseline 정합. 14 passed(신규 operator_report+safety_sell), 전체 537 passed/47 skipped 회귀 0, ruff clean.
- 2026-06-16 :: REQ-084·085·087 Approved → **Implemented** (confirm-req-implemented, BAT-045 release-candidate, dcy-029 P0/issue #29): 구조화 로깅 기반(ISO8601 KST·레벨·Loki JSON·correlation id) + 주문 결과 로깅(event=order_submit|fill|reject) + 거부 ERROR 가시화 구현·검증. 35 passed(신규 logging+주문결과), 전체 529 passed/47 skipped 회귀 0, ruff clean.
- 2026-06-16 :: REQ-084~090 신규 생성(Proposed, draft-req dcy-029): 운영 관측성 — REQ-084 구조화 로깅 기반(ISO8601 KST·레벨·Loki JSON·correlation id, FR-1+7), REQ-085 주문 결과 로깅(FR-2), REQ-086 청산 reason+actor 노출(FR-3, OQ-4 Option A), REQ-087 거부 ERROR 가시성(FR-4), REQ-088 운영 조회 CLI(FR-5, OQ-1 CLI), REQ-089 운영자 런북 docs/operations.md(FR-8), REQ-090 baseline 정합(FR-6). P0=084·085·087(+087은 P1이나 로깅 동반)/ P1=086·088·089·090.
- 2026-06-16 :: REQ-083·070 Approved → **Implemented** (confirm-req-implemented, BAT-044 release-candidate, dcy-030/issue #28): 슬리피지 KRX 호가단위 스냅 구현·검증 완료 — `domain/pricing.py`(tick 유틸) + `execution.py` 스냅(상한 준수 매수 floor/매도 ceil + band<tick 보장 + epsilon 가드). issue #28 케이스(147700→148400/54500→54700) 단위 검증, 47 passed(신규 pricing+슬리피지 정정), 전체 516 passed/47 skipped 회귀 0, ruff clean.
- 2026-06-16 :: REQ-083 Proposed → **Approved** (confirm-req): 필수 필드·구체 AC 충족, source dcy-030(confirmed, Open OQ 0) 정합. REQ-070(Approved, CHG)와 함께 dcy-030 단일 batch 편성 예정.
- 2026-06-16 :: REQ-083 신규 생성(Proposed, draft-req dcy-030): KRX 호가단위(tick) 산출 유틸 — 가격→tick 순수 함수(2023.1.25~ 구간표) + floor/ceil 배수 헬퍼. 주식 전용(OQ-1). REQ-070 슬리피지 스냅이 소비.
- 2026-06-16 :: REQ-070 change-req (CHG-20260616-01, dcy-030/issue #28): 슬리피지 실효 지정가에 KRX 호가단위 스냅 추가 + 반올림 방향 전환(체결 보장 → **상한 준수**: 매수 내림/매도 올림) + band<1tick 하한/상한 보장. 기존 int(ceil/floor) 무스냅 구현 무효화 → Implemented → **Approved** 되돌림. 신규 AC-7~10, AC-1·2 갱신. dcy-030 batch에서 재구현·검증.
- 2026-06-15 :: REQ-007 Approved → **Implemented** (confirm-req-implemented, BAT-043 release-candidate, issue #27): rejected SellIntent 응답 additive `retryable` 구현·검증 완료 — core 소유 `_SELL_REJECT_RETRYABLE` 매핑 + helper, 487 passed(+8), ruff clean. issue #27 close. - 2026-06-15 :: REQ-007 Approved → **Implemented** (confirm-req-implemented, BAT-043 release-candidate, issue #27): rejected SellIntent 응답 additive `retryable` 구현·검증 완료 — core 소유 `_SELL_REJECT_RETRYABLE` 매핑 + helper, 487 passed(+8), ruff clean. issue #27 close.
- 2026-06-15 :: REQ-007 change-req (CHG-20260615-01, issue #27): rejected SellIntent 응답에 additive `retryable: bool`(core 소유 reason→retryable 매핑, 미인식 기본 true). #26 sell-idempotency 후속 — 데몬 손절매 재시도 안전 경로 decoupling. 완전 additive(기존 불변) → 신규 AC-7 구현·검증 대기 → Implemented → **Approved** 되돌림. - 2026-06-15 :: REQ-007 change-req (CHG-20260615-01, issue #27): rejected SellIntent 응답에 additive `retryable: bool`(core 소유 reason→retryable 매핑, 미인식 기본 true). #26 sell-idempotency 후속 — 데몬 손절매 재시도 안전 경로 decoupling. 완전 additive(기존 불변) → 신규 AC-7 구현·검증 대기 → Implemented → **Approved** 되돌림.
- 2026-06-15 :: REQ-079·080·081·082·073·074 Approved → **Implemented** (confirm-req-implemented, BAT-042 release-candidate, dcy-028): slot strategy-state 저장소 구현·검증 완료 — `slot_strategy_state` 테이블+StrategyStateStore(in-memory+PG)+occupancy_seq·version CAS(REQ-079), write RPC `trex.slots.strategy_state.set`(REQ-080), occupancy_seq 가드+close 삭제(REQ-081), baseline 정합(REQ-082), slots.req 행 strategy_state·occupancy_seq additive(REQ-073 AC-7/REQ-074 AC-6). 479 passed (+33 신규: store CAS 6+ABA, occupancy bump/close 4경로, set RPC 9, slots read 5), ruff clean. PG 경로 게이트 테스트 작성(실 DB 검증 release 위임). Blocking Issues 없음. - 2026-06-15 :: REQ-079·080·081·082·073·074 Approved → **Implemented** (confirm-req-implemented, BAT-042 release-candidate, dcy-028): slot strategy-state 저장소 구현·검증 완료 — `slot_strategy_state` 테이블+StrategyStateStore(in-memory+PG)+occupancy_seq·version CAS(REQ-079), write RPC `trex.slots.strategy_state.set`(REQ-080), occupancy_seq 가드+close 삭제(REQ-081), baseline 정합(REQ-082), slots.req 행 strategy_state·occupancy_seq additive(REQ-073 AC-7/REQ-074 AC-6). 479 passed (+33 신규: store CAS 6+ABA, occupancy bump/close 4경로, set RPC 9, slots read 5), ruff clean. PG 경로 게이트 테스트 작성(실 DB 검증 release 위임). Blocking Issues 없음.
+113 -14
View File
@@ -80,30 +80,32 @@ def _buy_to_pending(intents, qty=10, entry_price=1000, timeout_sec=None, slippag
class TestSlippageDispatch: class TestSlippageDispatch:
def test_buy_slippage_ceil_applied_to_limit(self): def test_buy_slippage_floor_to_tick(self):
"""buy: effective_limit = ceil(entry_price * (1 + pct/100)).""" """buy: effective_limit = floor_to_tick(entry * (1 + pct/100)).
dcy-030/REQ-070 CHG: cap-respecting direction (was ceil). entry=1000
is in the <2,000 band (tick 1) so floor_to_tick(1005.0) = 1005.
"""
broker = _RecordingBroker() broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker) repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1000, slippage_pct=0.5) _buy_to_pending(intents, entry_price=1000, slippage_pct=0.5)
broker.responder = lambda side: FillStatus(state="accepted") broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() execution.run_once()
order = broker.orders[0] order = broker.orders[0]
# ceil(1000 * 1.005) = ceil(1005.0) = 1005
assert order.limit_price == 1005 assert order.limit_price == 1005
def test_buy_slippage_fractional_ceil(self): def test_buy_slippage_fractional_floors_down(self):
"""ceil(1001 * 1.003) = ceil(1004.003) = 1005.""" """floor_to_tick(1001 * 1.003) = floor(1004.003) = 1004 (tick 1)."""
broker = _RecordingBroker() broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker) repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1001, slippage_pct=0.3) _buy_to_pending(intents, entry_price=1001, slippage_pct=0.3)
broker.responder = lambda side: FillStatus(state="accepted") broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() execution.run_once()
import math # cap-respecting: floor (not ceil). 1004.003 -> 1004.
expected = int(math.ceil(1001 * 1.003)) assert broker.orders[0].limit_price == 1004
assert broker.orders[0].limit_price == expected
def test_sell_slippage_floor_applied_to_limit(self): def test_sell_slippage_ceil_to_tick(self):
"""sell: effective_limit = floor(limit_price * (1 - pct/100)).""" """sell: effective_limit = ceil_to_tick(limit_price * (1 - pct/100))."""
broker = _RecordingBroker() broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker) repo, svc, intents, execution = _wire(broker)
# drive to holding first # drive to holding first
@@ -122,10 +124,8 @@ class TestSlippageDispatch:
broker.responder = lambda side: FillStatus(state="accepted") broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch sell (pending state already has limit_price) execution.run_once() # dispatch sell (pending state already has limit_price)
sell_orders = [o for o in broker.orders if o.side == "sell"] sell_orders = [o for o in broker.orders if o.side == "sell"]
# floor(2000 * 0.99) = floor(1980.0) = 1980 # ceil_to_tick(2000 * 0.99) = ceil_to_tick(1980.0) = 1980 (tick 5, exact).
import math assert sell_orders[0].limit_price == 1980
expected = int(math.floor(2000 * 0.99))
assert sell_orders[0].limit_price == expected
def test_no_slippage_when_slippage_pct_is_none(self): def test_no_slippage_when_slippage_pct_is_none(self):
"""No slippage_pct → effective_limit = entry_price unchanged.""" """No slippage_pct → effective_limit = entry_price unchanged."""
@@ -158,6 +158,105 @@ class TestSlippageDispatch:
# 0.0 is falsy → slippage block skipped → no change # 0.0 is falsy → slippage block skipped → no change
assert broker.orders[0].limit_price == 1000 assert broker.orders[0].limit_price == 1000
def test_issue28_buy_147700_snaps_to_valid_tick(self):
"""issue #28: 147,700 @ 0.5% → floor_to_tick(148438.5) = 148400 (tick 100)."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=147700, slippage_pct=0.5)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
assert broker.orders[0].limit_price == 148400
def test_issue28_buy_54500_snaps_to_valid_tick(self):
"""issue #28: 54,500 @ 0.5% → floor_to_tick(54772.5) = 54700 (tick 100)."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=54500, slippage_pct=0.5)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
assert broker.orders[0].limit_price == 54700
def test_buy_sub_tick_band_floors_to_entry_tick(self):
"""band < 1 tick: buy effective ≥ entry's valid tick (no under-fill).
entry=147700 (on tick 100), pct=0.01 → raw=147714.77, floor_to_tick=147700;
guard max(147700, ceil_to_tick(147700)=147700) = 147700 ≥ entry, valid tick.
"""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=147700, slippage_pct=0.01)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
eff = broker.orders[0].limit_price
assert eff == 147700
assert eff % 100 == 0 # valid KRX tick
assert eff <= 147700 * 1.0001 # within slippage ceiling
def test_buy_snap_never_exceeds_slippage_ceiling(self):
"""cap-respecting: buy effective ≤ entry*(1+pct/100), always a valid tick."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=35000, slippage_pct=0.7)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
eff = broker.orders[0].limit_price
# 35000*1.007 = 35245 -> tick 50 -> floor_to_tick = 35200
assert eff == 35200
assert eff % 50 == 0
assert eff <= 35000 * 1.007
# ============================================================================
# REQ-085 / REQ-087: order-result logging + reject ERROR visibility (dcy-029)
# ============================================================================
class TestOrderResultLogging:
def test_submit_and_fill_are_logged_with_event_keys(self, caplog):
from trex_core.runtime import logging_setup as ls
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1000, slippage_pct=None)
broker.responder = lambda side: FillStatus(
state="filled", filled_qty=10, avg_price=1000.0,
execution_id="e1", fee=1.5, tax=0.0,
)
with caplog.at_level(logging.INFO):
execution.run_once() # dispatch (submit) + poll (fill)
text = "\n".join(r.getMessage() for r in caplog.records)
assert "event=order_submit" in text
assert "event=order_fill" in text
assert "fee=1.5" in text
# correlation id set to the slot_id (intent->order->fill join key)
sid = broker.orders[0].slot_id
assert ls.get_correlation_id() == sid
def test_reject_is_logged_at_error_level(self, caplog):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
broker.submit_order = lambda req: OrderAck(
accepted=False, broker_order_id=None, reason="주식주문호가단위 오류",
)
_buy_to_pending(intents, entry_price=1000)
with caplog.at_level(logging.INFO):
execution.run_once()
rejects = [r for r in caplog.records if "event=order_reject" in r.getMessage()]
assert rejects, "reject must be logged"
assert all(r.levelno == logging.ERROR for r in rejects)
assert "호가단위" in rejects[0].getMessage()
def test_normal_fill_emits_no_error(self, caplog):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1000)
broker.responder = lambda side: FillStatus(
state="filled", filled_qty=10, avg_price=1000.0,
execution_id="e1", fee=0.0, tax=0.0,
)
with caplog.at_level(logging.INFO):
execution.run_once()
assert not [r for r in caplog.records if r.levelno >= logging.ERROR]
# ============================================================================ # ============================================================================
# REQ-069: timeout auto-cancel # REQ-069: timeout auto-cancel
+76
View File
@@ -0,0 +1,76 @@
"""운영 조회 리포트 집계 단위 테스트 (REQ-088, dcy-029 FR-5)."""
from datetime import datetime, timezone
from trex_core.application.operator_report import (
Holding,
PendingOrder,
TradeRow,
build_report,
kst_day_bounds_utc,
realized_pnl,
render_json,
render_text,
)
class TestRealizedPnl:
def test_buy_then_sell_net_cash_flow_with_fee_tax(self):
trades = [
TradeRow(asset_id="A", side="buy", qty=10, price=1000, fee=5),
TradeRow(asset_id="A", side="sell", qty=10, price=1100, fee=5, tax=33),
]
total, by_asset = realized_pnl(trades)
# buy: -(10000+5) = -10005 ; sell: 11000-5-33 = 10962 ; net = 957
assert total == 957.0
assert by_asset == {"A": 957.0}
def test_per_asset_breakdown(self):
trades = [
TradeRow("A", "sell", 1, 100),
TradeRow("B", "buy", 1, 50),
]
total, by_asset = realized_pnl(trades)
assert by_asset["A"] == 100.0
assert by_asset["B"] == -50.0
assert total == 50.0
def test_empty(self):
total, by_asset = realized_pnl([])
assert total == 0.0 and by_asset == {}
class TestBuildAndRender:
def _rep(self):
return build_report(
holdings=[Holding("005930", "pf-005930", 10, 70000)],
pending=[PendingOrder("000660", "pf-000660", "pending_buy")],
trades=[TradeRow("pf-005930", "sell", 10, 71000, fee=10, tax=213)],
)
def test_build_counts_and_pnl(self):
rep = self._rep()
assert rep.fills_count == 1
assert len(rep.holdings) == 1 and len(rep.pending) == 1
assert rep.realized_pnl == 71000 * 10 - 10 - 213
def test_render_text_has_sections(self):
out = render_text(self._rep())
assert "현재 보유" in out and "대기 주문" in out and "실현 PnL" in out
assert "005930" in out
def test_render_json_shape(self):
j = render_json(self._rep())
assert j["fills_count"] == 1
assert j["holdings"][0]["symbol"] == "005930"
assert j["pending"][0]["status"] == "pending_buy"
assert "realized_pnl" in j
class TestKstBounds:
def test_today_boundary_excludes_yesterday(self):
# 2026-06-16 01:00 UTC == 10:00 KST -> KST day starts 2026-06-15 15:00 UTC
now = datetime(2026, 6, 16, 1, 0, tzinfo=timezone.utc)
start, end = kst_day_bounds_utc(now)
assert start == datetime(2026, 6, 15, 15, 0, tzinfo=timezone.utc)
assert end == datetime(2026, 6, 16, 15, 0, tzinfo=timezone.utc)
assert start <= now < end
@@ -98,6 +98,19 @@ def test_forced_close_fire_logs_warn(caplog):
assert rec and "forced_close_at" in rec[0].message and "inst-1" in rec[0].message assert rec and "forced_close_at" in rec[0].message and "inst-1" in rec[0].message
def test_fire_log_exposes_reason_and_actor(caplog):
"""REQ-086 (dcy-029): safety-sell 청산 로그가 reason+actor 를 함께 노출."""
repo, svc, broker, watchdog, service = _wire()
_holding(svc, repo, forced_close_at=_NOW - timedelta(seconds=1))
with caplog.at_level(logging.WARNING):
service.check_forced_close(_NOW)
rec = [r for r in caplog.records if "safety-sell fired" in r.message]
assert rec
msg = rec[0].message
assert "reason=forced_close_at" in msg
assert "actor=trex-safety-sell" in msg # 안전망 자율청산 구분
def test_take_profit_fire_logs_info(caplog): def test_take_profit_fire_logs_info(caplog):
repo, svc, broker, watchdog, service = _wire() repo, svc, broker, watchdog, service = _wire()
slot = _holding(svc, repo, take_profit_price=Decimal("60000")) slot = _holding(svc, repo, take_profit_price=Decimal("60000"))
+59
View File
@@ -0,0 +1,59 @@
"""KRX 호가단위(tick) 유틸 단위 테스트 (REQ-083, dcy-030)."""
import pytest
from trex_core.domain.pricing import ceil_to_tick, floor_to_tick, krx_stock_tick
class TestKrxStockTick:
@pytest.mark.parametrize("price,tick", [
(1_500, 1),
(3_000, 5),
(12_000, 10),
(35_000, 50),
(147_700, 100),
(300_000, 500),
(800_000, 1_000),
])
def test_representative_prices(self, price, tick):
assert krx_stock_tick(price) == tick
@pytest.mark.parametrize("boundary,tick", [
(2_000, 5), # [2000, 5000) -> 5 (lower bound inclusive)
(5_000, 10),
(20_000, 50),
(50_000, 100),
(200_000, 500),
(500_000, 1_000),
])
def test_boundaries_are_lower_inclusive(self, boundary, tick):
assert krx_stock_tick(boundary) == tick
def test_just_below_boundary_uses_lower_band(self):
assert krx_stock_tick(1_999) == 1
assert krx_stock_tick(49_999) == 50
assert krx_stock_tick(199_999) == 100
class TestFloorCeilToTick:
def test_issue28_floor_cases(self):
assert floor_to_tick(148_439) == 148_400
assert floor_to_tick(54_773) == 54_700
def test_ceil_case(self):
assert ceil_to_tick(54_773) == 54_800
def test_already_on_tick_is_unchanged(self):
assert floor_to_tick(147_700) == 147_700
assert ceil_to_tick(147_700) == 147_700
@pytest.mark.parametrize("price", [
1_500, 2_777, 4_999, 12_345, 33_333, 148_439, 234_567, 777_777,
])
def test_invariants_floor_le_price_le_ceil_and_valid_multiples(self, price):
tick = krx_stock_tick(price)
f = floor_to_tick(price)
c = ceil_to_tick(price)
assert f <= price <= c
assert f % krx_stock_tick(f) == 0
assert c % krx_stock_tick(c) == 0
assert c - f <= tick
+101
View File
@@ -0,0 +1,101 @@
"""구조화 로깅 기반 단위 테스트 (REQ-084, dcy-029 FR-1·FR-7)."""
import json
import logging
import pytest
from trex_core.runtime import logging_setup as ls
@pytest.fixture(autouse=True)
def _reset_corr():
ls.set_correlation_id("")
yield
ls.set_correlation_id("")
def _record(msg="hello", level=logging.INFO, **extra):
rec = logging.LogRecord("trex.test", level, __file__, 1, msg, None, None)
for k, v in extra.items():
setattr(rec, k, v)
return rec
class TestTextFormatter:
def test_has_iso8601_kst_timestamp_level_and_msg(self):
out = ls.TextFormatter().format(_record("boot ok"))
# ISO8601 with +09:00 KST offset, level, message
assert "+09:00" in out
assert "INFO" in out
assert "boot ok" in out
def test_includes_correlation_id_when_set(self):
ls.set_correlation_id("slot-123")
rec = _record("x")
ls._CorrelationFilter().filter(rec)
out = ls.TextFormatter().format(rec)
assert "cid=slot-123" in out
def test_omits_cid_when_unset(self):
rec = _record("x")
ls._CorrelationFilter().filter(rec)
out = ls.TextFormatter().format(rec)
assert "cid=" not in out
class TestJsonFormatter:
def test_is_valid_json_with_required_keys(self):
rec = _record("submitted")
ls._CorrelationFilter().filter(rec)
obj = json.loads(ls.JsonFormatter().format(rec))
assert obj["msg"] == "submitted"
assert obj["level"] == "INFO"
assert obj["logger"] == "trex.test"
assert "+09:00" in obj["ts"]
assert obj["correlation_id"] == ""
def test_correlation_id_propagates(self):
ls.set_correlation_id("slot-9")
rec = _record("y")
ls._CorrelationFilter().filter(rec)
obj = json.loads(ls.JsonFormatter().format(rec))
assert obj["correlation_id"] == "slot-9"
def test_extra_fields_included(self):
rec = _record("z", symbol="005930")
ls._CorrelationFilter().filter(rec)
obj = json.loads(ls.JsonFormatter().format(rec))
assert obj["symbol"] == "005930"
class TestConfigureAndContext:
def test_configure_text_default(self, monkeypatch, capsys):
monkeypatch.delenv("TREX_LOG_FORMAT", raising=False)
ls.configure_logging(level=logging.INFO)
logging.getLogger("trex.x").info("via root")
err = capsys.readouterr().err
assert "via root" in err and "INFO" in err and "+09:00" in err
def test_configure_json_format(self, monkeypatch, capsys):
monkeypatch.setenv("TREX_LOG_FORMAT", "json")
ls.configure_logging(level=logging.INFO)
with ls.correlation_id("slot-77"):
logging.getLogger("trex.x").info("jsonline")
line = [ln for ln in capsys.readouterr().err.splitlines() if "jsonline" in ln][-1]
obj = json.loads(line)
assert obj["msg"] == "jsonline"
assert obj["correlation_id"] == "slot-77"
# cleanup: restore text formatter for other tests
monkeypatch.delenv("TREX_LOG_FORMAT", raising=False)
ls.configure_logging(level=logging.INFO)
def test_correlation_id_context_manager_restores(self):
ls.set_correlation_id("outer")
with ls.correlation_id("inner"):
assert ls.get_correlation_id() == "inner"
assert ls.get_correlation_id() == "outer"
def test_configure_is_idempotent_single_handler(self):
ls.configure_logging()
ls.configure_logging()
assert len(logging.getLogger().handlers) == 1
+51 -15
View File
@@ -23,7 +23,6 @@ single producer for ledger work items (NFR-3, single record subject).
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Callable, Optional from typing import Callable, Optional
@@ -31,7 +30,9 @@ from typing import Callable, Optional
from trex_core.application.broker_port import BrokerPort, OrderRequest from trex_core.application.broker_port import BrokerPort, OrderRequest
from trex_core.application.ledger_outbox import LedgerOutbox from trex_core.application.ledger_outbox import LedgerOutbox
from trex_core.application.ledger_port import LedgerTrade from trex_core.application.ledger_port import LedgerTrade
from trex_core.domain.pricing import ceil_to_tick, floor_to_tick
from trex_core.domain.slot import SlotService, SlotState, SlotStateMismatchError from trex_core.domain.slot import SlotService, SlotState, SlotStateMismatchError
from trex_core.runtime.logging_setup import set_correlation_id
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@@ -137,24 +138,30 @@ class ExecutionService:
# limit_price is cleared on entering HOLDING, so a market sell # limit_price is cleared on entering HOLDING, so a market sell
# carries None -> order_type "01". # carries None -> order_type "01".
# dcy-022 / REQ-070: slippage_pct adjusts the effective limit price # dcy-022 / REQ-070: slippage_pct adjusts the effective limit price
# when entry_price is set. buy: ceil(limit*(1+pct/100)); sell: floor. # when entry_price is set. No slippage when limit is None (market
# No slippage when limit is None (market order — no reference price). # order — no reference price).
# dcy-030 / REQ-070 CHG-20260616-01: snap the slippage limit to a
# valid KRX tick (REQ-083). Direction = cap-respecting: buy floors
# to tick (never exceeds the slippage ceiling), sell ceils to tick
# (never breaches the floor). A band<1-tick guard keeps the order
# fillable at the entry's own valid tick (no under-fill).
limit = int(slot.limit_price) if slot.limit_price else None limit = int(slot.limit_price) if slot.limit_price else None
if limit is not None and slot.slippage_pct: if limit is not None and slot.slippage_pct:
factor = ( base = float(slot.limit_price)
1.0 + slot.slippage_pct / 100.0 if side == "buy":
if side == "buy" raw = base * (1.0 + slot.slippage_pct / 100.0)
else 1.0 - slot.slippage_pct / 100.0 limit = max(floor_to_tick(raw), ceil_to_tick(base))
) else:
limit = ( raw = base * (1.0 - slot.slippage_pct / 100.0)
int(math.ceil(limit * factor)) limit = min(ceil_to_tick(raw), floor_to_tick(base))
if side == "buy"
else int(math.floor(limit * factor))
)
_log.debug( _log.debug(
"slippage applied: slot=%s side=%s base_limit=%s pct=%.4f effective=%s", "slippage applied: slot=%s side=%s base_limit=%s pct=%.4f "
slot.slot_id, side, slot.limit_price, slot.slippage_pct, limit, "raw=%.2f effective=%s (tick-snapped)",
slot.slot_id, side, slot.limit_price, slot.slippage_pct, raw, limit,
) )
# REQ-084/085: correlation id (intent→order→fill join key = slot_id)
# so submit/fill/reject logs for one slot share a key.
set_correlation_id(str(slot.slot_id))
ack = self._broker.submit_order( ack = self._broker.submit_order(
OrderRequest( OrderRequest(
slot_id=str(slot.slot_id), slot_id=str(slot.slot_id),
@@ -168,7 +175,21 @@ class ExecutionService:
if ack.accepted and ack.broker_order_id: if ack.accepted and ack.broker_order_id:
self._repo.mark_dispatched(slot.slot_id, ack.broker_order_id) self._repo.mark_dispatched(slot.slot_id, ack.broker_order_id)
r.dispatched += 1 r.dispatched += 1
# REQ-085: order-result logging — tailable submit line.
_log.info(
"order submitted event=order_submit side=%s symbol=%s slot_id=%s "
"qty=%s limit_price=%s order_type=%s",
side, slot.symbol, slot.slot_id, qty, limit,
"00" if limit is not None else "01",
)
else: else:
# REQ-085/087: reject is visible (event key) and ERROR level
# (no more silent rollback — issue #28 surfaced too late).
_log.error(
"order rejected event=order_reject side=%s symbol=%s slot_id=%s "
"qty=%s reason=%s",
side, slot.symbol, slot.slot_id, qty, ack.reason or "order_rejected",
)
self._rollback(slot, reason=ack.reason or "order_rejected") self._rollback(slot, reason=ack.reason or "order_rejected")
r.rolled_back += 1 r.rolled_back += 1
@@ -176,6 +197,7 @@ class ExecutionService:
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)
for slot in self._repo.list_dispatched_pending(self._instance_key): for slot in self._repo.list_dispatched_pending(self._instance_key):
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell" side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
set_correlation_id(str(slot.slot_id)) # REQ-084: same key as submit
fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "") fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "")
if fs.is_filled: if fs.is_filled:
self._on_filled(slot, fs, side, r) self._on_filled(slot, fs, side, r)
@@ -186,6 +208,12 @@ class ExecutionService:
r.filled += 1 r.filled += 1
elif fs.is_terminal_reject: elif fs.is_terminal_reject:
# rejected or full external cancel (dcy-013 FR-1) -> rollback. # rejected or full external cancel (dcy-013 FR-1) -> rollback.
# REQ-085/087: surface the reject (event key + ERROR level).
_log.error(
"order rejected event=order_reject side=%s symbol=%s slot_id=%s "
"reason=order_%s",
side, slot.symbol, slot.slot_id, fs.state,
)
self._rollback(slot, reason=f"order_{fs.state}") self._rollback(slot, reason=f"order_{fs.state}")
r.rolled_back += 1 r.rolled_back += 1
else: else:
@@ -240,6 +268,14 @@ class ExecutionService:
event_type = "slot_filled_sell" event_type = "slot_filled_sell"
except SlotStateMismatchError: except SlotStateMismatchError:
return # slot moved concurrently; next tick reconciles return # slot moved concurrently; next tick reconciles
# REQ-085: order-result logging — tailable fill line (price/qty/fee/tax).
_log.info(
"order filled event=order_fill side=%s symbol=%s slot_id=%s "
"fill_price=%s filled_qty=%s fee=%s tax=%s",
side, slot.symbol, slot.slot_id,
fs.avg_price, fs.filled_qty,
getattr(fs, "fee", None), getattr(fs, "tax", None),
)
# REQ-018: enqueue ledger work item AFTER the slot transition is durable. # REQ-018: enqueue ledger work item AFTER the slot transition is durable.
# Slot transition is the critical path; ledger write is async (REQ-019). # 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)
+123
View File
@@ -0,0 +1,123 @@
"""운영 조회 리포트 — 보유·오늘 체결·실현 PnL·대기 주문 (REQ-088, dcy-029 FR-5).
CLI(`trex-core report`)가 DB에서 읽은 행을 이 순수 함수들에 넘겨 집계한다(테스트
가능). 실현 PnL은 `ledger_outbox`를 단일 권위 출처로 하는 **당일 net 매매 현금흐름**:
realized_pnl = Σ_sell (qty*price - fee - tax) - Σ_buy (qty*price + fee)
ledger_outbox에는 매도별 취득원가(cost basis)가 없으므로, 운영자가 수기로 계산하던
당일 net(매도 대금 − 매수 대금 − 수수료 − 세금)과 동일한 정의를 사용한다(dcy-029
NFR-3, BAT-038 fee/tax 정책 반영). 일중 진입·청산 전략에서는 net ≈ 실현손익.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
_KST = timezone(timedelta(hours=9))
@dataclass(frozen=True)
class Holding:
symbol: str
portfolio_id: str
qty: float
avg_price: float
@dataclass(frozen=True)
class PendingOrder:
symbol: str
portfolio_id: str
status: str # pending_buy | pending_sell
@dataclass(frozen=True)
class TradeRow:
asset_id: str
side: str # buy | sell
qty: float
price: float
fee: float = 0.0
tax: float = 0.0
@dataclass
class OperatorReport:
holdings: list[Holding] = field(default_factory=list)
pending: list[PendingOrder] = field(default_factory=list)
fills_count: int = 0
realized_pnl: float = 0.0
realized_pnl_by_asset: dict[str, float] = field(default_factory=dict)
def kst_day_bounds_utc(now: datetime | None = None) -> tuple[datetime, datetime]:
"""오늘(KST trading_date) 경계를 UTC `[start, end)` 로 반환한다."""
now = now or datetime.now(tz=timezone.utc)
now_kst = now.astimezone(_KST)
start_kst = now_kst.replace(hour=0, minute=0, second=0, microsecond=0)
end_kst = start_kst + timedelta(days=1)
return start_kst.astimezone(timezone.utc), end_kst.astimezone(timezone.utc)
def realized_pnl(trades: list[TradeRow]) -> tuple[float, dict[str, float]]:
"""당일 net 매매 현금흐름(실현 PnL)과 자산별 분해를 계산한다."""
total = 0.0
by_asset: dict[str, float] = {}
for t in trades:
gross = float(t.qty) * float(t.price)
if t.side == "sell":
net = gross - float(t.fee) - float(t.tax)
else: # buy
net = -(gross + float(t.fee))
total += net
by_asset[t.asset_id] = by_asset.get(t.asset_id, 0.0) + net
return total, by_asset
def build_report(
holdings: list[Holding],
pending: list[PendingOrder],
trades: list[TradeRow],
) -> OperatorReport:
total, by_asset = realized_pnl(trades)
return OperatorReport(
holdings=list(holdings),
pending=list(pending),
fills_count=len(trades),
realized_pnl=total,
realized_pnl_by_asset=by_asset,
)
def render_text(rep: OperatorReport) -> str:
"""사람용 표 요약."""
lines: list[str] = []
lines.append(f"=== 현재 보유 ({len(rep.holdings)}) ===")
for h in rep.holdings:
lines.append(f" {h.symbol} pf={h.portfolio_id} qty={h.qty} avg={h.avg_price}")
lines.append(f"=== 대기 주문 ({len(rep.pending)}) ===")
for p in rep.pending:
lines.append(f" {p.symbol} pf={p.portfolio_id} status={p.status}")
lines.append(f"=== 오늘 체결 {rep.fills_count}건 / 실현 PnL {rep.realized_pnl:,.0f} ===")
for asset, pnl in sorted(rep.realized_pnl_by_asset.items()):
lines.append(f" {asset}: {pnl:,.0f}")
return "\n".join(lines)
def render_json(rep: OperatorReport) -> dict:
"""머신 파싱용(`--json`)."""
return {
"holdings": [
{"symbol": h.symbol, "portfolio_id": h.portfolio_id,
"qty": h.qty, "avg_price": h.avg_price}
for h in rep.holdings
],
"pending": [
{"symbol": p.symbol, "portfolio_id": p.portfolio_id, "status": p.status}
for p in rep.pending
],
"fills_count": rep.fills_count,
"realized_pnl": rep.realized_pnl,
"realized_pnl_by_asset": rep.realized_pnl_by_asset,
}
+6 -2
View File
@@ -85,10 +85,14 @@ class SafetySellService:
"""Visibility for an actual safety-sell fire (dcy-017 FR-1, OQ-2).""" """Visibility for an actual safety-sell fire (dcy-017 FR-1, OQ-2)."""
self._count(f"fire:{reason}") self._count(f"fire:{reason}")
level = logging.WARNING if reason in self._WARN_REASONS else logging.INFO level = logging.WARNING if reason in self._WARN_REASONS else logging.INFO
# REQ-086 (dcy-029): expose reason AND actor together so an operator can
# tell a safety-net autonomous liquidation (actor=trex-safety-sell) apart
# from a strategy's own stop-loss. No new reason values / fields.
_log.log( _log.log(
level, level,
"safety-sell fired: phase=%s slot=%s portfolio_id=%s reason=%s instance_key=%s", "safety-sell fired: phase=%s slot=%s portfolio_id=%s reason=%s "
reason, slot.slot_id, slot.portfolio_id, reason, self._instance_key, "actor=%s instance_key=%s",
reason, slot.slot_id, slot.portfolio_id, reason, _ACTOR, self._instance_key,
) )
def _update_ownership(self, now: datetime) -> None: def _update_ownership(self, now: datetime) -> None:
+63
View File
@@ -0,0 +1,63 @@
"""KRX 호가단위(tick-size) 산출 — 순수 도메인 함수 (REQ-083, dcy-030).
slippage 보호 지정가(REQ-070)가 KIS에 제출되기 전에 유효한 KRX 호가단위
배수로 스냅되도록 가격→tick 산출과 배수 내림/올림 헬퍼를 제공한다.
주식 전용이다. ETF/ETN 등 비주식 호가체계는 범위 밖이며(dcy-030 OQ-1),
2,000원 미만 비주식은 미지원(known limitation). 종목 유형 분기는 두지 않는다.
호가단위 표(KRX 주식, 2023-01-25~). 구간은 하한 포함/상한 미만 `[lo, hi)`:
가격(원) tick(원)
< 2,000 1
2,000 ~ 5,000 5
5,000 ~ 20,000 10
20,000 ~ 50,000 50
50,000 ~ 200,000 100
200,000 ~ 500,000 500
>= 500,000 1,000
"""
from __future__ import annotations
import math
# (lower_bound_inclusive, tick). 내림차순으로 평가한다. 단일 변경 지점(dcy-030 R-3).
_KRX_STOCK_TICKS: tuple[tuple[int, int], ...] = (
(500_000, 1_000),
(200_000, 500),
(50_000, 100),
(20_000, 50),
(5_000, 10),
(2_000, 5),
(0, 1),
)
def krx_stock_tick(price: float) -> int:
"""`price`(원)가 속한 KRX 주식 호가단위(원)를 반환한다.
구간 경계는 하한 포함(예: 정확히 50,000원 → 100원 tick).
"""
p = float(price)
for lower, tick in _KRX_STOCK_TICKS:
if p >= lower:
return tick
return 1 # p < 0 방어 (도달하지 않음)
# 부동소수 잡음 흡수용 epsilon. slippage raw = base*(1+pct/100)는 수학적으로
# 정수여도 float로는 1004.9999999999999처럼 나올 수 있다(잡음 ~ q*1e-16).
# q(=price/tick)는 유계라 1e-9면 잡음을 안전하게 흡수하면서 실제 값은 안 넘긴다.
_EPS = 1e-9
def floor_to_tick(price: float) -> int:
"""`price`를 해당 가격대 호가단위의 **내림 배수**로 정규화한다 (≤ price)."""
tick = krx_stock_tick(price)
return int(math.floor(float(price) / tick + _EPS) * tick)
def ceil_to_tick(price: float) -> int:
"""`price`를 해당 가격대 호가단위의 **올림 배수**로 정규화한다 (≥ price)."""
tick = krx_stock_tick(price)
return int(math.ceil(float(price) / tick - _EPS) * tick)
+62
View File
@@ -395,9 +395,67 @@ def _build_parser() -> argparse.ArgumentParser:
_add_intent_guards(p_cancel) _add_intent_guards(p_cancel)
_add_common(p_cancel) _add_common(p_cancel)
# REQ-088 (dcy-029): operator report — holdings / today fills / realized PnL /
# pending. Direct-DB (DB-backed point-in-time aggregate, no new RPC).
p_report = sub.add_parser(
"report", help="Holdings, today's fills, realized PnL, pending orders (direct-DB)"
)
_add_common(p_report)
return parser return parser
def _cmd_report(args, cfg) -> int:
"""REQ-088: holdings / today's fills / realized PnL / pending — direct-DB."""
import psycopg
from trex_core.application.operator_report import (
Holding,
PendingOrder,
TradeRow,
build_report,
kst_day_bounds_utc,
render_json,
render_text,
)
start_utc, end_utc = kst_day_bounds_utc()
holdings: list[Holding] = []
pending: list[PendingOrder] = []
trades: list[TradeRow] = []
with psycopg.connect(cfg.dsn) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT symbol, portfolio_id, qty, avg_price FROM daily_slot "
"WHERE status = 'holding' ORDER BY symbol"
)
for symbol, pid, qty, avg in cur.fetchall():
holdings.append(Holding(symbol or "", pid or "",
float(qty or 0), float(avg or 0)))
cur.execute(
"SELECT symbol, portfolio_id, status FROM daily_slot "
"WHERE status IN ('pending_buy','pending_sell') ORDER BY symbol"
)
for symbol, pid, status in cur.fetchall():
pending.append(PendingOrder(symbol or "", pid or "", status))
cur.execute(
"SELECT asset_id, side, qty, price, fee, tax FROM ledger_outbox "
"WHERE COALESCE(executed_at, created_at) >= %s "
"AND COALESCE(executed_at, created_at) < %s",
(start_utc, end_utc),
)
for asset_id, side, qty, price, fee, tax in cur.fetchall():
trades.append(TradeRow(asset_id, side, float(qty), float(price),
float(fee or 0), float(tax or 0)))
rep = build_report(holdings, pending, trades)
if getattr(args, "json", False):
print(_json.dumps(render_json(rep), ensure_ascii=False))
else:
print(render_text(rep))
return 0
def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int: def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int:
"""CLI entrypoint. """CLI entrypoint.
@@ -417,6 +475,10 @@ def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int:
cfg = DaemonConfig.from_env() cfg = DaemonConfig.from_env()
confirm = confirm or _default_confirm confirm = confirm or _default_confirm
# REQ-088 (dcy-029): operator report — direct-DB point-in-time aggregate.
if args.cmd == "report":
return _cmd_report(args, cfg)
# direct-DB control commands (pause/resume stay direct-DB, OQ-3). # direct-DB control commands (pause/resume stay direct-DB, OQ-3).
if args.cmd in ("pause", "resume", "paused"): if args.cmd in ("pause", "resume", "paused"):
store = PostgreSQLPauseStore(cfg.dsn) store = PostgreSQLPauseStore(cfg.dsn)
+2 -1
View File
@@ -405,7 +405,8 @@ class Daemon:
def main() -> None: # pragma: no cover - process entrypoint def main() -> None: # pragma: no cover - process entrypoint
import argparse import argparse
logging.basicConfig(level=logging.INFO) from trex_core.runtime.logging_setup import configure_logging
configure_logging(level=logging.INFO) # REQ-084: ISO8601(KST)+level+json+cid
parser = argparse.ArgumentParser(prog="trex-core-daemon") parser = argparse.ArgumentParser(prog="trex-core-daemon")
parser.add_argument( parser.add_argument(
"--config", required=True, "--config", required=True,
+124
View File
@@ -0,0 +1,124 @@
"""구조화 로깅 기반 — ISO8601(KST) 타임스탬프 + 레벨 + Loki JSON + correlation id.
(BAT-045 / REQ-084, dcy-029 FR-1·FR-7)
기존 `logging.basicConfig(level=INFO)`는 format 미지정이라 타임스탬프·레벨 prefix가
없었다(타임라인 재구성 불가). 이 모듈은 단일 진입점 `configure_logging()`으로:
- 모든 로그 라인에 ISO8601(KST, Asia/Seoul, 환경 TZ 비의존) 타임스탬프 + 레벨 부여
- `TREX_LOG_FORMAT` env 로 `text`(기본, 사람용) / `json`(Loki 적재용) 선택
- correlation id(intent→order→fill join key = slot_id) 를 contextvar 로 보관하고
로깅 필터가 모든 레코드에 주입 — 한 거래의 로그를 단일 키로 묶는다
호출부(`_log.info(...)`)는 메시지를 바꾸지 않아도 포맷·correlation id 가 자동 적용된다.
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
from contextvars import ContextVar
from datetime import datetime, timezone, timedelta
from typing import Iterator, Optional
_KST = timezone(timedelta(hours=9))
# 현재 처리의 correlation id (slot_id 등). 미설정이면 빈 문자열.
_correlation_id: ContextVar[str] = ContextVar("trex_correlation_id", default="")
# JSON 출력에서 제외할 표준 LogRecord 속성.
_RESERVED = {
"name", "msg", "args", "levelname", "levelno", "pathname", "filename",
"module", "exc_info", "exc_text", "stack_info", "lineno", "funcName",
"created", "msecs", "relativeCreated", "thread", "threadName",
"processName", "process", "correlation_id", "taskName",
}
def set_correlation_id(value: Optional[str]) -> None:
"""현재 컨텍스트의 correlation id 를 설정한다(빈 값 허용)."""
_correlation_id.set(value or "")
def get_correlation_id() -> str:
return _correlation_id.get()
@contextlib.contextmanager
def correlation_id(value: Optional[str]) -> Iterator[None]:
"""`with correlation_id(slot_id):` 블록 동안 correlation id 를 설정/복원한다."""
token = _correlation_id.set(value or "")
try:
yield
finally:
_correlation_id.reset(token)
class _CorrelationFilter(logging.Filter):
"""모든 레코드에 `correlation_id` 속성을 주입한다(미설정 시 빈 문자열)."""
def filter(self, record: logging.LogRecord) -> bool:
if not hasattr(record, "correlation_id"):
record.correlation_id = _correlation_id.get()
return True
def _kst_ts(record: logging.LogRecord) -> str:
return datetime.fromtimestamp(record.created, tz=_KST).isoformat(timespec="milliseconds")
class TextFormatter(logging.Formatter):
"""사람용 한 줄: `<ISO8601 KST> <LEVEL> <logger> [cid=<id>] <msg>`."""
def format(self, record: logging.LogRecord) -> str:
cid = getattr(record, "correlation_id", "") or ""
cid_part = f" [cid={cid}]" if cid else ""
base = (
f"{_kst_ts(record)} {record.levelname} {record.name}{cid_part} "
f"{record.getMessage()}"
)
if record.exc_info:
base += "\n" + self.formatException(record.exc_info)
return base
class JsonFormatter(logging.Formatter):
"""Loki 적재용 구조화 한 줄 JSON."""
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": _kst_ts(record),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
"correlation_id": getattr(record, "correlation_id", "") or "",
}
# 호출부가 extra= 로 넘긴 구조화 필드를 그대로 포함.
for k, v in record.__dict__.items():
if k not in _RESERVED and not k.startswith("_"):
payload.setdefault(k, v)
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, default=str)
def _make_formatter() -> logging.Formatter:
fmt = os.getenv("TREX_LOG_FORMAT", "text").strip().lower()
return JsonFormatter() if fmt == "json" else TextFormatter()
def configure_logging(level: int = logging.INFO) -> None:
"""루트 로거를 단일 핸들러(포맷터 + correlation 필터)로 구성한다.
`logging.basicConfig` 를 대체한다. 재호출 시 기존 핸들러를 정리해 중복 출력을
막는다(데몬/reset 양쪽에서 안전).
"""
root = logging.getLogger()
for h in list(root.handlers):
root.removeHandler(h)
handler = logging.StreamHandler()
handler.setFormatter(_make_formatter())
handler.addFilter(_CorrelationFilter())
root.addHandler(handler)
root.setLevel(level)
+2 -1
View File
@@ -125,8 +125,9 @@ def main(argv: Optional[list[str]] = None) -> int: # pragma: no cover - CLI she
import argparse import argparse
from trex_core.runtime.config import DaemonConfig, resolve_config_toml from trex_core.runtime.config import DaemonConfig, resolve_config_toml
from trex_core.runtime.logging_setup import configure_logging
logging.basicConfig(level=logging.INFO) configure_logging(level=logging.INFO) # REQ-084
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="trex-core-reset", prog="trex-core-reset",
description="dcy-020/REQ-064: wipe the slot-core tables and reinitialize " description="dcy-020/REQ-064: wipe the slot-core tables and reinitialize "