Functional Weave
Code in TypeScript

invest.section-104-pool@1.0.1

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,
  };
}