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
+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)