英国 CGT 计算器
英国资本利得税 · 股票处置 · 2025 · Blick Rothenberg 工作期间个人项目
起因
我在 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 库,而是在每条规则算完一笔后,把 proceeds、cost、gain 和池子扣减统一收口到两位小数:
const round2 = (n: number) => Math.round(n * 100) / 100
这样做的目的很简单:不让浮点误差从第一笔交易一路带到最后一笔。
这是工程取舍,不是税法规则。它需要靠测试兜住,所以我把同日、30 日、Section 104 和多股票场景做成固定测试。
匹配引擎
匹配引擎是确定性代码。输入是按日期排序的交易,输出是逐笔处置底稿:哪条规则生效、配到哪笔买入、从池子扣了多少,全部列出来。
这比只给总数重要。税务复核看的是过程:为什么这笔成本可以扣,为什么这笔买入没有进池。
税率分档
税率层只做最后一步:接收总利得和纳税人档位,扣免税额,再套当前版本支持的税率。
测试
我把匹配逻辑拆成 6 个固定测试场景。它们不是为了证明覆盖所有 CGT,而是防止核心规则在重构时被改坏:
- 纯 Section 104 池(无同日 / 30 天)
- 同日规则
- Bed & Breakfast 30 天规则
- 三规则混合
- 多笔 Section 104 卖出——验证池子状态正确累减
- 多 ticker——每只独立池,顶层汇总
回归测试的意思是:只要算法没变,这些场景的输出就不该变。旧税率年份不能直接拿来测新税率,但同日、30 日和 Section 104 的匹配顺序可以作为规则测试保留下来。
边界
- 普通股票处置,不覆盖房产、carried interest、员工股权计划或 EIS 等特殊规则。
- 用 ticker 作为“同公司同类股”的工程代理;公司重组、不同 share class、rights issue 不在本版处理。
- 税率层只覆盖 2024 年 10 月 30 日之后的股票 CGT 主税率 18%/24%。
- 如果同一税年里有 2024 年 10 月 30 日前后的处置,需要把旧税率和新税率拆开处理。
- 加密货币虽然也有匹配规则,但数据源和资产识别方式不同,需要单独 ingestion。
- 非居民 CGT 和 NRCGT return 是另一套流程。
所以这个项目不是“英国 CGT 全能计算器”,而是一个股票处置匹配引擎,加上一个有明确边界的税额估算层。
反思
AI 可以帮我写代码,但产品运行时不调用大模型。导入、匹配、池子扣减和税率分档,全部是确定性 TypeScript。
为什么不让 LLM 参与计算:税务计算需要可复现。相同输入必须得到相同输出,每一步都要能解释到规则和数字。LLM 可以解释结果,但不能生成结果。
这对 TaxPilot 的启发:合规系统可以把“解释”和“计算”分开。解释层可以用 LLM,计算层必须是版本化规则、测试用例和可审计底稿。
这也是 TaxPilot 的起点:把税务知识、业务流程、外部工具和人工复核拆开,让每一层只做自己能负责的事。