Skip to content

Commit 58253af

Browse files
illenne77Copilot
andcommitted
feat(T002/phase3): FIFO Tax Lot Tracker (sentinelq/portfolio/tax_lots.py)
PREREG-0008 §4.1 — KR NTS 양도세 계산 1단계 (lot 매칭 + 매도별 실현손익). 추가: - Lot/LotConsumption/SaleRealization 데이터클래스 (frozen) - TaxLotLedger: apply / apply_all / realizations / open_lots(_all) - FIFO 매칭 per (market, ticker) 큐 — KR/US 같은 심볼 분리 - NTS 룰: 매수→취득가액 가산, 매도→양도가액 차감 - USD 거래: 자기 거래일 fx_rate 환산 (Decimal-only) - 예외: InsufficientLotsError, MissingFxRateError 테스트: - tests/test_tax_lots.py 21건 추가 (spec §6 14건 목표 초과) - pytest 46 → 67 passed, 회귀 0 - coverage sentinelq/portfolio/tax_lots.py: 99% - ruff check/format clean, pre-commit all hooks PASS 비스코프 (T003 이후): 250만 공제, 22% 세율, 국내해외 통산, 배당, NTS 양식. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 280debf commit 58253af

4 files changed

Lines changed: 658 additions & 5 deletions

File tree

.claude/baseline.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313
},
1414
"test": {
1515
"framework": "pytest",
16-
"pass": 46,
16+
"pass": 67,
1717
"fail": 0,
1818
"skip": 0,
19-
"total": 46,
19+
"total": 67,
2020
"skip_tolerance": 3,
21-
"measured_at": "2026-05-11",
21+
"measured_at": "2026-05-13",
2222
"_comment": "Phase 3 첫 측정 (T001 Build Phase A). 기존 Phase 0~2 테스트 32개 (portfolio/risk/walkforward/ports_smoke). 회귀 차단용 floor. T001 완료 시 신규 테스트 10개+ 추가 예정 → 42+로 갱신",
2323
"test_dir": "tests/",
2424
"coverage_target": 80,
@@ -90,6 +90,13 @@
9090
"fail": 0,
9191
"skip": 0,
9292
"note": "하네스 L4→L4+ 강화. CI(.github/workflows/ci.yml) + gitleaks(.gitleaks.toml) + pre-commit(.pre-commit-config.yaml) 추가. ruff 베이스라인 클린화 (113→0 errors, 14 파일 format, StrEnum 마이그레이션, tests/ E402 per-file-ignore). pytest 회귀 없음."
93+
},
94+
{
95+
"date": "2026-05-13",
96+
"pass": 67,
97+
"fail": 0,
98+
"skip": 0,
99+
"note": "T002 FIFO Tax Lot Tracker 완료. sentinelq/portfolio/tax_lots.py 신규 (Lot/LotConsumption/SaleRealization/TaxLotLedger, NTS 룰: 매수→취득가액 가산, 매도→양도가액 차감, USD 거래는 자기 거래일 fx_rate 환산). tests/test_tax_lots.py 21건 신규 통과. coverage 99% on tax_lots.py. feature/T002-tax-lots."
93100
}
94101
]
95102
}

sentinelq/portfolio/__init__.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
1-
"""Portfolio bookkeeping module — single-currency (KRW) NAV tracking."""
1+
"""Portfolio bookkeeping module — single-currency (KRW) NAV tracking + FIFO tax lots."""
22

33
from sentinelq.portfolio.portfolio import Fill, Portfolio, PositionState
4+
from sentinelq.portfolio.tax_lots import (
5+
InsufficientLotsError,
6+
Lot,
7+
LotConsumption,
8+
MissingFxRateError,
9+
SaleRealization,
10+
TaxLotError,
11+
TaxLotLedger,
12+
)
413

5-
__all__ = ["Fill", "Portfolio", "PositionState"]
14+
__all__ = [
15+
"Fill",
16+
"InsufficientLotsError",
17+
"Lot",
18+
"LotConsumption",
19+
"MissingFxRateError",
20+
"Portfolio",
21+
"PositionState",
22+
"SaleRealization",
23+
"TaxLotError",
24+
"TaxLotLedger",
25+
]

sentinelq/portfolio/tax_lots.py

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
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

Comments
 (0)