Functional Weave
Code in TypeScript

invest.section-104-pool@1.0.1

impl/rust.rs

11,016 bytes · the Rust 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.

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))
}