From d8d9021260cd35d27cfa48f844f51c7365f0f933 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Tue, 16 Jun 2026 13:19:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(execution,obs):=20=ED=98=B8=EA=B0=80?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=EC=8A=A4=EB=83=85(dcy-030/#28)=20+=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EA=B4=80=EC=B8=A1=EC=84=B1(dcy-029/#29)?= =?UTF-8?q?=20+=20REL-007=20=ED=99=98=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../design.md | 32 +++ .../implementation.md | 30 +++ .../index.md | 30 +++ .../planning.md | 32 +++ .../verification.md | 39 +++ .../design.md | 31 +++ .../implementation.md | 30 +++ .../index.md | 30 +++ .../planning.md | 33 +++ .../verification.md | 41 +++ .../design.md | 30 +++ .../implementation.md | 35 +++ .../index.md | 30 +++ .../planning.md | 34 +++ .../verification.md | 41 +++ docs/batches/index.md | 13 +- ...onal-observability-logging-and-pnl-view.md | 237 ++++++++++++++++++ ...slippage-limit-price-tick-size-snapping.md | 229 +++++++++++++++++ docs/discovery/index.md | 2 + docs/interface-contract.md | 10 + docs/operations.md | 124 +++++++++ docs/project-structure.md | 7 +- docs/releases/index.md | 2 +- ...7_20260612_slots-rpc-fee-paper-longterm.md | 15 +- docs/runtime-flows.md | 19 +- .../Documentation/req-089_operator-runbook.md | 54 ++++ ...eq-090_observability-baseline-alignment.md | 54 ++++ .../req-070_slippage-limit-price-execution.md | 74 +++++- .../req-083_krx-tick-size-utility.md | 61 +++++ .../req-085_order-result-logging.md | 56 +++++ ...q-086_liquidation-reason-actor-exposure.md | 53 ++++ .../req-088_operator-pnl-query-cli.md | 57 +++++ .../req-084_structured-logging-foundation.md | 54 ++++ ...eq-087_order-rejection-error-visibility.md | 53 ++++ docs/srs/index.md | 19 +- tests/application/test_execution_policy.py | 127 ++++++++-- tests/application/test_operator_report.py | 76 ++++++ .../test_safety_sell_visibility.py | 13 + tests/domain/test_pricing.py | 59 +++++ tests/runtime/test_logging_setup.py | 101 ++++++++ trex_core/application/execution.py | 66 +++-- trex_core/application/operator_report.py | 123 +++++++++ trex_core/application/safety_sell.py | 8 +- trex_core/domain/pricing.py | 63 +++++ trex_core/runtime/cli.py | 62 +++++ trex_core/runtime/daemon.py | 3 +- trex_core/runtime/logging_setup.py | 124 +++++++++ trex_core/runtime/reset.py | 3 +- 48 files changed, 2461 insertions(+), 58 deletions(-) create mode 100644 docs/batches/bat-044_20260616_slippage-tick-size-snapping/design.md create mode 100644 docs/batches/bat-044_20260616_slippage-tick-size-snapping/implementation.md create mode 100644 docs/batches/bat-044_20260616_slippage-tick-size-snapping/index.md create mode 100644 docs/batches/bat-044_20260616_slippage-tick-size-snapping/planning.md create mode 100644 docs/batches/bat-044_20260616_slippage-tick-size-snapping/verification.md create mode 100644 docs/batches/bat-045_20260616_observability-logging-foundation/design.md create mode 100644 docs/batches/bat-045_20260616_observability-logging-foundation/implementation.md create mode 100644 docs/batches/bat-045_20260616_observability-logging-foundation/index.md create mode 100644 docs/batches/bat-045_20260616_observability-logging-foundation/planning.md create mode 100644 docs/batches/bat-045_20260616_observability-logging-foundation/verification.md create mode 100644 docs/batches/bat-046_20260616_observability-classification-pnl-runbook/design.md create mode 100644 docs/batches/bat-046_20260616_observability-classification-pnl-runbook/implementation.md create mode 100644 docs/batches/bat-046_20260616_observability-classification-pnl-runbook/index.md create mode 100644 docs/batches/bat-046_20260616_observability-classification-pnl-runbook/planning.md create mode 100644 docs/batches/bat-046_20260616_observability-classification-pnl-runbook/verification.md create mode 100644 docs/discovery/dcy-029_20260616_operational-observability-logging-and-pnl-view.md create mode 100644 docs/discovery/dcy-030_20260616_slippage-limit-price-tick-size-snapping.md create mode 100644 docs/operations.md create mode 100644 docs/srs/Documentation/req-089_operator-runbook.md create mode 100644 docs/srs/Documentation/req-090_observability-baseline-alignment.md create mode 100644 docs/srs/Integration/req-083_krx-tick-size-utility.md create mode 100644 docs/srs/Integration/req-085_order-result-logging.md create mode 100644 docs/srs/Interface/req-086_liquidation-reason-actor-exposure.md create mode 100644 docs/srs/Interface/req-088_operator-pnl-query-cli.md create mode 100644 docs/srs/Non-Functional/req-084_structured-logging-foundation.md create mode 100644 docs/srs/Non-Functional/req-087_order-rejection-error-visibility.md create mode 100644 tests/application/test_operator_report.py create mode 100644 tests/domain/test_pricing.py create mode 100644 tests/runtime/test_logging_setup.py create mode 100644 trex_core/application/operator_report.py create mode 100644 trex_core/domain/pricing.py create mode 100644 trex_core/runtime/logging_setup.py diff --git a/docs/batches/bat-044_20260616_slippage-tick-size-snapping/design.md b/docs/batches/bat-044_20260616_slippage-tick-size-snapping/design.md new file mode 100644 index 0000000..b5a6997 --- /dev/null +++ b/docs/batches/bat-044_20260616_slippage-tick-size-snapping/design.md @@ -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 범위 경계](#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 diff --git a/docs/discovery/dcy-030_20260616_slippage-limit-price-tick-size-snapping.md b/docs/discovery/dcy-030_20260616_slippage-limit-price-tick-size-snapping.md new file mode 100644 index 0000000..2bcea47 --- /dev/null +++ b/docs/discovery/dcy-030_20260616_slippage-limit-price-tick-size-snapping.md @@ -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 범위 경계](#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 diff --git a/docs/discovery/index.md b/docs/discovery/index.md index e915758..9f5b9b5 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -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-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-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 | diff --git a/docs/interface-contract.md b/docs/interface-contract.md index df54d2e..1c9650f 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -138,9 +138,19 @@ | `enter [--capital/--qty/--stop-loss/--take-profit/--forced-close-at ...]` | `trex.intent.req[.{instance}]` | buy intent(`portfolio_id` 필수, symbol 서버 resolve; `--forced-close-at `→`forced_close_at`, REQ-032 CHG-20260609-01) | 안전장치 필수; `--forced-close-at` 형식은 제출 전 검증 | | `exit [--price]` | `trex.intent.req[.{instance}]` | sell intent(`slot_id`, optional `limit_price`) | 가격 미지정 시 시장가 | | `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 종료. +### 청산 사유 어휘 — 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) 운영자 제어 채널. `action` discriminator 로 분기한다(REQ-043). 기존 `trex.pause.req`(REQ-032)의 개념 확장이며, 단일 제어 큐 + action 패턴으로 기능마다 큐/consumer 를 늘리지 않는다. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..e15858f --- /dev/null +++ b/docs/operations.md @@ -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 ` (BAT-013 이후 필수)는 `config/.toml`을 해석 — `instance_key`(큐 이름 `trex.intent.req.` 등; `default`는 literal), `max_active_slots`(M cap). + +### 기동 / 종료 +```bash +# 기동 (로그 리다이렉트). 테스트는 trex_core_test DB로 — 라이브 DB 금지. +.venv/bin/trex-core-daemon --config &> /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 [options] # 콘솔 스크립트 +``` + +| 명령 | 용도 | 경로 | +|---|---|---| +| `slots [--status all\|holding\|buyable\|pending\|error]` | 슬롯/포지션 목록 | RPC | +| `universe` | 오늘 universe 자산 | RPC | +| `enter [--qty/--entry-price/--stop-loss/--take-profit/--forced-close-at]` | 매수 intent | RPC | +| `exit [--price]` | 매도 intent | RPC | +| `cancel ` | 대기 주문 취소(백스톱) | 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 ` 또는 `--instance `, 미지정 시 `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) + +### 포맷 +- 모든 로그 라인: ` [cid=] `. +- `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= 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 ` 백스톱; 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 에이전트용 실행 레시피(이 런북의 사실 자료 출처) diff --git a/docs/project-structure.md b/docs/project-structure.md index 7a99e53..bddd3d3 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -66,6 +66,7 @@ │ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존) │ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.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) │ │ ├── ports.py # IntentStore Protocol │ │ ├── intents.py # IntentService / BuyIntent / SellIntent / IntentDecision @@ -80,7 +81,8 @@ │ │ ├── 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) │ │ ├── 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) │ │ ├── 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) @@ -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) │ ├── 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) -│ └── 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/ ├── domain/slot/ # test_state / test_service / test_contract ├── domain/ # test_domain_import_sweep (NFR-1 가드, BAT-006/BAT-007) diff --git a/docs/releases/index.md b/docs/releases/index.md index f666605..dc0b46d 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -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-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-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) | diff --git a/docs/releases/rel-007_20260612_slots-rpc-fee-paper-longterm.md b/docs/releases/rel-007_20260612_slots-rpc-fee-paper-longterm.md index 7d91eba..a35ffca 100644 --- a/docs/releases/rel-007_20260612_slots-rpc-fee-paper-longterm.md +++ b/docs/releases/rel-007_20260612_slots-rpc-fee-paper-longterm.md @@ -1,6 +1,6 @@ # REL-007: slots RPC 개선 + 매도세 정정 + paper seq 영속화 + 중장기 인스턴스 프로파일 통합 릴리즈 -- Status: confirmed +- Status: feedback-captured - Profile: app-service - Included Batch: BAT-037, BAT-038, BAT-039, BAT-040, BAT-041 @@ -109,11 +109,16 @@ ## Feedback Handoff - 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: - - 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 이월 + - **(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: - - none + - none (라이브 환류는 위 dcy-030/dcy-029로 정규화되어 REQ-083/070/084~090로 이미 처리) - Change Request Input: - - none + - none (REQ-070 호가단위 정정은 dcy-030 CHG-20260616-01로 처리 완료) diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 0e45a95..0ac9169 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -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` 롤백. - **매도 수량 = 브로커 잔고 정본**(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-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`. - 외부 취소 감지(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 로 남지 않게 하는 안전망. @@ -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). 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` 대체): +- **포맷**: 모든 라인 ` [cid=] `. `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= 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 - SlotRepository Protocol :: 저장소 추상화(자산 중심: list/count/reconcile 모두 instance_key 스코프). 구현체(InMemorySlotRepository, PostgreSQLSlotRepository)를 주입. diff --git a/docs/srs/Documentation/req-089_operator-runbook.md b/docs/srs/Documentation/req-089_operator-runbook.md new file mode 100644 index 0000000..c087731 --- /dev/null +++ b/docs/srs/Documentation/req-089_operator-runbook.md @@ -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 없음. diff --git a/docs/srs/Documentation/req-090_observability-baseline-alignment.md b/docs/srs/Documentation/req-090_observability-baseline-alignment.md new file mode 100644 index 0000000..24673ac --- /dev/null +++ b/docs/srs/Documentation/req-090_observability-baseline-alignment.md @@ -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 없음. diff --git a/docs/srs/Integration/req-070_slippage-limit-price-execution.md b/docs/srs/Integration/req-070_slippage-limit-price-execution.md index 345f3bd..5587afc 100644 --- a/docs/srs/Integration/req-070_slippage-limit-price-execution.md +++ b/docs/srs/Integration/req-070_slippage-limit-price-execution.md @@ -9,25 +9,30 @@ - `slippage_pct` 가 지정된 주문을 dispatch 할 때 `entry_price*(1±pct)` 실효 지정가로 제출해 과도한 불리 체결을 막는다. `entry_price` 없으면 무시(순수 시장가는 슬리피지 기준 없음). (dcy-022 FR-3) ## 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 -1. Given 슬롯에 `limit_price=10000`, `slippage_pct=1.0`, side=buy, When dispatch, Then OrderRequest.limit_price = `ceil(10000*1.01)` = 10100. -2. Given 슬롯에 `limit_price=10000`, `slippage_pct=1.0`, side=sell, When dispatch, Then OrderRequest.limit_price = `floor(10000*0.99)` = 9900. -3. Given `limit_price=None`(시장가), `slippage_pct=1.0`, When dispatch, Then OrderRequest.limit_price = None(시장가 — slippage 무시). +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 = `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·스냅 무시). 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 기존 동작과 동일(전체 회귀 없음). +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 -- Docs: docs/runtime-flows.md(slippage dispatch 흐름) -- Modules: trex_core/application/execution.py(`_dispatch` slippage 계산) -- Tests: tests/application/test_execution.py(slippage dispatch 단위) +- Docs: docs/runtime-flows.md(slippage dispatch + 호가단위 스냅 흐름), docs/project-structure.md(신규 tick 유틸 — REQ-083) +- Modules: trex_core/application/execution.py(`_dispatch` slippage 계산 + 호가단위 스냅), REQ-083 tick 유틸 소비 +- Tests: tests/application/test_execution.py(slippage dispatch + 스냅 방향·경계·band0인 매수 한계가가 호가단위를 위반해 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 1004. + assert broker.orders[0].limit_price == 1004 - def test_sell_slippage_floor_applied_to_limit(self): - """sell: effective_limit = floor(limit_price * (1 - pct/100)).""" + def test_sell_slippage_ceil_to_tick(self): + """sell: effective_limit = ceil_to_tick(limit_price * (1 - pct/100)).""" broker = _RecordingBroker() repo, svc, intents, execution = _wire(broker) # drive to holding first @@ -122,10 +124,8 @@ class TestSlippageDispatch: broker.responder = lambda side: FillStatus(state="accepted") execution.run_once() # dispatch sell (pending state already has limit_price) sell_orders = [o for o in broker.orders if o.side == "sell"] - # floor(2000 * 0.99) = floor(1980.0) = 1980 - import math - expected = int(math.floor(2000 * 0.99)) - assert sell_orders[0].limit_price == expected + # ceil_to_tick(2000 * 0.99) = ceil_to_tick(1980.0) = 1980 (tick 5, exact). + assert sell_orders[0].limit_price == 1980 def test_no_slippage_when_slippage_pct_is_none(self): """No slippage_pct → effective_limit = entry_price unchanged.""" @@ -158,6 +158,105 @@ class TestSlippageDispatch: # 0.0 is falsy → slippage block skipped → no change 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 diff --git a/tests/application/test_operator_report.py b/tests/application/test_operator_report.py new file mode 100644 index 0000000..7d922d7 --- /dev/null +++ b/tests/application/test_operator_report.py @@ -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 diff --git a/tests/application/test_safety_sell_visibility.py b/tests/application/test_safety_sell_visibility.py index d5f7a68..c97581b 100644 --- a/tests/application/test_safety_sell_visibility.py +++ b/tests/application/test_safety_sell_visibility.py @@ -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 +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): repo, svc, broker, watchdog, service = _wire() slot = _holding(svc, repo, take_profit_price=Decimal("60000")) diff --git a/tests/domain/test_pricing.py b/tests/domain/test_pricing.py new file mode 100644 index 0000000..63fabfd --- /dev/null +++ b/tests/domain/test_pricing.py @@ -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 diff --git a/tests/runtime/test_logging_setup.py b/tests/runtime/test_logging_setup.py new file mode 100644 index 0000000..72f8799 --- /dev/null +++ b/tests/runtime/test_logging_setup.py @@ -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 diff --git a/trex_core/application/execution.py b/trex_core/application/execution.py index fddc9b6..c50f22c 100644 --- a/trex_core/application/execution.py +++ b/trex_core/application/execution.py @@ -23,7 +23,6 @@ single producer for ledger work items (NFR-3, single record subject). from __future__ import annotations import logging -import math from dataclasses import dataclass from datetime import datetime, timezone 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.ledger_outbox import LedgerOutbox 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.runtime.logging_setup import set_correlation_id _log = logging.getLogger(__name__) @@ -137,24 +138,30 @@ class ExecutionService: # limit_price is cleared on entering HOLDING, so a market sell # carries None -> order_type "01". # dcy-022 / REQ-070: slippage_pct adjusts the effective limit price - # when entry_price is set. buy: ceil(limit*(1+pct/100)); sell: floor. - # No slippage when limit is None (market order — no reference price). + # when entry_price is set. No slippage when limit is None (market + # 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 if limit is not None and slot.slippage_pct: - factor = ( - 1.0 + slot.slippage_pct / 100.0 - if side == "buy" - else 1.0 - slot.slippage_pct / 100.0 - ) - limit = ( - int(math.ceil(limit * factor)) - if side == "buy" - else int(math.floor(limit * factor)) - ) + base = float(slot.limit_price) + if side == "buy": + raw = base * (1.0 + slot.slippage_pct / 100.0) + limit = max(floor_to_tick(raw), ceil_to_tick(base)) + else: + raw = base * (1.0 - slot.slippage_pct / 100.0) + limit = min(ceil_to_tick(raw), floor_to_tick(base)) _log.debug( - "slippage applied: slot=%s side=%s base_limit=%s pct=%.4f effective=%s", - slot.slot_id, side, slot.limit_price, slot.slippage_pct, limit, + "slippage applied: slot=%s side=%s base_limit=%s pct=%.4f " + "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( OrderRequest( slot_id=str(slot.slot_id), @@ -168,7 +175,21 @@ class ExecutionService: if ack.accepted and ack.broker_order_id: self._repo.mark_dispatched(slot.slot_id, ack.broker_order_id) 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: + # 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") r.rolled_back += 1 @@ -176,6 +197,7 @@ class ExecutionService: now = datetime.now(tz=timezone.utc) for slot in self._repo.list_dispatched_pending(self._instance_key): 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 "") if fs.is_filled: self._on_filled(slot, fs, side, r) @@ -186,6 +208,12 @@ class ExecutionService: r.filled += 1 elif fs.is_terminal_reject: # 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}") r.rolled_back += 1 else: @@ -240,6 +268,14 @@ class ExecutionService: event_type = "slot_filled_sell" except SlotStateMismatchError: 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. # Slot transition is the critical path; ledger write is async (REQ-019). self._record_ledger(slot, fs, side, broker_order_id, r) diff --git a/trex_core/application/operator_report.py b/trex_core/application/operator_report.py new file mode 100644 index 0000000..72ba729 --- /dev/null +++ b/trex_core/application/operator_report.py @@ -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, + } diff --git a/trex_core/application/safety_sell.py b/trex_core/application/safety_sell.py index 96604f4..9186544 100644 --- a/trex_core/application/safety_sell.py +++ b/trex_core/application/safety_sell.py @@ -85,10 +85,14 @@ class SafetySellService: """Visibility for an actual safety-sell fire (dcy-017 FR-1, OQ-2).""" self._count(f"fire:{reason}") 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( level, - "safety-sell fired: phase=%s slot=%s portfolio_id=%s reason=%s instance_key=%s", - reason, slot.slot_id, slot.portfolio_id, reason, self._instance_key, + "safety-sell fired: phase=%s slot=%s portfolio_id=%s reason=%s " + "actor=%s instance_key=%s", + reason, slot.slot_id, slot.portfolio_id, reason, _ACTOR, self._instance_key, ) def _update_ownership(self, now: datetime) -> None: diff --git a/trex_core/domain/pricing.py b/trex_core/domain/pricing.py new file mode 100644 index 0000000..6c82c4b --- /dev/null +++ b/trex_core/domain/pricing.py @@ -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) diff --git a/trex_core/runtime/cli.py b/trex_core/runtime/cli.py index 1f0872c..c0eb4bf 100644 --- a/trex_core/runtime/cli.py +++ b/trex_core/runtime/cli.py @@ -395,9 +395,67 @@ def _build_parser() -> argparse.ArgumentParser: _add_intent_guards(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 +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: """CLI entrypoint. @@ -417,6 +475,10 @@ def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int: cfg = DaemonConfig.from_env() 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). if args.cmd in ("pause", "resume", "paused"): store = PostgreSQLPauseStore(cfg.dsn) diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index 49d88bb..e38e7bc 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -405,7 +405,8 @@ class Daemon: def main() -> None: # pragma: no cover - process entrypoint 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.add_argument( "--config", required=True, diff --git a/trex_core/runtime/logging_setup.py b/trex_core/runtime/logging_setup.py new file mode 100644 index 0000000..3974fd8 --- /dev/null +++ b/trex_core/runtime/logging_setup.py @@ -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): + """사람용 한 줄: ` [cid=] `.""" + + 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) diff --git a/trex_core/runtime/reset.py b/trex_core/runtime/reset.py index 1f48f29..3d625df 100644 --- a/trex_core/runtime/reset.py +++ b/trex_core/runtime/reset.py @@ -125,8 +125,9 @@ def main(argv: Optional[list[str]] = None) -> int: # pragma: no cover - CLI she import argparse 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( prog="trex-core-reset", description="dcy-020/REQ-064: wipe the slot-core tables and reinitialize "