|
| 1 | +"""FIFO Tax Lot Tracker — KR NTS 양도세 lot 매칭 엔진 (Phase 3 T002). |
| 2 | +
|
| 3 | +spec: ``.claude/queue/spec-T002.md`` |
| 4 | +PREREG: ``docs/preregistration/PREREG-0008-tax-tool.md`` §4.1 |
| 5 | +
|
| 6 | +본 모듈 책임: |
| 7 | +
|
| 8 | +* T001 ``Transaction`` 시퀀스를 입력받아 매도 한 건마다 어느 매수 lot이 |
| 9 | + 어떤 비율로 소비되었는지의 breakdown과 KRW 단위 실현손익을 산출한다. |
| 10 | +* FIFO (선입선출) 매칭을 ``(market, ticker)`` 키 단위로 수행한다. |
| 11 | +* 매수 수수료·세금은 취득가액에 가산, 매도 수수료·세금은 양도가액에서 |
| 12 | + 차감한다 (NTS 표준). |
| 13 | +* USD 거래는 자기 거래일 ``fx_rate`` 로 KRW 환산한다 (취득·매도 각각). |
| 14 | +
|
| 15 | +비스코프 (T003 이후): |
| 16 | +
|
| 17 | +* 250만원 기본공제 · 22% 세율 · 국내해외 합산 |
| 18 | +* 배당소득 · 환차익 분리 |
| 19 | +* 12월 손실 인식 권장 |
| 20 | +* NTS 양식 출력 |
| 21 | +
|
| 22 | +mandate 위반 금지 (ADR-0011·0012·0013): 알파·자동매매·시장 타이밍 코드 없음. |
| 23 | +""" |
| 24 | + |
| 25 | +from __future__ import annotations |
| 26 | + |
| 27 | +import itertools |
| 28 | +from collections import defaultdict, deque |
| 29 | +from collections.abc import Iterable |
| 30 | +from dataclasses import dataclass |
| 31 | +from datetime import date |
| 32 | +from decimal import Decimal |
| 33 | + |
| 34 | +from sentinelq.adapters.kis_history import Transaction |
| 35 | + |
| 36 | +# ---- 예외 ---- |
| 37 | + |
| 38 | + |
| 39 | +class TaxLotError(Exception): |
| 40 | + """tax_lots 모듈 공통 base 예외.""" |
| 41 | + |
| 42 | + |
| 43 | +class InsufficientLotsError(TaxLotError): |
| 44 | + """매도 수량이 누적 보유 lot 합보다 큼 (공매도).""" |
| 45 | + |
| 46 | + |
| 47 | +class MissingFxRateError(TaxLotError): |
| 48 | + """USD 거래에 ``fx_rate`` 가 ``None``.""" |
| 49 | + |
| 50 | + |
| 51 | +# ---- 데이터 모델 ---- |
| 52 | + |
| 53 | + |
| 54 | +@dataclass(frozen=True) |
| 55 | +class Lot: |
| 56 | + """단일 매수 lot. FIFO 큐의 원소.""" |
| 57 | + |
| 58 | + lot_id: int |
| 59 | + market: str # "KR" | "US" |
| 60 | + ticker: str |
| 61 | + acquired_date: date |
| 62 | + original_qty: int |
| 63 | + remaining_qty: int |
| 64 | + cost_per_share_krw: Decimal |
| 65 | + |
| 66 | + def with_remaining(self, new_remaining: int) -> Lot: |
| 67 | + """잔여 수량만 갱신한 새 인스턴스 (frozen 이라 교체).""" |
| 68 | + return Lot( |
| 69 | + lot_id=self.lot_id, |
| 70 | + market=self.market, |
| 71 | + ticker=self.ticker, |
| 72 | + acquired_date=self.acquired_date, |
| 73 | + original_qty=self.original_qty, |
| 74 | + remaining_qty=new_remaining, |
| 75 | + cost_per_share_krw=self.cost_per_share_krw, |
| 76 | + ) |
| 77 | + |
| 78 | + |
| 79 | +@dataclass(frozen=True) |
| 80 | +class LotConsumption: |
| 81 | + """매도 한 건이 단일 BUY lot 한 개를 소비한 분량 (FIFO breakdown 한 줄).""" |
| 82 | + |
| 83 | + lot_id: int |
| 84 | + qty: int |
| 85 | + acq_cost_krw: Decimal |
| 86 | + sale_proceeds_krw: Decimal |
| 87 | + realized_gain_krw: Decimal |
| 88 | + acquired_date: date |
| 89 | + sell_date: date |
| 90 | + holding_days: int |
| 91 | + |
| 92 | + |
| 93 | +@dataclass(frozen=True) |
| 94 | +class SaleRealization: |
| 95 | + """매도 한 건의 종합 실현 (소비된 lot들의 합).""" |
| 96 | + |
| 97 | + ticker: str |
| 98 | + market: str |
| 99 | + sell_date: date |
| 100 | + total_qty: int |
| 101 | + total_acq_cost_krw: Decimal |
| 102 | + total_proceeds_krw: Decimal |
| 103 | + total_realized_gain_krw: Decimal |
| 104 | + consumptions: tuple[LotConsumption, ...] |
| 105 | + |
| 106 | + |
| 107 | +# ---- 내부 헬퍼 ---- |
| 108 | + |
| 109 | + |
| 110 | +def _to_krw(amount: Decimal, currency: str, fx_rate: Decimal | None) -> Decimal: |
| 111 | + """단일 금액을 KRW로 환산. USD인데 fx_rate가 None이면 raise.""" |
| 112 | + if currency == "KRW": |
| 113 | + return amount |
| 114 | + if fx_rate is None: |
| 115 | + raise MissingFxRateError("USD amount requires fx_rate") |
| 116 | + return amount * fx_rate |
| 117 | + |
| 118 | + |
| 119 | +def _validate(tx: Transaction) -> None: |
| 120 | + """입력 검증 (모든 BUY/SELL 공통).""" |
| 121 | + if tx.quantity <= 0: |
| 122 | + raise ValueError(f"quantity must be > 0, got {tx.quantity}") |
| 123 | + if tx.market == "US" and tx.fx_rate is None: |
| 124 | + raise MissingFxRateError(f"US trade {tx.ticker} on {tx.trade_date} missing fx_rate") |
| 125 | + if tx.market == "KR" and tx.currency != "KRW": |
| 126 | + raise ValueError(f"KR market requires KRW currency, got {tx.currency} for {tx.ticker}") |
| 127 | + if tx.side not in ("BUY", "SELL"): |
| 128 | + raise ValueError(f"side must be 'BUY' or 'SELL', got {tx.side!r}") |
| 129 | + |
| 130 | + |
| 131 | +# ---- Ledger ---- |
| 132 | + |
| 133 | + |
| 134 | +class TaxLotLedger: |
| 135 | + """FIFO lot 매칭 상태 객체. |
| 136 | +
|
| 137 | + Usage |
| 138 | + ----- |
| 139 | + >>> ledger = TaxLotLedger() |
| 140 | + >>> ledger.apply_all(transactions) |
| 141 | + >>> for r in ledger.realizations(): |
| 142 | + ... print(r.ticker, r.total_realized_gain_krw) |
| 143 | + """ |
| 144 | + |
| 145 | + def __init__(self) -> None: |
| 146 | + self._lots: dict[tuple[str, str], deque[Lot]] = defaultdict(deque) |
| 147 | + self._realizations: list[SaleRealization] = [] |
| 148 | + self._lot_id_counter = itertools.count(1) |
| 149 | + |
| 150 | + # ---- public API ---- |
| 151 | + |
| 152 | + def apply(self, tx: Transaction) -> SaleRealization | None: |
| 153 | + """단일 거래 적용. BUY → None, SELL → SaleRealization 반환.""" |
| 154 | + _validate(tx) |
| 155 | + key = (tx.market, tx.ticker) |
| 156 | + if tx.side == "BUY": |
| 157 | + self._apply_buy(tx, key) |
| 158 | + return None |
| 159 | + realization = self._apply_sell(tx, key) |
| 160 | + self._realizations.append(realization) |
| 161 | + return realization |
| 162 | + |
| 163 | + def apply_all(self, txs: Iterable[Transaction]) -> list[SaleRealization]: |
| 164 | + """거래 시퀀스를 순서대로 적용. SELL 결과만 모아 반환.""" |
| 165 | + out: list[SaleRealization] = [] |
| 166 | + for tx in txs: |
| 167 | + r = self.apply(tx) |
| 168 | + if r is not None: |
| 169 | + out.append(r) |
| 170 | + return out |
| 171 | + |
| 172 | + def realizations(self) -> list[SaleRealization]: |
| 173 | + """지금까지 누적된 모든 매도 실현 내역.""" |
| 174 | + return list(self._realizations) |
| 175 | + |
| 176 | + def open_lots(self, market: str, ticker: str) -> list[Lot]: |
| 177 | + """특정 (market, ticker)의 잔여 lot 큐 사본.""" |
| 178 | + return list(self._lots.get((market, ticker), ())) |
| 179 | + |
| 180 | + def open_lots_all(self) -> dict[tuple[str, str], list[Lot]]: |
| 181 | + """잔여가 있는 모든 (market, ticker)의 lot 큐 사본.""" |
| 182 | + return {k: list(v) for k, v in self._lots.items() if v} |
| 183 | + |
| 184 | + # ---- 내부 구현 ---- |
| 185 | + |
| 186 | + def _apply_buy(self, tx: Transaction, key: tuple[str, str]) -> None: |
| 187 | + fx = tx.fx_rate if tx.currency == "USD" else None |
| 188 | + gross = _to_krw(tx.price * Decimal(tx.quantity), tx.currency, fx) |
| 189 | + fee_krw = _to_krw(tx.fee, tx.currency, fx) |
| 190 | + tax_krw = _to_krw(tx.tax, tx.currency, fx) |
| 191 | + # 매수: 취득가액 += fee + tax (NTS 룰) |
| 192 | + total_cost = gross + fee_krw + tax_krw |
| 193 | + cost_per_share = total_cost / Decimal(tx.quantity) |
| 194 | + lot = Lot( |
| 195 | + lot_id=next(self._lot_id_counter), |
| 196 | + market=tx.market, |
| 197 | + ticker=tx.ticker, |
| 198 | + acquired_date=tx.trade_date, |
| 199 | + original_qty=tx.quantity, |
| 200 | + remaining_qty=tx.quantity, |
| 201 | + cost_per_share_krw=cost_per_share, |
| 202 | + ) |
| 203 | + self._lots[key].append(lot) |
| 204 | + |
| 205 | + def _apply_sell(self, tx: Transaction, key: tuple[str, str]) -> SaleRealization: |
| 206 | + fx = tx.fx_rate if tx.currency == "USD" else None |
| 207 | + gross_proceeds = _to_krw(tx.price * Decimal(tx.quantity), tx.currency, fx) |
| 208 | + fee_krw = _to_krw(tx.fee, tx.currency, fx) |
| 209 | + tax_krw = _to_krw(tx.tax, tx.currency, fx) |
| 210 | + # 매도: 양도가액 -= fee + tax (NTS 룰) |
| 211 | + net_proceeds = gross_proceeds - fee_krw - tax_krw |
| 212 | + proceeds_per_share = net_proceeds / Decimal(tx.quantity) |
| 213 | + |
| 214 | + queue = self._lots[key] |
| 215 | + total_open = sum(lot.remaining_qty for lot in queue) |
| 216 | + if total_open < tx.quantity: |
| 217 | + raise InsufficientLotsError( |
| 218 | + f"SELL qty {tx.quantity} > open {total_open} for " |
| 219 | + f"{tx.market}/{tx.ticker} on {tx.trade_date}" |
| 220 | + ) |
| 221 | + |
| 222 | + consumptions: list[LotConsumption] = [] |
| 223 | + remaining = tx.quantity |
| 224 | + while remaining > 0: |
| 225 | + head = queue[0] |
| 226 | + take = min(head.remaining_qty, remaining) |
| 227 | + acq = head.cost_per_share_krw * Decimal(take) |
| 228 | + proc = proceeds_per_share * Decimal(take) |
| 229 | + gain = proc - acq |
| 230 | + consumptions.append( |
| 231 | + LotConsumption( |
| 232 | + lot_id=head.lot_id, |
| 233 | + qty=take, |
| 234 | + acq_cost_krw=acq, |
| 235 | + sale_proceeds_krw=proc, |
| 236 | + realized_gain_krw=gain, |
| 237 | + acquired_date=head.acquired_date, |
| 238 | + sell_date=tx.trade_date, |
| 239 | + holding_days=(tx.trade_date - head.acquired_date).days, |
| 240 | + ) |
| 241 | + ) |
| 242 | + new_rem = head.remaining_qty - take |
| 243 | + if new_rem == 0: |
| 244 | + queue.popleft() |
| 245 | + else: |
| 246 | + queue[0] = head.with_remaining(new_rem) |
| 247 | + remaining -= take |
| 248 | + |
| 249 | + total_acq = sum((c.acq_cost_krw for c in consumptions), Decimal(0)) |
| 250 | + total_proc = sum((c.sale_proceeds_krw for c in consumptions), Decimal(0)) |
| 251 | + return SaleRealization( |
| 252 | + ticker=tx.ticker, |
| 253 | + market=tx.market, |
| 254 | + sell_date=tx.trade_date, |
| 255 | + total_qty=tx.quantity, |
| 256 | + total_acq_cost_krw=total_acq, |
| 257 | + total_proceeds_krw=total_proc, |
| 258 | + total_realized_gain_krw=total_proc - total_acq, |
| 259 | + consumptions=tuple(consumptions), |
| 260 | + ) |
| 261 | + |
| 262 | + |
| 263 | +__all__ = [ |
| 264 | + "InsufficientLotsError", |
| 265 | + "Lot", |
| 266 | + "LotConsumption", |
| 267 | + "MissingFxRateError", |
| 268 | + "SaleRealization", |
| 269 | + "TaxLotError", |
| 270 | + "TaxLotLedger", |
| 271 | +] |
0 commit comments