use crate::conventions::{split_pro_rata, Sat, TaxDate, Usd, TRANSITION_DATE};
use crate::event::{BasisSource, LotPick};
use crate::identity::{EventId, LotId, WalletId};
use crate::state::Lot;
use crate::LotMethod;
use std::cmp::Ordering;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PoolKey {
Universal,
Wallet(WalletId),
}
pub fn pool_key(date: TaxDate, wallet: &WalletId) -> PoolKey {
if date < TRANSITION_DATE {
PoolKey::Universal
} else {
PoolKey::Wallet(wallet.clone())
}
}
#[derive(Debug, Default)]
pub struct PoolSet {
pub pools: BTreeMap<PoolKey, Vec<Lot>>,
next_split: BTreeMap<EventId, u32>,
}
impl PoolSet {
pub fn bump_split(&mut self, origin: &EventId) -> u32 {
let e = self.next_split.entry(origin.clone()).or_insert(0);
let v = *e;
*e += 1;
v
}
pub fn new_origin_lot(&mut self, key: PoolKey, mut lot: Lot) {
let s = self.bump_split(&lot.lot_id.origin_event_id);
lot.lot_id.split_sequence = s;
self.pools.entry(key).or_default().push(lot);
}
pub fn push_lot(&mut self, key: PoolKey, lot: Lot) {
self.pools.entry(key).or_default().push(lot);
}
pub fn init_split_counter(&mut self, origin: &EventId, next: u32) {
self.next_split.insert(origin.clone(), next);
}
pub fn consume_fifo(&mut self, key: &PoolKey, need: Sat) -> (Vec<Consumed>, Sat) {
let r = self.consume(key, need, LotMethod::Fifo, None);
(r.consumed, r.shortfall)
}
pub fn consume(
&mut self,
key: &PoolKey,
need: Sat,
method: LotMethod,
selection: Option<&[LotPick]>,
) -> ConsumeResult {
if let Some(picks) = selection {
if let Err(reason) = self.selection_feasible(key, picks) {
let (consumed, shortfall) = self.consume_ordered(key, need, method);
return ConsumeResult {
consumed,
shortfall,
selection_error: Some(reason),
};
}
let (consumed, shortfall) = self.consume_picks(key, picks);
return ConsumeResult {
consumed,
shortfall,
selection_error: None,
};
}
let (consumed, shortfall) = self.consume_ordered(key, need, method);
ConsumeResult {
consumed,
shortfall,
selection_error: None,
}
}
fn selection_feasible(&self, key: &PoolKey, picks: &[LotPick]) -> Result<(), String> {
let pool = self.pools.get(key).map(Vec::as_slice).unwrap_or(&[]);
let mut rem: BTreeMap<LotId, Sat> = BTreeMap::new();
for l in pool {
if l.remaining_sat > 0 {
*rem.entry(l.lot_id.clone()).or_insert(0) += l.remaining_sat;
}
}
for p in picks {
match rem.get_mut(&p.lot) {
None => {
let elsewhere = self
.pools
.iter()
.any(|(k, v)| k != key && v.iter().any(|l| l.lot_id == p.lot));
return Err(if elsewhere {
format!(
"picked lot {}#{} is in another wallet — cross-account identification is not permitted (§1.1012-1(j))",
p.lot.origin_event_id.canonical(),
p.lot.split_sequence
)
} else {
format!(
"picked lot {}#{} does not exist",
p.lot.origin_event_id.canonical(),
p.lot.split_sequence
)
});
}
Some(r) if *r < p.sat => {
return Err(format!(
"picked lot {}#{} has {} sat remaining < {} requested",
p.lot.origin_event_id.canonical(),
p.lot.split_sequence,
*r,
p.sat
))
}
Some(r) => {
*r -= p.sat;
}
}
}
Ok(())
}
fn consume_picks(&mut self, key: &PoolKey, picks: &[LotPick]) -> (Vec<Consumed>, Sat) {
let mut out = Vec::new();
if let Some(lots) = self.pools.get_mut(key) {
for p in picks {
let mut take = p.sat;
for lot in lots.iter_mut() {
if take <= 0 {
break;
}
if lot.lot_id != p.lot || lot.remaining_sat <= 0 {
continue;
}
let t = take.min(lot.remaining_sat);
out.push(Self::take_from(lot, t));
take -= t;
}
}
lots.retain(|l| l.remaining_sat > 0);
}
(out, 0) }
fn consume_ordered(
&mut self,
key: &PoolKey,
need: Sat,
method: LotMethod,
) -> (Vec<Consumed>, Sat) {
let mut out = Vec::new();
let mut remaining = need;
if let Some(lots) = self.pools.get_mut(key) {
for i in method_order(lots, method) {
if remaining <= 0 {
break;
}
let lot = &mut lots[i];
if lot.remaining_sat <= 0 {
continue;
}
let take = remaining.min(lot.remaining_sat);
out.push(Self::take_from(lot, take));
remaining -= take;
}
lots.retain(|l| l.remaining_sat > 0);
}
(out, remaining)
}
fn take_from(lot: &mut Lot, take: Sat) -> Consumed {
let (gain_basis, _rest) = split_pro_rata(lot.usd_basis, take, lot.remaining_sat);
let loss_basis = lot
.dual_loss_basis
.map(|l| split_pro_rata(l, take, lot.remaining_sat).0);
let c = Consumed {
lot_id: lot.lot_id.clone(),
sat: take,
gain_basis,
loss_basis,
gain_hp_start: lot.gain_hp_start(),
loss_hp_start: lot.loss_hp_start(),
basis_source: lot.basis_source,
dual: lot.dual_loss_basis.is_some(),
basis_pending: lot.basis_pending,
wallet: lot.wallet.clone(),
acquired_at: lot.acquired_at,
donor_acquired_at: lot.donor_acquired_at,
pseudo: lot.pseudo, };
lot.usd_basis -= gain_basis;
if let (Some(dl), Some(taken)) = (lot.dual_loss_basis.as_mut(), loss_basis) {
*dl -= taken;
}
lot.remaining_sat -= take;
c
}
}
#[derive(Debug, Clone)]
pub struct ConsumeResult {
pub consumed: Vec<Consumed>,
pub shortfall: Sat,
pub selection_error: Option<String>,
}
fn method_order(lots: &[Lot], method: LotMethod) -> Vec<usize> {
let mut idx: Vec<usize> = (0..lots.len())
.filter(|&i| lots[i].remaining_sat > 0)
.collect();
match method {
LotMethod::Fifo => idx.sort_by(|&a, &b| {
lots[a]
.acquired_at
.cmp(&lots[b].acquired_at)
.then(lots[a].lot_id.cmp(&lots[b].lot_id))
}),
LotMethod::Lifo => idx.sort_by(|&a, &b| {
lots[b]
.acquired_at
.cmp(&lots[a].acquired_at)
.then(lots[b].lot_id.cmp(&lots[a].lot_id))
}),
LotMethod::Hifo => idx.sort_by(|&a, &b| hifo_cmp(&lots[a], &lots[b])),
}
idx
}
fn hifo_cmp(a: &Lot, b: &Lot) -> Ordering {
let (az, bz) = (a.usd_basis == Usd::ZERO, b.usd_basis == Usd::ZERO);
match (az, bz) {
(true, false) => return Ordering::Greater,
(false, true) => return Ordering::Less,
_ => {}
}
let lhs = a.usd_basis * Usd::from(b.remaining_sat); let rhs = b.usd_basis * Usd::from(a.remaining_sat);
rhs.cmp(&lhs) .then(a.acquired_at.cmp(&b.acquired_at))
.then(a.lot_id.cmp(&b.lot_id))
}
#[derive(Debug, Clone)]
pub struct Consumed {
pub lot_id: LotId,
pub sat: Sat,
pub gain_basis: Usd,
pub loss_basis: Option<Usd>,
pub gain_hp_start: TaxDate,
pub loss_hp_start: TaxDate,
pub basis_source: BasisSource,
pub dual: bool,
pub basis_pending: bool,
pub wallet: WalletId,
pub acquired_at: TaxDate,
pub donor_acquired_at: Option<TaxDate>,
pub pseudo: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conventions::Usd;
use crate::event::{BasisSource, LotPick};
use crate::identity::{EventId, LotId, Source, SourceRef, WalletId};
use crate::LotMethod;
use rust_decimal_macros::dec;
use time::macros::date;
fn w() -> WalletId {
WalletId::SelfCustody { label: "x".into() }
}
fn lot(rf: &str, acq: time::Date, sat: i64, basis: Usd) -> Lot {
Lot {
lot_id: LotId {
origin_event_id: EventId::import(Source::Coinbase, SourceRef::new(rf)),
split_sequence: 0,
},
wallet: w(),
acquired_at: acq,
original_sat: sat,
remaining_sat: sat,
usd_basis: basis,
basis_source: BasisSource::ExchangeProvided,
dual_loss_basis: None,
donor_acquired_at: None,
basis_pending: false,
pseudo: false,
}
}
fn pid(rf: &str) -> LotId {
LotId {
origin_event_id: EventId::import(Source::Coinbase, SourceRef::new(rf)),
split_sequence: 0,
}
}
fn three() -> PoolSet {
let mut p = PoolSet::default();
p.push_lot(
PoolKey::Universal,
lot("A", date!(2025 - 02 - 01), 100_000, dec!(50.00)),
);
p.push_lot(
PoolKey::Universal,
lot("B", date!(2025 - 03 - 01), 100_000, dec!(90.00)),
);
p.push_lot(
PoolKey::Universal,
lot("C", date!(2025 - 04 - 01), 100_000, dec!(40.00)),
);
p
}
#[test]
fn fifo_consumes_oldest_first() {
let r = three().consume(&PoolKey::Universal, 100_000, LotMethod::Fifo, None);
assert_eq!(r.shortfall, 0);
assert_eq!(r.consumed[0].lot_id, pid("A"));
}
#[test]
fn lifo_consumes_newest_first() {
let r = three().consume(&PoolKey::Universal, 100_000, LotMethod::Lifo, None);
assert_eq!(r.consumed[0].lot_id, pid("C"));
}
#[test]
fn hifo_consumes_highest_gain_basis_per_sat_first() {
let r = three().consume(&PoolKey::Universal, 100_000, LotMethod::Hifo, None);
assert_eq!(r.consumed[0].lot_id, pid("B"));
}
#[test]
fn fifo_consumes_older_relocated_lot_before_newer_despite_insertion_order() {
let relocated = || {
let mut p = PoolSet::default();
p.push_lot(
PoolKey::Universal,
lot("NEW", date!(2025 - 08 - 01), 100_000, dec!(80.00)),
);
p.push_lot(
PoolKey::Universal,
lot("OLD", date!(2025 - 01 - 01), 100_000, dec!(40.00)),
);
p
};
let f = relocated().consume(&PoolKey::Universal, 100_000, LotMethod::Fifo, None);
assert_eq!(f.consumed[0].lot_id, pid("OLD"));
let l = relocated().consume(&PoolKey::Universal, 100_000, LotMethod::Lifo, None);
assert_eq!(l.consumed[0].lot_id, pid("NEW"));
let h = relocated().consume(&PoolKey::Universal, 100_000, LotMethod::Hifo, None);
assert_eq!(h.consumed[0].lot_id, pid("NEW"));
}
#[test]
fn hifo_basis_pending_sorts_last() {
let mut p = PoolSet::default();
let mut pend = lot("P", date!(2025 - 01 - 01), 100_000, dec!(0));
pend.basis_pending = true; p.push_lot(PoolKey::Universal, pend);
p.push_lot(
PoolKey::Universal,
lot("Q", date!(2025 - 06 - 01), 100_000, dec!(10.00)),
);
let r = p.consume(&PoolKey::Universal, 100_000, LotMethod::Hifo, None);
assert_eq!(r.consumed[0].lot_id, pid("Q")); }
#[test]
fn hifo_ties_break_oldest_then_lotid() {
let mut p = PoolSet::default();
p.push_lot(
PoolKey::Universal,
lot("OLD", date!(2025 - 02 - 01), 100_000, dec!(50.00)),
);
p.push_lot(
PoolKey::Universal,
lot("NEW", date!(2025 - 05 - 01), 100_000, dec!(50.00)),
); let r = p.consume(&PoolKey::Universal, 100_000, LotMethod::Hifo, None);
assert_eq!(r.consumed[0].lot_id, pid("OLD"));
}
#[test]
fn hifo_ignores_dual_loss_basis() {
let mut p = PoolSet::default();
let mut g = lot("G", date!(2025 - 02 - 01), 100_000, dec!(50.00));
g.dual_loss_basis = Some(dec!(5.00));
p.push_lot(PoolKey::Universal, g);
p.push_lot(
PoolKey::Universal,
lot("H", date!(2025 - 05 - 01), 100_000, dec!(50.00)),
);
let r = p.consume(&PoolKey::Universal, 100_000, LotMethod::Hifo, None);
assert_eq!(r.consumed[0].lot_id, pid("G")); }
#[test]
fn selection_consumes_exactly_named_lots() {
let picks = vec![
LotPick {
lot: pid("C"),
sat: 100_000,
},
LotPick {
lot: pid("A"),
sat: 100_000,
},
];
let r = three().consume(&PoolKey::Universal, 200_000, LotMethod::Hifo, Some(&picks));
assert!(r.selection_error.is_none());
assert_eq!(
r.consumed
.iter()
.map(|c| c.lot_id.clone())
.collect::<Vec<_>>(),
vec![pid("C"), pid("A")]
);
}
#[test]
fn selection_unknown_lot_reports_error_and_falls_back_to_method() {
let picks = vec![LotPick {
lot: pid("ZZZ"),
sat: 100_000,
}];
let r = three().consume(&PoolKey::Universal, 100_000, LotMethod::Fifo, Some(&picks));
assert!(r.selection_error.is_some());
assert_eq!(r.consumed[0].lot_id, pid("A")); }
#[test]
fn selection_insufficient_remaining_reports_error() {
let picks = vec![LotPick {
lot: pid("A"),
sat: 999_999,
}];
let r = three().consume(&PoolKey::Universal, 999_999, LotMethod::Fifo, Some(&picks));
assert!(r.selection_error.is_some());
}
#[test]
fn selection_cross_wallet_lot_reports_error() {
let mut p = PoolSet::default();
p.push_lot(
PoolKey::Wallet(WalletId::SelfCustody { label: "a".into() }),
lot("A", date!(2025 - 02 - 01), 100_000, dec!(50.00)),
);
p.push_lot(
PoolKey::Wallet(WalletId::SelfCustody { label: "b".into() }),
lot("B", date!(2025 - 02 - 01), 100_000, dec!(50.00)),
);
let picks = vec![LotPick {
lot: pid("B"),
sat: 100_000,
}];
let r = p.consume(
&PoolKey::Wallet(WalletId::SelfCustody { label: "a".into() }),
100_000,
LotMethod::Fifo,
Some(&picks),
);
assert!(r.selection_error.as_deref().unwrap().contains("wallet"));
}
#[test]
fn consumption_conserves_sat_and_basis_under_every_method() {
let total_sat = 300_000;
let total_basis = dec!(180.00); for m in [LotMethod::Fifo, LotMethod::Lifo, LotMethod::Hifo] {
let r = three().consume(&PoolKey::Universal, total_sat, m, None);
assert_eq!(r.shortfall, 0);
let sat: i64 = r.consumed.iter().map(|c| c.sat).sum();
let basis: Usd = r.consumed.iter().map(|c| c.gain_basis).sum();
assert_eq!(sat, total_sat, "Σsat must conserve for {m:?}");
assert_eq!(basis, total_basis, "Σbasis must conserve for {m:?}");
}
}
}