Functional Weave
Code in Rust

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

  • 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,256
  • section104_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 pool
  • section104_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.

pub fn section104_pool(transactions: &[ShareTransaction]) -> Section104Result
transactionsShareTransaction[]every purchase and sale of one class of shares in one company, in date order, in GBP
returnsSection104Resulteach 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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShareTransaction {
    pub date: String,
    pub kind: String,
    /// shares, greater than zero
    pub quantity: i64,
    /// a buy: the allowable cost, dealing costs included; a sale: the proceeds after dealing costs
    pub amount: Money,
}

// ShareTransactionKind is a string in Rust, one of: "buy", "sell".
// Parameters take it as &str and results hold it as String.

// MatchRule is a string in Rust, one of: "same-day", "bed-and-breakfast", "section-104".
// Parameters take it as &str and results hold it as String.

/// Part of a disposal identified with one acquisition, or with the pool.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShareMatch {
    pub rule: String,
    /// the matched purchase date; null for the Section 104 pool
    pub acquisition_date: Option<String>,
    pub quantity: i64,
    /// the disposal proceeds apportioned to these shares
    pub proceeds: Money,
    /// the allowable cost of these shares
    pub cost: Money,
    /// proceeds less cost; negative for a loss
    pub gain: Money,
}

/// All sales on one day, treated as one disposal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShareDisposal {
    pub date: String,
    pub quantity: i64,
    pub proceeds: Money,
    pub allowable_cost: Money,
    /// negative for a loss
    pub gain: Money,
    /// same-day first, then bed-and-breakfast, then the pool
    pub matches: Vec<ShareMatch>,
}

/// Every disposal, and the Section 104 holding after the last transaction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Section104Result {
    pub disposals: Vec<ShareDisposal>,
    pub pool_quantity: i64,
    pub pool_cost: Money,
}

Your code names it in one line, in the file that uses it

fune!(invest.section-104-pool@^1);  // then call section104_pool(…)
impl/rust.rs · 317 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::epoch_day_from_iso;  ← from dates.add-days ^1.0.0 · built alongside by fune
use super::money_amount::{money_from_value, money_to_value, 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).
const RULES_START: &str = "2008-04-06";

/// One day's purchases or sales, added together as the same-day rule requires.
struct DayTotal {
    date: String,
    day: i64,
    quantity: i64,
    amount: i128,
    /// Shares and cost still unmatched (purchases) or unidentified (sales).
    remaining: i64,
    remaining_amount: i128,
}

struct Pending {
    rule: &'static str,
    acquisition_date: Option<String>,
    quantity: i64,
    cost: i128,
}

fn gbp(minor: i128) -> Money {
    Money {
        minor: minor as i64,
        currency: "GBP".to_string(),
    }
}

/// a * b / c rounded half-up, all non-negative.
fn share(a: i128, b: i128, c: i128) -> i128 {
    (2 * a * b + c) / (2 * 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.
fn take(total: &mut DayTotal, quantity: i64) -> i128 {
    let cost = if quantity == total.remaining {
        total.remaining_amount
    } else {
        share(total.remaining_amount, quantity as i128, total.remaining as i128)
    };
    total.remaining -= quantity;
    total.remaining_amount -= cost;
    cost
}

fn check(tx: &ShareTransaction, previous: Option<&str>) {
    epoch_day_from_iso(&tx.date);
    if let Some(previous) = previous {
        if tx.date.as_str() < previous {
            panic!("transactions must be in date order: {} comes after {}", tx.date, previous);
        }
    }
    if tx.kind != "buy" && tx.kind != "sell" {
        panic!("kind must be buy or sell, received \"{}\"", tx.kind);
    }
    if tx.quantity <= 0 {
        panic!("quantity must be a whole number greater than zero, received {} on {}", tx.quantity, tx.date);
    }
    if tx.amount.currency != "GBP" {
        panic!("amounts must be in GBP, received {} on {}", tx.amount.currency, tx.date);
    }
    if tx.amount.minor < 0 {
        panic!(
            "amount must be a whole number of pence, not negative, received {} on {}",
            tx.amount.minor, tx.date
        );
    }
    if tx.kind == "sell" && tx.date.as_str() < RULES_START {
        panic!(
            "disposals before {} follow earlier identification rules, received {}",
            RULES_START, tx.date
        );
    }
}

fn new_day(date: &str) -> DayTotal {
    DayTotal {
        date: date.to_string(),
        day: epoch_day_from_iso(date),
        quantity: 0,
        amount: 0,
        remaining: 0,
        remaining_amount: 0,
    }
}

/// 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.
///
/// # Panics
/// Panics on transactions out of date order, a bad kind, quantity or amount,
/// an amount not in GBP, a disposal before 6 April 2008, or a sale of more
/// shares than are held.
pub fn section104_pool(transactions: &[ShareTransaction]) -> Section104Result {
    let mut buys: Vec<DayTotal> = Vec::new();
    let mut sells: Vec<DayTotal> = Vec::new();
    let mut previous: Option<String> = None;
    for tx in transactions {
        check(tx, previous.as_deref());
        previous = Some(tx.date.clone());
        let group = if tx.kind == "buy" { &mut buys } else { &mut sells };
        if group.last().map(|d| d.date != tx.date).unwrap_or(true) {
            group.push(new_day(&tx.date));
        }
        let last = group.last_mut().unwrap();
        last.quantity += tx.quantity;
        last.amount += tx.amount.minor as i128;
        last.remaining = last.quantity;
        last.remaining_amount = last.amount;
    }

    // Nobody can sell shares they do not hold at the end of that day.
    let mut held: i64 = 0;
    let mut b = 0;
    for sell in &sells {
        while b < buys.len() && buys[b].date <= sell.date {
            held += buys[b].quantity;
            b += 1;
        }
        held -= sell.quantity;
        if held < 0 {
            panic!("cannot sell more shares than are held: {} sold on {}", sell.quantity, sell.date);
        }
    }

    let mut matches: Vec<Vec<Pending>> = sells.iter().map(|_| Vec::new()).collect();

    // 1. Same day: TCGA 1992 s105(1)(b).
    for (s, sell) in sells.iter_mut().enumerate() {
        if let Some(buy) = buys.iter_mut().find(|candidate| candidate.date == sell.date) {
            let quantity = sell.remaining.min(buy.remaining);
            if quantity == 0 {
                continue;
            }
            let cost = take(buy, quantity);
            matches[s].push(Pending {
                rule: "same-day",
                acquisition_date: Some(buy.date.clone()),
                quantity,
                cost,
            });
            sell.remaining -= quantity;
        }
    }

    // 2. The next 30 days, earliest acquisition first: TCGA 1992 s106A(5).
    for (s, sell) in sells.iter_mut().enumerate() {
        for buy in buys.iter_mut() {
            if sell.remaining == 0 {
                break;
            }
            let after = buy.day - sell.day;
            if after < 1 || after > 30 || buy.remaining == 0 {
                continue;
            }
            let quantity = sell.remaining.min(buy.remaining);
            let cost = take(buy, quantity);
            matches[s].push(Pending {
                rule: "bed-and-breakfast",
                acquisition_date: Some(buy.date.clone()),
                quantity,
                cost,
            });
            sell.remaining -= quantity;
        }
    }

    // 3. Everything else through the Section 104 pool, in date order.
    let mut pool = new_day(RULES_START);
    let mut disposals: Vec<ShareDisposal> = Vec::new();
    let mut next = 0;
    for (s, sell) in sells.iter().enumerate() {
        while next < buys.len() && buys[next].date <= sell.date {
            pool.remaining += buys[next].remaining;
            pool.remaining_amount += buys[next].remaining_amount;
            next += 1;
        }
        if sell.remaining > 0 {
            if sell.remaining > pool.remaining {
                panic!("cannot sell more shares than are held: {} sold on {}", sell.quantity, sell.date);
            }
            let cost = take(&mut pool, sell.remaining);
            matches[s].push(Pending {
                rule: "section-104",
                acquisition_date: None,
                quantity: sell.remaining,
                cost,
            });
        }
        disposals.push(dispose(sell, &matches[s]));
    }
    while next < buys.len() {
        pool.remaining += buys[next].remaining;
        pool.remaining_amount += buys[next].remaining_amount;
        next += 1;
    }
    Section104Result {
        disposals,
        pool_quantity: pool.remaining,
        pool_cost: gbp(pool.remaining_amount),
    }
}

/// Apportion the day's proceeds across its matches by shares, the last match
/// taking what is left.
fn dispose(sell: &DayTotal, pending: &[Pending]) -> ShareDisposal {
    let mut proceeds_left = sell.amount;
    let mut shares_left = sell.quantity;
    let mut allowable: i128 = 0;
    let mut out: Vec<ShareMatch> = Vec::new();
    for m in pending {
        let proceeds = if m.quantity == shares_left {
            proceeds_left
        } else {
            share(proceeds_left, m.quantity as i128, shares_left as i128)
        };
        proceeds_left -= proceeds;
        shares_left -= m.quantity;
        allowable += m.cost;
        out.push(ShareMatch {
            rule: m.rule.to_string(),
            acquisition_date: m.acquisition_date.clone(),
            quantity: m.quantity,
            proceeds: gbp(proceeds),
            cost: gbp(m.cost),
            gain: gbp(proceeds - m.cost),
        });
    }
    ShareDisposal {
        date: sell.date.clone(),
        quantity: sell.quantity,
        proceeds: gbp(sell.amount),
        allowable_cost: gbp(allowable),
        gain: gbp(sell.amount - allowable),
        matches: out,
    }
}

pub fn share_match_to_value(m: &ShareMatch) -> Value {
    Value::obj(vec![
        ("rule", Value::str(&m.rule)),
        (
            "acquisitionDate",
            match &m.acquisition_date {
                Some(date) => Value::str(date),
                None => Value::Null,
            },
        ),
        ("quantity", Value::Int(m.quantity)),
        ("proceeds", money_to_value(&m.proceeds)),
        ("cost", money_to_value(&m.cost)),
        ("gain", money_to_value(&m.gain)),
    ])
}

pub fn share_disposal_to_value(d: &ShareDisposal) -> Value {
    Value::obj(vec![
        ("date", Value::str(&d.date)),
        ("quantity", Value::Int(d.quantity)),
        ("proceeds", money_to_value(&d.proceeds)),
        ("allowableCost", money_to_value(&d.allowable_cost)),
        ("gain", money_to_value(&d.gain)),
        ("matches", Value::Arr(d.matches.iter().map(share_match_to_value).collect())),
    ])
}

pub fn section104_result_to_value(r: &Section104Result) -> Value {
    Value::obj(vec![
        ("disposals", Value::Arr(r.disposals.iter().map(share_disposal_to_value).collect())),
        ("poolQuantity", Value::Int(r.pool_quantity)),
        ("poolCost", money_to_value(&r.pool_cost)),
    ])
}

pub fn share_transaction_from_value(v: &Value) -> ShareTransaction {
    // Refuse what the typed record cannot hold, with the wording TypeScript
    // and Python use, rather than let the conversion quietly truncate it.
    if let Value::Float(f) = v.get("quantity") {
        panic!(
            "quantity must be a whole number greater than zero, received {} on {}",
            f,
            v.get("date").as_str()
        );
    }
    if let Value::Float(f) = v.get("amount").get("minor") {
        panic!(
            "amount must be a whole number of pence, not negative, received {} on {}",
            f,
            v.get("date").as_str()
        );
    }
    ShareTransaction {
        date: v.get("date").as_str().to_string(),
        kind: v.get("kind").as_str().to_string(),
        quantity: v.get("quantity").as_i64(),
        amount: money_from_value(v.get("amount")),
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    let transactions: Vec<ShareTransaction> = args[0].as_arr().iter().map(share_transaction_from_value).collect();
    section104_result_to_value(&section104_pool(&transactions))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add invest.section-104-pool
Download for Rust invest.section-104-pool-1.0.0-rust.fune · 50,440 bytes sha256 aa45088ea6aa3e61fc3e5ef1aaa54e73a9781f1f10b7852e7492e0c8310893f0

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./invest.section-104-pool-1.0.0-rust.fune, or fetch it from a terminal with fune pull invest.section-104-pool@1.0.0:rust.

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md4,227
impl/python.py7,545
impl/rust.rs11,016
impl/typescript.ts7,542
vectors.json25,915