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:
@@ -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 — 완료.
|
||||||
+35
@@ -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 선례).
|
||||||
+41
@@ -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:
|
||||||
|
- 없음
|
||||||
@@ -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
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 를 늘리지 않는다.
|
||||||
|
|||||||
@@ -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 에이전트용 실행 레시피(이 런북의 사실 자료 출처)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 없음.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 "
|
||||||
|
|||||||
Reference in New Issue
Block a user