use crate::conservative::{window_reference, Coverage};
use crate::conventions::{round_cents, tax_date, Sat, TaxDate, Usd, SATS_PER_BTC};
use crate::event::{
Acknowledgment, ConsentTerm, EventPayload, FloorMethod, LedgerEvent, PromoteTranche,
};
use crate::identity::EventId;
use crate::price::PriceProvider;
use crate::project::resolve::resolve;
use crate::project::{project, ProjectionConfig};
use crate::state::{Disposal, LedgerState, Removal, RemovalKind};
use crate::tax::{compute_tax_year, TaxOutcome, TaxProfile, TaxTables};
use std::collections::BTreeSet;
use time::UtcOffset;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromoteRefusal {
NoCoverage,
PartialCoverage,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComputedFloor {
pub filed_basis: Usd,
pub coverage: Coverage,
}
pub fn filed_basis_for(
prices: &dyn PriceProvider,
sat: Sat,
window_start: TaxDate,
window_end: TaxDate,
) -> Result<ComputedFloor, PromoteRefusal> {
match window_reference(prices, window_start, window_end) {
None => Err(PromoteRefusal::NoCoverage),
Some(wr) if wr.coverage == Coverage::Partial => Err(PromoteRefusal::PartialCoverage),
Some(wr) if wr.coverage == Coverage::Full => Ok(ComputedFloor {
filed_basis: round_cents(wr.min * Usd::from(sat) / Usd::from(SATS_PER_BTC)),
coverage: Coverage::Full,
}),
Some(_) => Err(PromoteRefusal::PartialCoverage),
}
}
pub fn promote_drift_advisory(events: &[LedgerEvent], prices: &dyn PriceProvider) -> Vec<String> {
let config = ProjectionConfig::default();
let promotes = resolve(events, prices, &config).promotes;
let mut lines: Vec<String> = Vec::new();
for (target, entry) in &promotes {
let Some((ws, we)) = events.iter().find_map(|e| match &e.payload {
EventPayload::DeclareTranche(t) if e.id == *target => {
Some((t.window_start, t.window_end))
}
_ => None,
}) else {
continue;
};
let stored = entry.filed_basis;
let recomputed = match filed_basis_for(prices, entry.tranche_sat, ws, we) {
Ok(cf) => cf.filed_basis,
Err(_) => continue, };
match stored.cmp(&recomputed) {
std::cmp::Ordering::Greater => lines.push(format!(
"Promote-drift — the filed basis floor for the {ws}\u{2013}{we} tranche (${stored:.2} \
stored) now recomputes to ${recomputed:.2} against current price data \u{2014} LOWER, so \
the stored floor is OVERSTATED. If this position is not yet filed, void the promote and \
re-promote to the corrected lower number ${recomputed:.2} (G-4). If it is already filed, \
the filed number stands \u{2014} advisory only (the engine keeps folding the stored \
basis)."
)),
std::cmp::Ordering::Less => lines.push(format!(
"Promote-drift — the filed basis floor for the {ws}\u{2013}{we} tranche (${stored:.2} \
stored) now recomputes to ${recomputed:.2} against current price data \u{2014} HIGHER, so \
the stored floor is UNDERSTATED (conservative: it reports at least as much gain as the \
corrected floor would). No amendment is required; you may re-promote to the higher floor \
to reduce the reported gain going forward."
)),
std::cmp::Ordering::Equal => {} }
}
lines
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromoteEntry {
pub filed_basis: Usd,
pub tranche_sat: Sat,
}
pub type PromoteSet = std::collections::BTreeMap<EventId, PromoteEntry>;
pub fn estimate_share_of(p: &PromoteEntry, leg_sat: Sat) -> Usd {
round_cents(p.filed_basis * Usd::from(leg_sat) / Usd::from(p.tranche_sat))
}
pub fn clamped_leg_basis(
promote: Option<&PromoteEntry>,
leg_sat: Sat,
usd_basis_share: Usd,
net_proceeds_share: Usd,
) -> Usd {
let Some(p) = promote else {
return usd_basis_share; };
let estimate_share = estimate_share_of(p, leg_sat);
let documented_share = usd_basis_share - estimate_share; let estimate_basis = estimate_share.min((net_proceeds_share - documented_share).max(Usd::ZERO));
documented_share + estimate_basis
}
fn crypto_tax_of(
events: &[LedgerEvent],
state: &LedgerState,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
) -> Option<Usd> {
match compute_tax_year(events, state, year, profile, tables) {
TaxOutcome::Computed(r) => Some(r.total_federal_tax_attributable),
TaxOutcome::NotComputable(_) => None,
}
}
fn declare_tranche_sat(events: &[LedgerEvent], tranche_id: &EventId, remaining: Sat) -> Sat {
events
.iter()
.find_map(|e| match &e.payload {
EventPayload::DeclareTranche(t) if e.id == *tranche_id => Some(t.sat),
_ => None,
})
.unwrap_or(remaining)
}
#[allow(clippy::too_many_arguments)]
pub fn consent_terms(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
tranche_id: &EventId,
filed_basis: Usd,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
) -> Vec<ConsentTerm> {
let without_state = project(events, prices, config);
let with_events = with_synthetic_promote(events, tranche_id, filed_basis);
let with_state = project(&with_events, prices, config);
let mut terms: Vec<ConsentTerm> = Vec::new();
let mut years: BTreeSet<i32> = BTreeSet::new();
for st in [&with_state, &without_state] {
for d in &st.disposals {
years.insert(d.disposed_at.year());
}
for r in &st.removals {
years.insert(r.removed_at.year());
}
}
let mut carryover_source: Option<i32> = None;
let mut flagged: BTreeSet<i32> = BTreeSet::new();
for &y in &years {
let disp = |st: &LedgerState| -> Vec<Disposal> {
st.disposals
.iter()
.filter(|d| d.disposed_at.year() == y)
.cloned()
.collect()
};
let rem = |st: &LedgerState, k: RemovalKind| -> Vec<Removal> {
st.removals
.iter()
.filter(|r| r.removed_at.year() == y && r.kind == k)
.cloned()
.collect()
};
let disp_changed = disp(&with_state) != disp(&without_state);
let don_changed =
rem(&with_state, RemovalKind::Donation) != rem(&without_state, RemovalKind::Donation);
let gift_changed =
rem(&with_state, RemovalKind::Gift) != rem(&without_state, RemovalKind::Gift);
if !(disp_changed || don_changed || gift_changed) {
continue; }
flagged.insert(y);
let gain = |st: &LedgerState| -> Usd {
st.disposals
.iter()
.filter(|d| d.disposed_at.year() == y)
.flat_map(|d| &d.legs)
.map(|l| l.gain)
.sum()
};
let ded = |st: &LedgerState| -> Usd {
st.removals
.iter()
.filter(|r| r.removed_at.year() == y && r.kind == RemovalKind::Donation)
.filter_map(|r| r.claimed_deduction)
.sum()
};
let gift_basis = |st: &LedgerState| -> Usd {
st.removals
.iter()
.filter(|r| r.removed_at.year() == y && r.kind == RemovalKind::Gift)
.flat_map(|r| &r.legs)
.map(|l| l.basis)
.sum()
};
let gain_delta = gain(&without_state) - gain(&with_state);
let don_delta = ded(&without_state) - ded(&with_state); let gift_delta = gift_basis(&without_state) - gift_basis(&with_state); let removal_delta = don_delta + gift_delta;
let removal_diffed = don_changed || gift_changed;
if (disp_changed && gain_delta != Usd::ZERO) || (don_changed && don_delta != Usd::ZERO) {
carryover_source = Some(carryover_source.map_or(y, |c| c.min(y)));
}
match (
crypto_tax_of(events, &with_state, y, profile, tables),
crypto_tax_of(events, &without_state, y, profile, tables),
) {
(Some(t_with), Some(t_without)) => {
let delta_usd = t_without - t_with; let deduction_delta_usd = if removal_diffed {
Some(removal_delta)
} else {
None
};
if delta_usd != Usd::ZERO || deduction_delta_usd.is_some() {
terms.push(ConsentTerm::ComputedTax {
year: y,
delta_usd,
deduction_delta_usd,
});
}
}
_ => {
if gain_delta != Usd::ZERO || removal_delta != Usd::ZERO {
terms.push(ConsentTerm::Uncomputable {
year: y,
gain_delta_usd: gain_delta,
deduction_delta_usd: removal_delta,
});
}
}
}
}
if let Some(src) = carryover_source {
for &y in &years {
if y > src && !flagged.contains(&y) {
terms.push(ConsentTerm::CascadeNamed { year: y });
}
}
}
let remaining: Sat = with_state
.lots
.iter()
.filter(|l| l.lot_id.origin_event_id == *tranche_id)
.map(|l| l.remaining_sat)
.sum();
if remaining > 0 {
let tranche_sat = declare_tranche_sat(events, tranche_id, remaining);
let (hypothetical_reduction, as_of) =
unrealized_reduction(events, prices, filed_basis, tranche_sat, remaining);
terms.push(ConsentTerm::Unrealized {
sat: remaining,
hypothetical_reduction,
as_of,
});
}
terms
}
pub(crate) fn with_synthetic_promote(
events: &[LedgerEvent],
tranche_id: &EventId,
filed_basis: Usd,
) -> Vec<LedgerEvent> {
let seq = events
.iter()
.filter_map(|e| match e.id {
EventId::Decision { seq } => Some(seq),
_ => None,
})
.max()
.map_or(1, |m| m + 1);
let utc = events
.iter()
.map(|e| e.utc_timestamp)
.max()
.unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
let mut out = events.to_vec();
out.push(LedgerEvent {
id: EventId::Decision { seq },
utc_timestamp: utc,
original_tz: UtcOffset::UTC,
wallet: None,
payload: EventPayload::PromoteTranche(PromoteTranche {
target: tranche_id.clone(),
method: FloorMethod::WindowLowClose,
filed_basis,
coverage: Coverage::Full,
provenance_attested: true,
acknowledgment: Acknowledgment {
phrase: String::new(),
shown_terms: Vec::new(),
provenance_text: String::new(),
provenance_version: String::new(),
},
part_ii_narrative: String::new(),
}),
});
out
}
#[allow(clippy::too_many_arguments)]
pub fn clamped_promote_year_saving(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
tranche_id: &EventId,
filed_basis: Usd,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
) -> Usd {
let without_state = project(events, prices, config);
let with_events = with_synthetic_promote(events, tranche_id, filed_basis);
let with_state = project(&with_events, prices, config);
match (
crypto_tax_of(events, &with_state, year, profile, tables),
crypto_tax_of(events, &without_state, year, profile, tables),
) {
(Some(t_with), Some(t_without)) => (t_without - t_with).max(Usd::ZERO),
_ => Usd::ZERO,
}
}
fn unrealized_reduction(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
filed_basis: Usd,
tranche_sat: Sat,
remaining: Sat,
) -> (Option<Usd>, Option<TaxDate>) {
let prorata_floor = if tranche_sat > 0 {
round_cents(filed_basis * Usd::from(remaining) / Usd::from(tranche_sat))
} else {
filed_basis
};
let as_of = events
.iter()
.map(|e| tax_date(e.utc_timestamp, e.original_tz))
.max();
match as_of.and_then(|d| prices.usd_per_btc(d).map(|px| (d, px))) {
Some((d, px)) => {
let today_proceeds = round_cents(px * Usd::from(remaining) / Usd::from(SATS_PER_BTC));
(Some(today_proceeds.min(prorata_floor)), Some(d))
}
None => (None, None),
}
}