invest.capital-gains-tax Unreviewed
UK Capital Gains Tax for an individual's tax year: losses, annual exempt amount, and the rates by asset and date.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 24 tests, run in TypeScript, Python and Rust.
Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified tax adviser has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.
Not professional advice. This capability calculates investment figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a tax adviser review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.
What it does
**Status: needs review by a tax professional before it is published.**
Capital Gains Tax due from a UK individual for one tax year, from the year's gains and losses, the taxable income that has already used part of the basic rate band, and any losses brought forward. Returns each step: gains, losses, losses brought forward used, the annual exempt amount used, taxable gains, the gains charged at each rate with the tax on each, the total, and the losses left to carry forward.
For example
capital_gains_tax(2025-10-01, disposals ×1, £30,000.00, £0.00)→ tax year 2025/26, gains £20,000.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £3,000.00, annual exempt amount used £3,000.00, taxable gains £17,000.00,… 2025/26: £20,000 gain, £30,000 taxable income: £7,700 of band at 18%, the rest at 24%capital_gains_tax(2025-01-15, disposals ×2, £50,000.00, £0.00)→ tax year 2024/25, gains £20,000.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £3,000.00, annual exempt amount used £3,000.00, taxable gains £17,000.00,… 2024/25 either side of 30 October: the exempt amount goes against the 24% gain, not the first onecapital_gains_tax(2024-04-06, disposals ×2, £27,700.00, £0.00)→ tax year 2024/25, gains £20,000.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £3,000.00, annual exempt amount used £3,000.00, taxable gains £17,000.00,… 2024/25 with band left: the band goes to the 10%/20% gain, where it saves most
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def capital_gains_tax(tax_year_date: str, disposals: Sequence[CgtDisposal], taxable_income: Money, losses_brought_forward: Money) -> CgtResult
| tax_year_date | date | any date in the tax year |
| disposals | CgtDisposal[] | every gain and loss in that tax year, in GBP |
| taxable_income | Money | income after allowances and reliefs, which uses the basic rate band first |
| losses_brought_forward | Money | unused allowable losses from earlier years, 0 if none |
| returns | CgtResult |
The types it declares, generated into your project
AssetKind = Literal["residential", "other"]
@dataclass(frozen=True)
class CgtDisposal:
"""One chargeable gain or allowable loss."""
#: the date of disposal, which decides the rate
date: str
#: residential property, or anything else
kind: AssetKind
#: the gain; negative for a loss
gain: Money
@dataclass(frozen=True)
class CgtLine:
"""Taxable gains charged at one rate."""
#: 1800 = 18%
rate_basis_points: int
gains: Money
tax: Money
@dataclass(frozen=True)
class CgtResult:
"""The computation, step by step, and the tax."""
#: HMRC's label, 2024/25
tax_year: str
#: the year's gains added together
gains: Money
#: the year's losses added together, as a positive amount
losses: Money
losses_brought_forward_used: Money
annual_exempt_amount: Money
annual_exempt_amount_used: Money
taxable_gains: Money
#: what taxable income leaves of the basic rate band
basic_rate_band_available: Money
#: lowest rate first
lines: List[CgtLine]
tax: Money
#: losses still unused, to carry to next year
losses_carried_forward: Money
Your code names it in one line, in the file that uses it
from fune.invest.capital_gains_tax import capital_gains_tax # invest.capital-gains-tax@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Dict, List, Optional, Sequence
from .dates_uk_tax_year import uk_tax_year ← from dates.uk-tax-year ^1.0.0 · built alongside by fune
from .invest_capital_gains_tax_data import CGT_RATES, CGT_RATES_HISTORY, CGT_RATES_HORIZON, CGT_YEARS, CGT_YEARS_HISTORY, CGT_YEARS_HORIZON ← this capability’s own data, compiled from data/cgt-years.json into the same file by fune build
from .invest_capital_gains_tax_types import CgtDisposal, CgtLine, CgtResult
from .money_amount import Money ← from money.amount ^1.0.0 · built alongside by fune
from .money_apply_rate import apply_rate ← from money.apply-rate ^1.0.0 · built alongside by fune
def _gbp(minor: int) -> Money:
return Money(minor=minor, currency="GBP")
def _check_money(value: Money, name: str) -> None:
if value.currency != "GBP":
raise ValueError("%s must be in GBP, received %s" % (name, value.currency))
if isinstance(value.minor, bool) or not isinstance(value.minor, int):
raise ValueError("%s must be a whole number of pence, received %s" % (name, value.minor))
def _pruned(history: str, horizon: Optional[str], date: str) -> str:
if horizon is not None and date < horizon:
return (
": this build was installed with history=%s, so it only carries rules from %s. "
"Reinstall with history=full for earlier years." % (history, horizon)
)
return ""
def _rate_for(disposal: CgtDisposal) -> List[int]:
for rule in CGT_RATES:
if rule.kind != disposal.kind or disposal.date < rule.valid_from:
continue
if rule.valid_to is not None and disposal.date > rule.valid_to:
continue
return [rule.lower_basis_points, rule.higher_basis_points, 0]
raise ValueError(
"no CGT rate for %s gains on %s" % (disposal.kind, disposal.date)
+ _pruned(CGT_RATES_HISTORY, CGT_RATES_HORIZON, disposal.date)
)
def capital_gains_tax(
tax_year_date: str,
disposals: Sequence[CgtDisposal],
taxable_income: Money,
losses_brought_forward: Money,
) -> CgtResult:
"""Capital Gains Tax for an individual for one UK tax year.
The year's losses come off its gains in full, even when that wastes the
annual exempt amount; losses brought forward only bring the net gains down
to the annual exempt amount. Losses and the exempt amount go against the
gains charged at the highest rate first, and what taxable income leaves of
the basic rate band goes to the gains whose two rates are furthest apart:
that is the "most beneficial" order TCGA 1992 s1F and s1K allow.
"""
year = uk_tax_year(tax_year_date)
rules = next(
(row for row in CGT_YEARS if row.valid_from <= year.start and (row.valid_to is None or year.start <= row.valid_to)),
None,
)
if rules is None:
raise ValueError(
"no CGT rules for the %s tax year" % (year.label,)
+ _pruned(CGT_YEARS_HISTORY, CGT_YEARS_HORIZON, year.start)
)
_check_money(taxable_income, "taxableIncome")
_check_money(losses_brought_forward, "lossesBroughtForward")
if taxable_income.minor < 0:
raise ValueError("taxableIncome must not be negative, received %d" % (taxable_income.minor,))
if losses_brought_forward.minor < 0:
raise ValueError("lossesBroughtForward must not be negative, received %d" % (losses_brought_forward.minor,))
# [lower, higher, amount]: gains that share a pair of rates.
buckets: List[List[int]] = []
gains = 0
losses = 0
for disposal in disposals:
_check_money(disposal.gain, "gain")
if disposal.kind not in ("residential", "other"):
raise ValueError('kind must be residential or other, received "%s"' % (disposal.kind,))
if disposal.date < year.start or disposal.date > year.end:
raise ValueError("the disposal on %s is outside the %s tax year" % (disposal.date, year.label))
rate = _rate_for(disposal)
if disposal.gain.minor <= 0:
losses -= disposal.gain.minor
continue
gains += disposal.gain.minor
bucket = next((b for b in buckets if b[0] == rate[0] and b[1] == rate[1]), None)
if bucket is None:
buckets.append([rate[0], rate[1], disposal.gain.minor])
else:
bucket[2] += disposal.gain.minor
net = gains - losses
annual_exempt_amount = rules.annual_exempt_amount
brought_forward_used = 0
exempt_used = 0
taxable = 0
carried = losses_brought_forward.minor
if net > 0:
brought_forward_used = min(losses_brought_forward.minor, max(0, net - annual_exempt_amount))
exempt_used = min(annual_exempt_amount, net - brought_forward_used)
taxable = net - brought_forward_used - exempt_used
carried -= brought_forward_used
else:
carried += -net
# Deductions against the gains taxed hardest first.
deductions = gains - taxable
for bucket in sorted(buckets, key=lambda b: (-b[1], -b[0])):
used = min(bucket[2], deductions)
bucket[2] -= used
deductions -= used
# The basic rate band where it saves the most: the widest gap between the two rates.
band_available = max(0, rules.basic_rate_band - taxable_income.minor)
band = band_available
charged: Dict[int, int] = {}
def charge(rate: int, amount: int) -> None:
if amount > 0:
charged[rate] = charged.get(rate, 0) + amount
for bucket in sorted(buckets, key=lambda b: (-(b[1] - b[0]), -b[1])):
lower = min(bucket[2], band)
band -= lower
charge(bucket[0], lower)
charge(bucket[1], bucket[2] - lower)
lines = [
CgtLine(rate_basis_points=rate, gains=_gbp(amount), tax=apply_rate(_gbp(amount), rate, "down"))
for rate, amount in sorted(charged.items())
]
tax = sum(line.tax.minor for line in lines)
return CgtResult(
tax_year=year.label,
gains=_gbp(gains),
losses=_gbp(losses),
losses_brought_forward_used=_gbp(brought_forward_used),
annual_exempt_amount=_gbp(annual_exempt_amount),
annual_exempt_amount_used=_gbp(exempt_used),
taxable_gains=_gbp(taxable),
basic_rate_band_available=_gbp(band_available),
lines=lines,
tax=_gbp(tax),
losses_carried_forward=_gbp(carried),
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add invest.capital-gains-tax
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./invest.capital-gains-tax-1.0.1-python.fune, or fetch it from a terminal with fune pull invest.capital-gains-tax@1.0.1:python.
The whole function, every language, is one file too: invest.capital-gains-tax-1.0.1.fune, 64,796 bytes, sha256 8e0d4a77f71e50cd266ad151218feae9edd76aaa073c769d22c87ac68830b186. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before invest.capital-gains-tax
after — your function gets the result and the arguments, and returns the final result.
# fune: after invest.capital-gains-tax
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
# fune: replace dates.uk-tax-year in invest.capital-gains-tax
# fune: replace money.amount in invest.capital-gains-tax
# fune: replace money.apply-rate in invest.capital-gains-tax
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show invest.capital-gains-tax --steps.
# fune: step invest.capital-gains-tax after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| 2025/26: £20,000 gain, £30,000 taxable income: £7,700 of band at 18%, the rest at 24% | 2025-10-01, disposals ×1, £30,000.00, £0.00 | → | tax year 2025/26, gains £20,000.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £3,000.00, annual exempt amount used £3,000.00, taxable gains £17,000.00,… |
| 2024/25 either side of 30 October: the exempt amount goes against the 24% gain, not the first one | 2025-01-15, disposals ×2, £50,000.00, £0.00 | → | tax year 2024/25, gains £20,000.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £3,000.00, annual exempt amount used £3,000.00, taxable gains £17,000.00,… |
| 2024/25 with band left: the band goes to the 10%/20% gain, where it saves most | 2024-04-06, disposals ×2, £27,700.00, £0.00 | → | tax year 2024/25, gains £20,000.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £3,000.00, annual exempt amount used £3,000.00, taxable gains £17,000.00,… |
| 2024/25 residential property is 24% from 6 April 2024, not 28% | 2024-05-01, disposals ×1, £50,000.00, £0.00 | → | tax year 2024/25, gains £10,000.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £3,000.00, annual exempt amount used £3,000.00, taxable gains £7,000.00, … |
| 5 April 2024 residential gain is still 28%, with the £6,000 exempt amount of 2023/24 | 2024-04-05, disposals ×1, £50,000.00, £0.00 | → | tax year 2023/24, gains £10,000.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £6,000.00, annual exempt amount used £6,000.00, taxable gains £4,000.00, … |
| 2023/24: exempt amount against residential property (28%) before shares (20%) | 2023-12-01, disposals ×2, £40,000.00, £0.00 | → | tax year 2023/24, gains £30,000.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £6,000.00, annual exempt amount used £6,000.00, taxable gains £24,000.00,… |
| 2022/23: in-year losses are set off in full even though that wastes the exempt amount; losses brought forward are kept | 2022-06-01, disposals ×2, £20,000.00, £5,000.00 | → | tax year 2022/23, gains £10,000.00, losses £8,000.00, losses brought forward used £0.00, annual exempt amount £12,300.00, annual exempt amount used £2,000.00, taxable gains £0.00,… |
| 2023/24: losses brought forward only bring gains down to the exempt amount | 2023-06-01, disposals ×1, £10,000.00, £30,000.00 | → | tax year 2023/24, gains £20,000.00, losses £0.00, losses brought forward used £14,000.00, annual exempt amount £6,000.00, annual exempt amount used £6,000.00, taxable gains £0.00,… |
| 2025/26: losses bigger than gains are carried forward with the old ones | 2025-06-01, disposals ×2, £0.00, £1,000.00 | → | tax year 2025/26, gains £5,000.00, losses £8,000.00, losses brought forward used £0.00, annual exempt amount £3,000.00, annual exempt amount used £0.00, taxable gains £0.00, basic… |
| 2020/21: basic rate band £37,500 and exempt amount £12,300 | 2020-10-01, disposals ×1, £20,000.00, £0.00 | → | tax year 2020/21, gains £50,000.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £12,300.00, annual exempt amount used £12,300.00, taxable gains £37,700.0… |
Show the other 14 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 2021/22: basic rate band £37,700 | 2021-10-01, disposals ×1, £20,000.00, £0.00 | → | tax year 2021/22, gains £50,000.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £12,300.00, annual exempt amount used £12,300.00, taxable gains £37,700.0… |
| tax on each rate is rounded down to the penny | 2026-06-01, disposals ×1, £60,000.00, £0.00 | → | tax year 2026/27, gains £3,100.55, losses £0.00, losses brought forward used £0.00, annual exempt amount £3,000.00, annual exempt amount used £3,000.00, taxable gains £100.55, bas… |
| income exactly filling the basic rate band leaves none for gains | 2025-06-01, disposals ×1, £37,700.00, £0.00 | → | tax year 2025/26, gains £4,000.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £3,000.00, annual exempt amount used £3,000.00, taxable gains £1,000.00, b… |
| 2025/26: residential and other gains share 18% and 24%, and each rate is one line | 2025-06-01, disposals ×2, £35,000.00, £0.00 | → | tax year 2025/26, gains £15,000.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £3,000.00, annual exempt amount used £3,000.00, taxable gains £12,000.00,… |
| no disposals: nothing to pay, losses carried as they were | 2025-06-01, , £10,000.00, £2,500.00 | → | tax year 2025/26, gains £0.00, losses £0.00, losses brought forward used £0.00, annual exempt amount £3,000.00, annual exempt amount used £0.00, taxable gains £0.00, basic rate ba… |
| a loss on property and a gain on shares in 2024/25 after 30 October net off | 2024-12-01, disposals ×2, £80,000.00, £0.00 | → | tax year 2024/25, gains £12,000.00, losses £4,000.00, losses brought forward used £0.00, annual exempt amount £3,000.00, annual exempt amount used £3,000.00, taxable gains £5,000.… |
| a tax year before the rules held is refused | 2019-07-01, , £0.00, £0.00 | → | error: no CGT rules for the 2019/20 tax year |
| a tax year after the rules held is refused | 2027-05-01, , £0.00, £0.00 | → | error: no CGT rules for the 2027/28 tax year |
| a disposal outside the tax year is refused | 2025-06-01, disposals ×1, £0.00, £0.00 | → | error: the disposal on 2025-04-05 is outside the 2025/26 tax year |
| amounts must be in sterling | 2025-06-01, disposals ×1, £0.00, £0.00 | → | error: gain must be in GBP |
| negative taxable income is refused | 2025-06-01, , -£1.00, £0.00 | → | error: taxableIncome must not be negative |
| negative losses brought forward are refused | 2025-06-01, , £0.00, -£1.00 | → | error: lossesBroughtForward must not be negative |
| an unknown kind is refused | 2025-06-01, disposals ×1, £0.00, £0.00 | → | error: kind must be residential or other |
| fractional pence are refused | 2025-06-01, disposals ×1, £0.00, £0.00 | → | error: gain must be a whole number of pence |
More from the author
## How it is worked out
1. The year's losses are set against the year's gains in full, even where that takes the net gains below the annual exempt amount and wastes it (CG21500). 2. Losses brought forward are used only to bring the net gains down to the annual exempt amount; the rest carry on (CG21500). 3. The annual exempt amount comes off what is left. 4. The losses and exempt amount are set against the gains charged at the highest rate first, as TCGA 1992 s1F and s1K allow ("in the way that is most beneficial ... generally against gains charged at the highest rate"). 5. What taxable income leaves of the basic rate band is charged at the lower rate, given first to the gains whose two rates are furthest apart (in 2024/25, 10%/20% gains before 18%/24% ones), since that saves the most. 6. Tax is worked out for each rate and rounded down to the penny.
For the rates in the data this order gives the lowest tax. The rates follow the date of each disposal, so 2024/25 mixes 10%/20% for shares sold up to 29 October 2024 with 18%/24% from 30 October.
## Rules data
`data/cgt-years.json` has, per tax year, the annual exempt amount and the basic rate band (in pence). `data/cgt-rates.json` has the lower and higher rate for residential property and other assets by disposal date. Years covered: 2020/21 to 2026/27. A tax year outside those, or a date before a pruned build's horizon, is refused rather than guessed.
| Tax year | AEA | Basic rate band | |---|---|---| | 2020/21 | £12,300 | £37,500 | | 2021/22, 2022/23 | £12,300 | £37,700 | | 2023/24 | £6,000 | £37,700 | | 2024/25 to 2026/27 | £3,000 | £37,700 |
| Disposals | Residential property | Other | |---|---|---| | 6 Apr 2016 to 5 Apr 2024 | 18% / 28% | 10% / 20% | | 6 Apr 2024 to 29 Oct 2024 | 18% / 24% | 10% / 20% | | from 30 Oct 2024 | 18% / 24% | 18% / 24% |
## Out of scope
Business Asset Disposal Relief and Investors' Relief (and the basic rate band they use first), carried interest, trusts and personal representatives, losses carried back on death, non-residents, and the reliefs that extend the basic rate band (gift aid and personal pension contributions: pass `taxableIncome` less the grossed-up contributions). CGT bands are UK-wide, so a Scottish taxpayer uses the UK basic rate band here, not the Scottish income tax bands. Each gain is the caller's: work it out per disposal first (for shares, `invest.section-104-pool`).
## Sources
Read on 2026-09-23:
- GOV.UK, "Capital Gains Tax rates and allowances" (AEA 2021/22 to 2026/27; rates by period including 6 April 2024 to 29 October 2024 and from 30 October 2024): https://www.gov.uk/guidance/capital-gains-tax-rates-and-allowances - HMRC Capital Gains Manual CG10246, "Capital Gains Tax: rates of tax - before 30 October 2024" (10%/20% and 18%/28% from 6 April 2016; basic rate band use): https://www.gov.uk/hmrc-internal-manuals/capital-gains-manual/cg10246 - The Capital Gains Tax (Annual Exempt Amount) Order 2020, SI 2020/333 (£12,300 for 2020/21): https://www.legislation.gov.uk/uksi/2020/333/made - CG21500, "Individuals: losses: assessment" (order of loss set-off; TCGA92/S1F most beneficial): https://www.gov.uk/hmrc-internal-manuals/capital-gains-manual/cg21500 - CG10150, "Partial re-write of TCGA 1992" (old s4B is now s1F and s1K): https://www.gov.uk/hmrc-internal-manuals/capital-gains-manual/cg10150 - GOV.UK, "Income Tax: Personal Allowance and basic rate limit from 2019-20" (£37,500 for 2019/20 and 2020/21): https://www.gov.uk/government/publications/income-tax-personal-allowance-and-basic-rate-limit-from-2019-to-2020/income-tax-personal-allowance-and-basic-rate-limit-from-2019-20 - GOV.UK, Budget 2021 OOTLAR Annex A (basic rate band £37,700 for 2021/22 to 2025/26): https://www.gov.uk/government/publications/budget-2021-overview-of-tax-legislation-and-rates-ootlar/annex-a-rates-and-allowances - GOV.UK, "Income Tax rates and allowances for current and previous tax years" (£37,700 for 2023/24 to 2026/27): https://www.gov.uk/government/publications/rates-and-allowances-income-tax/income-tax-rates-and-allowances-current-and-past
## What a reviewer should check
- The allocation order in steps 4 and 5, and the line rounding (down to the penny per rate; HMRC's return rounds gains to whole pounds). - That a zero gain is simply ignored and a loss of any kind offsets gains of any kind.
## Before you rely on this
**Not professional advice.** This capability calculates investment figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed above, and have a tax adviser review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.
**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified tax adviser has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.
## Notices
Contains public sector information licensed under the Open Government Licence v3.0 (https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/).
Legislation: Crown copyright and database right.
1.0.1 marks it unreviewed and adds its attribution notices (NOTICE). The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| NOTICE | 237 |
| README.md | 6,275 |
| data/cgt-rates.json | 875 |
| data/cgt-years.json | 1,521 |
| impl/python.py | 6,123 |
| impl/rust.rs | 8,982 |
| impl/typescript.ts | 6,180 |
| vectors.json | 23,986 |