use crate::conventions::{is_long_term, one_year_after, Sat, TaxDate, Usd, TRANSITION_DATE};
use crate::event::{DisposeKind, LedgerEvent, LotPick};
use crate::identity::{EventId, LotId, SourceRef, WalletId};
use crate::price::{fmv_of, PriceProvider};
use crate::project::fold::{fold, pools_before, state_as_of};
use crate::project::pools::{pool_key, PoolKey};
use crate::project::resolve::{resolve, Eff, Op};
use crate::project::{
disposal_compliance, evaluate_disposal, project, CandidateDisposal, ComplianceStatus,
DisposalCompliance, EvaluateError, ProjectionConfig,
};
use crate::state::{Blocker, LedgerState, Lot};
use crate::tax::{compute_tax_year, MarginalRates, TaxOutcome, TaxProfile, TaxResult, TaxTables};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Persistability {
ContemporaneousNow,
NeedsAttestation,
ForbiddenBroker2027,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisposalProposal {
pub disposal: EventId,
pub wallet: WalletId,
pub date: TaxDate,
pub current_selection: Vec<LotPick>, pub proposed_selection: Vec<LotPick>, pub status: ComplianceStatus, pub persistable: Persistability,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApproxReason {
ComboCapExceeded { combos: usize, cap: usize },
ContentionUnenumerated {
contended: usize,
combos: usize,
cap: usize,
},
PoolHeuristic { lots: usize, bound: usize },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OptimizeProposal {
pub year: i32,
pub baseline_tax: Usd, pub optimized_tax: Usd, pub delta: Usd, pub per_disposal: Vec<DisposalProposal>,
pub marginal_rates: MarginalRates,
pub approximate: bool,
pub approx_reason: Option<ApproxReason>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsultRequest {
pub sell_sat: Sat,
pub wallet: WalletId,
pub at: TaxDate,
pub proceeds: Option<Usd>, pub kind: DisposeKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimingInsight {
pub st_sat_in_selection: Sat, pub latest_crossover: TaxDate, pub tax_if_sold_long_term: Usd, pub saving_if_waited: Usd, }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsultReport {
pub req: ConsultRequest,
pub proposed_selection: Vec<LotPick>,
pub st_gain: Usd,
pub lt_gain: Usd,
pub total_federal_tax_attributable: Usd,
pub marginal_tax: Usd,
pub timing: Option<TimingInsight>,
pub approximate: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OptimizeError {
YearNotComputable(Blocker),
Evaluate(EvaluateError),
NoDisposals,
NoLots,
PreTransitionYear(i32),
}
fn fold_with(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
assignment: &BTreeMap<EventId, Vec<LotPick>>,
) -> LedgerState {
let mut res = resolve(events, prices, config);
for (disposal, picks) in assignment {
res.selections.insert(disposal.clone(), picks.clone());
}
fold(res, prices, config)
}
fn assignment_conserves_principal(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
assignment: &BTreeMap<EventId, Vec<LotPick>>,
) -> bool {
let base = project(events, prices, config);
let mut principal: BTreeMap<EventId, Sat> = BTreeMap::new();
for d in &base.disposals {
let sum: Sat = d.legs.iter().map(|l| l.sat).sum();
principal.insert(d.event.clone(), sum);
}
assignment.iter().all(|(disposal, picks)| {
let picked: Sat = picks.iter().map(|p| p.sat).sum();
matches!(principal.get(disposal), Some(&p) if p > 0 && p == picked)
})
}
pub fn score_assignment(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
assignment: &BTreeMap<EventId, Vec<LotPick>>,
) -> TaxOutcome {
debug_assert!(
assignment_conserves_principal(events, prices, config, assignment),
"score_assignment: injected assignment violates Σpicks == principal (R0-M1)"
);
let state = fold_with(events, prices, config, assignment);
compute_tax_year(events, &state, year, profile, tables)
}
const LOT_ENUM_BOUND: usize = 12;
fn available_lots_before(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
disposal: &EventId,
date: TaxDate,
wallet: &WalletId,
) -> Vec<Lot> {
available_lots_before_with(
events,
prices,
config,
disposal,
date,
wallet,
&BTreeMap::new(),
)
}
fn available_lots_before_with(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
disposal: &EventId,
date: TaxDate,
wallet: &WalletId,
injected: &BTreeMap<EventId, Vec<LotPick>>,
) -> Vec<Lot> {
let mut res = resolve(events, prices, config);
if !res.timeline.iter().any(|e| &e.id == disposal) {
return Vec::new();
}
for (d, picks) in injected {
res.selections.insert(d.clone(), picks.clone());
}
let pools = pools_before(res, prices, config, disposal);
let want = pool_key(date, wallet); let mut lots: Vec<Lot> = pools
.pools
.into_values()
.flatten()
.filter(|l| l.remaining_sat > 0 && pool_key(date, &l.wallet) == want)
.collect();
lots.sort_by(|a, b| a.lot_id.cmp(&b.lot_id)); lots
}
fn candidate_selections(lots: &[Lot], need: Sat) -> (Vec<Vec<LotPick>>, bool) {
let heuristic = lots.len() > LOT_ENUM_BOUND; let mut out: std::collections::BTreeSet<Vec<LotPick>> = std::collections::BTreeSet::new();
let canon = |mut v: Vec<LotPick>| {
v.sort_by(|a, b| a.lot.cmp(&b.lot));
v
};
if lots.len() <= LOT_ENUM_BOUND {
for mask in 0u32..(1u32 << lots.len()) {
let mut whole: Vec<LotPick> = Vec::new();
let mut sum: Sat = 0;
for (i, l) in lots.iter().enumerate() {
if mask & (1 << i) != 0 {
whole.push(LotPick {
lot: l.lot_id.clone(),
sat: l.remaining_sat,
});
sum += l.remaining_sat;
}
}
if sum == need {
out.insert(canon(whole)); } else if sum < need {
let short = need - sum; for (i, l) in lots.iter().enumerate() {
if mask & (1 << i) == 0 && l.remaining_sat >= short {
let mut v = whole.clone();
v.push(LotPick {
lot: l.lot_id.clone(),
sat: short,
});
out.insert(canon(v));
}
}
}
}
} else {
let fill = |order: Vec<usize>| -> Option<Vec<LotPick>> {
let mut v = Vec::new();
let mut rem = need;
for i in order {
if rem <= 0 {
break;
}
let take = rem.min(lots[i].remaining_sat);
if take > 0 {
v.push(LotPick {
lot: lots[i].lot_id.clone(),
sat: take,
});
rem -= take;
}
}
(rem == 0).then(|| canon(v))
};
let by = |key: &dyn Fn(&Lot, &Lot) -> std::cmp::Ordering| {
let mut ix: Vec<usize> = (0..lots.len()).collect();
ix.sort_by(|&a, &b| key(&lots[a], &lots[b]));
ix
};
use std::cmp::Ordering;
let hifo = |a: &Lot, b: &Lot| {
(b.usd_basis * Usd::from(a.remaining_sat))
.cmp(&(a.usd_basis * Usd::from(b.remaining_sat)))
.then(a.acquired_at.cmp(&b.acquired_at))
.then(a.lot_id.cmp(&b.lot_id))
};
let fifo = |a: &Lot, b: &Lot| {
a.acquired_at
.cmp(&b.acquired_at)
.then(a.lot_id.cmp(&b.lot_id))
};
let lifo = |a: &Lot, b: &Lot| {
b.acquired_at
.cmp(&a.acquired_at)
.then(b.lot_id.cmp(&a.lot_id))
};
let lt_first = |a: &Lot, b: &Lot| {
a.gain_hp_start()
.cmp(&b.gain_hp_start())
.then(a.lot_id.cmp(&b.lot_id))
};
for k in [
&hifo as &dyn Fn(&Lot, &Lot) -> Ordering,
&fifo,
&lifo,
<_first,
] {
if let Some(v) = fill(by(k)) {
out.insert(v);
}
}
for lead in 0..lots.len() {
let mut order = vec![lead];
order.extend(by(&hifo).into_iter().filter(|&i| i != lead));
if let Some(v) = fill(order) {
out.insert(v);
}
}
}
(out.into_iter().collect(), heuristic) }
fn is_broker(w: &WalletId) -> bool {
matches!(w, WalletId::Exchange { .. })
}
pub fn persistability(
wallet: &WalletId,
sale_date: TaxDate,
selection_made: TaxDate,
) -> Persistability {
if is_broker(wallet) && sale_date.year() >= 2027 {
Persistability::ForbiddenBroker2027
} else if selection_made <= sale_date {
Persistability::ContemporaneousNow
} else {
Persistability::NeedsAttestation
}
}
pub fn proposed_compliance_status(
wallet: &WalletId,
sale_date: TaxDate,
made: TaxDate,
proposed: &[LotPick],
current: &[LotPick],
baseline_status: &ComplianceStatus,
) -> ComplianceStatus {
if proposed == current {
return baseline_status.clone(); }
if is_broker(wallet) && sale_date.year() >= 2027 {
return ComplianceStatus::NonCompliant;
}
if made <= sale_date {
ComplianceStatus::Contemporaneous
} else {
ComplianceStatus::NonCompliant }
}
pub fn compliance_overlay(
base: &[DisposalCompliance],
attested: &BTreeSet<EventId>,
unchanged: &BTreeSet<EventId>,
) -> Vec<DisposalCompliance> {
base.iter()
.map(|c| {
let upgrade = matches!(c.status, ComplianceStatus::NonCompliant)
&& attested.contains(&c.disposal)
&& unchanged.contains(&c.disposal) && !(is_broker(&c.wallet) && c.date.year() >= 2027);
let mut out = c.clone();
if upgrade {
out.status = ComplianceStatus::AttestedRecording;
}
out
})
.collect()
}
const GROUP_COMBO_BOUND: usize = 4_096;
fn contention_groups(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
targets: &[(EventId, WalletId, TaxDate, Sat)],
) -> Vec<Vec<usize>> {
let n = targets.len();
let infos: Vec<(PoolKey, BTreeSet<LotId>)> = targets
.iter()
.map(|(id, wallet, date, _need)| {
let lots = available_lots_before(events, prices, config, id, *date, wallet);
let ids: BTreeSet<LotId> = lots.into_iter().map(|l| l.lot_id).collect();
(pool_key(*date, wallet), ids)
})
.collect();
fn find(parent: &mut [usize], mut x: usize) -> usize {
while parent[x] != x {
parent[x] = parent[parent[x]]; x = parent[x];
}
x
}
let mut parent: Vec<usize> = (0..n).collect();
for i in 0..n {
for j in (i + 1)..n {
if infos[i].0 == infos[j].0 && !infos[i].1.is_disjoint(&infos[j].1) {
let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
if ri != rj {
parent[ri] = rj;
}
}
}
}
let mut by_root: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for i in 0..n {
let r = find(&mut parent, i);
by_root.entry(r).or_default().push(i);
}
let mut groups: Vec<Vec<usize>> = by_root.into_values().collect();
groups.sort_by(|a, b| targets[a[0]].0.cmp(&targets[b[0]].0));
groups
}
type JointMaps = (Vec<BTreeMap<EventId, Vec<LotPick>>>, Option<usize>);
type RowMeta = (EventId, WalletId, TaxDate, Vec<LotPick>, Vec<LotPick>);
fn group_candidate_assignments(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
group: &[(EventId, WalletId, TaxDate, Sat)],
) -> Option<JointMaps> {
let mut order: Vec<usize> = (0..group.len()).collect();
order.sort_by(|&a, &b| {
group[a]
.2
.cmp(&group[b].2)
.then(group[a].0.cmp(&group[b].0))
});
let mut partials: Vec<BTreeMap<EventId, Vec<LotPick>>> = vec![BTreeMap::new()];
let mut max_heur: Option<usize> = None;
for &mi in &order {
let (id, wallet, date, need) = &group[mi];
let mut next: Vec<BTreeMap<EventId, Vec<LotPick>>> = Vec::new();
for partial in &partials {
let lots =
available_lots_before_with(events, prices, config, id, *date, wallet, partial);
let (cands, heuristic) = candidate_selections(&lots, *need);
if heuristic {
max_heur = Some(max_heur.map_or(lots.len(), |m| m.max(lots.len())));
}
for c in cands {
let mut p2 = partial.clone();
p2.insert(id.clone(), c);
next.push(p2);
if next.len() > GROUP_COMBO_BOUND {
return None; }
}
}
partials = next;
}
partials.sort();
partials.dedup();
Some((partials, max_heur))
}
fn independent_group_maps(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
baseline_state: &LedgerState,
group: &[(EventId, WalletId, TaxDate, Sat)],
) -> Vec<BTreeMap<EventId, Vec<LotPick>>> {
let mut maps: Vec<BTreeMap<EventId, Vec<LotPick>>> = vec![BTreeMap::new()];
for (id, wallet, date, need) in group {
let lots = available_lots_before(events, prices, config, id, *date, wallet);
let (mut cands, _heuristic) = candidate_selections(&lots, *need);
if cands.is_empty() {
cands.push(baseline_selection(baseline_state, id));
}
let mut next: Vec<BTreeMap<EventId, Vec<LotPick>>> = Vec::new();
for m in &maps {
for c in &cands {
let mut m2 = m.clone();
m2.insert(id.clone(), c.clone());
next.push(m2);
}
}
maps = next;
}
maps.sort();
maps.dedup();
maps
}
const MAX_COMBOS: usize = 50_000;
#[allow(clippy::too_many_arguments)]
pub fn optimize_year(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
attested: &BTreeSet<EventId>,
proposal_made: TaxDate,
) -> Result<OptimizeProposal, OptimizeError> {
if year < TRANSITION_DATE.year() {
return Err(OptimizeError::PreTransitionYear(year));
}
let baseline_state = fold_with(events, prices, config, &BTreeMap::new());
let base = match compute_tax_year(events, &baseline_state, year, profile, tables) {
TaxOutcome::Computed(r) => r,
TaxOutcome::NotComputable(b) => return Err(OptimizeError::YearNotComputable(b)),
};
let mut targets: Vec<(EventId, WalletId, TaxDate, Sat)> = baseline_state
.disposals
.iter()
.filter(|d| !d.fee_mini_disposition && d.disposed_at.year() == year)
.filter_map(|d| {
let wallet = events
.iter()
.find(|e| e.id == d.event)
.and_then(|e| e.wallet.clone())?;
let sat: Sat = d.legs.iter().map(|l| l.sat).sum();
Some((d.event.clone(), wallet, d.disposed_at, sat))
})
.collect();
targets.sort_by(|a, b| a.0.cmp(&b.0));
if targets.is_empty() {
return Err(OptimizeError::NoDisposals);
}
let groups = contention_groups(events, prices, config, &targets);
let mut group_lists: Vec<Vec<BTreeMap<EventId, Vec<LotPick>>>> = Vec::new();
let mut product: usize = 1;
let mut approximate = false;
let mut contended_unenum = 0usize;
let mut pool_heuristic_lots: Option<usize> = None;
for g in &groups {
let members: Vec<(EventId, WalletId, TaxDate, Sat)> =
g.iter().map(|&i| targets[i].clone()).collect();
let maps: Vec<BTreeMap<EventId, Vec<LotPick>>> = if members.len() == 1 {
let (id, wallet, date, need) = &members[0]; let lots = available_lots_before(events, prices, config, id, *date, wallet);
let (mut cands, heuristic) = candidate_selections(&lots, *need);
if heuristic {
pool_heuristic_lots =
Some(pool_heuristic_lots.map_or(lots.len(), |m| m.max(lots.len())));
}
if cands.is_empty() {
cands.push(baseline_selection(&baseline_state, id));
}
cands
.into_iter()
.map(|p| BTreeMap::from([(id.clone(), p)]))
.collect()
} else {
match group_candidate_assignments(events, prices, config, &members) {
Some((joint, heur_lots)) => {
if let Some(n) = heur_lots {
pool_heuristic_lots = Some(pool_heuristic_lots.map_or(n, |m| m.max(n)));
}
joint
}
None => {
approximate = true;
contended_unenum += members.len();
independent_group_maps(events, prices, config, &baseline_state, &members)
}
}
};
product = product.saturating_mul(maps.len());
group_lists.push(maps);
}
if pool_heuristic_lots.is_some() {
approximate = true; }
let baseline_assignment: BTreeMap<EventId, Vec<LotPick>> = targets
.iter()
.map(|(id, ..)| (id.clone(), baseline_selection(&baseline_state, id)))
.collect();
let (best, best_total): (BTreeMap<EventId, Vec<LotPick>>, Usd) = if product <= MAX_COMBOS {
exhaustive_min(
events,
prices,
config,
year,
profile,
tables,
&group_lists,
&baseline_assignment,
&base,
)
} else {
approximate = true;
coordinate_descent(
events,
prices,
config,
year,
profile,
tables,
&group_lists,
&baseline_assignment,
&base,
)
};
let approx_reason = if product > MAX_COMBOS {
Some(ApproxReason::ComboCapExceeded {
combos: product,
cap: MAX_COMBOS,
})
} else if contended_unenum > 0 {
Some(ApproxReason::ContentionUnenumerated {
contended: contended_unenum,
combos: product,
cap: MAX_COMBOS,
})
} else {
pool_heuristic_lots.map(|lots| ApproxReason::PoolHeuristic {
lots,
bound: LOT_ENUM_BOUND,
})
};
let opt_state = fold_with(events, prices, config, &best);
let opt = match compute_tax_year(events, &opt_state, year, profile, tables) {
TaxOutcome::Computed(r) => r,
TaxOutcome::NotComputable(b) => return Err(OptimizeError::YearNotComputable(b)),
};
let base_comp = disposal_compliance(events, &baseline_state);
let mut rows: Vec<DisposalCompliance> = Vec::new();
let mut row_meta: Vec<RowMeta> = Vec::new();
for (id, wallet, date, _need) in &targets {
let current = baseline_selection(&baseline_state, id);
let proposed = best.get(id).cloned().unwrap_or_else(|| current.clone());
let baseline_status = base_comp
.iter()
.find(|c| &c.disposal == id)
.map(|c| c.status.clone())
.unwrap_or(ComplianceStatus::NonCompliant);
let status = proposed_compliance_status(
wallet,
*date,
proposal_made,
&proposed,
¤t,
&baseline_status,
);
rows.push(DisposalCompliance {
disposal: id.clone(),
wallet: wallet.clone(),
date: *date,
status,
});
row_meta.push((id.clone(), wallet.clone(), *date, current, proposed));
}
let unchanged: BTreeSet<EventId> = row_meta
.iter()
.filter(|(_, _, _, current, proposed)| proposed == current)
.map(|(id, ..)| id.clone())
.collect();
let rows = compliance_overlay(&rows, attested, &unchanged);
let per_disposal: Vec<DisposalProposal> = row_meta
.into_iter()
.zip(rows)
.map(
|((id, wallet, date, current, proposed), row)| DisposalProposal {
disposal: id,
wallet: wallet.clone(),
date,
current_selection: current,
proposed_selection: proposed,
status: row.status,
persistable: persistability(&wallet, date, proposal_made),
},
)
.collect();
Ok(OptimizeProposal {
year,
baseline_tax: base.total_federal_tax_attributable,
optimized_tax: best_total,
delta: best_total - base.total_federal_tax_attributable,
per_disposal,
marginal_rates: opt.marginal_rates, approximate,
approx_reason,
})
}
fn baseline_selection(state: &LedgerState, disposal: &EventId) -> Vec<LotPick> {
let mut picks: Vec<LotPick> = state
.disposals
.iter()
.find(|d| &d.event == disposal)
.map(|d| {
d.legs
.iter()
.map(|l| LotPick {
lot: l.lot_id.clone(),
sat: l.sat,
})
.collect()
})
.unwrap_or_default();
picks.sort_by(|a, b| a.lot.cmp(&b.lot));
picks
}
#[allow(clippy::too_many_arguments)]
fn exhaustive_min(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
group_lists: &[Vec<BTreeMap<EventId, Vec<LotPick>>>],
baseline_assignment: &BTreeMap<EventId, Vec<LotPick>>,
base: &TaxResult,
) -> (BTreeMap<EventId, Vec<LotPick>>, Usd) {
let mut best_total = base.total_federal_tax_attributable;
let mut best_assign = baseline_assignment.clone();
let lens: Vec<usize> = group_lists.iter().map(|g| g.len()).collect();
if lens.contains(&0) {
return (best_assign, best_total); }
let mut idx = vec![0usize; group_lists.len()];
loop {
let mut assign: BTreeMap<EventId, Vec<LotPick>> = BTreeMap::new();
for (gi, &ci) in idx.iter().enumerate() {
for (k, v) in &group_lists[gi][ci] {
assign.insert(k.clone(), v.clone());
}
}
if let TaxOutcome::Computed(r) =
score_assignment(events, prices, config, year, profile, tables, &assign)
{
let total = r.total_federal_tax_attributable;
if total < best_total {
best_total = total;
best_assign = assign;
}
}
let mut k = 0;
loop {
if k == idx.len() {
return (best_assign, best_total);
}
idx[k] += 1;
if idx[k] < lens[k] {
break;
}
idx[k] = 0;
k += 1;
}
}
}
#[allow(clippy::too_many_arguments)]
fn coordinate_descent(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
group_lists: &[Vec<BTreeMap<EventId, Vec<LotPick>>>],
baseline_assignment: &BTreeMap<EventId, Vec<LotPick>>,
base: &TaxResult,
) -> (BTreeMap<EventId, Vec<LotPick>>, Usd) {
let mut current = baseline_assignment.clone();
let mut current_total = base.total_federal_tax_attributable;
let n_groups = group_lists.len();
let pass_cap = n_groups + 1; let mut passes = 0;
let mut changed = true;
while changed && passes < pass_cap {
changed = false;
passes += 1;
for group in group_lists.iter() {
let mut best: Option<(Usd, BTreeMap<EventId, Vec<LotPick>>)> = None;
for cand in group {
let mut assign = current.clone();
for (k, v) in cand {
assign.insert(k.clone(), v.clone());
}
if let TaxOutcome::Computed(r) =
score_assignment(events, prices, config, year, profile, tables, &assign)
{
let total = r.total_federal_tax_attributable;
match &best {
None => best = Some((total, assign)),
Some((bt, ba)) => {
if total < *bt || (total == *bt && &assign < ba) {
best = Some((total, assign));
}
}
}
}
}
if let Some((bt, ba)) = best {
if bt < current_total {
current = ba;
current_total = bt;
changed = true;
}
}
}
}
(current, current_total)
}
pub fn consult_sale(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year_profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
req: &ConsultRequest,
) -> Result<ConsultReport, OptimizeError> {
let year = req.at.year();
if year < TRANSITION_DATE.year() {
return Err(OptimizeError::PreTransitionYear(year));
}
let pre = fold_as_of(events, prices, config, req.at);
let want = pool_key(req.at, &req.wallet);
let mut lots: Vec<Lot> = pre
.lots
.into_iter()
.filter(|l| {
l.remaining_sat > 0 && pool_key(req.at, &l.wallet) == want && l.acquired_at <= req.at
})
.collect();
lots.sort_by(|a, b| a.lot_id.cmp(&b.lot_id));
if lots.iter().map(|l| l.remaining_sat).sum::<Sat>() < req.sell_sat {
return Err(OptimizeError::NoLots);
}
let candidate = CandidateDisposal {
existing_event: None, wallet: req.wallet.clone(),
date: req.at,
sat: req.sell_sat,
kind: req.kind,
proceeds: req.proceeds, };
if req.proceeds.is_none() && fmv_of(prices, req.at, req.sell_sat).is_none() {
return Err(OptimizeError::Evaluate(EvaluateError::ProceedsRequired));
}
let (cands, heuristic) = candidate_selections(&lots, req.sell_sat);
let mut best: Option<(Usd, Vec<LotPick>, Usd, Usd)> = None; for picks in &cands {
let (st, lt, total) = score_synthetic(
events,
prices,
config,
year_profile,
tables,
&candidate,
picks,
)?;
let cand = (total, picks.clone(), st, lt);
best = Some(match best {
None => cand,
Some(b) if (cand.0, &cand.1) < (b.0, &b.1) => cand, Some(b) => b,
});
}
let (total, proposed_selection, st_gain, lt_gain) = best.ok_or(OptimizeError::NoLots)?;
let baseline_total = match compute_tax_year(
events,
&fold(resolve(events, prices, config), prices, config),
year,
year_profile,
tables,
) {
TaxOutcome::Computed(r) => r.total_federal_tax_attributable,
TaxOutcome::NotComputable(b) => return Err(OptimizeError::YearNotComputable(b)),
};
let marginal_tax = total - baseline_total;
let timing = timing_insight(
events,
prices,
config,
year_profile,
tables,
&candidate,
&proposed_selection,
&lots,
total,
);
Ok(ConsultReport {
req: req.clone(),
proposed_selection,
st_gain,
lt_gain,
total_federal_tax_attributable: total,
marginal_tax, timing,
approximate: heuristic, })
}
pub(crate) fn fold_as_of(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
at: TaxDate,
) -> LedgerState {
let res = resolve(events, prices, config);
state_as_of(res, prices, config, at)
}
pub(crate) fn synthetic_state(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
candidate: &CandidateDisposal,
picks: Option<&[LotPick]>,
) -> Result<LedgerState, EvaluateError> {
let mut res = resolve(events, prices, config);
let proceeds = match candidate.proceeds {
Some(p) => p,
None => {
fmv_of(prices, candidate.date, candidate.sat).ok_or(EvaluateError::ProceedsRequired)?
}
};
let id = EventId::Decision { seq: u64::MAX };
let utc = candidate.date.midnight().assume_utc();
res.timeline.push(Eff {
id: id.clone(),
utc,
tz: time::UtcOffset::UTC,
src_priority: 0,
src_ref: SourceRef::new("__synthetic__"),
wallet: Some(candidate.wallet.clone()),
op: Op::Dispose {
sat: candidate.sat,
proceeds,
fee_usd: Usd::ZERO,
fee_sat: None,
kind: candidate.kind,
},
pseudo: false, });
if let Some(picks) = picks {
res.selections.insert(id, picks.to_vec());
}
Ok(fold(res, prices, config))
}
#[allow(clippy::too_many_arguments)]
fn score_synthetic(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year_profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
candidate: &CandidateDisposal,
picks: &[LotPick],
) -> Result<(Usd, Usd, Usd), OptimizeError> {
let out = evaluate_disposal(events, prices, config, candidate, Some(picks))
.map_err(OptimizeError::Evaluate)?;
let state = synthetic_state(events, prices, config, candidate, Some(picks))
.map_err(OptimizeError::Evaluate)?;
let year = candidate.date.year();
match compute_tax_year(events, &state, year, year_profile, tables) {
TaxOutcome::Computed(r) => Ok((out.st_gain, out.lt_gain, r.total_federal_tax_attributable)),
TaxOutcome::NotComputable(b) => Err(OptimizeError::YearNotComputable(b)),
}
}
#[allow(clippy::too_many_arguments)]
fn timing_insight(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year_profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
candidate: &CandidateDisposal,
proposed_selection: &[LotPick],
lots: &[Lot],
total_now: Usd,
) -> Option<TimingInsight> {
let at = candidate.date;
let mut st_sat: Sat = 0;
let mut crossover: Option<TaxDate> = None;
for p in proposed_selection {
let lot = lots.iter().find(|l| l.lot_id == p.lot)?;
if !is_long_term(lot.gain_hp_start(), at) {
st_sat += p.sat;
let lt_date = one_year_after(lot.gain_hp_start()).next_day()?;
crossover = Some(crossover.map_or(lt_date, |c: TaxDate| c.max(lt_date)));
}
}
let latest_crossover = crossover?;
if latest_crossover.year() != at.year()
|| tables.table_for(at.year()).is_none()
|| year_profile.is_none()
{
return None;
}
let proceeds = candidate
.proceeds
.or_else(|| fmv_of(prices, at, candidate.sat))?;
let lt_candidate = CandidateDisposal {
existing_event: None,
wallet: candidate.wallet.clone(),
date: latest_crossover,
sat: candidate.sat,
kind: candidate.kind,
proceeds: Some(proceeds),
};
let (_st, _lt, tax_if_sold_long_term) = score_synthetic(
events,
prices,
config,
year_profile,
tables,
<_candidate,
proposed_selection,
)
.ok()?;
let saving_if_waited = (total_now - tax_if_sold_long_term).max(Usd::ZERO);
Some(TimingInsight {
st_sat_in_selection: st_sat,
latest_crossover,
tax_if_sold_long_term,
saving_if_waited,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::LotPick;
#[test]
fn error_variants_are_constructible_and_eq() {
let e = OptimizeError::PreTransitionYear(2024);
assert_eq!(e, OptimizeError::PreTransitionYear(2024));
assert_ne!(e, OptimizeError::NoDisposals);
assert_eq!(
Persistability::ForbiddenBroker2027,
Persistability::ForbiddenBroker2027
);
}
#[test]
fn lot_pick_is_totally_ordered() {
use std::collections::BTreeSet;
let mut s: BTreeSet<Vec<LotPick>> = BTreeSet::new();
s.insert(vec![]);
s.insert(vec![]);
let _sorted: Vec<Vec<LotPick>> = s.into_iter().collect(); }
#[test]
fn approx_reason_variants_are_eq() {
assert_eq!(
ApproxReason::ComboCapExceeded {
combos: 100,
cap: 50_000
},
ApproxReason::ComboCapExceeded {
combos: 100,
cap: 50_000
}
);
assert_eq!(
ApproxReason::ContentionUnenumerated {
contended: 2,
combos: 60_000,
cap: 50_000
},
ApproxReason::ContentionUnenumerated {
contended: 2,
combos: 60_000,
cap: 50_000
}
);
assert_eq!(
ApproxReason::PoolHeuristic {
lots: 15,
bound: 12
},
ApproxReason::PoolHeuristic {
lots: 15,
bound: 12
}
);
}
}
#[cfg(test)]
mod candidate_tests {
use super::*;
use crate::event::{
Acquire, AllocLot, AllocMethod, BasisSource, Dispose, EventPayload, SafeHarborAllocation,
};
use crate::identity::{LotId, Source, SourceRef};
use crate::price::StaticPrices;
use crate::LotMethod;
use rust_decimal_macros::dec;
use std::collections::BTreeSet;
use time::macros::{date, datetime, offset};
const LOT: Sat = 100_000_000;
fn cold() -> WalletId {
WalletId::SelfCustody {
label: "cold".into(),
}
}
fn hot() -> WalletId {
WalletId::SelfCustody {
label: "hot".into(),
}
}
fn eid(rf: &str) -> EventId {
EventId::import(Source::Swan, SourceRef::new(rf))
}
fn lid(rf: &str) -> LotId {
LotId {
origin_event_id: eid(rf),
split_sequence: 0,
}
}
fn pick(rf: &str, sat: Sat) -> LotPick {
LotPick { lot: lid(rf), sat }
}
fn canon(mut v: Vec<LotPick>) -> Vec<LotPick> {
v.sort_by(|a, b| a.lot.cmp(&b.lot));
v
}
fn mklot(rf: &str, acquired: TaxDate, sat: Sat, basis: Usd, wallet: WalletId) -> Lot {
Lot {
lot_id: lid(rf),
wallet,
acquired_at: acquired,
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 ev(rf: &str, ts: time::OffsetDateTime, wallet: WalletId, p: EventPayload) -> LedgerEvent {
LedgerEvent {
id: eid(rf),
utc_timestamp: ts,
original_tz: offset!(+00:00),
wallet: Some(wallet),
payload: p,
}
}
fn buy(
rf: &str,
ts: time::OffsetDateTime,
wallet: WalletId,
sat: Sat,
cost: Usd,
) -> LedgerEvent {
ev(
rf,
ts,
wallet,
EventPayload::Acquire(Acquire {
sat,
usd_cost: cost,
fee_usd: dec!(0),
basis_source: BasisSource::ExchangeProvided,
}),
)
}
fn sell(
rf: &str,
ts: time::OffsetDateTime,
wallet: WalletId,
sat: Sat,
proceeds: Usd,
) -> LedgerEvent {
ev(
rf,
ts,
wallet,
EventPayload::Dispose(Dispose {
sat,
usd_proceeds: proceeds,
fee_usd: dec!(0),
kind: DisposeKind::Sell,
}),
)
}
fn cfg() -> ProjectionConfig {
ProjectionConfig::default()
}
fn alloc_event(seq: u64, made: time::OffsetDateTime, lots: Vec<AllocLot>) -> LedgerEvent {
LedgerEvent {
id: EventId::decision(seq),
utc_timestamp: made,
original_tz: offset!(+00:00),
wallet: None,
payload: EventPayload::SafeHarborAllocation(SafeHarborAllocation {
lots,
as_of_date: date!(2025 - 01 - 01),
method: AllocMethod::ActualPosition,
timely_allocation_attested: true, pre2025_method: LotMethod::Fifo,
}),
}
}
fn alloc_lot(w: WalletId, sat: Sat, basis: Usd, acq: TaxDate) -> AllocLot {
AllocLot {
wallet: w,
sat,
usd_basis: basis,
acquired_at: acq,
dual_loss_basis: None,
donor_acquired_at: None,
}
}
#[test]
fn complete_vertex_set_three_whole_lots() {
let lots = vec![
mklot("A", date!(2025 - 02 - 01), LOT, dec!(10000), cold()),
mklot("B", date!(2025 - 03 - 01), LOT, dec!(20000), cold()),
mklot("C", date!(2025 - 04 - 01), LOT, dec!(30000), cold()),
];
let (cands, heuristic) = candidate_selections(&lots, 2 * LOT);
assert!(!heuristic, "≤ LOT_ENUM_BOUND ⇒ complete enumeration");
let got: BTreeSet<Vec<LotPick>> = cands.iter().cloned().collect();
let want: BTreeSet<Vec<LotPick>> = [
canon(vec![pick("A", LOT), pick("B", LOT)]),
canon(vec![pick("A", LOT), pick("C", LOT)]),
canon(vec![pick("B", LOT), pick("C", LOT)]),
]
.into_iter()
.collect();
assert_eq!(got, want, "enumerated vertices == brute-force vertex set");
for c in &cands {
assert_eq!(
c.iter().map(|p| p.sat).sum::<Sat>(),
2 * LOT,
"principal conservation"
);
}
}
#[test]
fn one_partial_top_up_vertices() {
let half = LOT / 2;
let lots = vec![
mklot("A", date!(2025 - 02 - 01), LOT, dec!(10000), cold()),
mklot("B", date!(2025 - 03 - 01), LOT, dec!(20000), cold()),
mklot("C", date!(2025 - 04 - 01), LOT, dec!(30000), cold()),
];
let (cands, heuristic) = candidate_selections(&lots, LOT + half);
assert!(!heuristic);
let got: BTreeSet<Vec<LotPick>> = cands.iter().cloned().collect();
let want: BTreeSet<Vec<LotPick>> = [
canon(vec![pick("A", LOT), pick("B", half)]),
canon(vec![pick("A", LOT), pick("C", half)]),
canon(vec![pick("B", LOT), pick("A", half)]),
canon(vec![pick("B", LOT), pick("C", half)]),
canon(vec![pick("C", LOT), pick("A", half)]),
canon(vec![pick("C", LOT), pick("B", half)]),
]
.into_iter()
.collect();
assert_eq!(got, want);
assert!(got.contains(&canon(vec![pick("A", LOT), pick("B", half)])));
assert!(got.contains(&canon(vec![pick("B", LOT), pick("A", half)])));
for c in &cands {
assert_eq!(c.iter().map(|p| p.sat).sum::<Sat>(), LOT + half);
}
}
#[test]
fn candidate_selections_is_deterministic() {
let lots = vec![
mklot("A", date!(2025 - 02 - 01), LOT, dec!(10000), cold()),
mklot("B", date!(2025 - 03 - 01), LOT, dec!(20000), cold()),
mklot("C", date!(2025 - 04 - 01), LOT, dec!(30000), cold()),
];
let (c1, h1) = candidate_selections(&lots, 2 * LOT);
let (c2, h2) = candidate_selections(&lots, 2 * LOT);
assert_eq!(c1, c2);
assert_eq!(h1, h2);
}
#[test]
fn heuristic_flag_false_at_bound() {
let lots: Vec<Lot> = (0..LOT_ENUM_BOUND)
.map(|i| {
mklot(
&format!("L{i}"),
date!(2025 - 02 - 01),
LOT,
dec!(10000),
cold(),
)
})
.collect();
let (_cands, heuristic) = candidate_selections(&lots, 2 * LOT);
assert!(!heuristic, "exactly LOT_ENUM_BOUND lots ⇒ still complete");
}
#[test]
fn heuristic_flag_true_above_bound_returns_strict_subset() {
let n = LOT_ENUM_BOUND + 1; let lots: Vec<Lot> = (0..n)
.map(|i| {
mklot(
&format!("L{i:02}"),
date!(2025 - 02 - 01),
LOT,
dec!(10000),
cold(),
)
})
.collect();
let (cands, heuristic) = candidate_selections(&lots, 2 * LOT);
assert!(heuristic, "> LOT_ENUM_BOUND ⇒ heuristic branch");
let mut full: BTreeSet<Vec<LotPick>> = BTreeSet::new();
for i in 0..n {
for j in (i + 1)..n {
full.insert(canon(vec![
LotPick {
lot: lots[i].lot_id.clone(),
sat: LOT,
},
LotPick {
lot: lots[j].lot_id.clone(),
sat: LOT,
},
]));
}
}
assert_eq!(full.len(), 78);
let got: BTreeSet<Vec<LotPick>> = cands.iter().cloned().collect();
assert!(got.is_subset(&full), "heuristic candidates are vertices");
assert!(
got.len() < full.len(),
"STRICT subset (incomplete): {} of {}",
got.len(),
full.len()
);
for c in &cands {
assert_eq!(c.iter().map(|p| p.sat).sum::<Sat>(), 2 * LOT);
}
}
#[test]
fn available_lots_before_respects_canonical_not_load_order() {
let events = vec![
buy(
"LATE",
datetime!(2025-09-01 00:00:00 UTC),
cold(),
LOT,
dec!(50000),
),
sell(
"D",
datetime!(2025-06-01 00:00:00 UTC),
cold(),
LOT,
dec!(60000),
),
buy(
"EARLY",
datetime!(2025-02-01 00:00:00 UTC),
cold(),
LOT,
dec!(10000),
),
];
let prices = StaticPrices::default();
let lots = available_lots_before(
&events,
&prices,
&cfg(),
&eid("D"),
date!(2025 - 06 - 01),
&cold(),
);
let ids: BTreeSet<LotId> = lots.iter().map(|l| l.lot_id.clone()).collect();
assert!(
ids.contains(&lid("EARLY")),
"acquired-before-D-in-time ⇒ available"
);
assert!(
!ids.contains(&lid("LATE")),
"acquired-after-D-in-time ⇒ NOT available"
);
}
#[test]
fn available_lots_before_excludes_cross_wallet_lot() {
let events = vec![
buy(
"CL",
datetime!(2025-02-01 00:00:00 UTC),
cold(),
LOT,
dec!(10000),
),
buy(
"HL",
datetime!(2025-02-01 00:00:00 UTC),
hot(),
LOT,
dec!(10000),
),
sell(
"D",
datetime!(2025-06-01 00:00:00 UTC),
cold(),
LOT,
dec!(60000),
),
];
let prices = StaticPrices::default();
let lots = available_lots_before(
&events,
&prices,
&cfg(),
&eid("D"),
date!(2025 - 06 - 01),
&cold(),
);
let ids: BTreeSet<LotId> = lots.iter().map(|l| l.lot_id.clone()).collect();
assert!(ids.contains(&lid("CL")), "same-wallet lot available");
assert!(
!ids.contains(&lid("HL")),
"cross-wallet lot excluded (per-wallet pool)"
);
}
#[test]
fn available_lots_before_path_b_first_2025_disposal_returns_seeded_lots() {
let alloc_id = EventId::decision(1);
let events = vec![
buy(
"OLD",
datetime!(2024-06-01 00:00:00 UTC),
cold(),
LOT,
dec!(60),
),
sell(
"D",
datetime!(2025-02-01 00:00:00 UTC),
cold(),
LOT,
dec!(80),
),
alloc_event(
1,
datetime!(2025-03-01 00:00:00 UTC),
vec![
alloc_lot(cold(), 40_000_000, dec!(20), date!(2024 - 05 - 01)),
alloc_lot(cold(), 60_000_000, dec!(40), date!(2024 - 06 - 01)),
],
),
];
let prices = StaticPrices::default();
let lots = available_lots_before(
&events,
&prices,
&cfg(),
&eid("D"),
date!(2025 - 02 - 01),
&cold(),
);
assert!(!lots.is_empty(), "the post-seed Path-B pool is non-empty");
assert!(
lots.iter().all(|l| l.lot_id.origin_event_id == alloc_id),
"Path-B first-2025-disposal MUST return the SEEDED lots (origin = allocation); got origins {:?}",
lots.iter()
.map(|l| l.lot_id.origin_event_id.clone())
.collect::<Vec<_>>()
);
assert!(
lots.iter()
.all(|l| l.basis_source == BasisSource::SafeHarborAllocated),
"seeded lots carry the SafeHarborAllocated basis_source (not the residue's ExchangeProvided)"
);
let ids: BTreeSet<LotId> = lots.iter().map(|l| l.lot_id.clone()).collect();
assert!(
!ids.contains(&lid("OLD")),
"the DISCARDED FIFO residue lot must NOT appear in a Path-B pool"
);
assert_eq!(lots.len(), 2, "both seed lots present");
assert_eq!(
lots.iter().map(|l| l.remaining_sat).sum::<Sat>(),
LOT,
"seed conserves principal"
);
assert_eq!(
lots.iter().map(|l| l.usd_basis).sum::<Usd>(),
dec!(60),
"seed conserves basis"
);
}
#[test]
fn available_lots_before_path_a_first_2025_disposal_relocates_residue() {
let events = vec![
buy(
"OLD",
datetime!(2024-06-01 00:00:00 UTC),
cold(),
LOT,
dec!(60),
),
sell(
"D",
datetime!(2025-02-01 00:00:00 UTC),
cold(),
LOT,
dec!(80),
),
];
let prices = StaticPrices::default();
let lots = available_lots_before(
&events,
&prices,
&cfg(),
&eid("D"),
date!(2025 - 02 - 01),
&cold(),
);
assert_eq!(lots.len(), 1, "the single relocated residue lot");
let l = &lots[0];
assert_eq!(l.lot_id, lid("OLD"), "Path A preserves the residue lot_id");
assert_eq!(l.usd_basis, dec!(60), "Path A preserves basis");
assert_eq!(l.remaining_sat, LOT);
assert_eq!(
l.basis_source,
BasisSource::ReconstructedPerWallet,
"Path A drains the residue into the per-wallet pool at the boundary seed"
);
}
#[test]
fn available_lots_before_is_deterministic() {
let events = vec![
buy(
"A",
datetime!(2025-02-01 00:00:00 UTC),
cold(),
LOT,
dec!(10000),
),
buy(
"B",
datetime!(2025-03-01 00:00:00 UTC),
cold(),
LOT,
dec!(20000),
),
sell(
"D",
datetime!(2025-06-01 00:00:00 UTC),
cold(),
LOT,
dec!(60000),
),
];
let prices = StaticPrices::default();
let l1 = available_lots_before(
&events,
&prices,
&cfg(),
&eid("D"),
date!(2025 - 06 - 01),
&cold(),
);
let l2 = available_lots_before(
&events,
&prices,
&cfg(),
&eid("D"),
date!(2025 - 06 - 01),
&cold(),
);
assert_eq!(l1, l2);
}
#[test]
fn contention_groups_one_group_and_joint_reaches_deviation() {
let events = vec![
buy(
"R",
datetime!(2025-05-01 00:00:00 UTC),
cold(),
LOT,
dec!(5000),
),
buy(
"P",
datetime!(2025-06-15 00:00:00 UTC),
cold(),
LOT,
dec!(5000),
),
sell(
"D1",
datetime!(2026-06-01 00:00:00 UTC),
cold(),
LOT,
dec!(10000),
),
sell(
"D2",
datetime!(2026-06-20 00:00:00 UTC),
cold(),
LOT,
dec!(10000),
),
];
let prices = StaticPrices::default();
let mut targets = vec![
(eid("D1"), cold(), date!(2026 - 06 - 01), LOT),
(eid("D2"), cold(), date!(2026 - 06 - 20), LOT),
];
targets.sort_by(|a, b| a.0.cmp(&b.0));
let groups = contention_groups(&events, &prices, &cfg(), &targets);
assert_eq!(
groups.len(),
1,
"overlapping same-wallet disposals → one group"
);
assert_eq!(groups[0].len(), 2);
let members: Vec<_> = groups[0].iter().map(|&i| targets[i].clone()).collect();
let (maps, heur) =
group_candidate_assignments(&events, &prices, &cfg(), &members).expect("within bound");
assert_eq!(heur, None, "small pools → no heuristic branch");
let rp: BTreeMap<EventId, Vec<LotPick>> = [
(eid("D1"), vec![pick("R", LOT)]),
(eid("D2"), vec![pick("P", LOT)]),
]
.into_iter()
.collect();
let pr: BTreeMap<EventId, Vec<LotPick>> = [
(eid("D1"), vec![pick("P", LOT)]),
(eid("D2"), vec![pick("R", LOT)]),
]
.into_iter()
.collect();
assert!(maps.contains(&rp), "baseline-consistent sequence present");
assert!(
maps.contains(&pr),
"cross-period deviation sequence present (joint-only)"
);
assert_eq!(maps.len(), 2);
}
#[test]
fn contention_groups_singletons_for_different_wallets() {
let events = vec![
buy(
"CL",
datetime!(2026-05-01 00:00:00 UTC),
cold(),
LOT,
dec!(5000),
),
buy(
"HL",
datetime!(2026-05-01 00:00:00 UTC),
hot(),
LOT,
dec!(5000),
),
sell(
"DC",
datetime!(2026-06-01 00:00:00 UTC),
cold(),
LOT,
dec!(10000),
),
sell(
"DH",
datetime!(2026-06-01 00:00:00 UTC),
hot(),
LOT,
dec!(10000),
),
];
let prices = StaticPrices::default();
let mut targets = vec![
(eid("DC"), cold(), date!(2026 - 06 - 01), LOT),
(eid("DH"), hot(), date!(2026 - 06 - 01), LOT),
];
targets.sort_by(|a, b| a.0.cmp(&b.0));
let groups = contention_groups(&events, &prices, &cfg(), &targets);
assert_eq!(groups.len(), 2, "different wallets → two singleton groups");
assert!(groups.iter().all(|g| g.len() == 1));
}
#[test]
fn group_candidate_assignments_none_beyond_bound() {
let mut events: Vec<LedgerEvent> = (0..10)
.map(|i| {
buy(
&format!("L{i:02}"),
datetime!(2026-05-01 00:00:00 UTC),
cold(),
LOT,
dec!(5000),
)
})
.collect();
let dates = [
date!(2026 - 06 - 01),
date!(2026 - 06 - 02),
date!(2026 - 06 - 03),
date!(2026 - 06 - 04),
];
for (k, d) in dates.iter().enumerate() {
events.push(sell(
&format!("D{k}"),
datetime!(2026-06-01 00:00:00 UTC).replace_date(*d),
cold(),
LOT,
dec!(10000),
));
}
let prices = StaticPrices::default();
let members: Vec<_> = (0..4)
.map(|k| (eid(&format!("D{k}")), cold(), dates[k], LOT))
.collect();
let res = group_candidate_assignments(&events, &prices, &cfg(), &members);
assert!(res.is_none(), "joint count 5040 > GROUP_COMBO_BOUND ⇒ None");
}
}