Skip to content

Commit 4631dbe

Browse files
illenne77Copilot
andcommitted
feat(T003/phase3): Capital Gains Calculator (sentinelq/tax/capital_gains.py)
PREREG-0008 §2.2 — KR NTS 양도세 계산 2단계 (250만 공제 + 22% 합산). 추가: - TaxYearRules / MarketBreakdown / TaxYearSummary 데이터클래스 (frozen) - UnknownTaxYearError 예외 - TAX_YEAR_RULES_2026 + DEFAULT_RULES (2024~2026) - calculate_year(realizations, tax_year, rules=None) — 단일 연도, 다른 연도 매도 자동 필터 - calculate_all(realizations, rules_by_year=None) — 자동 연도분할, 이월공제 없음 - KR 룰: 합산 net → max(0, net-250만) × 0.22 → ROUND_DOWN - market(KR/US) breakdown 부가 노출 (T006 NTS 양식 준비) 테스트: - tests/test_capital_gains.py 17건 추가 (spec §6 12건 목표 초과) - pytest 67 → 84 passed, 회귀 0 - coverage sentinelq/tax/capital_gains.py: 100% - ruff check/format clean, pre-commit all hooks PASS 비스코프 (T004 이후): 배당, 환차익 분리, 12월 손실 인식, 세제 한도, NTS 양식. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 58253af commit 4631dbe

4 files changed

Lines changed: 582 additions & 3 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": 67,
16+
"pass": 84,
1717
"fail": 0,
1818
"skip": 0,
19-
"total": 67,
19+
"total": 84,
2020
"skip_tolerance": 3,
21-
"measured_at": "2026-05-13",
21+
"measured_at": "2026-05-15",
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,
@@ -97,6 +97,13 @@
9797
"fail": 0,
9898
"skip": 0,
9999
"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."
100+
},
101+
{
102+
"date": "2026-05-15",
103+
"pass": 84,
104+
"fail": 0,
105+
"skip": 0,
106+
"note": "T003 Capital Gains Calculator 완료. sentinelq/tax/__init__.py + sentinelq/tax/capital_gains.py 신규 (TaxYearRules/MarketBreakdown/TaxYearSummary + calculate_year + calculate_all). KR 룰: 250만 공제 + 22% 합산 + ROUND_DOWN, 이월공제 불가. tests/test_capital_gains.py 17건 신규 통과. coverage 100% on tax/capital_gains.py. feature/T003-capital-gains."
100107
}
101108
]
102109
}

sentinelq/tax/__init__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Tax modules — KR NTS 양도세·세제 한도·손실 인식 (Phase 3 PREREG-0008)."""
2+
3+
from sentinelq.tax.capital_gains import (
4+
DEFAULT_RULES,
5+
TAX_YEAR_RULES_2026,
6+
MarketBreakdown,
7+
TaxYearRules,
8+
TaxYearSummary,
9+
UnknownTaxYearError,
10+
calculate_all,
11+
calculate_year,
12+
)
13+
14+
__all__ = [
15+
"DEFAULT_RULES",
16+
"TAX_YEAR_RULES_2026",
17+
"MarketBreakdown",
18+
"TaxYearRules",
19+
"TaxYearSummary",
20+
"UnknownTaxYearError",
21+
"calculate_all",
22+
"calculate_year",
23+
]

sentinelq/tax/capital_gains.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""Capital Gains Calculator — KR 양도세 계산 엔진 (Phase 3 T003).
2+
3+
spec: ``.claude/queue/spec-T003.md``
4+
PREREG: ``docs/preregistration/PREREG-0008-tax-tool.md`` §2.2
5+
predecessor: T002 (``sentinelq.portfolio.tax_lots.SaleRealization``)
6+
7+
본 모듈 책임:
8+
9+
* T002 ``SaleRealization`` 시퀀스를 입력받아 KR NTS 룰
10+
(250만원 기본공제 + 22% 세율 + 국내·해외 합산)을 적용한
11+
연도별 ``TaxYearSummary`` 를 산출한다.
12+
* 이월공제 불가 — 다년 입력 시 각 연도 독립 계산.
13+
* 최종 양도세는 NTS 양식 표준 ``ROUND_DOWN`` (원 단위 절사).
14+
* 룰은 ``TaxYearRules`` frozen dataclass 로 외부 주입 가능.
15+
16+
비스코프 (T003 OUT):
17+
18+
* 배당소득 (15.4% 원천징수, 별도 모듈)
19+
* 환차익 분리과세 (NTS 는 KRW 환산값만 본다)
20+
* 12월 손실 인식 권장 (T005 = ``loss_harvesting.py``)
21+
* 세제 우대 한도 추적 (T004 = ``deduction.py``)
22+
* NTS 양식 PDF 출력 (T006 = ``reports/nts_form.py``)
23+
* 국내 상장주식 소액주주 비과세 / 대주주 판정
24+
(PREREG §2.2 가 합산 단순화 채택, 차이는 amendment 영역)
25+
26+
mandate 위반 금지 (ADR-0011·0012·0013): 알파·자동매매·시장 타이밍 코드 없음.
27+
"""
28+
29+
from __future__ import annotations
30+
31+
from collections import defaultdict
32+
from collections.abc import Iterable
33+
from dataclasses import dataclass
34+
from decimal import ROUND_DOWN, Decimal
35+
36+
from sentinelq.portfolio.tax_lots import SaleRealization
37+
38+
# ---- 예외 ----
39+
40+
41+
class UnknownTaxYearError(KeyError):
42+
"""``rules`` None 인데 ``DEFAULT_RULES`` 에 해당 연도 키가 없음."""
43+
44+
45+
# ---- 룰 ----
46+
47+
48+
@dataclass(frozen=True)
49+
class TaxYearRules:
50+
"""단일 과세기간(연도) KR 양도세 룰. 변경 대비 frozen + 외부 주입 가능."""
51+
52+
basic_deduction_krw: Decimal
53+
tax_rate: Decimal
54+
55+
56+
TAX_YEAR_RULES_2026: TaxYearRules = TaxYearRules(
57+
basic_deduction_krw=Decimal("2500000"),
58+
tax_rate=Decimal("0.22"),
59+
)
60+
61+
62+
DEFAULT_RULES: dict[int, TaxYearRules] = {
63+
2024: TAX_YEAR_RULES_2026,
64+
2025: TAX_YEAR_RULES_2026,
65+
2026: TAX_YEAR_RULES_2026,
66+
}
67+
68+
69+
# ---- 데이터 모델 ----
70+
71+
72+
@dataclass(frozen=True)
73+
class MarketBreakdown:
74+
"""국내·해외 market 별 raw 합산 (정보 노출용, 과세 단위 아님).
75+
76+
합산 과세는 ``TaxYearSummary.total_realized_gain_krw`` 가 담당하며,
77+
본 breakdown 은 NTS 양식의 분리 칸 출력(T006) 준비 + 사용자 가시성용.
78+
"""
79+
80+
market: str
81+
total_proceeds_krw: Decimal
82+
total_acq_cost_krw: Decimal
83+
realized_gain_krw: Decimal
84+
sale_count: int
85+
86+
87+
@dataclass(frozen=True)
88+
class TaxYearSummary:
89+
"""단일 과세기간(연도) 양도세 요약."""
90+
91+
tax_year: int
92+
rules: TaxYearRules
93+
by_market: tuple[MarketBreakdown, ...]
94+
total_realized_gain_krw: Decimal
95+
deduction_applied_krw: Decimal
96+
taxable_base_krw: Decimal
97+
capital_gains_tax_krw: Decimal
98+
sale_count: int
99+
100+
101+
# ---- 내부 헬퍼 ----
102+
103+
104+
def _resolve_rules(tax_year: int, rules: TaxYearRules | None) -> TaxYearRules:
105+
if rules is not None:
106+
return rules
107+
try:
108+
return DEFAULT_RULES[tax_year]
109+
except KeyError as exc:
110+
raise UnknownTaxYearError(
111+
f"no DEFAULT_RULES entry for tax_year={tax_year}; "
112+
"pass an explicit `rules=TaxYearRules(...)`"
113+
) from exc
114+
115+
116+
def _build_breakdowns(
117+
realizations: list[SaleRealization],
118+
) -> tuple[MarketBreakdown, ...]:
119+
grouped: dict[str, list[SaleRealization]] = defaultdict(list)
120+
for r in realizations:
121+
grouped[r.market].append(r)
122+
out: list[MarketBreakdown] = []
123+
for market in sorted(grouped):
124+
sales = grouped[market]
125+
out.append(
126+
MarketBreakdown(
127+
market=market,
128+
total_proceeds_krw=sum((s.total_proceeds_krw for s in sales), Decimal("0")),
129+
total_acq_cost_krw=sum((s.total_acq_cost_krw for s in sales), Decimal("0")),
130+
realized_gain_krw=sum((s.total_realized_gain_krw for s in sales), Decimal("0")),
131+
sale_count=len(sales),
132+
)
133+
)
134+
return tuple(out)
135+
136+
137+
def _empty_summary(tax_year: int, rules: TaxYearRules) -> TaxYearSummary:
138+
return TaxYearSummary(
139+
tax_year=tax_year,
140+
rules=rules,
141+
by_market=(),
142+
total_realized_gain_krw=Decimal("0"),
143+
deduction_applied_krw=Decimal("0"),
144+
taxable_base_krw=Decimal("0"),
145+
capital_gains_tax_krw=Decimal("0"),
146+
sale_count=0,
147+
)
148+
149+
150+
# ---- public API ----
151+
152+
153+
def calculate_year(
154+
realizations: Iterable[SaleRealization],
155+
tax_year: int,
156+
rules: TaxYearRules | None = None,
157+
) -> TaxYearSummary:
158+
"""단일 과세기간 양도세 계산.
159+
160+
입력에 다른 연도 매도가 섞여 있으면 ``sell_date.year != tax_year`` 항목은
161+
조용히 필터링한다 (다년 통합 입력에서 단일 연도 추출 유스케이스).
162+
매도 0건이어도 영(零) summary 를 반환한다.
163+
"""
164+
resolved = _resolve_rules(tax_year, rules)
165+
matched = [r for r in realizations if r.sell_date.year == tax_year]
166+
if not matched:
167+
return _empty_summary(tax_year, resolved)
168+
169+
by_market = _build_breakdowns(matched)
170+
net = sum((b.realized_gain_krw for b in by_market), Decimal("0"))
171+
172+
if net <= 0:
173+
deduction_applied = Decimal("0")
174+
taxable_base = Decimal("0")
175+
else:
176+
deduction_applied = min(net, resolved.basic_deduction_krw)
177+
taxable_base = net - deduction_applied
178+
179+
raw_tax = taxable_base * resolved.tax_rate
180+
tax = raw_tax.quantize(Decimal("1"), rounding=ROUND_DOWN)
181+
182+
return TaxYearSummary(
183+
tax_year=tax_year,
184+
rules=resolved,
185+
by_market=by_market,
186+
total_realized_gain_krw=net,
187+
deduction_applied_krw=deduction_applied,
188+
taxable_base_krw=taxable_base,
189+
capital_gains_tax_krw=tax,
190+
sale_count=len(matched),
191+
)
192+
193+
194+
def calculate_all(
195+
realizations: Iterable[SaleRealization],
196+
rules_by_year: dict[int, TaxYearRules] | None = None,
197+
) -> list[TaxYearSummary]:
198+
"""입력의 ``sell_date.year`` 기준 자동 분할 → 연도별 summary 리스트.
199+
200+
빈 입력은 빈 리스트를 반환. 이월공제는 적용되지 않는다 (KR 룰).
201+
각 연도 summary 는 ``calculate_year`` 와 동일한 룰 해소 절차를 거친다.
202+
"""
203+
materialised = list(realizations)
204+
if not materialised:
205+
return []
206+
207+
years = sorted({r.sell_date.year for r in materialised})
208+
out: list[TaxYearSummary] = []
209+
for year in years:
210+
override = rules_by_year.get(year) if rules_by_year else None
211+
out.append(calculate_year(materialised, year, rules=override))
212+
return out

0 commit comments

Comments
 (0)