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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user