英国 CGT 计算器

英国资本利得税 · 股票处置 · 2025 · Blick Rothenberg 工作期间个人项目

在线预览 selfloom.ai/apps/cgt-calculator
打开完整计算器

起因

我在 Blick Rothenberg 做税务期间发现,股票 CGT 很多时间耗在重复配对上。同日、30 日和 Section 104 的顺序是固定的,适合交给确定性程序处理。

这个工具的目标很窄:导入交易,按 HMRC share identification rules 生成逐笔底稿,再把人的时间留给复核、例外情况和客户判断。

HMRC 的四条规则

股票 CGT 的难点不是公式,而是顺序。每次卖出都要先看同日买入,再看之后 30 天买入,最后才动 Section 104 池。顺序一错,成本和利得都会变。

1. 同日匹配 (Same Day)

规则:同一天里卖出的股,先跟当天买入配对,配完才看其他规则。

例子:1 月 15 日上午以 £5 买入 100 股,同日下午以 £6 卖出 60 股。60 股直接按当天 £5 的买入成本配对,利得 = 60 × (£6 − £5) = £60。剩下 40 股再进入 Section 104 池。

系统怎么处理

1. 找到 sale date 等于 buy date 的买入。
2. 卖出数量和当天剩余买入数量取较小值。
3. 用当天买入价作为 allowable cost。
4. 已配对的数量不进入 Section 104 池。

这条容易被手工表格漏掉。一旦把同日买入先并进池子,平均成本就会被提前污染。

2. 30 日匹配 (Bed & Breakfast)

规则:卖出之后 30 天内再买回同公司同类股,要按买入日期从早到晚跟那笔卖出配对。

例子:1 月 10 日以 £8 卖出 100 股,1 月 25 日以 £7 买回 100 股。1 月 25 日在卖出后的 30 天内,所以这 100 股跟 1 月 10 日的卖出配对,利得 = 100 × (£8 − £7) = £100。这笔买入不进池。

系统怎么处理

1. 只看卖出日之后的买入,不看卖出日前的买入。
2. 日期差必须大于 0 且不超过 30 天。
3. 多笔买入按日期从早到晚配对。
4. 已配对的买入数量不再进入 Section 104 池。

代码里的核心边界就是 daysDiff > 0 && daysDiff <= 30。这不是估算,而是规则本身。

3. Section 104 池

规则:同公司同类股、且没有被同日或 30 日规则配走的部分,进入同一个 Section 104 池。池只维护两个状态:总股数和总成本。

cost_of_disposal = (units_sold / pool_units) × pool_cost
pool_units      -= units_sold
pool_cost       -= cost_of_disposal

例子:池里 200 股,总成本 £1,000,平均 £5/股。某日以 £7 卖出 50 股。分配成本 = (50 / 200) × £1,000 = £250,利得 = 50 × £7 − £250 = £100。卖完后池子剩 150 股 / £750。

系统怎么处理

1. 每只股票维护自己的 pool:shares + totalCost。
2. 卖出时用 pool.totalCost / pool.shares 得到平均成本。
3. 本次卖出成本 = 卖出股数 × 平均成本。
4. 卖出后同时扣 pool.shares 和 pool.totalCost。

关键是池子必须跨多笔卖出持续更新,不能每次从原始买入列表重新算。否则第一笔卖出扣掉的成本,会在第二笔卖出里又被用一次。

4. 税率分档

规则:先汇总全年股票利得,再扣 annual exempt amount。当前实现使用 2024 年 10 月 30 日之后股票处置的税率:basic 18%,higher / additional 24%。

例子:全年总利得 £20,000,higher rate 纳税人。应税利得 = £20,000 − £3,000 = £17,000,应缴税 = £17,000 × 24% = £4,080

系统怎么处理

1. 汇总所有股票的 totalGain。
2. 扣 £3,000 annual exempt amount。
3. 按用户选择的 taxpayer band 套 18% 或 24%。
4. 税率和免税额集中放在 tax-constants.ts。

这里有一个明确边界:2024/25 同时存在 10%/20% 和 18%/24% 两套主税率。这个版本只覆盖 2024 年 10 月 30 日之后适用 18%/24% 的股票处置。

架构

通用 CSV / Excel 导入

券商导出格式都不同。导入层先把 CSV / Excel 统一成一个最小内部结构:

interface Transaction {
  id:            string
  date:          string   // YYYY-MM-DD
  ticker:        string
  type:          'buy' | 'sell'
  shares:        number
  pricePerShare: number
}

这段结构不是为了展示 TypeScript,而是说明计算只需要五类信息:日期、股票、买卖方向、数量和单价。其他列不进入匹配引擎。

数字精度

金额最终要落到便士。这里没有引入 Decimal 库,而是在每条规则算完一笔后,把 proceedscostgain 和池子扣减统一收口到两位小数:

const round2 = (n: number) => Math.round(n * 100) / 100

这样做的目的很简单:不让浮点误差从第一笔交易一路带到最后一笔。

这是工程取舍,不是税法规则。它需要靠测试兜住,所以我把同日、30 日、Section 104 和多股票场景做成固定测试。

匹配引擎

匹配引擎是确定性代码。输入是按日期排序的交易,输出是逐笔处置底稿:哪条规则生效、配到哪笔买入、从池子扣了多少,全部列出来。

这比只给总数重要。税务复核看的是过程:为什么这笔成本可以扣,为什么这笔买入没有进池。

税率分档

税率层只做最后一步:接收总利得和纳税人档位,扣免税额,再套当前版本支持的税率。

测试

我把匹配逻辑拆成 6 个固定测试场景。它们不是为了证明覆盖所有 CGT,而是防止核心规则在重构时被改坏:

  1. 纯 Section 104 池(无同日 / 30 天)
  2. 同日规则
  3. Bed & Breakfast 30 天规则
  4. 三规则混合
  5. 多笔 Section 104 卖出——验证池子状态正确累减
  6. 多 ticker——每只独立池,顶层汇总

回归测试的意思是:只要算法没变,这些场景的输出就不该变。旧税率年份不能直接拿来测新税率,但同日、30 日和 Section 104 的匹配顺序可以作为规则测试保留下来。

边界

所以这个项目不是“英国 CGT 全能计算器”,而是一个股票处置匹配引擎,加上一个有明确边界的税额估算层。

反思

AI 可以帮我写代码,但产品运行时不调用大模型。导入、匹配、池子扣减和税率分档,全部是确定性 TypeScript。

为什么不让 LLM 参与计算:税务计算需要可复现。相同输入必须得到相同输出,每一步都要能解释到规则和数字。LLM 可以解释结果,但不能生成结果。

这对 TaxPilot 的启发:合规系统可以把“解释”和“计算”分开。解释层可以用 LLM,计算层必须是版本化规则、测试用例和可审计底稿。

这也是 TaxPilot 的起点:把税务知识、业务流程、外部工具和人工复核拆开,让每一层只做自己能负责的事。