use crate::error::PortfolioError;
use crate::lot::{GiftBasis, Lot};
use crate::method::{self, CostBasisMethod, LotSelection};
use crate::report::{IncomeEvent, RealizedGain, Term};
use crate::transaction::Transaction;
use rust_decimal::Decimal;
use std::collections::HashMap;
pub(crate) enum Strategy<'a> {
Auto(CostBasisMethod),
Specific(&'a LotSelection),
}
impl Strategy<'_> {
fn method(&self) -> CostBasisMethod {
match self {
Strategy::Auto(m) => *m,
Strategy::Specific(_) => CostBasisMethod::SpecificId,
}
}
}
#[derive(Debug)]
pub(crate) struct EngineOutput {
pub realized: Vec<RealizedGain>,
#[allow(dead_code)]
pub income: Vec<IncomeEvent>,
pub holdings: Vec<Lot>,
}
struct Consumed {
quantity: Decimal,
cost_basis: Decimal,
acquired_at: Option<chrono::DateTime<chrono::Utc>>,
gift: Option<GiftBasis>,
lot_id: u64,
}
struct Engine<'a> {
strategy: Strategy<'a>,
next_lot_id: u64,
pools: HashMap<(String, String), Vec<Lot>>,
realized: Vec<RealizedGain>,
income: Vec<IncomeEvent>,
acq_to_lot: HashMap<usize, u64>,
}
impl<'a> Engine<'a> {
fn new(strategy: Strategy<'a>) -> Self {
Engine {
strategy,
next_lot_id: 0,
pools: HashMap::new(),
realized: Vec::new(),
income: Vec::new(),
acq_to_lot: HashMap::new(),
}
}
fn pool(&mut self, asset: &str, wallet: &str) -> &mut Vec<Lot> {
self.pools
.entry((asset.to_string(), wallet.to_string()))
.or_default()
}
fn available(&self, asset: &str, wallet: &str) -> Decimal {
self.pools
.get(&(asset.to_string(), wallet.to_string()))
.map(|ls| ls.iter().map(|l| l.quantity).sum())
.unwrap_or(Decimal::ZERO)
}
#[allow(clippy::too_many_arguments)]
fn acquire(
&mut self,
orig_index: usize,
asset: &str,
wallet: &str,
quantity: Decimal,
cost_basis: Decimal,
acquired_at: chrono::DateTime<chrono::Utc>,
gift: Option<GiftBasis>,
) {
let lot_id = self.next_lot_id;
self.next_lot_id += 1;
self.acq_to_lot.insert(orig_index, lot_id);
self.pool(asset, wallet).push(Lot {
asset: asset.to_string(),
wallet: wallet.to_string(),
quantity,
cost_basis,
acquired_at,
lot_id,
gift,
});
}
fn consume(
&mut self,
orig_index: usize,
asset: &str,
wallet: &str,
quantity: Decimal,
) -> Result<Vec<Consumed>, PortfolioError> {
let available = self.available(asset, wallet);
if quantity > available {
return Err(PortfolioError::InsufficientLots {
asset: asset.to_string(),
wallet: wallet.to_string(),
attempted: quantity,
available,
});
}
match self.strategy.method() {
CostBasisMethod::Average => self.consume_average(asset, wallet, quantity),
CostBasisMethod::SpecificId => {
self.consume_specific(orig_index, asset, wallet, quantity)
}
auto => self.consume_ordered(auto, asset, wallet, quantity),
}
}
fn consume_ordered(
&mut self,
method: CostBasisMethod,
asset: &str,
wallet: &str,
quantity: Decimal,
) -> Result<Vec<Consumed>, PortfolioError> {
let lots = self.pool(asset, wallet);
let order = method::order_for(method, lots);
let mut remaining = quantity;
let mut out = Vec::new();
for i in order {
if remaining <= Decimal::ZERO {
break;
}
let take = remaining.min(lots[i].quantity);
if take <= Decimal::ZERO {
continue;
}
let per_unit = lots[i].cost_basis_per_unit();
let basis = per_unit * take;
out.push(Consumed {
quantity: take,
cost_basis: basis,
acquired_at: Some(lots[i].acquired_at),
gift: lots[i].gift.clone(),
lot_id: lots[i].lot_id,
});
lots[i].quantity -= take;
lots[i].cost_basis -= basis;
remaining -= take;
}
lots.retain(|l| l.quantity > Decimal::ZERO);
Ok(out)
}
fn consume_average(
&mut self,
asset: &str,
wallet: &str,
quantity: Decimal,
) -> Result<Vec<Consumed>, PortfolioError> {
let lots = self.pool(asset, wallet);
let total_qty: Decimal = lots.iter().map(|l| l.quantity).sum();
let total_basis: Decimal = lots.iter().map(|l| l.cost_basis).sum();
let avg = total_basis / total_qty;
let basis = avg * quantity;
let remaining_qty = total_qty - quantity;
let lot_id = lots.first().map(|l| l.lot_id).unwrap_or(0);
let acquired_at = lots.iter().map(|l| l.acquired_at).min().unwrap();
lots.clear();
if remaining_qty > Decimal::ZERO {
lots.push(Lot {
asset: asset.to_string(),
wallet: wallet.to_string(),
quantity: remaining_qty,
cost_basis: total_basis - basis,
acquired_at,
lot_id,
gift: None,
});
}
Ok(vec![Consumed {
quantity,
cost_basis: basis,
acquired_at: None, gift: None,
lot_id,
}])
}
fn consume_specific(
&mut self,
orig_index: usize,
asset: &str,
wallet: &str,
quantity: Decimal,
) -> Result<Vec<Consumed>, PortfolioError> {
let picks = match &self.strategy {
Strategy::Specific(sel) => sel.get(&orig_index).cloned(),
Strategy::Auto(_) => None,
};
let picks = picks.ok_or(PortfolioError::MissingLotSelection {
asset: asset.to_string(),
tx_index: orig_index,
})?;
let total_picked: Decimal = picks.iter().map(|p| p.quantity).sum();
if total_picked != quantity {
return Err(PortfolioError::MissingLotSelection {
asset: asset.to_string(),
tx_index: orig_index,
});
}
let mut out = Vec::new();
for pick in picks {
let target_lot_id = *self.acq_to_lot.get(&pick.acquisition_index).ok_or(
PortfolioError::InvalidLotSelection {
acquisition_index: pick.acquisition_index,
},
)?;
let lots = self.pool(asset, wallet);
let pos = lots
.iter()
.position(|l| l.lot_id == target_lot_id && l.quantity >= pick.quantity)
.ok_or(PortfolioError::InvalidLotSelection {
acquisition_index: pick.acquisition_index,
})?;
let per_unit = lots[pos].cost_basis_per_unit();
let basis = per_unit * pick.quantity;
out.push(Consumed {
quantity: pick.quantity,
cost_basis: basis,
acquired_at: Some(lots[pos].acquired_at),
gift: lots[pos].gift.clone(),
lot_id: target_lot_id,
});
lots[pos].quantity -= pick.quantity;
lots[pos].cost_basis -= basis;
lots.retain(|l| l.quantity > Decimal::ZERO);
}
Ok(out)
}
fn take(
&mut self,
asset: &str,
wallet: &str,
quantity: Decimal,
) -> Result<Vec<Consumed>, PortfolioError> {
let available = self.available(asset, wallet);
if quantity > available {
return Err(PortfolioError::InsufficientLots {
asset: asset.to_string(),
wallet: wallet.to_string(),
attempted: quantity,
available,
});
}
let method = match self.strategy.method() {
CostBasisMethod::Average => CostBasisMethod::Average,
_ => CostBasisMethod::Fifo,
};
if method == CostBasisMethod::Average {
self.consume_average(asset, wallet, quantity)
} else {
self.consume_ordered(method, asset, wallet, quantity)
}
}
fn gain_for(c: &Consumed, proceeds: Decimal) -> (Decimal, Decimal) {
match &c.gift {
None => (proceeds - c.cost_basis, c.cost_basis),
Some(g) => {
let donor_basis = c.cost_basis; let fmv = g.fmv_per_unit * c.quantity;
if proceeds > donor_basis {
(proceeds - donor_basis, donor_basis)
} else {
let loss_basis = donor_basis.min(fmv);
if proceeds < loss_basis {
(proceeds - loss_basis, loss_basis)
} else {
(Decimal::ZERO, proceeds)
}
}
}
}
}
fn dispose(
&mut self,
orig_index: usize,
asset: &str,
wallet: &str,
quantity: Decimal,
total_proceeds: Decimal,
disposed_at: chrono::DateTime<chrono::Utc>,
) -> Result<(), PortfolioError> {
let consumed = self.consume(orig_index, asset, wallet, quantity)?;
for c in &consumed {
let proceeds = total_proceeds * (c.quantity / quantity);
let (gain, basis) = Self::gain_for(c, proceeds);
let term = c.acquired_at.map(|a| Term::classify(a, disposed_at));
self.realized.push(RealizedGain {
asset: asset.to_string(),
wallet: wallet.to_string(),
disposed_at,
acquired_at: c.acquired_at,
quantity: c.quantity,
proceeds,
cost_basis: basis,
gain,
term,
});
}
Ok(())
}
fn process(&mut self, orig_index: usize, tx: &Transaction) -> Result<(), PortfolioError> {
match tx {
Transaction::Buy {
timestamp,
wallet,
asset,
quantity,
unit_price,
fee,
} => {
let basis = *quantity * *unit_price + *fee;
self.acquire(
orig_index, asset, wallet, *quantity, basis, *timestamp, None,
);
}
Transaction::Income {
timestamp,
wallet,
asset,
quantity,
value,
source,
} => {
self.acquire(
orig_index, asset, wallet, *quantity, *value, *timestamp, None,
);
self.income.push(IncomeEvent {
asset: asset.clone(),
wallet: wallet.clone(),
received_at: *timestamp,
quantity: *quantity,
value: *value,
source: *source,
});
}
Transaction::GiftReceived {
timestamp,
wallet,
asset,
quantity,
donor_basis,
fmv_at_receipt,
donor_acquired_at,
} => {
let gift = if self.strategy.method() == CostBasisMethod::Average {
None
} else {
Some(GiftBasis {
fmv_per_unit: *fmv_at_receipt / *quantity,
})
};
self.acquire(
orig_index,
asset,
wallet,
*quantity,
*donor_basis,
*donor_acquired_at,
gift,
);
let _ = timestamp; }
Transaction::Sell {
timestamp,
wallet,
asset,
quantity,
unit_price,
fee,
} => {
let proceeds = *quantity * *unit_price - *fee;
self.dispose(orig_index, asset, wallet, *quantity, proceeds, *timestamp)?;
}
Transaction::Trade {
timestamp,
wallet,
from_asset,
from_quantity,
to_asset,
to_quantity,
value,
fee,
} => {
self.dispose(
orig_index,
from_asset,
wallet,
*from_quantity,
*value,
*timestamp,
)?;
self.acquire(
orig_index,
to_asset,
wallet,
*to_quantity,
*value + *fee,
*timestamp,
None,
);
}
Transaction::Spend {
timestamp,
wallet,
asset,
quantity,
value,
fee,
} => {
let proceeds = *value - *fee;
self.dispose(orig_index, asset, wallet, *quantity, proceeds, *timestamp)?;
}
Transaction::Transfer {
timestamp,
asset,
quantity,
from_wallet,
to_wallet,
fee,
fee_value,
} => {
let available = self.available(asset, from_wallet);
if *quantity + *fee > available {
return Err(PortfolioError::InsufficientTransfer {
asset: asset.clone(),
wallet: from_wallet.clone(),
quantity: *quantity,
fee: *fee,
available,
});
}
if *fee > Decimal::ZERO {
self.dispose(orig_index, asset, from_wallet, *fee, *fee_value, *timestamp)?;
}
let moved = self.take(asset, from_wallet, *quantity)?;
for m in moved {
let acquired_at = m.acquired_at.unwrap_or(*timestamp);
self.pool(asset, to_wallet).push(Lot {
asset: asset.clone(),
wallet: to_wallet.clone(),
quantity: m.quantity,
cost_basis: m.cost_basis,
acquired_at,
lot_id: m.lot_id,
gift: m.gift,
});
}
}
Transaction::GiftSent {
timestamp,
wallet,
asset,
quantity,
} => {
let _ = self.take(asset, wallet, *quantity)?;
let _ = timestamp;
}
}
Ok(())
}
fn finish(self) -> EngineOutput {
let mut holdings: Vec<Lot> = self.pools.into_values().flatten().collect();
holdings.sort_by(|a, b| a.lot_id.cmp(&b.lot_id));
EngineOutput {
realized: self.realized,
income: self.income,
holdings,
}
}
}
pub(crate) fn run(txs: &[Transaction], strategy: Strategy) -> Result<EngineOutput, PortfolioError> {
let mut order: Vec<usize> = (0..txs.len()).collect();
order.sort_by(|&a, &b| txs[a].timestamp().cmp(&txs[b].timestamp()));
let mut engine = Engine::new(strategy);
for oi in order {
engine.process(oi, &txs[oi])?;
}
Ok(engine.finish())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::method::CostBasisMethod;
use crate::transaction::Transaction;
use chrono::{TimeZone, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
fn ts(y: i32, m: u32, d: u32) -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(y, m, d, 0, 0, 0).unwrap()
}
#[test]
fn fifo_sell_consumes_oldest_lot_with_term() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2020, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Buy {
timestamp: ts(2021, 6, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(300),
fee: dec!(0),
},
Transaction::Sell {
timestamp: ts(2022, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(500),
fee: dec!(0),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap();
assert_eq!(out.realized.len(), 1);
let g = &out.realized[0];
assert_eq!(g.cost_basis, dec!(100));
assert_eq!(g.proceeds, dec!(500));
assert_eq!(g.gain, dec!(400));
assert_eq!(g.term, Some(crate::report::Term::Long));
assert_eq!(out.holdings.len(), 1);
assert_eq!(out.holdings[0].cost_basis, dec!(300));
}
#[test]
fn buy_fee_folds_into_basis() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "eth".into(),
quantity: dec!(2),
unit_price: dec!(100),
fee: dec!(10),
},
Transaction::Sell {
timestamp: ts(2021, 2, 1),
wallet: "w".into(),
asset: "eth".into(),
quantity: dec!(1),
unit_price: dec!(150),
fee: dec!(0),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap();
assert_eq!(out.realized[0].cost_basis, dec!(105));
assert_eq!(out.realized[0].gain, dec!(45));
}
#[test]
fn oversell_errors_per_wallet() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "hot".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Sell {
timestamp: ts(2021, 2, 1),
wallet: "cold".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(150),
fee: dec!(0),
},
];
let err = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap_err();
assert!(matches!(
err,
crate::error::PortfolioError::InsufficientLots { .. }
));
}
#[test]
fn income_records_event_and_sets_basis() {
let txs = vec![
Transaction::Income {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "eth".into(),
quantity: dec!(1),
value: dec!(50),
source: crate::transaction::IncomeSource::Staking,
},
Transaction::Sell {
timestamp: ts(2021, 2, 1),
wallet: "w".into(),
asset: "eth".into(),
quantity: dec!(1),
unit_price: dec!(70),
fee: dec!(0),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap();
assert_eq!(out.income.len(), 1);
assert_eq!(out.income[0].value, dec!(50));
assert_eq!(out.realized[0].cost_basis, dec!(50)); assert_eq!(out.realized[0].gain, dec!(20));
}
#[test]
fn average_pools_basis_and_drops_term() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2020, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(300),
fee: dec!(0),
},
Transaction::Sell {
timestamp: ts(2022, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(500),
fee: dec!(0),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Average)).unwrap();
assert_eq!(out.realized[0].cost_basis, dec!(200));
assert_eq!(out.realized[0].gain, dec!(300));
assert_eq!(out.realized[0].term, None);
assert_eq!(
out.holdings.iter().map(|l| l.quantity).sum::<Decimal>(),
dec!(1)
);
}
#[test]
fn specific_id_consumes_named_acquisition() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2020, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(400),
fee: dec!(0),
},
Transaction::Sell {
timestamp: ts(2022, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(500),
fee: dec!(0),
},
];
let mut sel: crate::method::LotSelection = std::collections::HashMap::new();
sel.insert(
2,
vec![crate::method::LotPick {
acquisition_index: 1,
quantity: dec!(1),
}],
);
let out = run(&txs, Strategy::Specific(&sel)).unwrap();
assert_eq!(out.realized[0].cost_basis, dec!(400));
assert_eq!(out.realized[0].gain, dec!(100));
}
#[test]
fn specific_id_missing_selection_errors() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2020, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Sell {
timestamp: ts(2022, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(500),
fee: dec!(0),
},
];
let sel: crate::method::LotSelection = std::collections::HashMap::new();
let err = run(&txs, Strategy::Specific(&sel)).unwrap_err();
assert!(matches!(
err,
crate::error::PortfolioError::MissingLotSelection { .. }
));
}
#[test]
fn trade_disposes_from_leg_and_opens_to_leg() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Trade {
timestamp: ts(2021, 6, 1),
wallet: "w".into(),
from_asset: "btc".into(),
from_quantity: dec!(1),
to_asset: "eth".into(),
to_quantity: dec!(10),
value: dec!(500),
fee: dec!(5),
},
Transaction::Sell {
timestamp: ts(2021, 7, 1),
wallet: "w".into(),
asset: "eth".into(),
quantity: dec!(10),
unit_price: dec!(60),
fee: dec!(0),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap();
let btc = out.realized.iter().find(|r| r.asset == "btc").unwrap();
assert_eq!(btc.gain, dec!(400));
let eth = out.realized.iter().find(|r| r.asset == "eth").unwrap();
assert_eq!(eth.cost_basis, dec!(505));
assert_eq!(eth.gain, dec!(95));
}
#[test]
fn spend_is_a_disposal_at_fmv() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Spend {
timestamp: ts(2021, 2, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
value: dec!(180),
fee: dec!(0),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap();
assert_eq!(out.realized[0].proceeds, dec!(180));
assert_eq!(out.realized[0].gain, dec!(80));
}
#[test]
fn transfer_preserves_basis_and_acquisition_date() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2020, 1, 1),
wallet: "hot".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Transfer {
timestamp: ts(2020, 6, 1),
asset: "btc".into(),
quantity: dec!(1),
from_wallet: "hot".into(),
to_wallet: "cold".into(),
fee: dec!(0),
fee_value: dec!(0),
},
Transaction::Sell {
timestamp: ts(2022, 1, 1),
wallet: "cold".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(500),
fee: dec!(0),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap();
assert_eq!(out.realized.len(), 1);
assert_eq!(out.realized[0].wallet, "cold");
assert_eq!(out.realized[0].cost_basis, dec!(100));
assert_eq!(out.realized[0].term, Some(crate::report::Term::Long));
}
#[test]
fn transfer_fee_is_a_disposal() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "hot".into(),
asset: "eth".into(),
quantity: dec!(10),
unit_price: dec!(10),
fee: dec!(0),
}, Transaction::Transfer {
timestamp: ts(2021, 2, 1),
asset: "eth".into(),
quantity: dec!(9),
from_wallet: "hot".into(),
to_wallet: "cold".into(),
fee: dec!(1),
fee_value: dec!(15),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap();
assert_eq!(out.realized.len(), 1);
assert_eq!(out.realized[0].gain, dec!(5));
let cold: Decimal = out
.holdings
.iter()
.filter(|l| l.wallet == "cold")
.map(|l| l.cost_basis)
.sum();
assert_eq!(cold, dec!(90));
}
#[test]
fn transfer_insufficient_balance_errors() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "hot".into(),
asset: "eth".into(),
quantity: dec!(1),
unit_price: dec!(10),
fee: dec!(0),
},
Transaction::Transfer {
timestamp: ts(2021, 2, 1),
asset: "eth".into(),
quantity: dec!(1),
from_wallet: "hot".into(),
to_wallet: "cold".into(),
fee: dec!(1),
fee_value: dec!(15),
},
];
let err = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap_err();
assert!(matches!(
err,
crate::error::PortfolioError::InsufficientTransfer { .. }
));
}
fn gift_received(qty: i64, donor_basis: i64, fmv: i64, donor_day_year: i32) -> Transaction {
Transaction::GiftReceived {
timestamp: ts(2021, 6, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: Decimal::from(qty),
donor_basis: Decimal::from(donor_basis),
fmv_at_receipt: Decimal::from(fmv),
donor_acquired_at: ts(donor_day_year, 1, 1),
}
}
#[test]
fn gift_sent_removes_lots_without_gain() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(2),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::GiftSent {
timestamp: ts(2021, 2, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap();
assert!(out.realized.is_empty());
assert_eq!(
out.holdings.iter().map(|l| l.quantity).sum::<Decimal>(),
dec!(1)
);
}
#[test]
fn gift_gain_uses_donor_basis() {
let txs = vec![
gift_received(1, 100, 120, 2018),
Transaction::Sell {
timestamp: ts(2022, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(200),
fee: dec!(0),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap();
assert_eq!(out.realized[0].cost_basis, dec!(100));
assert_eq!(out.realized[0].gain, dec!(100));
assert_eq!(out.realized[0].term, Some(crate::report::Term::Long));
}
#[test]
fn gift_loss_uses_lesser_of_basis_or_fmv() {
let txs = vec![
gift_received(1, 100, 80, 2018),
Transaction::Sell {
timestamp: ts(2022, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(50),
fee: dec!(0),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap();
assert_eq!(out.realized[0].cost_basis, dec!(80));
assert_eq!(out.realized[0].gain, dec!(-30));
}
#[test]
fn gift_dead_zone_realizes_nothing() {
let txs = vec![
gift_received(1, 100, 80, 2018),
Transaction::Sell {
timestamp: ts(2022, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(90),
fee: dec!(0),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap();
assert_eq!(out.realized[0].gain, dec!(0));
}
#[test]
fn gift_sent_over_balance_errors_not_panics() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::GiftSent {
timestamp: ts(2021, 2, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(5),
},
];
let err = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap_err();
assert!(matches!(
err,
crate::error::PortfolioError::InsufficientLots { .. }
));
}
#[test]
fn specific_id_multiple_picks_across_two_acquisitions() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2020, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
}, Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(300),
fee: dec!(0),
}, Transaction::Sell {
timestamp: ts(2022, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(2),
unit_price: dec!(500),
fee: dec!(0),
}, ];
let mut sel: crate::method::LotSelection = std::collections::HashMap::new();
sel.insert(
2,
vec![
crate::method::LotPick {
acquisition_index: 0,
quantity: dec!(1),
},
crate::method::LotPick {
acquisition_index: 1,
quantity: dec!(1),
},
],
);
let out = run(&txs, Strategy::Specific(&sel)).unwrap();
let total_gain: Decimal = out.realized.iter().map(|r| r.gain).sum();
assert_eq!(out.realized.len(), 2);
assert_eq!(total_gain, dec!(600));
}
#[test]
fn specific_id_partial_lot_pick() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2020, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(2),
unit_price: dec!(100),
fee: dec!(0),
}, Transaction::Sell {
timestamp: ts(2022, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(500),
fee: dec!(0),
}, ];
let mut sel: crate::method::LotSelection = std::collections::HashMap::new();
sel.insert(
1,
vec![crate::method::LotPick {
acquisition_index: 0,
quantity: dec!(1),
}],
);
let out = run(&txs, Strategy::Specific(&sel)).unwrap();
assert_eq!(out.realized[0].cost_basis, dec!(100));
assert_eq!(out.realized[0].gain, dec!(400));
assert_eq!(
out.holdings.iter().map(|l| l.quantity).sum::<Decimal>(),
dec!(1)
);
}
#[test]
fn specific_id_unknown_acquisition_index_errors() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2020, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Sell {
timestamp: ts(2022, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(500),
fee: dec!(0),
},
];
let mut sel: crate::method::LotSelection = std::collections::HashMap::new();
sel.insert(
1,
vec![crate::method::LotPick {
acquisition_index: 9,
quantity: dec!(1),
}],
);
let err = run(&txs, Strategy::Specific(&sel)).unwrap_err();
assert!(matches!(
err,
crate::error::PortfolioError::InvalidLotSelection {
acquisition_index: 9
}
));
}
#[test]
fn specific_id_quantity_mismatch_errors() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2020, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(2),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Sell {
timestamp: ts(2022, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(2),
unit_price: dec!(500),
fee: dec!(0),
},
];
let mut sel: crate::method::LotSelection = std::collections::HashMap::new();
sel.insert(
1,
vec![crate::method::LotPick {
acquisition_index: 0,
quantity: dec!(1),
}],
);
let err = run(&txs, Strategy::Specific(&sel)).unwrap_err();
assert!(matches!(
err,
crate::error::PortfolioError::MissingLotSelection { .. }
));
}
#[test]
fn trade_acquired_lot_is_specific_id_addressable_by_trade_index() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
}, Transaction::Trade {
timestamp: ts(2021, 6, 1),
wallet: "w".into(),
from_asset: "btc".into(),
from_quantity: dec!(1),
to_asset: "eth".into(),
to_quantity: dec!(10),
value: dec!(500),
fee: dec!(0),
}, Transaction::Sell {
timestamp: ts(2021, 7, 1),
wallet: "w".into(),
asset: "eth".into(),
quantity: dec!(10),
unit_price: dec!(60),
fee: dec!(0),
}, ];
let mut sel: crate::method::LotSelection = std::collections::HashMap::new();
sel.insert(
1,
vec![crate::method::LotPick {
acquisition_index: 0,
quantity: dec!(1),
}],
);
sel.insert(
2,
vec![crate::method::LotPick {
acquisition_index: 1,
quantity: dec!(10),
}],
);
let out = run(&txs, Strategy::Specific(&sel)).unwrap();
let eth = out.realized.iter().find(|r| r.asset == "eth").unwrap();
assert_eq!(eth.cost_basis, dec!(500));
assert_eq!(eth.gain, dec!(100));
}
#[test]
fn transfer_under_average_moves_pooled_basis() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2020, 1, 1),
wallet: "hot".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Buy {
timestamp: ts(2020, 2, 1),
wallet: "hot".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(300),
fee: dec!(0),
},
Transaction::Transfer {
timestamp: ts(2020, 6, 1),
asset: "btc".into(),
quantity: dec!(2),
from_wallet: "hot".into(),
to_wallet: "cold".into(),
fee: dec!(0),
fee_value: dec!(0),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Average)).unwrap();
let cold: Decimal = out
.holdings
.iter()
.filter(|l| l.wallet == "cold")
.map(|l| l.cost_basis)
.sum();
assert_eq!(cold, dec!(400));
assert!(out.holdings.iter().all(|l| l.wallet == "cold"));
}
#[test]
fn transfer_under_specific_id_strategy_uses_fifo_fallback() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2020, 1, 1),
wallet: "hot".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Transfer {
timestamp: ts(2020, 6, 1),
asset: "btc".into(),
quantity: dec!(1),
from_wallet: "hot".into(),
to_wallet: "cold".into(),
fee: dec!(0),
fee_value: dec!(0),
},
];
let sel: crate::method::LotSelection = std::collections::HashMap::new();
let out = run(&txs, Strategy::Specific(&sel)).unwrap();
let cold: Decimal = out
.holdings
.iter()
.filter(|l| l.wallet == "cold")
.map(|l| l.cost_basis)
.sum();
assert_eq!(cold, dec!(100));
}
#[test]
fn gift_received_under_average_pools_at_donor_basis() {
let txs = vec![
Transaction::GiftReceived {
timestamp: ts(2021, 6, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
donor_basis: dec!(100),
fmv_at_receipt: dec!(80),
donor_acquired_at: ts(2018, 1, 1),
},
Transaction::Sell {
timestamp: ts(2022, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(50),
fee: dec!(0),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Average)).unwrap();
assert_eq!(out.realized[0].gain, dec!(-50));
assert_eq!(out.realized[0].term, None);
}
#[test]
fn equal_timestamp_events_keep_input_order_under_fifo() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(300),
fee: dec!(0),
},
Transaction::Sell {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(500),
fee: dec!(0),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap();
assert_eq!(out.realized[0].cost_basis, dec!(100));
}
#[test]
fn spend_with_fee_nets_proceeds() {
let txs = vec![
Transaction::Buy {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
unit_price: dec!(100),
fee: dec!(0),
},
Transaction::Spend {
timestamp: ts(2021, 2, 1),
wallet: "w".into(),
asset: "btc".into(),
quantity: dec!(1),
value: dec!(180),
fee: dec!(20),
},
];
let out = run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap();
assert_eq!(out.realized[0].proceeds, dec!(160));
assert_eq!(out.realized[0].gain, dec!(60));
}
#[test]
fn trade_disposal_of_unowned_asset_errors() {
let txs = vec![Transaction::Trade {
timestamp: ts(2021, 1, 1),
wallet: "w".into(),
from_asset: "btc".into(),
from_quantity: dec!(1),
to_asset: "eth".into(),
to_quantity: dec!(10),
value: dec!(500),
fee: dec!(0),
}];
assert!(matches!(
run(&txs, Strategy::Auto(CostBasisMethod::Fifo)).unwrap_err(),
crate::error::PortfolioError::InsufficientLots { .. }
));
}
}