feat(broker): BAT-038 — 매도 증권거래세 누락 정정 + fee/tax 단일 정책 지점 (dcy-026/REQ-018, issue #24)

dcy-026 confirm-discovery + REQ-018 change-req(CHG-20260612-01)→구현→
confirm-req-implemented(CHG-20260612-02) 전체 delivery.

- broker_port.py: calculate_tax(side, amount) 신규 + fee/tax 율 env 상수화
  (TREX_KIS_FEE_BPS=1.5, TREX_KRX_TAX_SELL_BPS=20.0). 매도 0.2%/매수 0.
- paper.py / kis.py: tax=0.0 하드코딩 3곳 제거 → 정책 함수 호출.
  KIS daily-ccld 응답에 fee/tax 필드 부재 → 정책 폴백(향후 정본 우선 훅).
- runtime-flows.md: 체결→ledger fee/tax 산출 흐름 갱신.
- tests: fee/tax 정책 단위 + issue #24 4건 회귀(fee+tax=1,376.9원).
  429 passed (+6), ruff clean.

REQ-018 Approved→Implemented, BAT-038 release-candidate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 17:57:15 +09:00
co-authored by Claude Opus 4.8
parent 08fdf680bf
commit 1cf13b060f
15 changed files with 695 additions and 24 deletions
@@ -0,0 +1,46 @@
# BAT-038 Design
- Status: confirmed
- Batch: BAT-038
## Architecture Summary
- `broker_port.py` 의 fee 단일 정책 지점을 fee+tax 로 확장한다. 기존 `calculate_fee(side, amount)` 옆에 `calculate_tax(side, amount)` 를 추가하고, 수수료율·세율을 env 로딩 상수로 통일한다(`_FEE_BPS_*` 하드코딩 → env 기본값).
- paper/kis 어댑터의 3개 `tax=0.0` 사이트(paper.py:90, kis.py:213·230)를 `calculate_tax(side, amount)` 호출로 교체한다.
- KIS daily-ccld 응답은 fee/tax 필드를 제공하지 않으므로(아래 Key Decision 3) KIS 도 paper 와 동일하게 정책 추정값을 사용한다. 브로커 응답 정본 우선(FR-3)은 향후 확장 가능한 guarded 파싱 훅으로 표현하되 현재 endpoint 에서는 폴백 경로만 활성.
- fee/tax 전달 경로(`execution.py:_record_ledger` → `LedgerTrade`)는 이미 `fs.tax` 를 운반하므로 계약 변경 없음(planning R-3 점검 완료).
## Changed Areas
- Modules:
- `trex_core/application/broker_port.py`:
- `_FEE_BPS_BUY`/`_FEE_BPS_SELL` → env(`TREX_KIS_FEE_BPS`, 기본 1.5) 로딩 상수로 통일
- `_TAX_BPS_SELL` 신규 → env(`TREX_KRX_TAX_SELL_BPS`, 기본 20.0)
- `calculate_tax(side, amount) -> float` 신규: 매도 `amount × _TAX_BPS_SELL / 10_000`, 매수 0
- `FillStatus.tax` 주석 갱신(0 고정 → 매도 정책 계산, dcy-026)
- `trex_core/adapters/broker/paper.py`: line 90 `tax=0.0` → `tax=calculate_tax(o.side, o.filled * o.price)`
- `trex_core/adapters/broker/kis.py`:
- line 213(전량)·230(부분) `tax=0.0` → `calculate_tax(side, ccld_qty * fill_price)`
- `calculate_tax` import 추가
- (선택) `_parse_broker_cost(row)` guarded 훅 — fee/tax 필드 부재 시 None → 정책 폴백(FR-3 future-proof)
- Tests:
- `tests/application/` 또는 `tests/adapters/broker/`: 매수/매도 fee·tax 단위, issue #24 4건 회귀(1,376.9원), paper·KIS 동일 정책, KIS 폴백 경로
- Docs: `docs/runtime-flows.md` line 254
## Key Decisions
1. **`calculate_tax` 신규 함수**(통합 아님): `calculate_fee` 와 대칭으로 별도 함수. side 분기 단순(매수 0/매도 세율), 호출부에서 `fee=calculate_fee(...)`, `tax=calculate_tax(...)` 명시적 페어. 단일 정책 지점 원칙 충족(AC-7).
2. **env 로딩, 기본값 1.5 / 20.0**: 기존 `TREX_*` os.environ 패턴(config.py 사례) 준용. 모듈 로드 시 `float(os.environ.get("TREX_KIS_FEE_BPS", "1.5"))`, `float(os.environ.get("TREX_KRX_TAX_SELL_BPS", "20.0"))`. paper·KIS 가 동일 모듈 상수 공유 → 동일 정책 보장(NFR-2).
3. **KIS 응답 fee/tax 필드 부재 → 항상 정책 폴백** (R-2 해소): 어댑터가 쓰는 `주식일별주문체결조회`(inquire-daily-ccld, `output1`)는 per-fill 수수료/증권거래세 필드를 제공하지 않는다(`tot_ccld_amt` 만 존재, docs/broker/kis/rest/국내주식/주문/계좌/021 확인). 따라서 KIS 도 `calculate_fee`/`calculate_tax` 정책값을 사용한다. FR-3 "브로커 정본 우선" 은 필드가 없어 구조적으로 폴백 경로만 활성 — **오파싱 리스크 자체가 제거됨**. 향후 fee/tax 노출 endpoint 도입 시 guarded 훅 지점에 파싱 추가.
4. **소급 보정 없음**(dcy-026 OQ-3): 과거 행 미변경, 신규 체결부터 정확값.
## Edge Cases
- `amount <= 0`: `calculate_tax` 도 `calculate_fee` 처럼 0 반환(0/음수 가드).
- 매수 체결: `calculate_tax("buy", amount) == 0` (AC-6 매수 무세).
- 부분 체결(kis.py:230): 체결분 `ccld_qty * fill_price` 기준으로 fee/tax 계산(전량과 동일 공식, 체결분만).
- `fill_price == 0`(avg 부재): amount=0 → fee/tax 0(기존 fee 동작과 일관).
- KIS 응답 fee/tax 필드 부재(현재 항상): 정책 폴백, 로그 불필요(설계상 기본 경로).
## Architecture Impact
- runtime-flows
## Reference Doc Update Plan
- project-structure.md: none (모듈 구조 불변, 함수 1개 추가만)
- runtime-flows.md: line 254 `체결 → ledger outbox` flow — "계산된 `fee`(...), `tax=0` 을 운반" → "계산된 `fee`(`calculate_fee`)·`tax`(`calculate_tax`, 매도 정책 세율 0.2%/매수 0) 를 운반" 으로 갱신. fee/tax 단일 정책 지점(env `TREX_KIS_FEE_BPS`/`TREX_KRX_TAX_SELL_BPS`) 주석 추가.
@@ -0,0 +1,32 @@
# BAT-038 Implementation
- Status: completed
- Batch: BAT-038
## Plan Summary
- `broker_port.py` 에 `calculate_tax(side, amount)` 신규 + fee/tax 율을 env 로딩 상수로 통일. paper/kis 의 3개 `tax=0.0` 사이트를 `calculate_tax` 호출로 교체. runtime-flows fee/tax 흐름 갱신. 회귀 테스트 추가.
## Changed Files
- `trex_core/application/broker_port.py` — `import os`; `_FEE_BPS`(`TREX_KIS_FEE_BPS`, 1.5)·`_TAX_BPS_SELL`(`TREX_KRX_TAX_SELL_BPS`, 20.0) env 상수; `calculate_tax(side, amount)` 신규; `calculate_fee` 단일 rate 정리; `FillStatus.tax` 주석 갱신
- `trex_core/adapters/broker/paper.py` — `calculate_tax` import; line 90 `tax=0.0` → `tax=calculate_tax(o.side, amount)`
- `trex_core/adapters/broker/kis.py` — `calculate_tax` import; 전량(213)·부분(230) `tax=0.0` → `tax=calculate_tax(side, amount)`; KIS 응답 fee/tax 필드 부재 주석
- `docs/runtime-flows.md` — `체결 → ledger outbox` flow line 254 fee/tax 정책 + Change Log
- `tests/application/test_fee_tax_policy.py` — 신규 (6 테스트)
## Execution Log
1. `broker_port.py`: `os` import 추가, `_FEE_BPS`/`_TAX_BPS_SELL` env 로딩 상수, `calculate_tax` 추가(매도 `amount × _TAX_BPS_SELL / 10_000`, 매수/비양수 0), `FillStatus.tax` 주석을 dcy-026 정책으로 갱신
2. `paper.py`: `calculate_tax` import, fill 시 `amount = o.filled * o.price` 로 fee/tax 동시 계산
3. `kis.py`: `calculate_tax` import, 전량·부분 체결 두 경로 모두 `amount` 기준 fee/tax 계산. daily-ccld 응답 fee/tax 필드 부재 → 정책 추정값 사용(주석)
4. `runtime-flows.md`: line 254 `tax=0` → `calculate_tax` 정책 계산 + env 변수 + paper·KIS 공유 설명, Change Log 항목 추가
5. `test_fee_tax_policy.py`: policy 함수 단위(fee buy/sell 동일, tax sell-only, 비양수 0), issue #24 4건 회귀(1,376.9원), paper sell fill tax 정책 운반, paper buy fill tax=0
## Validation
- `pytest tests/application/test_fee_tax_policy.py test_execution_ledger.py test_execution_cancel.py` — 19 passed (신규 6 포함, 기존 회귀 없음)
- `pytest tests/` — **429 passed, 45 skipped** (BAT-037 기준 423 → +6 신규, 회귀 0)
- `ruff check trex_core tests` — All checks passed
- 회귀 기준 확인: issue #24 4건 `fee` 합계 177.9 + `tax` 합계 1,199 = **1,376.9원** (테스트로 고정)
- 기존 `test_buy_fill...:61` `t.tax == 0.0` 은 매수 케이스로 정합 유지(AC-6). `test_execution_cancel.py` stub FillStatus(tax=0.0)는 execution 로직 테스트로 무영향.
## Remaining Risks
- 없음. R-1(세율 오류)은 1,376.9원 회귀로 고정, R-2(KIS 오파싱)는 설계상 제거(daily-ccld fee/tax 필드 부재 → 정책 폴백), R-3(전달 경로)은 점검 완료(불변).
- 라이브 KIS 스모크는 운영 배포 단계(REL) 사항 — paper 정책과 동일 산식이므로 코드 경로 동일.
@@ -0,0 +1,35 @@
# BAT-038: 매도 증권거래세 누락 정정 + fee/tax 단일 정책 지점 (CHG-20260612-01)
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-026 (issue #24)
- Included REQ: REQ-018 (CHG-20260612-01)
- Excluded REQ: 없음
## Summary
- 매도 체결의 증권거래세가 전 체결에서 `tax=0.0` 으로 하드코딩되어 실현 손익·일일 수익률이 과대 계산되던 운영 버그(issue #24)를 닫는다. `FillStatus.tax` 가 매도 정책 세율(0.2%)로 계산되도록 정정하고, 수수료율·세율을 단일 정책 지점(`calculate_fee`/`calculate_tax`)에서 env 설정으로 관리한다. KIS 어댑터는 브로커 응답 fee/tax 가 있으면 정본 우선, 없으면 정책 폴백.
## Scope
- In Scope:
- `trex_core/application/broker_port.py` — `calculate_tax(side, amount)` 신규 + 세율/수수료율 env 상수(`TREX_KIS_FEE_BPS`=1.5, `TREX_KRX_TAX_SELL_BPS`=20.0). `FillStatus.tax` 의미 정정(주석 갱신).
- `trex_core/adapters/broker/paper.py` — `tax=0.0` → `calculate_tax(...)` (line 90)
- `trex_core/adapters/broker/kis.py` — `tax=0.0` → 브로커 응답 fee/tax 파싱 + 정책 폴백 (line 213·230)
- `docs/runtime-flows.md` — 체결→ledger fee/tax 산출·저장 흐름 갱신
- 테스트: 매수/매도 fee·tax 단위, issue #24 4건 회귀(1,376.9원), paper·KIS 동일 정책, KIS 응답 우선 경로
- Out of Scope:
- 과거 잘못 저장된 ledger 행 소급 보정 (dcy-026 OQ-3 Closed — 보정 안 함)
- portfolio-hub 측 손익 재계산 로직 (수신측 저장소 책임)
- REQ-011 (BrokerPort 포트 계약) 변경 — fee/tax 는 REQ-018 영역, REQ-011 무영향
- 세율 동적 설정 UI/운영 도구
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- 인터페이스/계약 의미 변경(`FillStatus.tax` 0 고정 → 매도 정책 계산) + KIS 응답 파싱(신규 동작, R-2) + env 설정 + baseline 문서(runtime-flows) 정렬을 함께 포함하므로 standard profile.
- 회귀 기준값: issue #24 4건 `fee+tax` 합계 **1,376.9원**(fee 177.9 + tax 1,199). 이슈 기재 1,357원은 원작성자 추정 오차(~20원), baseline 아님.
- `TREX_KIS_FEE_BPS`/`TREX_KRX_TAX_SELL_BPS` 변수명에 도메인 라벨이 있으나 단일 정책 지점으로 paper·KIS 가 동일 공유.
- 핵심 리스크: R-1(세율 산식 오류 → 손익 역왜곡, 완화: 1,376.9원 회귀 고정), R-2(KIS 응답 필드명/단위 오파싱 → 라이브 전용 오류, 완화: raw 필드 확인 + 폴백 + 로그).
@@ -0,0 +1,37 @@
# BAT-038 Planning
- Status: confirmed
- Batch: BAT-038
- Profile: standard
## Included REQ
- REQ-018 (CHG-20260612-01): 매도 증권거래세 정책 계산, `tax=0.0` 하드코딩 제거, 단일 정책 지점 + env 설정, KIS 응답 정본 우선. AC-3 갱신 + AC-6~9 신규.
## Delivery Plan
1. `broker_port.py` — 세율 env 상수(`TREX_KRX_TAX_SELL_BPS`=20.0) + 수수료율 env 상수(`TREX_KIS_FEE_BPS`=1.5, 기존 하드코딩 `_FEE_BPS_*` 대체) 도입. `calculate_tax(side, amount)` 신규(매도 정책 세율, 매수 0). `FillStatus.tax` 주석 갱신.
2. `paper.py:90` — `tax=0.0` → `calculate_tax(side, amount)` 호출.
3. `kis.py:213·230` — 브로커 체결 응답 fee/tax 필드 파싱(있으면 정본), 없으면 `calculate_fee`/`calculate_tax` 폴백 + 로그.
4. 테스트 — 매수/매도 fee·tax 단위(매도 0.2%, 매수 tax=0), issue #24 4건 회귀(1,376.9원), paper·KIS 동일 정책 일치, KIS 응답 우선·폴백 경로.
5. `docs/runtime-flows.md` — 체결→ledger fee/tax 산출 흐름 갱신.
## Dependencies
- Internal:
- `trex_core/application/broker_port.py` — `calculate_fee` 기존 함수, env 로딩 패턴(`TREX_*` os.environ) 기존 사례 준용
- `trex_core/adapters/broker/{paper,kis}.py` — FillStatus 생성 지점(이미 fee 계산 호출 중)
- `trex_core/application/execution.py`·`ledger_outbox.py` — fee/tax 전달 경로(계약 불변 예상, 검증 필요)
- External:
- KIS 체결 응답 raw 필드(수수료/세금 포함 여부) — delivery 단계 raw 확인(CONFIRM, dcy-026)
## Design Gate
- Design Required Before Implementation: yes
- Reason: (1) `FillStatus.tax` 계약 의미 변경(0 고정 → 매도 정책 계산), (2) KIS 응답 fee/tax 파싱 경로 — 필드 존재 여부·단위 결정이 라이브 정확성에 직결(R-2), (3) env 상수 도입과 기존 `_FEE_BPS_*` 하드코딩 정리 방식, (4) `calculate_tax` 신규 vs `calculate_fee` 통합 결정. design 에서 확정 후 구현.
## Milestones
1. Design confirmed — `calculate_tax` 시그니처, env 로딩, KIS 파싱·폴백 경로 확정
2. Implementation — broker_port + paper + kis 수정 + 테스트
3. Verification — AC-3·6~9 매핑 + issue #24 회귀(1,376.9원) + 전체 회귀 + ruff
## Risks
- R-1 (High): 세율 산식 오류 시 손익 역방향 왜곡 — 완화: §4 공식 기대 1,376.9원 회귀 테스트 고정, 매수/매도 각 케이스 단위.
- R-2 (Medium): KIS 응답 수수료/세금 필드명·단위(원 vs bp) 오파싱 → 라이브 전용 오류(paper 미검출) — 완화: raw 필드 확인 후 파싱, 미확인 시 정책 폴백 + 로그. design 에서 KIS 응답 스키마 확인.
- R-3 (Low → 해소): execution.py/ledger_outbox.py fee/tax 전달 경로 계약 변경 가능성 — **점검 완료: 불변 확인**. `execution.py:_record_ledger` (line 396-397)가 이미 `fee=float(fs.fee)`, `tax=float(fs.tax)` 를 ledger 로 전달 중. 어댑터 소스(`fs.tax`)만 정확히 채우면 되며 전달 경로 코드 변경 불필요.
@@ -0,0 +1,62 @@
# BAT-038 Verification
- Status: confirmed
- Batch: BAT-038
## Acceptance Mapping
- REQ-018 AC-1 (매수 체결 → holding + buy 원장 1건):
- 이번 배치 변경 범위 밖(기록 경로 불변). BAT-006 evidence 유효.
- Evidence: `test_buy_fill_enqueues_one_ledger_item_and_writer_records_it` PASSED.
- REQ-018 AC-2 (매도 체결 → closed/inactive + sell 원장 1건):
- Evidence: `test_sell_fill_enqueues_sell_ledger_item` PASSED.
- REQ-018 AC-3 (`FillStatus` 결정적 execution_id + 계산된 fee + 계산된 tax 운반):
- Evidence:
- 매수: `test_buy_fill_...` — `t.fee > 0.0 and t.tax == 0.0` (매수 무세) PASSED.
- 매도: `test_paper_sell_fill_carries_policy_tax` — `fs.fee == calculate_fee("sell", amount)`, `fs.tax == calculate_tax("sell", amount)`, `fs.tax > 0.0` PASSED.
- REQ-018 AC-4 (portfolio_id 부재 → 기록 skip + 슬롯 전이 정상):
- 변경 범위 밖. Evidence: `test_portfolio_id_absent_skips_ledger_but_transitions_slot` PASSED + 전체 회귀.
- REQ-018 AC-5 (원장 기록 단일 주체 = 집행 경로):
- 변경 범위 밖. fee/tax 는 어댑터 산출, 기록 주체(`execution.py:_record_ledger`) 불변.
- Evidence: `_record_ledger` 단일 enqueue 지점 유지(line 396-397 `fee`/`tax` 전달), 전체 회귀 429 passed.
- REQ-018 AC-6 (매도 tax = amount × 0.2%, 매수 tax = 0):
- Evidence:
- `test_calculate_tax_sell_only`: `calculate_tax("sell", 1_000_000) == 2000.0`, `calculate_tax("buy", ...) == 0.0` PASSED.
- `test_paper_buy_fill_has_zero_tax`: paper 매수 fill `fs.tax == 0.0` PASSED.
- REQ-018 AC-7 (단일 정책 지점 + env, paper·KIS 동일, `tax=0.0` 하드코딩 제거):
- Evidence:
- `test_calculate_fee_same_rate_buy_and_sell`: 매수·매도 동일 수수료율 PASSED.
- `test_fee_and_tax_zero_for_nonpositive_amount`: 비양수 amount 가드 PASSED.
- 단일 정책 지점: `broker_port.py` `calculate_fee`/`calculate_tax` (env `TREX_KIS_FEE_BPS`/`TREX_KRX_TAX_SELL_BPS`). paper·kis 가 동일 함수 import 사용(코드 리뷰).
- `tax=0.0` 하드코딩 제거 확인: `grep -rn "tax=0.0\|tax=0," trex_core/adapters trex_core/application` → 코드 사이트 0건(주석 1건만).
- REQ-018 AC-8 (KIS 응답 fee/tax 정본 우선, 부재 시 폴백):
- Evidence (design + 코드 리뷰): KIS 어댑터가 쓰는 `주식일별주문체결조회`(inquire-daily-ccld, `output1`)는 per-fill 수수료/증권거래세 필드를 제공하지 않음(`docs/broker/kis/rest/국내주식/주문/계좌/021...` output1 38필드 중 fee/tax 없음, `tot_ccld_amt` 만 존재). 따라서 KIS 는 정책 추정값(`calculate_fee`/`calculate_tax`)으로 폴백 — 구조적으로 폴백 경로만 활성, 오파싱 리스크 없음(design Key Decision 3). 향후 fee/tax 노출 endpoint 도입 시 정본 우선 파싱 추가 지점 명시.
- 잔여: 라이브 KIS 스모크는 REL 배포 단계 사항(paper 와 동일 산식이므로 코드 경로 동일). Blocking 아님.
- REQ-018 AC-9 (issue #24 4건 → fee+tax 합계 1,376.9원):
- Evidence: `test_issue_24_four_fill_regression` — `total_fee == 177.9`, `total_tax == 1199.0`, `total == 1376.9` PASSED.
## Evidence
- Test:
- `pytest tests/application/test_fee_tax_policy.py` — 6 passed (신규)
- `pytest tests/application/test_fee_tax_policy.py test_execution_ledger.py test_execution_cancel.py` — 19 passed
- `pytest tests/` — **429 passed, 45 skipped** (BAT-037 423 → +6, 회귀 0)
- `ruff check trex_core tests` — All checks passed
- Manual / Baseline 동기화 (Architecture Impact: runtime-flows):
- `docs/runtime-flows.md` `체결 → ledger outbox` flow (line 254): `tax=0` → `calculate_tax(side, amount)` 정책 계산 + env 변수(`TREX_KIS_FEE_BPS`/`TREX_KRX_TAX_SELL_BPS`) + paper·KIS 공유 설명 갱신. Change Log 항목(2026-06-12 BAT-038) 추가. ✓ 코드와 동기화 확인.
- `docs/project-structure.md`: 변경 불필요(모듈 구조 불변, 함수 1개 추가) — design Reference Doc Update Plan = none.
- `tax=0.0` 하드코딩 제거 grep 확인(위 AC-7).
## Result
- Ready for Release: true
- Blocking Issues: 없음
- AC-8 라이브 KIS 스모크는 REL 배포 단계(비거래 시간) 사항이며, paper 와 동일 정책 산식·동일 코드 경로이므로 release-ready 차단 사유 아님.
+1
View File
@@ -45,3 +45,4 @@
| BAT-035 | standard | confirmed | 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 | confirmed | 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-038 | standard | release-candidate | REQ-018 (CHG-20260612-01) | dcy-026 (issue #24) | [bat-038](./bat-038_20260612_sell-transaction-tax-fee/) |