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/) |
@@ -0,0 +1,246 @@
# 0. 문서 상태
- 상태: confirmed
- 문서 ID: dcy-026_20260612_sell-transaction-tax-and-fee-policy
- 이슈명: 매도 증권거래세 누락 + 수수료 정책 정정 (issue #24)
- 작성 시각(KST): 2026-06-12
- 마지막 갱신 시각(KST): 2026-06-12 (confirm-discovery: Justin Ko 승인 — 상태 confirmed 전환, §8 체크 완료, Freeze 플래그 갱신)
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조: 없음
# 1. 계획 상태 요약
- 연계 문서 현황:
- 관련 점수 파일: 없음
- 관련 TODO/메모: GitHub issue #24 (`bug` 라벨, 2026-06-11 trex-admin-daily-trading 4건 체결 증거 포함)
- 해석:
- 매도 증권거래세가 전 체결에서 `tax=0.0`으로 저장되어 실현 손익·일일 수익률이 과대 계산되는 운영 버그. 코드상 `dcy-004 OQ-3`로 미뤄둔 "정밀 세금 모델"이 운영에서 표면화된 사안.
- 단순 핫픽스가 아니라 수수료율/세율 정책(매수·매도 구분, bp 단위, 세금 산식)을 계약으로 확정해야 하므로 신규 Discovery로 정식화한다.
# 2. 요구사항 판정 결과
- 판정: 신규
- 근거
- 기존 ledger 계열 Discovery(dcy-004 연동, dcy-015 outbox 백오프, dcy-018 cash write-back)는 fee/tax **정책 자체**를 다루지 않음. fee/tax 정확성은 미해결 영역.
- `trex_core/application/broker_port.py:51` 주석이 "tax defaults to 0 (precise tax model is a follow-up Discovery, dcy-004 OQ-3)"로 후속 Discovery 필요를 명시 — 그 후속을 본 Discovery로 개설.
# 3. 문제점의 요약
- 현재 상태
- `trex_core/application/broker_port.py:118-119`: `_FEE_BPS_BUY = _FEE_BPS_SELL = 1.5`(0.015%) — 수수료만 계산. `calculate_fee(side, amount)` 단일 함수.
- `trex_core/adapters/broker/paper.py:90`·`kis.py:213·230`: 체결 시 `tax=0.0` **하드코딩** — 매도 증권거래세가 전혀 반영되지 않음. 한국 주식 매도세(증권거래세+농특세 약 0.18~0.20%)는 수수료(0.015%)보다 10배 이상 큰 비용인데 누락.
- 결과(issue #24 증거): 2026-06-11 4건 체결의 저장 `fee+tax` 합계 177.9원 vs 실제 기대 1,357원 — 차액 대부분이 누락된 매도세.
- 기대 상태
- 매도 체결 시 증권거래세를 정책 기준으로 계산해 `FillStatus.tax`에 반영하고 `ledger_outbox`에 정확히 저장한다.
- 수수료율·세율을 단일 정책 지점(`calculate_fee`/`calculate_tax`)에서 관리하고, paper·KIS 어댑터가 동일 정책을 사용한다.
- 이해관계자
- 운영자/시스템 관리자 (Justin Ko): 정확한 일일 손익·수익률 리포트 필요
- portfolio-hub(ledger 수신측): `fee_amount`/`tax_amount` 정확값 수신
- 전략 모듈(trex-orb-v1): 비용 반영된 실현 손익 기반 의사결정
- 제약사항
- KIS 실 체결 응답에 수수료/세금 필드가 포함되면 우선 사용하고, 없으면 정책 추정값으로 폴백해야 함(브로커 정본 우선 원칙).
- 정책 상수는 코드 하드코딩 대신 설정 가능성을 검토(세율 변경 대응).
## 주요 사용자 시나리오
- 매도 round-trip 체결 후 운영자가 일일 리포트를 확인 → 수수료+세금이 실제 부과액과 일치하고 실현 손익이 과대 계산되지 않음.
- KIS 라이브 매도 체결 시 브로커 응답의 세금/수수료가 있으면 그 값이, 없으면 정책 추정값이 ledger에 기록됨.
- paper 브로커 검증 시에도 동일 정책으로 세금이 계산되어 라이브와 비교 가능한 ledger 엔트리 생성.
# 4. 이번에 정의할 변경
- 매도 증권거래세 산식 도입 — `calculate_tax(side, amount)`(매수 0, 매도 정책 세율) 추가 또는 `calculate_fee`를 fee+tax 동시 산출로 확장.
- paper·KIS 어댑터의 `tax=0.0` 하드코딩 제거 — 정책 함수 호출로 교체, KIS는 브로커 응답값 우선·정책 폴백.
- 수수료/세율 정책 상수를 env 설정으로 노출 (OQ-2 Closed) 및 baseline 문서(`runtime-flows.md`) fee/tax 흐름 갱신.
## 제비용 계산 공식 (OQ-1 Closed 기준)
정책 파라미터 (env 설정, 기본값):
```
TREX_KIS_FEE_BPS = 1.5 # 수수료율 (매수·매도 공통, 1.5bps = 0.015%)
TREX_KRX_TAX_SELL_BPS = 20.0 # KRX(KOSPI·KOSDAQ) 매도 증권거래세율 (20.0bps = 0.2%)
```
> 변수명에 `KIS`/`KRX` 도메인 라벨이 붙지만, 이 값은 단일 정책 지점으로 **paper·KIS 어댑터가 동일하게 공유한다**(FR-2·NFR-2). paper 브로커는 KIS 제비용을 모사하므로 같은 상수를 사용한다.
체결 단위 제비용:
```
amount = price × qty
fee = amount × TREX_KIS_FEE_BPS / 10_000 # 매수·매도 동일
tax = amount × TREX_KRX_TAX_SELL_BPS / 10_000 # 매도만 적용; 매수 = 0
total = fee + tax
```
side별 전개:
| side | fee | tax | total |
|------|-----|-----|-------|
| buy | amount × 0.00015 | 0 | amount × 0.00015 |
| sell | amount × 0.00015 | amount × 0.002 | amount × 0.00215 |
issue #24 역산 검증 (qty=1, OQ-1):
```
BUY 291,500원 → fee=43.73원, tax=0원, total=43.73원
SELL 301,000원 → fee=45.15원, tax=602원, total=647.15원
BUY 295,000원 → fee=44.25원, tax=0원, total=44.25원
SELL 298,500원 → fee=44.78원, tax=597원, total=641.78원
─────────────────────────────────────────────────────
합계 fee=177.9원, tax=1,199원, total=1,376.9원
(이슈 기재 1,357원과 차이 약 20원 — 이슈 원작성자 추정 오차)
```
## 영향 범위
- 코드 영향:
- `trex_core/application/broker_port.py`: `calculate_fee` 정정 + `calculate_tax` 신규(또는 통합), 세율/수수료율 상수
- `trex_core/adapters/broker/paper.py`: `tax=0.0` → `calculate_tax(...)` (line 90 주변)
- `trex_core/adapters/broker/kis.py`: `tax=0.0` → 브로커 응답 파싱 + 정책 폴백 (line 213·230 주변)
- `trex_core/application/ledger_outbox.py`·`execution.py`: fee/tax 전달 경로(계약 불변 가능성 높음, 검증 필요)
- 문서 영향:
- REQ-018(체결→원장 기록) change-req: fee/tax 산출 정책 명시
- REQ-011(BrokerPort + paper double) change-req 가능성: 수수료/세율 정책 함수 계약
- `docs/runtime-flows.md`: 체결→ledger fee/tax 산출 흐름
- 운영 영향:
- 배포 후 신규 체결부터 정확한 fee/tax 저장. **과거 잘못 저장된 행(2026-06-11 등) 보정 여부는 별도 결정**(OQ).
- portfolio-hub 측 손익 재계산 영향 — 수신측 정합 확인 필요.
# 5. 요구사항 목록
## 기능 요구사항
- FR-1: 매도 체결에 대해 정책 기준 증권거래세를 계산해 `FillStatus.tax`에 반영한다. 매수 체결의 세금은 0으로 한다(한국 주식 매수 무세 전제).
- FR-2: 수수료율·세율을 단일 정책 지점에서 관리하고 paper·KIS 어댑터가 동일 정책을 사용한다. `tax=0.0` 하드코딩을 제거한다.
- FR-3: KIS 어댑터는 브로커 체결 응답에 수수료/세금 필드가 있으면 그 값을 정본으로 사용하고, 없을 때만 정책 추정값으로 폴백한다.
- FR-4: `docs/runtime-flows.md`에 체결→ledger fee/tax 산출·저장 흐름을 갱신한다.
## 비기능 요구사항
- NFR-1: fee/tax 계산은 결정적이며 단위 테스트로 매수/매도 각 케이스의 기대값을 검증 가능해야 한다(issue #24 증거 수치 회귀 테스트 포함).
- NFR-2: paper·KIS 어댑터가 동일 입력(side, amount)에 동일 정책 결과를 산출한다(브로커 응답 미존재 시).
- NFR-3: 정책 변경(세율 조정) 시 코드 한 지점 수정으로 전 경로에 반영된다.
## 범위 경계
- In Scope
- 매도 증권거래세 산식 + 수수료율 정정
- paper·KIS `tax=0.0` 하드코딩 제거 및 정책 함수 적용
- KIS 브로커 응답 fee/tax 파싱(존재 시 정본)
- REQ-018(+REQ-011) change-req + runtime-flows 갱신 + 회귀 테스트
- Out of Scope
- 과거 잘못 저장된 ledger 행 소급 보정: **Out of Scope** — 보정하지 않음 (OQ-3 Closed)
- portfolio-hub 측 손익 재계산 로직 변경(수신측 저장소 책임)
- 세율의 동적 설정 UI/운영 도구(필요 시 후속)
# 6. 리스크/가정 목록
## 리스크
- R-1 (High): 세율 산식 오류 시 손익이 반대 방향으로 다시 왜곡됨 — 검증 부족하면 버그를 버그로 교체.
- 완화 방안: §4 공식 기준 기대 합계(fee 177.9 + tax 1,199 = **1,376.9원**)를 회귀 테스트 기준으로 고정. 매수/매도 각 케이스 단위 테스트. (이슈 기재 1,357원은 원작성자 추정 오차 약 20원 — baseline 아님.)
- R-2 (Medium): KIS 실 체결 응답의 수수료/세금 필드명·단위(원 vs bp)를 잘못 파싱하면 라이브에서만 오류 발생(paper에서 미검출).
- 완화 방안: KIS 응답 raw 필드 확인 후 파싱, 미확인 필드는 정책 폴백 + 로그. 라이브 스모크 시 실제 값 대조.
- R-3 (Medium): 과거 저장 행 미보정 시 2026-06-11 이전 리포트는 계속 왜곡 상태로 남음.
- 완화 방안: OQ-3에서 소급 보정 범위·방법 결정. 보정 안 하면 release 문서에 알려진 한계로 명시.
## 가정
- A-1: 한국 주식 기준 매수는 무세, 매도에 증권거래세(+농특세)가 부과된다. 정확한 세율 값은 사용자 확인 대상(OQ-1).
- A-2: 신규 REQ 없이 REQ-018(+필요 시 REQ-011) change-req로 닫힌다 — 계약 구조는 유지, 산식·값만 정정.
- A-3: KIS 체결 응답에 수수료/세금이 포함될 수 있으나 항상 보장되지는 않아 정책 폴백이 필요하다.
## 오픈 질문
- OQ-1: 적용할 정확한 매도 세율(증권거래세+농특세 합산 %)과 수수료율(매수/매도 bp)은?
- 상태: Closed
- 결정: 매도 증권거래세 **0.2%** (매수 무세), 수수료율 **0.015% (1.5bps)** 매수·매도 동일. issue #24 4건 체결 역산(qty=1 확인, 계산 기대 합계 1,376.9원)으로 현행 코드값 0.015% 정합 확인. 이슈 기재 1,357원은 약 20원 추정 오차.
- 종료 조건: 충족
- OQ-2: 정책 상수를 코드 상수로 둘지, 설정(env/TOML)으로 노출할지?
- 상태: Closed
- 결정: **env 설정**으로 노출한다. 확정 변수명:
- `TREX_KIS_FEE_BPS` — 한국투자증권 수수료율 (bps, 기본 `1.5` = 0.015%, 매수·매도 공통)
- `TREX_KRX_TAX_SELL_BPS` — KRX(KOSPI·KOSDAQ) 매도 증권거래세율 (bps, 기본 `20.0` = 0.2%)
- 기존 패턴(`TREX_WATCHDOG_TIMEOUT`, `TREX_PAPER_FILL_AFTER_POLLS` 등) 준용 — `TREX_{도메인}_{파라미터}` 형식
- 종료 조건: 충족
- OQ-3: 과거 잘못 저장된 ledger 행(2026-06-11 등) 소급 보정 여부와 방법은?
- 상태: Closed
- 결정: **소급 보정하지 않는다** — 과거 행은 그대로 둔다. 배포 후 신규 체결부터 정확한 fee/tax 저장. Out of Scope 확정.
- 종료 조건: 충족
# 7. 초기 성공 기준
- S-1: 매도 체결의 `tax`가 정책 세율로 계산되어 0이 아닌 정확값으로 `ledger_outbox`에 저장된다. issue #24의 4건 시나리오 재현 시 `fee+tax` 합계가 §4 공식 기대값(fee 177.9 + tax 1,199 = **1,376.9원**)에 정확히 수렴한다.
- S-2: paper·KIS 어댑터가 동일 정책으로 fee/tax를 산출하며, KIS는 브로커 응답값이 있으면 그 값을 사용한다.
- S-3: 기존 단위·통합 테스트 회귀 없음 + 신규 fee/tax 회귀 테스트 green, `ruff check` clean.
## 측정 방식 및 데이터 출처
- S-1 측정 방식: 단위 테스트 — issue #24 4건 체결값(price/qty) 입력 후 `fee+tax` 합계가 §4 공식 기대 1,376.9원에 수렴 확인 / 데이터 출처: issue #24 Evidence + §4 공식 + `tests/application/` 신규 테스트
- S-2 측정 방식: paper·KIS 어댑터 단위 테스트로 동일 입력 동일 출력 확인 + KIS 응답값 우선 경로 테스트 / 데이터 출처: `tests/adapters/broker/`
- S-3 측정 방식: `pytest` 전체 회귀 + `ruff check trex_core tests` / 데이터 출처: 로컬/CI 결과
# 8. REQ로 넘기기 전 확인 체크
- [x] 범위(In Scope/Out of Scope) 확정
- 기준: 소급 보정(OQ-3) In/Out 확정 + 정책 상수 위치(OQ-2) 확정
- 결과: OQ-2 env 설정 확정, OQ-3 소급 보정 Out of Scope 확정
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 기준: §4 공식 기대값 1,376.9원 역산에 필요한 세율/수수료율(OQ-1) 확정
- 결과: OQ-1 매도세 0.2%, 수수료 0.015% 확정 — §4 역산 합계 1,376.9원
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 기준: R-1(세율 오류) 회귀 테스트 기준값 확정 담당 Justin Ko
- 결과: R-1 완화 방안 확정 — §4 기대 1,376.9원 회귀 테스트 기준 고정, Justin Ko 담당
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 기준: OQ 목록에 `Open` 상태 항목이 0개이거나, 잔여 항목 전부 `Deferred` 처리 완료
- 결과: OQ-1·OQ-2·OQ-3 모두 Closed — Open 항목 0개
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 기준: Justin Ko 승인 후 `confirm-discovery` 수행
- 결과: Justin Ko 승인, confirm-discovery 완료
# 9. 파일 처리 결과
- 처리 결과: 생성
- 생성 경로: docs/discovery/dcy-026_20260612_sell-transaction-tax-and-fee-policy.md
- 참조 문서:
- GitHub issue #24 — fee/tax 저장 오류 증거(2026-06-11 4건 체결, 기대 1,357원 vs 저장 177.9원)
- trex_core/application/broker_port.py:51·118-131 — fee 모델 + tax follow-up 주석(dcy-004 OQ-3)
- trex_core/adapters/broker/paper.py:90, kis.py:213·230 — `tax=0.0` 하드코딩(변경 대상)
- docs/discovery/dcy-004_20260604_portfolio-hub-ledger-integration.md — OQ-3 정밀 세금 모델 선행 참조
# 10. 사용자 결정 필요 항목 요약
## DECIDE
- 매도 세율: **0.2%**, 수수료율: **0.015% (1.5bps)** — 확정 (OQ-1 Closed)
- 정책 상수 위치: **env 설정** — `TREX_KIS_FEE_BPS` (기본 1.5), `TREX_KRX_TAX_SELL_BPS` (기본 20.0) — 확정 (OQ-2 Closed)
- 과거 ledger 행 소급 보정: **Out of Scope** — 확정 (OQ-3 Closed)
## CONFIRM
- KIS 체결 응답에 수수료/세금 필드가 실제 포함되는지 확인 — 포함 시 정본 우선, 미포함 시 정책 폴백(FR-3·R-2 연결). delivery 단계 KIS raw 응답 확인으로 종료.
## DATA
- 없음 — issue #24 4건 체결의 기대 수수료/세금 분해값은 §4 "제비용 계산 공식"에서 체결별로 제공됨(answered).
# 11. Discovery Freeze
## 섹션 참조
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: REQ Drafting 진행 가능
- Handoff Rationale: OQ-1(세율/수수료율), OQ-2(정책 위치), OQ-3(소급 보정) 3건 모두 Closed. §8 체크 완료. 구현 부재는 정상(신규 작업 정의 Discovery).
- Ready for REQ Drafting: true
- Confirmed By: Justin Ko
- Confirmed At (KST): 2026-06-12
+1
View File
@@ -31,3 +31,4 @@
| dcy-023_20260610_allocated-capital-dead-field-removal | 20260610 | allocated_capital 죽은 필드 제거 — `enter --capital` 옵션 + slot/intent/snapshot 계약 정리 (issue #22) | [Discovery](./dcy-023_20260610_allocated-capital-dead-field-removal.md) | confirmed |
| dcy-024_20260611_safety-sell-disable-mode | 20260611 | safety-sell 자율 청산 비활성화 모드 — per-instance TOML `safety_sell_enabled=false` 시 워커 미기동 (issue #15) | [Discovery](./dcy-024_20260611_safety-sell-disable-mode.md) | confirmed |
| dcy-025_20260611_strategy-read-surface-slots-req | 20260611 | 전략 read 표면 재설계 — `trex.snapshot.req`→`trex.slots.req` + 슬롯 상태 필터 + 슬롯 단일 행(assets[]/available_slots/watchdog[] 흡수·제거) | [Discovery](./dcy-025_20260611_strategy-read-surface-slots-req.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 |
+2 -1
View File
@@ -251,7 +251,7 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:
- ledger skip 가시화(dcy-011/REQ-018, issue #11): `_record_ledger` 가 `portfolio_id` 또는 `execution_id` 누락으로 skip 하면 무성 반환하지 않고 **WARN 로그 + `ExecutionResult.ledger_skipped` 카운터**로 노출한다(슬롯 전이는 그대로 진행). portfolio_id 필수화(REQ-007)로 주원인은 제거됐고, 잔여 누락은 운영 점검 쿼리(REQ-055: closed 슬롯 ↔ `ledger_outbox` anti-join, `find_ledger_gap_slots`)로 영구 탐지한다.
1. fill 감지: `BrokerPort.poll_fill` 이 `state="filled"` 와 결정적 `execution_id`(paper: `paper:{broker_order_id}:{filled_qty}` / KIS: `kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}`), 계산된 `fee`(BrokerPort `calculate_fee(side, amount)`), `tax=0` 을 운반.
1. fill 감지: `BrokerPort.poll_fill` 이 `state="filled"` 와 결정적 `execution_id`(paper: `paper:{broker_order_id}:{filled_qty}` / KIS: `kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}`), 계산된 `fee`(`calculate_fee(side, amount)`)·`tax`(`calculate_tax(side, amount)`)를 운반. fee/tax 는 단일 정책 지점(`broker_port.py`)에서 env 로 관리한다(dcy-026/REQ-018 CHG-20260612-01): `TREX_KIS_FEE_BPS`(기본 1.5 = 0.015%, 매수·매도 공통)·`TREX_KRX_TAX_SELL_BPS`(기본 20.0 = 0.2%, **매도 증권거래세**; 매수 tax=0). paper·KIS 가 동일 정책을 공유하며, KIS daily-ccld 응답은 per-fill fee/tax 필드를 제공하지 않아 정책 추정값을 사용한다(향후 노출 endpoint 도입 시 브로커 정본 우선).
2. 슬롯 전이: `pending_buy→holding`(avg_price/qty) 또는 `pending_sell→closed` 를 advisory lock 안에서 먼저 commit. 이 단계가 critical path.
3. ledger enqueue: 슬롯의 `portfolio_id`(== ledger `asset_id`, OQ-1 결정) 가 있으면 `LedgerTrade(asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at)` 을 `LedgerOutbox.enqueue` 로 적재. 행 식별자는 `correlation_id = {asset_id}:{order_id}:{execution_id}`(로컬 dedup). `portfolio_id` 부재면 ledger 기록 skip 후 슬롯 전이는 정상 유지.
4. `clear_dispatch` 로 `broker_order_id` 해제.
@@ -328,3 +328,4 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:
- 2026-06-11 :: BAT-035 (dcy-025) — `trex.snapshot.req` → `trex.slots.req` lockstep 교체: `trex-snapshot-rpc` → `trex-slots-rpc` worker, `make_snapshot_rpc_handler` → `make_slots_rpc_handler`, `build_snapshot_envelope`/`View` 제거. 슬롯 RPC 응답 산출 flow, 워치독 spy=true 갱신 억제 flow 추가. 운영자 CLI flow: `trex.snapshot.req` → `trex.slots.req` (REQ-073/074/075).
- 2026-06-11 :: BAT-036 (dcy-024) — 데몬 lifecycle: `safety_sell_enabled=false` 시 `trex-safety-sell` worker 미기동 + WARNING 로그(REQ-048 CHG-01/REQ-005 CHG-01).
- 2026-06-10 :: BAT-031 (dcy-021) — 포지션 사이징(증액·부분 감액) 흐름 추가, transit 13 전이.
- 2026-06-12 :: BAT-038 (dcy-026/issue #24) — 매도 증권거래세 누락 정정: `체결 → ledger outbox` flow 의 `tax=0` → `calculate_tax(side, amount)`(매도 0.2%/매수 0) 정책 계산. fee/tax 단일 정책 지점 + env(`TREX_KIS_FEE_BPS`/`TREX_KRX_TAX_SELL_BPS`), paper·KIS 동일 공유, `tax=0.0` 하드코딩 제거 (REQ-018 CHG-20260612-01).
@@ -10,26 +10,33 @@
## Requirement
- 집행 루프가 매수 체결(`pending_buy→holding`)·매도 체결(`pending_sell→closed`) 시 LedgerPort 로 거래를 기록한다. ledger path 의 `asset_id` 는 슬롯 `portfolio_id`(= asset_id)를 직접 사용하고 side/qty/price/execution_id/fee/tax/executed_at 을 함께 보낸다.
- `FillStatus`(broker_port) 가 `execution_id` 와 `fee` 를 운반하도록 계약을 확장한다. `execution_id` 는 결정적으로 합성한다(KIS: `kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}`, paper: `paper:{broker_order_id}:{filled_qty}`). `fee` 는 BrokerPort fee 계산(orbit `calculate_fee(side, amount)` 패턴), `tax` 는 기본 0 으로 한다.
- `FillStatus`(broker_port) 가 `execution_id`, `fee`, `tax` 를 운반하도록 계약을 확장한다. `execution_id` 는 결정적으로 합성한다(KIS: `kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}`, paper: `paper:{broker_order_id}:{filled_qty}`). `fee` 는 `calculate_fee(side, amount)`(매수·매도 공통 수수료율), `tax` 는 `calculate_tax(side, amount)`(매도 정책 증권거래세율, 매수 0)로 계산한다. 수수료율·세율은 단일 정책 지점에서 env(`TREX_KIS_FEE_BPS`=1.5, `TREX_KRX_TAX_SELL_BPS`=20.0)로 관리하고 paper·KIS 어댑터가 동일 정책을 사용한다(`tax=0.0` 하드코딩 금지). KIS 어댑터는 브로커 체결 응답에 수수료/세금 필드가 있으면 그 값을 정본으로 사용하고 없을 때만 정책 폴백한다. (dcy-026)
- 원장 기록 주체는 코어 집행 경로 단일로 한정한다(전략/도메인은 원장에 접근하지 않는다).
## Acceptance Criteria
1. Given idle 슬롯에 매수 intent → 체결, When 집행 루프 fill 전이, Then 슬롯이 holding 으로 전이되고 원장에 buy 거래 1건이 기록된다(asset_id = 슬롯 portfolio_id).
2. Given holding 슬롯에 매도 intent → 체결, When 집행 루프 fill 전이, Then 슬롯이 closed 로 전이되고 원장에 sell 거래 1건이 기록된다.
3. Given 체결된 주문, When fill 을 읽음, Then `FillStatus` 가 결정적 `execution_id` 와 계산된 `fee`(tax=0)를 운반한다.
3. Given 체결된 주문, When fill 을 읽음, Then `FillStatus` 가 결정적 `execution_id`, 계산된 `fee`, 계산된 `tax`(매도 정책 세율, 매수 0)를 운반한다.
4. Given 슬롯에 `portfolio_id` 가 없는 체결, When fill 전이, Then 원장 기록을 건너뛰거나(또는 정의된 정책으로 처리) 슬롯 전이 자체는 정상 수행된다.
5. Given 전략/도메인 코드, When 점검, Then 원장 기록은 코어 집행 경로에서만 발생한다(단일 기록 주체). (NFR-3)
6. Given 매도 체결(amount=price×qty), When fill 을 읽음, Then `tax` = `amount × TREX_KRX_TAX_SELL_BPS / 10_000`(기본 0.2%)로 계산되고, 매수 체결의 `tax` 는 0 이다.
7. Given 수수료/세율 정책, When paper·KIS 어댑터로 동일 `(side, amount)` 입력, Then 동일 정책 결과를 산출한다(단일 정책 지점 `calculate_fee`/`calculate_tax`, env 설정). `tax=0.0` 하드코딩이 코드에 남지 않는다.
8. Given KIS 체결 응답에 수수료/세금 필드 존재, When fill 파싱, Then 그 값을 정본으로 사용하고, 필드 부재 시에만 정책 추정값으로 폴백한다.
9. Given issue #24 4건 체결(BUY 291,500 / SELL 301,000 / BUY 295,000 / SELL 298,500, qty=1), When fee/tax 계산, Then `fee+tax` 합계가 1,376.9원(fee 177.9 + tax 1,199)에 수렴한다.
## Impacted Area
- Docs: docs/runtime-flows.md(체결→원장 기록 흐름), docs/interface-contract.md(FillStatus 확장) — REQ-020 에서 정합
- Modules: `trex_core/application/execution.py`(fill 전이에 ledger 기록 단계), `trex_core/application/broker_port.py`(FillStatus.execution_id/fee), `trex_core/adapters/broker/{paper,kis}.py`(execution_id 합성 + fee 계산)
- Tests: stub 원장으로 매수/매도 체결 시 buy/sell 1건 + 슬롯 전이, FillStatus execution_id/fee 운반, portfolio_id 부재 처리
- Docs: docs/runtime-flows.md(체결→원장 기록 + fee/tax 산출 흐름), docs/interface-contract.md(FillStatus 확장) — REQ-020 에서 정합
- Modules: `trex_core/application/execution.py`(fill 전이에 ledger 기록 단계), `trex_core/application/broker_port.py`(FillStatus.execution_id/fee/tax + `calculate_fee`/`calculate_tax` 단일 정책 지점 + env 세율 상수), `trex_core/adapters/broker/{paper,kis}.py`(execution_id 합성 + fee/tax 계산, KIS 응답 정본 우선)
- Tests: stub 원장으로 매수/매도 체결 시 buy/sell 1건 + 슬롯 전이, FillStatus execution_id/fee/tax 운반, portfolio_id 부재 처리, 매도 세율 + issue #24 4건 회귀(1,376.9원), paper·KIS 동일 정책 + KIS 응답 우선
## Notes
- 출처: dcy-004 FR-2, OQ-1(asset_id == portfolio_id, orbit 근거), OQ-3(execution_id/fee/tax 출처), NFR-3.
- OQ-1: 슬롯은 이미 buy intent 가 `portfolio_id` 를 공급해 캐시하므로 추가 매핑/조회 없이 path param 으로 사용한다.
- `portfolio_id` 부재 시 기록 skip vs 거부 정책은 구현 시 확정(AC-4 는 둘 다 허용하되 슬롯 전이 일관성을 요구).
- 멱등/무유실/재시도(correlation_id, outbox)는 REQ-019 에서 다룬다. 본 REQ 는 기록 호출 경로와 데이터 운반에 한정한다.
- (dcy-026/CHG-20260612-01) 매도 증권거래세 정책 확정: 매도세 0.2%(`TREX_KRX_TAX_SELL_BPS`=20.0), 수수료 0.015%(`TREX_KIS_FEE_BPS`=1.5, 매수·매도 공통). 변수명에 `KIS`/`KRX` 도메인 라벨이 있으나 단일 정책 지점으로 paper·KIS 가 동일 공유. dcy-004 OQ-3 "정밀 세금 모델" 후속 해소.
- 과거 잘못 저장된 ledger 행 소급 보정은 Out of Scope(dcy-026 OQ-3). 배포 후 신규 체결부터 정확값 저장.
- REQ-011(BrokerPort 추상화)는 본 변경 대상 아님: fee/tax 는 REQ-018 의 `FillStatus` 확장 + `calculate_fee`/`calculate_tax` 정책 영역이며, REQ-011 의 포트 계약(submit/poll/cancel async 더블 + 도메인 순수성)은 영향받지 않음.
## Change Log
@@ -267,3 +274,99 @@
#### Validation Plan
- BAT-019: delivery_failed WARN 로그(caplog) + 카운터 단위.
### CHG-20260612-01
- Date: 2026-06-12
- Author: Justin Ko
#### Change Summary
- (change-req, dcy-026/issue #24) 매도 증권거래세 누락 정정. `FillStatus.tax` 가 매도 체결에 대해 정책 세율(증권거래세)로 계산되도록 계약을 정정한다. 기존 `tax 는 기본 0 으로 한다` 규정과 `tax=0.0` 하드코딩(paper/kis 어댑터)을 제거하고, 수수료율·세율을 단일 정책 지점(`calculate_fee`/`calculate_tax`)에서 env 설정으로 관리한다. KIS 어댑터는 브로커 응답 fee/tax 가 있으면 정본 우선, 없으면 정책 폴백. 기존 `tax=0` 전제 구현/검증 evidence 무효화 → **Implemented → Approved 되돌림**.
#### Intent
- 한국 주식 매도 증권거래세(약 0.2%)가 전 체결에서 `tax=0.0` 으로 저장되어 실현 손익·일일 수익률이 과대 계산되던 운영 버그(issue #24, 2026-06-11 4건 체결 증거)를 닫는다. `broker_port.py` 가 dcy-004 OQ-3 로 미뤄둔 "정밀 세금 모델"을 정식 정책으로 확정한다.
#### Acceptance Criteria Delta
- Updated:
- AC-3: `FillStatus` 가 결정적 `execution_id` 와 계산된 `fee`(tax=0) 운반 → `fee` 와 **매도 정책 세율로 계산된 `tax`**(매수 tax=0) 운반.
- Added:
- AC-6: 매도 체결의 `tax` 는 `amount × TREX_KRX_TAX_SELL_BPS / 10_000`(기본 20.0bps = 0.2%)로 계산되고, 매수 체결의 `tax` 는 0 이다.
- AC-7: 수수료율·세율은 단일 정책 지점(`calculate_fee`/`calculate_tax`)에서 env(`TREX_KIS_FEE_BPS`=1.5, `TREX_KRX_TAX_SELL_BPS`=20.0)로 관리되고 paper·KIS 어댑터가 동일 정책을 사용한다. `tax=0.0` 하드코딩은 제거된다.
- AC-8: KIS 어댑터는 브로커 체결 응답에 수수료/세금 필드가 있으면 그 값을 정본으로 사용하고, 없을 때만 정책 추정값으로 폴백한다.
- AC-9: issue #24 4건 체결(BUY 291,500 / SELL 301,000 / BUY 295,000 / SELL 298,500, qty=1) 재현 시 `fee+tax` 합계가 §4 공식 기대값 **1,376.9원**(fee 177.9 + tax 1,199)에 수렴한다(회귀 테스트).
#### Impacted Area Delta
- Docs:
- docs/runtime-flows.md(체결→ledger fee/tax 산출·저장 흐름 갱신)
- Modules:
- `trex_core/application/broker_port.py`(`calculate_tax` 신규 + 세율 상수, env 로딩)
- `trex_core/adapters/broker/paper.py`(`tax=0.0` → `calculate_tax(...)`)
- `trex_core/adapters/broker/kis.py`(`tax=0.0` → 브로커 응답 파싱 + 정책 폴백)
- Tests:
- 매수/매도 fee·tax 단위 테스트 + issue #24 4건 회귀(1,376.9원) + KIS 응답 우선 경로 + paper·KIS 동일 정책 일치
#### Delivery Trace Delta
- Batches:
- BAT-038 (예정, dcy-026 fee/tax)
- Releases:
- none
#### Revalidation Impact
- Reverification Needed: yes (BAT-038 에서 매도 tax 계산·회귀 재검증)
- Existing Implementation Invalidated: yes (`tax=0` 전제 — AC-3 의 tax=0 evidence 및 paper/kis `tax=0.0` 하드코딩 무효)
- Status Recommendation: Implemented → **Approved** (구현/검증 후 confirm-req-implemented 로 복귀)
- Follow-up Action: confirm-req → draft-batch(BAT-038) → 구현·검증 → confirm-req-implemented
#### Validation Plan
- BAT-038: 매수/매도 fee·tax 단위 테스트(매도 0.2% 세율, 매수 tax=0), issue #24 4건 회귀(`fee+tax`=1,376.9원), KIS 응답 fee/tax 우선·폴백 경로, paper·KIS 동일 입력 동일 출력. `pytest` 전체 회귀 + `ruff check` clean.
### CHG-20260612-02
- Date: 2026-06-12
- Author: Justin Ko
#### Change Summary
- Status 변경: Approved → **Implemented** (confirm-req-implemented, 근거 batch BAT-038 release-candidate).
#### Intent
- BAT-038 verification.md 의 acceptance mapping + evidence(429 passed / fee·tax 정책 단위 / issue #24 1,376.9원 회귀 / `tax=0.0` 하드코딩 제거 grep / runtime-flows 동기화)로 AC-1~9 충족 → 구현 완료 상태로 동기화.
#### Acceptance Criteria Delta
- Added/Updated/Removed: 없음 (상태 전이만)
#### Impacted Area Delta
- Docs/Modules/Tests: 없음 (상태 전이만)
#### Delivery Trace Delta
- Batches:
- BAT-038 (release-candidate)
- Releases:
- none (release 편성은 별도 진행)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: Implemented
- Follow-up Action: draft-release 로 release 편성(BAT-038 포함)
#### Validation Plan
- BAT-038 verification evidence 재참조로 충분.
- AC-3/6: `test_paper_sell_fill_carries_policy_tax`, `test_calculate_tax_sell_only`, `test_paper_buy_fill_has_zero_tax`.
- AC-7: `test_calculate_fee_same_rate_buy_and_sell`, `test_fee_and_tax_zero_for_nonpositive_amount`, `tax=0.0` grep 0건.
- AC-8: design Key Decision 3(KIS daily-ccld fee/tax 필드 부재 → 정책 폴백) + 코드 리뷰.
- AC-9: `test_issue_24_four_fill_regression` (1,376.9원).
- AC-1/2/4/5: BAT-006 evidence 유효 + 전체 회귀 429 passed.
+2 -2
View File
@@ -159,8 +159,8 @@
## Recent Change Log Summary
- 2026-06-12 :: REQ-018 Approved → **Implemented** (confirm-req-implemented, BAT-038): 매도 증권거래세 정책 계산 + `tax=0.0` 제거 구현·검증 완료. 429 passed (fee·tax 정책 6 신규 포함), issue #24 1,376.9원 회귀 green, `tax=0.0` 하드코딩 grep 0건, runtime-flows 동기화. Blocking Issues 없음.
- 2026-06-12 :: REQ-018 change-req (CHG-20260612-01, dcy-026/issue #24): 매도 증권거래세 누락 정정 — `FillStatus.tax` 매도 정책 세율(0.2%) 계산, `tax=0.0` 하드코딩(paper/kis) 제거, 단일 정책 지점 + env(`TREX_KIS_FEE_BPS`/`TREX_KRX_TAX_SELL_BPS`). AC-3 갱신·AC-6~9 신규. `tax=0` evidence 무효 → Implemented → **Approved** 되돌림. BAT-038 구현·검증 후 복귀.
- 2026-06-12 :: REQ-073 Approved → **Implemented** (confirm-req-implemented, BAT-037): `available_slots: int|null` capacity hint 추가. AC-3 반전·AC-6 신규. 16 tests passed (AC-6 3개 포함), 423 전체 회귀 없음. Blocking Issues 없음.
- 2026-06-12 :: REQ-073 change-req (CHG-20260612-01): 응답 payload 에 `available_slots: int|null` 추가. AC-3 반전(available_slots 포함), AC-6 신규(hint 값 규칙). BAT-035 AC-3 evidence 무효 → Implemented → **Approved** 되돌림. 차기 batch 구현·검증 후 복귀.
- 2026-06-11 :: REQ-058 change-req (CHG-20260611-01): `reconcile_universe` 슬롯 생성 시 `symbol` 누락 수정. 시그니처 `set[str]` → `dict[str, str]`(portfolio_id→symbol), INSERT에 symbol 포함, `symbol IS NULL` 기존 슬롯 backfill 추가. AC-5 신규 추가. 기존 AC 1~4 evidence 유효 → **Implemented 유지**.
- 2026-06-11 :: BAT-035/036 완료 — REQ-073/074/075/021/034/063/048/005 Approved → **Implemented**, REQ-028 → **Deprecated** (trex.snapshot.req 코드 삭제·trex.slots.req 대체)
- 2026-06-11 :: REQ-028 Implemented → **Deprecated** (BAT-035, dcy-025): `trex.snapshot.req` envelope (`assets[]`/`View`/`build_snapshot_envelope`) 코드 삭제 완료. 후속 표면은 REQ-073/074 (`trex.slots.req`) 로 대체.
+76
View File
@@ -0,0 +1,76 @@
"""Fee/tax policy tests (REQ-018 CHG-20260612-01, dcy-026, issue #24).
Validates the single fee/tax policy point (`calculate_fee`/`calculate_tax`)
and the paper broker carrying the policy-computed sell transaction tax — the
correction for the `tax=0.0` hardcoding that under-counted realized P&L.
"""
from __future__ import annotations
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.application.broker_port import OrderRequest, calculate_fee, calculate_tax
# --- AC-6/AC-7: policy functions -------------------------------------------
def test_calculate_fee_same_rate_buy_and_sell():
# 0.015 % commission on both sides (TREX_KIS_FEE_BPS=1.5 default)
assert calculate_fee("buy", 1_000_000) == 150.0
assert calculate_fee("sell", 1_000_000) == 150.0
def test_calculate_tax_sell_only():
# 0.2 % securities transaction tax on sells (TREX_KRX_TAX_SELL_BPS=20.0)
assert calculate_tax("sell", 1_000_000) == 2000.0
# buys are tax-free (KR equities)
assert calculate_tax("buy", 1_000_000) == 0.0
def test_fee_and_tax_zero_for_nonpositive_amount():
assert calculate_fee("sell", 0) == 0.0
assert calculate_fee("buy", -100) == 0.0
assert calculate_tax("sell", 0) == 0.0
assert calculate_tax("sell", -100) == 0.0
# --- AC-9: issue #24 four-fill regression (expected total 1,376.9원) --------
def test_issue_24_four_fill_regression():
# (side, price) with qty=1 — from issue #24 evidence (2026-06-11).
fills = [
("buy", 291_500),
("sell", 301_000),
("buy", 295_000),
("sell", 298_500),
]
total_fee = sum(calculate_fee(side, amount) for side, amount in fills)
total_tax = sum(calculate_tax(side, amount) for side, amount in fills)
assert round(total_fee, 1) == 177.9
assert round(total_tax, 1) == 1199.0
assert round(total_fee + total_tax, 1) == 1376.9
# --- AC-7: paper broker carries policy tax (parity with KIS policy) ---------
def _fill(broker, *, side, symbol, qty, price):
ack = broker.submit_order(
OrderRequest(slot_id="s1", side=side, symbol=symbol, qty=qty, limit_price=price)
)
fs = broker.poll_fill(ack.broker_order_id, side=side, symbol=symbol)
assert fs.is_filled
return fs
def test_paper_sell_fill_carries_policy_tax():
broker = PaperBroker(fill_after_polls=0)
fs = _fill(broker, side="sell", symbol="005930", qty=1, price=301_000)
assert fs.fee == calculate_fee("sell", 301_000)
assert fs.tax == calculate_tax("sell", 301_000)
assert fs.tax > 0.0 # the bug: previously hardcoded to 0.0
def test_paper_buy_fill_has_zero_tax():
broker = PaperBroker(fill_after_polls=0)
fs = _fill(broker, side="buy", symbol="005930", qty=1, price=291_500)
assert fs.fee == calculate_fee("buy", 291_500)
assert fs.tax == 0.0
+13 -4
View File
@@ -22,6 +22,7 @@ from trex_core.application.broker_port import (
OrderAck,
OrderRequest,
calculate_fee,
calculate_tax,
)
_KST = timezone(timedelta(hours=9))
@@ -206,11 +207,17 @@ class KISBroker:
row_odno = str(row.get("odno") or odno)
execution_id = f"kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{row_odno}:{ccld_qty}"
fill_price = float(avg) if avg else 0.0
fee = calculate_fee(side, ccld_qty * fill_price)
# dcy-026: inquire-daily-ccld (output1) does not expose per-fill
# fee/tax fields, so use the shared policy estimate (same as paper).
# A future endpoint that returns broker-authoritative fee/tax would
# be parsed here first, with this as fallback (FR-3).
amount = ccld_qty * fill_price
fee = calculate_fee(side, amount)
tax = calculate_tax(side, amount)
return FillStatus(
state="filled", filled_qty=ccld_qty,
avg_price=fill_price or None,
execution_id=execution_id, fee=fee, tax=0.0, raw=row,
execution_id=execution_id, fee=fee, tax=tax, raw=row,
)
# 0 < ccld_qty < ord_qty
if cancelled:
@@ -223,11 +230,13 @@ class KISBroker:
row_odno = str(row.get("odno") or odno)
execution_id = f"kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{row_odno}:{ccld_qty}"
fill_price = float(avg) if avg else 0.0
fee = calculate_fee(side, ccld_qty * fill_price)
amount = ccld_qty * fill_price
fee = calculate_fee(side, amount)
tax = calculate_tax(side, amount)
return FillStatus(
state="partially_filled", filled_qty=ccld_qty,
avg_price=fill_price or None,
execution_id=execution_id, fee=fee, tax=0.0,
execution_id=execution_id, fee=fee, tax=tax,
cancelled=True, raw=row,
)
return FillStatus(state="partially_filled", filled_qty=ccld_qty,
+5 -2
View File
@@ -17,6 +17,7 @@ from trex_core.application.broker_port import (
OrderAck,
OrderRequest,
calculate_fee,
calculate_tax,
)
@@ -84,10 +85,12 @@ class PaperBroker:
# Deterministic execution_id (dcy-004 OQ-3):
# paper:{broker_order_id}:{filled_qty}
execution_id = f"paper:{o.oid}:{o.filled}"
fee = calculate_fee(o.side, o.filled * o.price)
amount = o.filled * o.price
fee = calculate_fee(o.side, amount)
tax = calculate_tax(o.side, amount)
return FillStatus(
state="filled", filled_qty=o.filled, avg_price=o.price,
execution_id=execution_id, fee=fee, tax=0.0,
execution_id=execution_id, fee=fee, tax=tax,
)
return FillStatus(state="partially_filled", filled_qty=o.filled, avg_price=o.price)
+29 -10
View File
@@ -7,6 +7,7 @@ trex_core.adapters.broker. Fills are observed asynchronously via poll_fill
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any, Optional, Protocol
@@ -48,8 +49,11 @@ class FillStatus:
filled_qty: int = 0
avg_price: Optional[float] = None
# REQ-018: deterministic execution_id + broker-computed fee/tax carried
# all the way to the ledger writer. tax defaults to 0 (precise tax model
# is a follow-up Discovery, dcy-004 OQ-3).
# all the way to the ledger writer. fee = calculate_fee(side, amount),
# tax = calculate_tax(side, amount) (sell transaction tax; buy = 0).
# dcy-026/CHG-20260612-01 closed the dcy-004 OQ-3 follow-up: tax is no
# longer hardcoded to 0 — adapters MUST compute it via calculate_tax.
# The field defaults to 0.0 only for non-fill states (accepted/rejected/...).
execution_id: Optional[str] = None
fee: float = 0.0
tax: float = 0.0
@@ -112,20 +116,35 @@ class BrokerPort(Protocol):
...
# --- shared fee model (REQ-018, orbit calculate_fee pattern) -----------------
# Conservative KR equities approximation (bps). Brokers can override, but we
# need a single default so paper and KIS produce comparable ledger entries.
_FEE_BPS_BUY = 1.5 # 0.015 % broker commission on buys
_FEE_BPS_SELL = 1.5 # 0.015 % broker commission on sells (tax is tracked separately)
# --- shared fee/tax policy (REQ-018, dcy-026, orbit calculate_fee pattern) ----
# Single policy point so paper and KIS produce comparable ledger entries
# (NFR-2/NFR-3). Rates are bps (1 bp = 0.01 %) and configurable via env so a
# rate change reaches every broker/ledger path from one place. The variable
# names carry KIS/KRX domain labels, but the values are shared by all adapters
# (the paper double simulates KIS costs). dcy-026 OQ-1/OQ-2 Closed.
_FEE_BPS = float(os.environ.get("TREX_KIS_FEE_BPS", "1.5")) # 0.015 % broker commission (buy & sell)
_TAX_BPS_SELL = float(os.environ.get("TREX_KRX_TAX_SELL_BPS", "20.0")) # 0.2 % KRX sell transaction tax
def calculate_fee(side: str, amount: float) -> float:
"""Estimate broker fee for a notional `amount` (qty * price).
Mirrors orbit `calculate_fee(side, amount)` shape — a single point of
fee policy so all brokers and the ledger agree on the number.
fee policy so all brokers and the ledger agree on the number. Buy and sell
share the same commission rate (`TREX_KIS_FEE_BPS`).
"""
if amount <= 0:
return 0.0
bps = _FEE_BPS_BUY if side == "buy" else _FEE_BPS_SELL
return round(amount * bps / 10_000.0, 4)
return round(amount * _FEE_BPS / 10_000.0, 4)
def calculate_tax(side: str, amount: float) -> float:
"""Estimate transaction tax for a notional `amount` (qty * price).
KR equities: buys are tax-free; sells incur securities transaction tax
(`TREX_KRX_TAX_SELL_BPS`, default 0.2 %). Single policy point alongside
`calculate_fee` (dcy-026 — replaces the old hardcoded `tax=0.0`).
"""
if amount <= 0 or side != "sell":
return 0.0
return round(amount * _TAX_BPS_SELL / 10_000.0, 4)