invest.section-104-pool
UK share matching for CGT: same-day, 30-day bed-and-breakfast, then the Section 104 pool; allowable cost per disposal.
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 23 tests, run in TypeScript, Python and Rust.
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
section104Pool(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,256section104Pool(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 poolsection104Pool(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.
export function section104Pool(transactions: readonly 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
/** One purchase or sale of shares of the same class in the same company. */
export interface ShareTransaction {
readonly date: string;
readonly kind: ShareTransactionKind;
/** shares, greater than zero */
readonly quantity: number;
/** a buy: the allowable cost, dealing costs included; a sale: the proceeds after dealing costs */
readonly amount: Money;
}
export type ShareTransactionKind = "buy" | "sell";
export type MatchRule = "same-day" | "bed-and-breakfast" | "section-104";
/** Part of a disposal identified with one acquisition, or with the pool. */
export interface ShareMatch {
readonly rule: MatchRule;
/** the matched purchase date; null for the Section 104 pool */
readonly acquisitionDate: string | null;
readonly quantity: number;
/** the disposal proceeds apportioned to these shares */
readonly proceeds: Money;
/** the allowable cost of these shares */
readonly cost: Money;
/** proceeds less cost; negative for a loss */
readonly gain: Money;
}
/** All sales on one day, treated as one disposal. */
export interface ShareDisposal {
readonly date: string;
readonly quantity: number;
readonly proceeds: Money;
readonly allowableCost: Money;
/** negative for a loss */
readonly gain: Money;
/** same-day first, then bed-and-breakfast, then the pool */
readonly matches: readonly ShareMatch[];
}
/** Every disposal, and the Section 104 holding after the last transaction. */
export interface Section104Result {
readonly disposals: readonly ShareDisposal[];
readonly poolQuantity: number;
readonly poolCost: Money;
}
Your code names it in one line, in the file that uses it
import { section104Pool } from "#fune/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.
import { epochDayFromIso } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { type Money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type MatchRule, type Section104Result, type ShareDisposal, type ShareMatch, type ShareTransaction } from "./invest_section_104_pool_types.ts";
/** The identification rules below apply to disposals on or after this date (FA 2008). */
const RULES_START = "2008-04-06";
/** One day's purchases or sales, added together as the same-day rule requires. */
interface DayTotal {
date: string;
day: number;
quantity: number;
amount: bigint;
/** Shares and cost still unmatched (purchases) or unidentified (sales). */
remaining: number;
remainingAmount: bigint;
}
interface Pending {
rule: MatchRule;
acquisitionDate: string | null;
quantity: number;
cost: bigint;
}
function gbp(minor: bigint): Money {
return { minor: Number(minor), currency: "GBP" };
}
/** a * b / c rounded half-up, all non-negative. */
function share(a: bigint, b: bigint, c: bigint): bigint {
return (2n * a * b + c) / (2n * c);
}
/**
* 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.
*/
function take(total: DayTotal, quantity: number): bigint {
const cost =
quantity === total.remaining
? total.remainingAmount
: share(total.remainingAmount, BigInt(quantity), BigInt(total.remaining));
total.remaining -= quantity;
total.remainingAmount -= cost;
return cost;
}
function check(tx: ShareTransaction, previous: string | null): void {
epochDayFromIso(tx.date);
if (previous !== null && tx.date < previous) {
throw new RangeError(`transactions must be in date order: ${tx.date} comes after ${previous}`);
}
if (tx.kind !== "buy" && tx.kind !== "sell") {
throw new RangeError(`kind must be buy or sell, received "${tx.kind}"`);
}
if (!Number.isInteger(tx.quantity) || tx.quantity <= 0) {
throw new RangeError(`quantity must be a whole number greater than zero, received ${tx.quantity} on ${tx.date}`);
}
if (tx.amount.currency !== "GBP") {
throw new RangeError(`amounts must be in GBP, received ${tx.amount.currency} on ${tx.date}`);
}
if (!Number.isInteger(tx.amount.minor) || tx.amount.minor < 0) {
throw new RangeError(`amount must be a whole number of pence, not negative, received ${tx.amount.minor} on ${tx.date}`);
}
if (tx.kind === "sell" && tx.date < RULES_START) {
throw new RangeError(`disposals before ${RULES_START} follow earlier identification rules, received ${tx.date}`);
}
}
/**
* 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.
*/
export function section104Pool(transactions: readonly ShareTransaction[]): Section104Result {
const buys: DayTotal[] = [];
const sells: DayTotal[] = [];
let previous: string | null = null;
let held = 0;
for (const tx of transactions) {
check(tx, previous);
previous = tx.date;
const list = tx.kind === "buy" ? buys : sells;
let last = list.length > 0 ? list[list.length - 1] : null;
if (last === null || last.date !== tx.date) {
last = { date: tx.date, day: epochDayFromIso(tx.date), quantity: 0, amount: 0n, remaining: 0, remainingAmount: 0n };
list.push(last);
}
last.quantity += tx.quantity;
last.amount += BigInt(tx.amount.minor);
last.remaining = last.quantity;
last.remainingAmount = last.amount;
}
// Nobody can sell shares they do not hold at the end of that day.
let b = 0;
for (const sell of sells) {
while (b < buys.length && buys[b].date <= sell.date) held += buys[b++].quantity;
held -= sell.quantity;
if (held < 0) {
throw new RangeError(`cannot sell more shares than are held: ${sell.quantity} sold on ${sell.date}`);
}
}
const matches = new Map<string, Pending[]>();
for (const sell of sells) matches.set(sell.date, []);
// 1. Same day: TCGA 1992 s105(1)(b).
for (const sell of sells) {
const buy = buys.find((candidate) => candidate.date === sell.date);
if (buy === undefined) continue;
const quantity = Math.min(sell.remaining, buy.remaining);
if (quantity === 0) continue;
matches.get(sell.date)!.push({ rule: "same-day", acquisitionDate: buy.date, quantity, cost: take(buy, quantity) });
sell.remaining -= quantity;
}
// 2. The next 30 days, earliest acquisition first: TCGA 1992 s106A(5).
for (const sell of sells) {
for (const buy of buys) {
if (sell.remaining === 0) break;
const after = buy.day - sell.day;
if (after < 1 || after > 30 || buy.remaining === 0) continue;
const quantity = Math.min(sell.remaining, buy.remaining);
matches.get(sell.date)!.push({ rule: "bed-and-breakfast", acquisitionDate: buy.date, quantity, cost: take(buy, quantity) });
sell.remaining -= quantity;
}
}
// 3. Everything else through the Section 104 pool, in date order.
const pool: DayTotal = { date: "", day: 0, quantity: 0, amount: 0n, remaining: 0, remainingAmount: 0n };
const disposals: ShareDisposal[] = [];
let next = 0;
for (const sell of sells) {
while (next < buys.length && buys[next].date <= sell.date) {
pool.remaining += buys[next].remaining;
pool.remainingAmount += buys[next].remainingAmount;
next++;
}
const pending = matches.get(sell.date)!;
if (sell.remaining > 0) {
if (sell.remaining > pool.remaining) {
throw new RangeError(`cannot sell more shares than are held: ${sell.quantity} sold on ${sell.date}`);
}
pending.push({ rule: "section-104", acquisitionDate: null, quantity: sell.remaining, cost: take(pool, sell.remaining) });
}
disposals.push(dispose(sell, pending));
}
while (next < buys.length) {
pool.remaining += buys[next].remaining;
pool.remainingAmount += buys[next].remainingAmount;
next++;
}
return { disposals, poolQuantity: pool.remaining, poolCost: gbp(pool.remainingAmount) };
}
/** Apportion the day's proceeds across its matches by shares, the last match taking what is left. */
function dispose(sell: DayTotal, pending: readonly Pending[]): ShareDisposal {
let proceedsLeft = sell.amount;
let sharesLeft = sell.quantity;
let allowable = 0n;
const out: ShareMatch[] = pending.map((match) => {
const proceeds =
match.quantity === sharesLeft ? proceedsLeft : share(proceedsLeft, BigInt(match.quantity), BigInt(sharesLeft));
proceedsLeft -= proceeds;
sharesLeft -= match.quantity;
allowable += match.cost;
return {
rule: match.rule,
acquisitionDate: match.acquisitionDate,
quantity: match.quantity,
proceeds: gbp(proceeds),
cost: gbp(match.cost),
gain: gbp(proceeds - match.cost),
};
});
return {
date: sell.date,
quantity: sell.quantity,
proceeds: gbp(sell.amount),
allowableCost: gbp(allowable),
gain: gbp(sell.amount - allowable),
matches: out,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript 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 TypeScript implementation. Install it without the registry with fune add ./invest.section-104-pool-1.0.0-typescript.fune, or fetch it from a terminal with fune pull invest.section-104-pool@1.0.0:typescript.
The whole function, every language, is one file too: invest.section-104-pool-1.0.0.fune, 66,041 bytes, sha256 86f7a6b5c95e6efd15123a84948372931748ee9e17d854dc99fa63d4246fd72c. 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.
Files
| Path | Bytes |
|---|---|
| README.md | 4,227 |
| impl/python.py | 7,545 |
| impl/rust.rs | 11,016 |
| impl/typescript.ts | 7,542 |
| vectors.json | 25,915 |