use crate::conventions::{round_cents, Sat, TaxDate, Usd, SATS_PER_BTC, TRANSITION_DATE};
use crate::event::{DisposeKind, LedgerEvent, LotPick};
use crate::identity::{LotId, WalletId};
use crate::optimize::{fold_as_of, synthetic_state};
use crate::price::{fmv_of, PriceProvider};
use crate::project::pools::{method_order, pool_key};
use crate::project::{
evaluate_disposal, project, CandidateDisposal, EvaluateError, LotMethod, ProjectionConfig,
};
use crate::state::{Blocker, Lot, Term};
use crate::tax::{compute_tax_year, TaxOutcome, TaxProfile, TaxResult, TaxTables};
use rust_decimal::prelude::ToPrimitive;
use std::collections::BTreeSet;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SellMethod {
Method(LotMethod),
Lots(Vec<LotPick>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SellRequest {
pub sell_sat: Sat,
pub wallet: WalletId,
pub at: TaxDate,
pub price: Option<Usd>,
pub method: Option<SellMethod>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LtcgBracket {
Zero,
Fifteen,
Twenty,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsumedLot {
pub lot_id: LotId,
pub sat: Sat,
pub basis: Usd,
pub acquired_at: TaxDate,
pub sold_at: TaxDate,
pub term: Term,
pub gain: Usd,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CarryforwardDelta {
pub short: Usd,
pub long: Usd,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SellStatus {
Gain,
Loss,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SellReport {
pub req: SellRequest,
pub proceeds: Usd,
pub lots: Vec<ConsumedLot>,
pub st_gain: Usd,
pub lt_gain: Usd,
pub bracket: LtcgBracket,
pub bracket_room: Option<Usd>,
pub marginal_tax: Usd,
pub effective_rate: Option<Usd>,
pub carryforward_delta: CarryforwardDelta,
pub ordinary_offset_delta: Usd,
pub niit_incremental: Usd,
pub niit_applies: bool,
pub status: SellStatus,
pub baseline: TaxResult,
pub withhyp: TaxResult,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WhatIfError {
YearNotComputable(Blocker),
Evaluate(EvaluateError),
NoLots {
wallet: WalletId,
at: TaxDate,
available: Sat,
requested: Sat,
},
PreTransitionYear(i32),
InvalidTarget(String),
}
fn computed(o: TaxOutcome) -> Result<TaxResult, WhatIfError> {
match o {
TaxOutcome::Computed(r) => Ok(r),
TaxOutcome::NotComputable(b) => Err(WhatIfError::YearNotComputable(b)),
}
}
#[allow(clippy::too_many_arguments)]
pub fn synthetic_year(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
candidate: &CandidateDisposal,
picks: Option<&[LotPick]>,
) -> Result<(TaxResult, TaxResult), WhatIfError> {
let baseline_state = project(events, prices, config);
let baseline = computed(compute_tax_year(
events,
&baseline_state,
year,
profile,
tables,
))?;
let with_state =
synthetic_state(events, prices, config, candidate, picks).map_err(WhatIfError::Evaluate)?;
let withhyp = computed(compute_tax_year(events, &with_state, year, profile, tables))?;
Ok((baseline, withhyp))
}
fn method_selection(lots: &[Lot], method: LotMethod, sell_sat: Sat) -> Vec<LotPick> {
let mut need = sell_sat;
let mut picks = Vec::new();
for i in method_order(lots, method) {
if need <= 0 {
break;
}
let take = need.min(lots[i].remaining_sat);
if take > 0 {
picks.push(LotPick {
lot: lots[i].lot_id.clone(),
sat: take,
});
need -= take;
}
}
picks
}
pub fn sell(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
req: &SellRequest,
) -> Result<SellReport, WhatIfError> {
let year = req.at.year();
if year < TRANSITION_DATE.year() {
return Err(WhatIfError::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));
let available: Sat = lots.iter().map(|l| l.remaining_sat).sum();
if available < req.sell_sat {
return Err(WhatIfError::NoLots {
wallet: req.wallet.clone(),
at: req.at,
available,
requested: req.sell_sat,
});
}
let proceeds: Option<Usd> = req
.price
.map(|px| round_cents(px * Usd::from(req.sell_sat) / Usd::from(SATS_PER_BTC)));
if proceeds.is_none() && fmv_of(prices, req.at, req.sell_sat).is_none() {
return Err(WhatIfError::Evaluate(EvaluateError::ProceedsRequired));
}
let candidate = CandidateDisposal {
existing_event: None,
wallet: req.wallet.clone(),
date: req.at,
sat: req.sell_sat,
kind: DisposeKind::Sell,
proceeds,
};
let picks: Option<Vec<LotPick>> = match &req.method {
None => None,
Some(SellMethod::Method(m)) => Some(method_selection(&lots, *m, req.sell_sat)),
Some(SellMethod::Lots(p)) => Some(p.clone()),
};
let picks_ref = picks.as_deref();
let out = evaluate_disposal(events, prices, config, &candidate, picks_ref)
.map_err(WhatIfError::Evaluate)?;
let (baseline, withhyp) = synthetic_year(
events, prices, config, year, profile, tables, &candidate, picks_ref,
)?;
let resolved_proceeds = candidate
.proceeds
.or_else(|| fmv_of(prices, req.at, req.sell_sat))
.unwrap_or(Usd::ZERO);
let lots_consumed: Vec<ConsumedLot> = out
.legs
.iter()
.map(|l| ConsumedLot {
lot_id: l.lot_id.clone(),
sat: l.sat,
basis: l.basis,
acquired_at: l.acquired_at,
sold_at: req.at,
term: l.term,
gain: l.gain,
})
.collect();
let ps = withhyp.pref_split;
let bracket = if ps.at_20 > Usd::ZERO {
LtcgBracket::Twenty
} else if ps.at_15 > Usd::ZERO {
LtcgBracket::Fifteen
} else {
LtcgBracket::Zero
};
let bp = profile
.map(|p| p.filing_status)
.and_then(|fs| tables.table_for(year).map(|t| *t.ltcg_for(fs)));
let top = withhyp.bottom_with + ps.at_0 + ps.at_15 + ps.at_20;
let bracket_room = bp.and_then(|bp| match bracket {
LtcgBracket::Zero => Some((bp.max_zero - top).max(Usd::ZERO)),
LtcgBracket::Fifteen => Some((bp.max_fifteen - top).max(Usd::ZERO)),
LtcgBracket::Twenty => None,
});
let st_gain = out.st_gain;
let lt_gain = out.lt_gain;
let gain = st_gain + lt_gain;
let marginal_tax =
withhyp.total_federal_tax_attributable - baseline.total_federal_tax_attributable;
let effective_rate = if gain > Usd::ZERO {
Some((marginal_tax / gain).round_dp(4))
} else {
None
};
let carryforward_delta = CarryforwardDelta {
short: withhyp.carryforward_out.short - baseline.carryforward_out.short,
long: withhyp.carryforward_out.long - baseline.carryforward_out.long,
};
let ordinary_offset_delta = withhyp.loss_deduction - baseline.loss_deduction;
let niit_incremental = withhyp.niit - baseline.niit;
let status = if gain < Usd::ZERO {
SellStatus::Loss
} else {
SellStatus::Gain
};
Ok(SellReport {
req: req.clone(),
proceeds: resolved_proceeds,
lots: lots_consumed,
st_gain,
lt_gain,
bracket,
bracket_room,
marginal_tax,
effective_rate,
carryforward_delta,
ordinary_offset_delta,
niit_incremental,
niit_applies: niit_incremental != Usd::ZERO,
status,
baseline,
withhyp,
})
}
pub const HARVEST_TAU_SAT: Sat = 1_024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HarvestTarget {
ZeroLtcg,
FifteenLtcg,
Gain(Usd),
Tax(Usd),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HarvestTargetParseError {
UnrecognizedTarget(String),
BadAmount(String),
}
impl fmt::Display for HarvestTargetParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HarvestTargetParseError::UnrecognizedTarget(s) => write!(
f,
"bad --target {s:?}: expected zero-ltcg | fifteen-ltcg | gain=$X | tax=$X"
),
HarvestTargetParseError::BadAmount(v) => {
write!(f, "bad --target amount {v:?}: expected a USD number")
}
}
}
}
impl std::error::Error for HarvestTargetParseError {}
impl FromStr for HarvestTarget {
type Err = HarvestTargetParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let lower = s.trim().to_ascii_lowercase();
match lower.as_str() {
"zero-ltcg" | "zero_ltcg" | "zeroltcg" => return Ok(HarvestTarget::ZeroLtcg),
"fifteen-ltcg" | "fifteen_ltcg" | "fifteenltcg" => {
return Ok(HarvestTarget::FifteenLtcg)
}
_ => {}
}
if let Some(v) = lower.strip_prefix("gain=") {
return Ok(HarvestTarget::Gain(parse_target_amount(v)?));
}
if let Some(v) = lower.strip_prefix("tax=") {
return Ok(HarvestTarget::Tax(parse_target_amount(v)?));
}
Err(HarvestTargetParseError::UnrecognizedTarget(s.to_string()))
}
}
fn parse_target_amount(v: &str) -> Result<Usd, HarvestTargetParseError> {
let cleaned = v.trim().replace(['$', ','], "");
Usd::from_str(&cleaned).map_err(|_| HarvestTargetParseError::BadAmount(v.to_string()))
}
pub fn parse_btc_amount(s: &str) -> Result<Sat, String> {
let cleaned = s.trim().replace(['_', ','], "");
if cleaned.is_empty() {
return Err("enter a BTC amount to sell".to_string());
}
let btc = Usd::from_str(&cleaned).map_err(|e| format!("bad BTC amount {s:?}: {e}"))?;
if btc < Usd::ZERO {
return Err(format!("BTC amount must be \u{2265} 0 (got {s:?})"));
}
let sats = btc * Usd::from(SATS_PER_BTC);
if sats.fract() != Usd::ZERO {
return Err(format!(
"BTC amount {s:?} is finer than 1 satoshi (max 8 decimal places)"
));
}
sats.to_i64()
.ok_or_else(|| format!("BTC amount {s:?} is too large"))
}
pub fn parse_sell_arg(s: &str) -> Result<Sat, String> {
let trimmed = s.trim();
if trimmed.contains('.') {
parse_btc_amount(s)
} else {
i64::from_str(trimmed).map_err(|e| format!("expected an integer sat amount: {e}"))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HarvestRequest {
pub wallet: WalletId,
pub at: TaxDate,
pub price: Option<Usd>,
pub target: HarvestTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HarvestStatus {
Found,
NotBinding,
AlreadyBreached,
NoLots,
ProceedsRequired,
PreTransitionYear,
YearNotComputable(Blocker),
}
impl HarvestStatus {
pub fn of_refusal(e: &WhatIfError) -> HarvestStatus {
match e {
WhatIfError::NoLots { .. } => HarvestStatus::NoLots,
WhatIfError::Evaluate(_) => HarvestStatus::ProceedsRequired,
WhatIfError::PreTransitionYear(_) => HarvestStatus::PreTransitionYear,
WhatIfError::YearNotComputable(b) => HarvestStatus::YearNotComputable(b.clone()),
WhatIfError::InvalidTarget(_) => HarvestStatus::NoLots,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HarvestReport {
pub req: HarvestRequest,
pub n_sat: Sat,
pub n_btc: Usd,
pub status: HarvestStatus,
pub binding_constraint: String,
pub st_gain: Usd,
pub lt_gain: Usd,
pub with_result: TaxResult,
pub baseline: TaxResult,
pub marginal_tax: Usd,
pub carryforward_delta: CarryforwardDelta,
pub niit_incremental: Usd,
pub niit_applies: bool,
pub pending_capped: bool,
pub plateau_note: Option<String>,
}
#[derive(Debug, Clone)]
struct Probe {
tax: TaxResult,
st_gain: Usd,
lt_gain: Usd,
}
fn predicate_holds(target: HarvestTarget, p: &Probe, baseline: &TaxResult) -> bool {
match target {
HarvestTarget::ZeroLtcg => p.tax.pref_split.at_15 + p.tax.pref_split.at_20 <= Usd::ZERO,
HarvestTarget::FifteenLtcg => p.tax.pref_split.at_20 <= Usd::ZERO,
HarvestTarget::Gain(x) => p.st_gain + p.lt_gain <= x,
HarvestTarget::Tax(x) => {
p.tax.total_federal_tax_attributable - baseline.total_federal_tax_attributable <= x
}
}
}
fn analytic_seed(
target: HarvestTarget,
baseline: &TaxResult,
lo: Sat,
lo_p: &Probe,
hi: Sat,
hi_p: &Probe,
) -> Option<Sat> {
let span = hi - lo;
if span <= 1 {
return None;
}
let (x, f_lo, f_hi) = match target {
HarvestTarget::Tax(x) => (
x,
lo_p.tax.total_federal_tax_attributable - baseline.total_federal_tax_attributable,
hi_p.tax.total_federal_tax_attributable - baseline.total_federal_tax_attributable,
),
HarvestTarget::Gain(x) => (x, lo_p.st_gain + lo_p.lt_gain, hi_p.st_gain + hi_p.lt_gain),
_ => return None,
};
if f_hi <= f_lo {
return None; }
let offset = ((x - f_lo) * Usd::from(span) / (f_hi - f_lo))
.floor()
.to_i64()?;
Some((lo + offset).clamp(lo + 1, hi - 1))
}
#[allow(clippy::too_many_arguments)]
pub fn harvest(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
req: &HarvestRequest,
) -> Result<HarvestReport, WhatIfError> {
let year = req.at.year();
if year < TRANSITION_DATE.year() {
return Err(WhatIfError::PreTransitionYear(year));
}
let kind = DisposeKind::Sell;
if let HarvestTarget::Gain(x) | HarvestTarget::Tax(x) = req.target {
if x < Usd::ZERO {
return Err(WhatIfError::InvalidTarget(format!(
"target amount must be >= 0 (got {x}); to harvest LOSSES use `what-if sell` \
(a loss sale is not a max-N-under-a-cap problem)"
)));
}
}
let baseline_state = project(events, prices, config);
let baseline = computed(compute_tax_year(
events,
&baseline_state,
year,
profile,
tables,
))?;
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));
let total_all: Sat = lots.iter().map(|l| l.remaining_sat).sum();
let pending_ids: BTreeSet<LotId> = lots
.iter()
.filter(|l| l.basis_pending)
.map(|l| l.lot_id.clone())
.collect();
if total_all == 0 {
return Ok(no_op_report(
req,
&baseline,
HarvestStatus::NoLots,
"no lots available to harvest from that wallet as of that date".into(),
false,
));
}
if req.price.is_none() && fmv_of(prices, req.at, total_all).is_none() {
return Err(WhatIfError::Evaluate(EvaluateError::ProceedsRequired));
}
let full_proceeds = req
.price
.map(|px| round_cents(px * Usd::from(total_all) / Usd::from(SATS_PER_BTC)));
let full_candidate = CandidateDisposal {
existing_event: None,
wallet: req.wallet.clone(),
date: req.at,
sat: total_all,
kind,
proceeds: full_proceeds,
};
let full = evaluate_disposal(events, prices, config, &full_candidate, None)
.map_err(WhatIfError::Evaluate)?;
let mut edges: Vec<Sat> = Vec::new();
let mut cum: Sat = 0;
let mut pending_capped = false;
for leg in &full.legs {
if pending_ids.contains(&leg.lot_id) {
pending_capped = true;
break;
}
cum += leg.sat;
edges.push(cum);
}
let n_avail = edges.last().copied().unwrap_or(0);
if n_avail == 0 {
return Ok(no_op_report(
req,
&baseline,
HarvestStatus::NoLots,
"N capped at 0 — the first lot in consumption order has pending basis".into(),
true,
));
}
let probe = |n: Sat| -> Result<Probe, WhatIfError> {
if n <= 0 {
return Ok(Probe {
tax: baseline.clone(),
st_gain: Usd::ZERO,
lt_gain: Usd::ZERO,
});
}
let proceeds = req
.price
.map(|px| round_cents(px * Usd::from(n) / Usd::from(SATS_PER_BTC)));
let cand = CandidateDisposal {
existing_event: None,
wallet: req.wallet.clone(),
date: req.at,
sat: n,
kind,
proceeds,
};
let out = evaluate_disposal(events, prices, config, &cand, None)
.map_err(WhatIfError::Evaluate)?;
let state =
synthetic_state(events, prices, config, &cand, None).map_err(WhatIfError::Evaluate)?;
let tax = computed(compute_tax_year(events, &state, year, profile, tables))?;
Ok(Probe {
tax,
st_gain: out.st_gain,
lt_gain: out.lt_gain,
})
};
let base_probe = Probe {
tax: baseline.clone(),
st_gain: Usd::ZERO,
lt_gain: Usd::ZERO,
};
if !predicate_holds(req.target, &base_probe, &baseline) {
return Ok(no_op_report(
req,
&baseline,
HarvestStatus::AlreadyBreached,
format!(
"target already breached at N=0 ({})",
binding_label(req.target)
),
pending_capped,
));
}
let mut lo_edge: Sat = 0;
let mut lo_probe = base_probe;
let mut break_edge: Option<(Sat, Probe)> = None;
for &e in &edges {
let p = probe(e)?;
if predicate_holds(req.target, &p, &baseline) {
lo_edge = e;
lo_probe = p;
} else {
break_edge = Some((e, p));
break;
}
}
let (n_star, star_probe, status) = match break_edge {
None => (n_avail, lo_probe, HarvestStatus::NotBinding),
Some((hi_edge, hi_probe)) => {
let mut lo = lo_edge;
let mut hi = hi_edge;
if let Some(seed) = analytic_seed(req.target, &baseline, lo, &lo_probe, hi, &hi_probe) {
if predicate_holds(req.target, &probe(seed)?, &baseline) {
lo = seed;
} else {
hi = seed;
}
}
while hi - lo > HARVEST_TAU_SAT {
let mid = lo + (hi - lo) / 2;
if predicate_holds(req.target, &probe(mid)?, &baseline) {
lo = mid;
} else {
hi = mid;
}
}
let final_p = probe(lo)?;
debug_assert!(
predicate_holds(req.target, &final_p, &baseline),
"harvest invariant: the returned N* must be engine-verified true"
);
(lo, final_p, HarvestStatus::Found)
}
};
let with_result = star_probe.tax.clone();
let marginal_tax =
with_result.total_federal_tax_attributable - baseline.total_federal_tax_attributable;
let carryforward_delta = CarryforwardDelta {
short: with_result.carryforward_out.short - baseline.carryforward_out.short,
long: with_result.carryforward_out.long - baseline.carryforward_out.long,
};
let niit_incremental = with_result.niit - baseline.niit;
let plateau_note = plateau_note(&baseline, &with_result, carryforward_delta, pending_capped);
let binding_constraint = match status {
HarvestStatus::NotBinding => {
"available pool exhausted — the target does not bind (the full position fits)"
.to_string()
}
_ => binding_label(req.target),
};
Ok(HarvestReport {
req: req.clone(),
n_sat: n_star,
n_btc: Usd::from(n_star) / Usd::from(SATS_PER_BTC),
status,
binding_constraint,
st_gain: star_probe.st_gain,
lt_gain: star_probe.lt_gain,
with_result,
baseline,
marginal_tax,
carryforward_delta,
niit_incremental,
niit_applies: niit_incremental != Usd::ZERO,
pending_capped,
plateau_note,
})
}
fn binding_label(target: HarvestTarget) -> String {
match target {
HarvestTarget::ZeroLtcg => "0% LTCG bracket ceiling (\u{00a7}1(h) max_zero)".to_string(),
HarvestTarget::FifteenLtcg => {
"15% LTCG bracket ceiling (\u{00a7}1(h) max_fifteen)".to_string()
}
HarvestTarget::Gain(x) => format!("realized-gain cap {x}"),
HarvestTarget::Tax(x) => format!("marginal federal-tax cap {x}"),
}
}
fn plateau_note(
baseline: &TaxResult,
with_result: &TaxResult,
cf_delta: CarryforwardDelta,
pending_capped: bool,
) -> Option<String> {
let carried = cf_delta.short + cf_delta.long;
let mut parts: Vec<String> = Vec::new();
if carried < Usd::ZERO {
parts.push(format!(
"this harvest absorbs {} of loss carryforward (short {} / long {}) \u{2014} the \
carryforward is SPENT, not deductible again",
-carried, -cf_delta.short, -cf_delta.long
));
} else if carried > Usd::ZERO {
let offset = with_result.loss_deduction - baseline.loss_deduction;
parts.push(format!(
"net loss: only {} is deductible against ordinary income this year (\u{00a7}1211(b) cap); \
{} carried to next year (short {} / long {})",
offset, carried, cf_delta.short, cf_delta.long
));
}
if pending_capped {
parts.push(
"N capped: further lots in consumption order have PENDING basis (resolve them to harvest more)"
.to_string(),
);
}
if parts.is_empty() {
None
} else {
Some(parts.join("; "))
}
}
fn no_op_report(
req: &HarvestRequest,
baseline: &TaxResult,
status: HarvestStatus,
binding_constraint: String,
pending_capped: bool,
) -> HarvestReport {
HarvestReport {
req: req.clone(),
n_sat: 0,
n_btc: Usd::ZERO,
status,
binding_constraint,
st_gain: Usd::ZERO,
lt_gain: Usd::ZERO,
with_result: baseline.clone(),
baseline: baseline.clone(),
marginal_tax: Usd::ZERO,
carryforward_delta: CarryforwardDelta {
short: Usd::ZERO,
long: Usd::ZERO,
},
niit_incremental: Usd::ZERO,
niit_applies: false,
pending_capped,
plateau_note: None,
}
}