use btctax_core::conventions::Usd;
use btctax_core::event::*;
use btctax_core::identity::*;
use btctax_core::optimize::{consult_sale, ConsultRequest, OptimizeError};
use btctax_core::price::StaticPrices;
use btctax_core::project::fold::state_as_of;
use btctax_core::project::resolve::resolve;
use btctax_core::project::{evaluate_disposal, project, EvaluateError, ProjectionConfig};
use btctax_core::tax::tables::{
LtcgBreakpoints, OrdinaryBracket, OrdinarySchedule, TaxTable, TaxTables,
};
use btctax_core::tax::types::{Carryforward, FilingStatus, TaxProfile};
use rust_decimal_macros::dec;
use std::collections::BTreeMap;
use time::macros::{date, datetime, offset};
const LOT: i64 = 100_000_000;
struct OneTable(TaxTable);
impl TaxTables for OneTable {
fn table_for(&self, year: i32) -> Option<&TaxTable> {
(year == self.0.year).then_some(&self.0)
}
}
fn synth(year: i32) -> OneTable {
let mut ordinary = BTreeMap::new();
ordinary.insert(
FilingStatus::Single,
OrdinarySchedule {
brackets: vec![
OrdinaryBracket {
lower: dec!(0),
rate: dec!(0.10),
},
OrdinaryBracket {
lower: dec!(50000),
rate: dec!(0.22),
},
OrdinaryBracket {
lower: dec!(250000),
rate: dec!(0.32),
},
],
},
);
let mut ltcg = BTreeMap::new();
ltcg.insert(
FilingStatus::Single,
LtcgBreakpoints {
max_zero: dec!(40000),
max_fifteen: dec!(400000),
},
);
OneTable(TaxTable {
year,
source: "SYNTHETIC",
ordinary,
ltcg,
gift_annual_exclusion: dec!(19000),
ss_wage_base: dec!(176100),
gift_lifetime_exclusion: dec!(13_990_000),
})
}
fn profile() -> TaxProfile {
TaxProfile {
filing_status: FilingStatus::Single,
ordinary_taxable_income: dec!(0),
magi_excluding_crypto: dec!(0),
qualified_dividends_and_other_pref_income: dec!(0),
other_net_capital_gain: dec!(0),
capital_loss_carryforward_in: Carryforward {
short: dec!(0),
long: dec!(0),
},
w2_ss_wages: dec!(0),
w2_medicare_wages: dec!(0),
schedule_c_expenses: dec!(0),
}
}
fn cold() -> WalletId {
WalletId::SelfCustody {
label: "cold".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: i64) -> LotPick {
LotPick { lot: lid(rf), sat }
}
fn ev(rf: &str, ts: time::OffsetDateTime, w: WalletId, p: EventPayload) -> LedgerEvent {
LedgerEvent {
id: eid(rf),
utc_timestamp: ts,
original_tz: offset!(+00:00),
wallet: Some(w),
payload: p,
}
}
fn buy(rf: &str, ts: time::OffsetDateTime, w: WalletId, sat: i64, cost: Usd) -> LedgerEvent {
ev(
rf,
ts,
w,
EventPayload::Acquire(Acquire {
sat,
usd_cost: cost,
fee_usd: dec!(0),
basis_source: BasisSource::ExchangeProvided,
}),
)
}
fn sell(rf: &str, ts: time::OffsetDateTime, w: WalletId, sat: i64, proceeds: Usd) -> LedgerEvent {
ev(
rf,
ts,
w,
EventPayload::Dispose(Dispose {
sat,
usd_proceeds: proceeds,
fee_usd: dec!(0),
kind: DisposeKind::Sell,
}),
)
}
fn cfg() -> ProjectionConfig {
ProjectionConfig::default() }
fn req(sat: i64, at: time::Date, proceeds: Option<Usd>) -> ConsultRequest {
ConsultRequest {
sell_sat: sat,
wallet: cold(),
at,
proceeds,
kind: DisposeKind::Sell,
}
}
#[test]
fn consult_picks_high_basis_lot_min_tax() {
let events = vec![
buy(
"LB",
datetime!(2025-01-02 00:00:00 UTC),
cold(),
LOT,
dec!(1000),
),
buy(
"HB",
datetime!(2025-02-02 00:00:00 UTC),
cold(),
LOT,
dec!(5000),
),
];
let prices = StaticPrices::default();
let tables = synth(2025);
let prof = profile();
let r = consult_sale(
&events,
&prices,
&cfg(),
Some(&prof),
&tables,
&req(LOT, date!(2025 - 06 - 01), Some(dec!(10000))),
)
.expect("consult succeeds");
assert_eq!(r.proposed_selection, vec![pick("HB", LOT)]);
assert_eq!(r.st_gain, dec!(5000));
assert_eq!(r.lt_gain, dec!(0));
assert_eq!(r.total_federal_tax_attributable, dec!(500.00));
assert!(
r.timing.is_none(),
"2026 crossover is unbundled → no insight"
);
let candidate = btctax_core::project::CandidateDisposal {
existing_event: None,
wallet: cold(),
date: date!(2025 - 06 - 01),
sat: LOT,
kind: DisposeKind::Sell,
proceeds: Some(dec!(10000)),
};
let out = evaluate_disposal(
&events,
&prices,
&cfg(),
&candidate,
Some(&r.proposed_selection),
)
.expect("evaluate the proposed pick");
assert_eq!(out.st_gain, r.st_gain);
assert_eq!(out.lt_gain, r.lt_gain);
}
#[test]
fn timing_insight_same_year_crossover() {
let events = vec![buy(
"L",
datetime!(2024-06-01 00:00:00 UTC),
cold(),
LOT,
dec!(10000),
)];
let prices = StaticPrices::default();
let tables = synth(2025);
let prof = profile();
let r = consult_sale(
&events,
&prices,
&cfg(),
Some(&prof),
&tables,
&req(LOT, date!(2025 - 02 - 15), Some(dec!(50000))),
)
.expect("consult succeeds");
assert_eq!(r.st_gain, dec!(40000));
assert_eq!(r.lt_gain, dec!(0));
assert_eq!(r.total_federal_tax_attributable, dec!(4000.00));
let t = r
.timing
.expect("a short-term-but-soon-long-term lot fires the insight");
assert_eq!(t.st_sat_in_selection, LOT);
assert_eq!(t.latest_crossover, date!(2025 - 06 - 02)); assert_eq!(t.tax_if_sold_long_term, dec!(0.00)); assert_eq!(t.saving_if_waited, dec!(4000.00)); }
#[test]
fn purely_long_term_selection_omits_timing() {
let events = vec![buy(
"L",
datetime!(2024-01-01 00:00:00 UTC),
cold(),
LOT,
dec!(10000),
)];
let prices = StaticPrices::default();
let tables = synth(2025);
let prof = profile();
let r = consult_sale(
&events,
&prices,
&cfg(),
Some(&prof),
&tables,
&req(LOT, date!(2025 - 06 - 01), Some(dec!(50000))),
)
.expect("consult succeeds");
assert_eq!(r.st_gain, dec!(0));
assert_eq!(r.lt_gain, dec!(40000));
assert!(r.timing.is_none(), "no short-term leg ⇒ no timing insight");
}
#[test]
fn timing_degrades_to_none_for_unbundled_crossover_year() {
let events = vec![buy(
"L",
datetime!(2025-06-01 00:00:00 UTC),
cold(),
LOT,
dec!(10000),
)];
let prices = StaticPrices::default();
let tables = synth(2025); let prof = profile();
let r = consult_sale(
&events,
&prices,
&cfg(),
Some(&prof),
&tables,
&req(LOT, date!(2025 - 12 - 15), Some(dec!(50000))),
);
let report = r.expect("degrade, never error: consult still returns Ok");
assert!(
report.timing.is_none(),
"crossover year 2026 is unbundled ⇒ timing omitted, not an error"
);
assert_eq!(report.st_gain, dec!(40000));
assert_eq!(report.total_federal_tax_attributable, dec!(4000.00));
}
#[test]
fn timing_omitted_on_next_day_max_date_edge() {
let events = vec![buy(
"L",
datetime!(9998-12-31 00:00:00 UTC),
cold(),
LOT,
dec!(10000),
)];
let prices = StaticPrices::default();
let tables = synth(9999);
let prof = profile();
let r = consult_sale(
&events,
&prices,
&cfg(),
Some(&prof),
&tables,
&req(LOT, date!(9999 - 06 - 01), Some(dec!(50000))),
);
let report = r.expect("no panic; consult returns Ok");
assert!(
report.timing.is_none(),
"next_day(9999-12-31) is None ⇒ timing omitted (R0-M4)"
);
assert_eq!(report.st_gain, dec!(40000)); }
#[test]
fn consult_pool_is_as_of_at_not_end_of_timeline() {
let events = vec![
buy(
"EARLY",
datetime!(2025-02-01 00:00:00 UTC),
cold(),
LOT,
dec!(1000),
),
buy(
"LATE",
datetime!(2025-09-01 00:00:00 UTC),
cold(),
LOT,
dec!(2000),
),
sell(
"DISP",
datetime!(2025-10-01 00:00:00 UTC),
cold(),
LOT,
dec!(20000),
),
];
let prices = StaticPrices::default();
let tables = synth(2025);
let prof = profile();
let r = consult_sale(
&events,
&prices,
&cfg(),
Some(&prof),
&tables,
&req(LOT, date!(2025 - 06 - 01), Some(dec!(10000))),
)
.expect("consult succeeds");
assert_eq!(
r.proposed_selection,
vec![pick("EARLY", LOT)],
"the as-of-`at` pool holds only EARLY"
);
assert!(
!r.proposed_selection.iter().any(|p| p.lot == lid("LATE")),
"a lot acquired after `at` must never be selectable"
);
}
#[test]
fn state_as_of_mixed_tz_straddle_disposal_not_skipped() {
let at = date!(2025 - 06 - 01);
let acq = LedgerEvent {
id: eid("ACQ"),
utc_timestamp: datetime!(2025-03-01 00:00:00 UTC),
original_tz: offset!(+00:00),
wallet: Some(cold()),
payload: EventPayload::Acquire(Acquire {
sat: LOT,
usd_cost: dec!(50000),
fee_usd: dec!(0),
basis_source: BasisSource::ExchangeProvided,
}),
};
let noise = LedgerEvent {
id: eid("NOISE"),
utc_timestamp: datetime!(2025-06-01 12:00:00 UTC),
original_tz: offset!(+14:00),
wallet: Some(cold()),
payload: EventPayload::Acquire(Acquire {
sat: 1_000,
usd_cost: dec!(50),
fee_usd: dec!(0),
basis_source: BasisSource::ExchangeProvided,
}),
};
let disp = LedgerEvent {
id: eid("DISP"),
utc_timestamp: datetime!(2025-06-01 23:00:00 UTC),
original_tz: offset!(+00:00),
wallet: Some(cold()),
payload: EventPayload::Dispose(Dispose {
sat: LOT,
usd_proceeds: dec!(60000),
fee_usd: dec!(0),
kind: DisposeKind::Sell,
}),
};
let events = vec![acq, noise, disp];
let prices = StaticPrices::default();
let config = cfg();
let res = resolve(&events, &prices, &config);
let st = state_as_of(res, &prices, &config, at);
assert!(
st.blockers.is_empty(),
"no blockers expected (clean disposal); got: {:?}",
st.blockers
);
assert!(
st.lots.is_empty(),
"the at-dated disposal must consume the lot: st.lots should be empty but is {:?}",
st.lots
);
assert_eq!(
st.disposals.len(),
1,
"exactly one disposal (DISP, date = at) must be recorded; got {:?}",
st.disposals.len()
);
}
#[test]
fn future_date_requires_proceeds() {
let events = vec![buy(
"X",
datetime!(2025-02-01 00:00:00 UTC),
cold(),
LOT,
dec!(1000),
)];
let prices = StaticPrices::default(); let tables = synth(2025);
let prof = profile();
let err = consult_sale(
&events,
&prices,
&cfg(),
Some(&prof),
&tables,
&req(LOT, date!(2025 - 12 - 20), None),
)
.expect_err("no price + no proceeds ⇒ error");
assert_eq!(
err,
OptimizeError::Evaluate(EvaluateError::ProceedsRequired)
);
let ok = consult_sale(
&events,
&prices,
&cfg(),
Some(&prof),
&tables,
&req(LOT, date!(2025 - 12 - 20), Some(dec!(60000))),
);
assert!(ok.is_ok(), "explicit proceeds ⇒ Ok");
}
#[test]
fn consult_writes_nothing() {
let events = vec![
buy(
"LB",
datetime!(2025-01-02 00:00:00 UTC),
cold(),
LOT,
dec!(1000),
),
buy(
"HB",
datetime!(2025-02-02 00:00:00 UTC),
cold(),
LOT,
dec!(5000),
),
];
let events_before = events.clone();
let prices = StaticPrices::default();
let tables = synth(2025);
let prof = profile();
let proj_before = project(&events, &prices, &cfg());
let _ = consult_sale(
&events,
&prices,
&cfg(),
Some(&prof),
&tables,
&req(LOT, date!(2025 - 06 - 01), Some(dec!(10000))),
)
.expect("consult succeeds");
assert_eq!(
events, events_before,
"events slice is byte-identical (no append)"
);
let proj_after = project(&events, &prices, &cfg());
assert_eq!(
proj_before.lots, proj_after.lots,
"the canonical projection's lots are unperturbed"
);
assert_eq!(
proj_before.disposals.len(),
proj_after.disposals.len(),
"no disposal was added to the ledger"
);
}
#[test]
fn consult_is_deterministic() {
let events = vec![
buy(
"L",
datetime!(2024-06-01 00:00:00 UTC),
cold(),
LOT,
dec!(10000),
),
buy(
"M",
datetime!(2025-02-02 00:00:00 UTC),
cold(),
LOT,
dec!(5000),
),
];
let prices = StaticPrices::default();
let tables = synth(2025);
let prof = profile();
let r = || {
consult_sale(
&events,
&prices,
&cfg(),
Some(&prof),
&tables,
&req(LOT, date!(2025 - 02 - 15), Some(dec!(50000))),
)
.unwrap()
};
assert_eq!(r(), r());
}