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