invest.section-104-pool Unreviewed
UK share matching for CGT: same-day, 30-day bed-and-breakfast, then the Section 104 pool; allowable cost per disposal.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 23 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 (CTA) 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 (CTA) 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.**
Works out the allowable cost of every disposal of one holding of shares under the UK share identification rules for individuals, for disposals on or after 6 April 2008. Give it every purchase and sale of one class of shares in one company, in date order and in sterling; it returns each day's disposal with the shares it was matched against, the proceeds and cost of each match, the gain or loss, and the Section 104 holding left at the end.
For example
section104_pool(transactions ×6)→ disposals ×2, pool quantity 300, pool cost £444.00 HMRC CG51590 example: 30-day match gains 60, then the pool costs 2,200 of 2,500 shares at 3,256section104_pool(transactions ×3)→ disposals ×1, pool quantity 6,000, pool cost £6,000.00 HMRC HS284 example 2: 500 matched with shares bought 12 days later at a 100 loss, 3,500 from the poolsection104_pool(transactions ×4)→ disposals ×1, pool quantity 700, pool cost £700.00 same-day purchase is matched before the pool, and the day's sales are one disposal
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 section104_pool(transactions: Sequence[ShareTransaction]) -> Section104Result
| transactions | ShareTransaction[] | every purchase and sale of one class of shares in one company, in date order, in GBP |
| returns | Section104Result | each disposal matched and costed, and the pool left at the end |
The types it declares, generated into your project
@dataclass(frozen=True)
class ShareTransaction:
"""One purchase or sale of shares of the same class in the same company."""
date: str
kind: ShareTransactionKind
#: shares, greater than zero
quantity: int
#: a buy: the allowable cost, dealing costs included; a sale: the proceeds after dealing costs
amount: Money
ShareTransactionKind = Literal["buy", "sell"]
MatchRule = Literal["same-day", "bed-and-breakfast", "section-104"]
@dataclass(frozen=True)
class ShareMatch:
"""Part of a disposal identified with one acquisition, or with the pool."""
rule: MatchRule
#: the matched purchase date; null for the Section 104 pool
acquisition_date: Optional[str]
quantity: int
#: the disposal proceeds apportioned to these shares
proceeds: Money
#: the allowable cost of these shares
cost: Money
#: proceeds less cost; negative for a loss
gain: Money
@dataclass(frozen=True)
class ShareDisposal:
"""All sales on one day, treated as one disposal."""
date: str
quantity: int
proceeds: Money
allowable_cost: Money
#: negative for a loss
gain: Money
#: same-day first, then bed-and-breakfast, then the pool
matches: List[ShareMatch]
@dataclass(frozen=True)
class Section104Result:
"""Every disposal, and the Section 104 holding after the last transaction."""
disposals: List[ShareDisposal]
pool_quantity: int
pool_cost: Money
Your code names it in one line, in the file that uses it
from fune.invest.section_104_pool import section104_pool # invest.section-104-pool@^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_add_days import epoch_day_from_iso ← from dates.add-days ^1.0.0 · built alongside by fune
from .invest_section_104_pool_types import Section104Result, ShareDisposal, ShareMatch, ShareTransaction
from .money_amount import Money ← from money.amount ^1.0.0 · built alongside by fune
#: The identification rules below apply to disposals on or after this date (FA 2008).
_RULES_START = "2008-04-06"
class _DayTotal:
"""One day's purchases or sales, added together as the same-day rule requires."""
def __init__(self, date: str) -> None:
self.date = date
self.day = epoch_day_from_iso(date)
self.quantity = 0
self.amount = 0
# Shares and cost still unmatched (purchases) or unidentified (sales).
self.remaining = 0
self.remaining_amount = 0
def _gbp(minor: int) -> Money:
return Money(minor=minor, currency="GBP")
def _share(a: int, b: int, c: int) -> int:
"""a * b / c rounded half-up, all non-negative."""
return (2 * a * b + c) // (2 * c)
def _take(total: _DayTotal, quantity: int) -> int:
"""Take `quantity` shares from a purchase (or the pool) and the cost that
goes with them. Cost is apportioned by number of shares, rounded half-up to
the penny, and taken from what is left, so the pieces always add back to
the whole: the last shares out take the last pennies."""
if quantity == total.remaining:
cost = total.remaining_amount
else:
cost = _share(total.remaining_amount, quantity, total.remaining)
total.remaining -= quantity
total.remaining_amount -= cost
return cost
def _is_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def _check(tx: ShareTransaction, previous: Optional[str]) -> None:
epoch_day_from_iso(tx.date)
if previous is not None and tx.date < previous:
raise ValueError("transactions must be in date order: %s comes after %s" % (tx.date, previous))
if tx.kind not in ("buy", "sell"):
raise ValueError('kind must be buy or sell, received "%s"' % (tx.kind,))
if not _is_int(tx.quantity) or tx.quantity <= 0:
raise ValueError(
"quantity must be a whole number greater than zero, received %s on %s" % (tx.quantity, tx.date)
)
if tx.amount.currency != "GBP":
raise ValueError("amounts must be in GBP, received %s on %s" % (tx.amount.currency, tx.date))
if not _is_int(tx.amount.minor) or tx.amount.minor < 0:
raise ValueError(
"amount must be a whole number of pence, not negative, received %s on %s" % (tx.amount.minor, tx.date)
)
if tx.kind == "sell" and tx.date < _RULES_START:
raise ValueError(
"disposals before %s follow earlier identification rules, received %s" % (_RULES_START, tx.date)
)
def section104_pool(transactions: Sequence[ShareTransaction]) -> Section104Result:
"""Identify each disposal of shares with acquisitions the way TCGA 1992
s105-106A require for individuals from 6 April 2008: first with shares
bought the same day, then with shares bought in the 30 days after the
disposal (earliest first), then with the Section 104 pool at average cost.
Same-day matching is resolved for every day before any 30-day matching,
and 30-day matching for every disposal (earliest disposal first) before
the pool is walked, because a purchase claimed by an earlier rule never
enters the pool.
"""
buys: List[_DayTotal] = []
sells: List[_DayTotal] = []
previous: Optional[str] = None
for tx in transactions:
_check(tx, previous)
previous = tx.date
group = buys if tx.kind == "buy" else sells
if not group or group[-1].date != tx.date:
group.append(_DayTotal(tx.date))
last = group[-1]
last.quantity += tx.quantity
last.amount += tx.amount.minor
last.remaining = last.quantity
last.remaining_amount = last.amount
# Nobody can sell shares they do not hold at the end of that day.
held = 0
b = 0
for sell in sells:
while b < len(buys) and buys[b].date <= sell.date:
held += buys[b].quantity
b += 1
held -= sell.quantity
if held < 0:
raise ValueError("cannot sell more shares than are held: %d sold on %s" % (sell.quantity, sell.date))
matches: Dict[str, List[list]] = {sell.date: [] for sell in sells}
# 1. Same day: TCGA 1992 s105(1)(b).
for sell in sells:
buy = next((candidate for candidate in buys if candidate.date == sell.date), None)
if buy is None:
continue
quantity = min(sell.remaining, buy.remaining)
if quantity == 0:
continue
matches[sell.date].append(["same-day", buy.date, quantity, _take(buy, quantity)])
sell.remaining -= quantity
# 2. The next 30 days, earliest acquisition first: TCGA 1992 s106A(5).
for sell in sells:
for buy in buys:
if sell.remaining == 0:
break
after = buy.day - sell.day
if after < 1 or after > 30 or buy.remaining == 0:
continue
quantity = min(sell.remaining, buy.remaining)
matches[sell.date].append(["bed-and-breakfast", buy.date, quantity, _take(buy, quantity)])
sell.remaining -= quantity
# 3. Everything else through the Section 104 pool, in date order.
pool = _DayTotal(_RULES_START)
disposals: List[ShareDisposal] = []
nxt = 0
for sell in sells:
while nxt < len(buys) and buys[nxt].date <= sell.date:
pool.remaining += buys[nxt].remaining
pool.remaining_amount += buys[nxt].remaining_amount
nxt += 1
pending = matches[sell.date]
if sell.remaining > 0:
if sell.remaining > pool.remaining:
raise ValueError(
"cannot sell more shares than are held: %d sold on %s" % (sell.quantity, sell.date)
)
pending.append(["section-104", None, sell.remaining, _take(pool, sell.remaining)])
disposals.append(_dispose(sell, pending))
while nxt < len(buys):
pool.remaining += buys[nxt].remaining
pool.remaining_amount += buys[nxt].remaining_amount
nxt += 1
return Section104Result(disposals=disposals, pool_quantity=pool.remaining, pool_cost=_gbp(pool.remaining_amount))
def _dispose(sell: _DayTotal, pending: List[list]) -> ShareDisposal:
"""Apportion the day's proceeds across its matches by shares, the last
match taking what is left."""
proceeds_left = sell.amount
shares_left = sell.quantity
allowable = 0
out: List[ShareMatch] = []
for rule, acquisition_date, quantity, cost in pending:
if quantity == shares_left:
proceeds = proceeds_left
else:
proceeds = _share(proceeds_left, quantity, shares_left)
proceeds_left -= proceeds
shares_left -= quantity
allowable += cost
out.append(
ShareMatch(
rule=rule,
acquisition_date=acquisition_date,
quantity=quantity,
proceeds=_gbp(proceeds),
cost=_gbp(cost),
gain=_gbp(proceeds - cost),
)
)
return ShareDisposal(
date=sell.date,
quantity=sell.quantity,
proceeds=_gbp(sell.amount),
allowable_cost=_gbp(allowable),
gain=_gbp(sell.amount - allowable),
matches=out,
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 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.section-104-pool
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./invest.section-104-pool-1.0.1-python.fune, or fetch it from a terminal with fune pull invest.section-104-pool@1.0.1:python.
The whole function, every language, is one file too: invest.section-104-pool-1.0.1.fune, 67,172 bytes, sha256 b4e96750fab062b5580292f177bf38dd9bfdd097e49233c3b5cdfe8b0b18694d. 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.section-104-pool
after — your function gets the result and the arguments, and returns the final result.
# fune: after invest.section-104-pool
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.add-days in invest.section-104-pool
# fune: replace money.amount in invest.section-104-pool
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.section-104-pool --steps.
# fune: step invest.section-104-pool 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 | |
|---|---|---|---|
| HMRC CG51590 example: 30-day match gains 60, then the pool costs 2,200 of 2,500 shares at 3,256 | transactions ×6 | → | disposals ×2, pool quantity 300, pool cost £444.00 |
| HMRC HS284 example 2: 500 matched with shares bought 12 days later at a 100 loss, 3,500 from the pool | transactions ×3 | → | disposals ×1, pool quantity 6,000, pool cost £6,000.00 |
| same-day purchase is matched before the pool, and the day's sales are one disposal | transactions ×4 | → | disposals ×1, pool quantity 700, pool cost £700.00 |
| a purchase on day 30 after the sale is matched | transactions ×3 | → | disposals ×1, pool quantity 100, pool cost £100.00 |
| a purchase on day 31 after the sale goes to the pool instead | transactions ×3 | → | disposals ×1, pool quantity 100, pool cost £150.00 |
| a purchase before the sale is never bed-and-breakfast: it joins the pool at average cost | transactions ×3 | → | disposals ×1, pool quantity 100, pool cost £200.00 |
| the earlier disposal takes the later purchase first (s106A(5)); a pool-first reading gets both wrong | transactions ×4 | → | disposals ×2, pool quantity 800, pool cost £800.00 |
| a same-day match outranks a 30-day match for an earlier sale | transactions ×4 | → | disposals ×2, pool quantity 400, pool cost £400.00 |
| pool cost is apportioned to the penny, half-up, and the pool keeps the remainder | transactions ×3 | → | disposals ×2, pool quantity 1, pool cost £3.33 |
| proceeds are apportioned across matches by shares, the last match taking the remainder | transactions ×3 | → | disposals ×1, pool quantity 0, pool cost £0.00 |
Show the other 13 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| selling the whole holding at a loss empties the pool | transactions ×3 | → | disposals ×1, pool quantity 0, pool cost £0.00 |
| no transactions: no disposals and an empty pool | → | disposals , pool quantity 0, pool cost £0.00 | |
| purchases only build the pool, whenever they were made | transactions ×2 | → | disposals , pool quantity 15, pool cost £62.34 |
| a purchase partly matched by bed-and-breakfast puts only its remainder into the pool | transactions ×4 | → | disposals ×2, pool quantity 60, pool cost £105.00 |
| selling more than is held is refused | transactions ×2 | → | error: cannot sell more shares than are held |
| a sale covered only by a later purchase (a short sale) is refused | transactions ×2 | → | error: cannot sell more shares than are held |
| disposals before 6 April 2008 are refused | transactions ×2 | → | error: disposals before 2008-04-06 follow earlier identification rules |
| transactions out of date order are refused | transactions ×2 | → | error: transactions must be in date order |
| amounts must be in sterling | transactions ×1 | → | error: amounts must be in GBP |
| a fractional quantity is refused | transactions ×1 | → | error: quantity must be a whole number greater than zero |
| a zero quantity is refused | transactions ×1 | → | error: quantity must be a whole number greater than zero |
| a negative amount is refused | transactions ×1 | → | error: amount must be a whole number of pence, not negative |
| an unknown kind is refused | transactions ×1 | → | error: kind must be buy or sell |
More from the author
## The rules
Each disposal is identified with acquisitions in this order:
1. **Same day.** Shares bought on the same day as the disposal (TCGA 1992 s105(1)(b)). All purchases on one day are one acquisition and all sales on one day are one disposal (s105(1)(a)), so the result has one disposal per day however many trades there were. 2. **The next 30 days** ("bed and breakfasting"). Shares bought in the 30 days after the disposal, earliest purchase first (s106A(5)). Day 30 counts; day 31 does not. Where two disposals could both claim one later purchase, the earlier disposal is matched first. 3. **The Section 104 holding.** Everything else comes out of the pool at its average cost (s104). Shares matched under 1 or 2 never enter the pool (CG51550).
Same-day matching is settled for every day before any 30-day matching, so a purchase is taken by a sale on its own day before it can be claimed by an earlier sale's 30-day window.
## Rounding
HMRC's own guidance apportions pool cost "by reference to the number of shares sold" (CG51575). Here the cost of a part of a purchase or of the pool is `cost left × shares taken / shares left`, rounded half-up to the penny, and taken from what is left, so the pieces always add back to the whole: selling the last share takes the last pennies. The day's proceeds are split across its matches the same way, so each match has its own gain or loss, as in the HS284 example. HMRC's worked examples use whole pounds; a reviewer should confirm the penny rounding.
## Edge cases and limits
- A sale of more shares than are held at the end of that day is refused, even when a later purchase would be matched with it (a short sale). - Disposals before 6 April 2008 are refused: they followed different identification rules (and indexation). Purchases may be any date; for shares held on 31 March 1982, pass the purchase at its 31 March 1982 market value (CG51550). - Amounts are in GBP pence. Convert foreign-currency costs and proceeds at the rate on each transaction date before calling. - Out of scope: bonus and rights issues, reorganisations, takeovers, employee share schemes, the non-resident and trading-company variants of the 30-day rule (s106A(5A)), and the older "kink test". A buy's `amount` is its full allowable cost including dealing costs and stamp duty; a sell's is the proceeds after dealing costs.
## Sources
Read on 2026-09-23:
- HMRC Capital Gains Manual CG51550, "Shares and securities: identification rules: shares pooling from 6 April 2008": https://www.gov.uk/hmrc-internal-manuals/capital-gains-manual/cg51550 - CG51560, "Identification rules for individuals from 6 April 2008" (same day, then 30 days, then Section 104; TCGA92/S105(1), S106A(5) and (5A)): https://www.gov.uk/hmrc-internal-manuals/capital-gains-manual/cg51560 - CG51575, "Section 104 holding: part disposal" (apportion by number of shares): https://www.gov.uk/hmrc-internal-manuals/capital-gains-manual/cg51575 - CG51590, Example 1 (Ms Davy), used as a vector: https://www.gov.uk/hmrc-internal-manuals/capital-gains-manual/cg51590 - HS284 "Shares and Capital Gains Tax (2025)", Example 2 (Mr Schneider), used as a vector with a pool cost of our own (the helpsheet gives none): https://www.gov.uk/government/publications/shares-and-capital-gains-tax-hs284-self-assessment-helpsheet/hs284-shares-and-capital-gains-tax-2025
## What a reviewer should check
- Penny rounding of apportioned costs and proceeds. - That refusing short sales, rather than matching them with later purchases, is the right default. - The order of 30-day matching when several disposals and purchases overlap.
## 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 (CTA) 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 (CTA) 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.
1.0.1 marks it unreviewed. The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| README.md | 5,320 |
| impl/python.py | 7,545 |
| impl/rust.rs | 11,016 |
| impl/typescript.ts | 7,542 |
| vectors.json | 25,915 |