impl/typescript.ts
7,542 bytes · the TypeScript implementation · view raw
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,
};
}