pub mod discovery;
pub mod era;
use crate::conservative::{flagged_years, method_inversion_advisory, tranche_dip_advisory};
use crate::conservative_promote::{
clamped_promote_year_saving, filed_basis_for, with_synthetic_promote,
};
use crate::defensive::discovery::{shortfalls, triage, Shortfall, Triage};
use crate::event::{BasisSource, DeclareTranche, EventPayload};
use crate::identity::{EventId, WalletId};
use crate::price::PriceProvider;
use crate::project::pools::{pool_key, PoolKey};
use crate::project::{in_force_methods, project, ProjectionConfig};
use crate::state::LedgerState;
use crate::tax::{compute_tax_year, TaxOutcome, TaxProfile, TaxTables};
use crate::tranche_guard::{in_force_allocation_exists, pre2025_tranche_exists, void_targets};
use crate::LedgerEvent;
use crate::Usd;
use std::collections::BTreeSet;
use time::UtcOffset;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrancheRow {
pub target: EventId,
pub sat: i64,
pub status: TrancheStatus,
pub clamped_saving: Vec<SavingFlavor>,
pub advisories: Vec<Advisory>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrancheStatus {
DeclaredZero,
Promoted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Advisory {
OverCovered { by_sat: i64 },
NowDisplacing,
MethodInversion(String),
TrancheDip(String),
FeeOnlyPromoteNoop,
WouldDisplaceIfPromoted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SavingFlavor {
ComputedTax { year: i32, delta: Usd },
Uncomputable { year: i32, gain_delta: Usd },
Named(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PoolShort {
pub pool: PoolKey,
pub short_sat: i64,
pub live_tranche_sat: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefensiveFilingView {
pub candidates: Vec<Shortfall>,
pub resolve_first: Vec<Triage>,
pub tranches: Vec<TrancheRow>,
pub still_short: Vec<PoolShort>,
pub flagged_years: BTreeSet<i32>,
pub safe_harbor_blocked: bool,
}
fn pseudo_off(cfg: &ProjectionConfig) -> ProjectionConfig {
let mut c = *cfg;
c.pseudo_reconcile = false;
c
}
fn tranche_pool(t: &DeclareTranche) -> PoolKey {
pool_key(t.window_end, &t.wallet)
}
fn find_live_promote_id(events: &[LedgerEvent], tranche_id: &EventId) -> Option<EventId> {
let voided = void_targets(events);
events.iter().find_map(|e| match &e.payload {
EventPayload::PromoteTranche(p) if p.target == *tranche_id && !voided.contains(&e.id) => {
Some(e.id.clone())
}
_ => None,
})
}
fn covering_shortfalls(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
off_cfg: &ProjectionConfig,
tranche_id: &EventId,
pool: &PoolKey,
) -> Vec<Shortfall> {
let without_events: Vec<LedgerEvent> = events
.iter()
.filter(|e| e.id != *tranche_id)
.cloned()
.collect();
let without_state = project(&without_events, prices, off_cfg);
shortfalls(&without_state)
.into_iter()
.filter(|s| {
s.wallet
.as_ref()
.is_some_and(|w| pool_key(s.date, w) == *pool)
})
.collect()
}
fn displaces_documented_basis(
with_state: &LedgerState,
without_state: &LedgerState,
tranche_id: &EventId,
) -> bool {
for wd in &with_state.disposals {
let touches_tranche = wd
.legs
.iter()
.any(|l| l.lot_id.origin_event_id == *tranche_id);
if !touches_tranche {
continue;
}
let Some(wod) = without_state.disposals.iter().find(|d| d.event == wd.event) else {
continue;
};
let with_sources: Vec<BasisSource> = wd.legs.iter().map(|l| l.basis_source).collect();
let displaced = wod.legs.iter().any(|l| {
l.basis_source != BasisSource::EstimatedConservative
&& !with_sources.contains(&l.basis_source)
});
if displaced {
return true;
}
}
false
}
fn now_displacing(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
off_cfg: &ProjectionConfig,
tranche_id: &EventId,
promote_id: &EventId,
) -> bool {
let with_state = project(events, prices, off_cfg);
let without_events: Vec<LedgerEvent> = events
.iter()
.filter(|e| e.id != *promote_id)
.cloned()
.collect();
let without_state = project(&without_events, prices, off_cfg);
displaces_documented_basis(&with_state, &without_state, tranche_id)
}
fn would_displace_if_promoted(
events: &[LedgerEvent],
without_state: &LedgerState,
prices: &dyn PriceProvider,
off_cfg: &ProjectionConfig,
tranche_id: &EventId,
filed_basis: Usd,
) -> bool {
let with_events = with_synthetic_promote(events, tranche_id, filed_basis);
let with_state = project(&with_events, prices, off_cfg);
displaces_documented_basis(&with_state, without_state, tranche_id)
}
#[allow(clippy::too_many_arguments)]
fn clamped_saving_for(
events: &[LedgerEvent],
state: &LedgerState,
prices: &dyn PriceProvider,
tables: &dyn TaxTables,
off_cfg: &ProjectionConfig,
tranche_id: &EventId,
t: &DeclareTranche,
) -> Vec<SavingFlavor> {
let cf = match filed_basis_for(prices, t.sat, t.window_start, t.window_end) {
Ok(cf) => cf,
Err(_) => {
return vec![SavingFlavor::Named(
"no clamped saving is computable for this tranche — the declared window lacks full \
price coverage (Coverage::Full) to compute a promotion floor"
.to_string(),
)];
}
};
let mut years: BTreeSet<i32> = BTreeSet::new();
for d in &state.disposals {
if d.legs.iter().any(|l| {
l.lot_id.origin_event_id == *tranche_id
&& l.basis_source == BasisSource::EstimatedConservative
}) {
years.insert(d.disposed_at.year());
}
}
if years.is_empty() {
return Vec::new();
}
let without_state = project(events, prices, off_cfg);
let with_events = with_synthetic_promote(events, tranche_id, cf.filed_basis);
let with_state = project(&with_events, prices, off_cfg);
let gain_for_year = |st: &LedgerState, year: i32| -> Usd {
st.disposals
.iter()
.filter(|d| d.disposed_at.year() == year)
.flat_map(|d| &d.legs)
.map(|l| l.gain)
.sum()
};
years
.into_iter()
.map(|year| {
let with_outcome = compute_tax_year(events, &with_state, year, None, tables);
let without_outcome = compute_tax_year(events, &without_state, year, None, tables);
match (with_outcome, without_outcome) {
(TaxOutcome::Computed(_), TaxOutcome::Computed(_)) => {
let delta = clamped_promote_year_saving(
events,
prices,
off_cfg,
tranche_id,
cf.filed_basis,
year,
None,
tables,
);
SavingFlavor::ComputedTax { year, delta }
}
_ => {
let gain_delta =
gain_for_year(&without_state, year) - gain_for_year(&with_state, year);
SavingFlavor::Uncomputable { year, gain_delta }
}
}
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn build_tranche_row(
events: &[LedgerEvent],
state: &LedgerState,
prices: &dyn PriceProvider,
tables: &dyn TaxTables,
off_cfg: &ProjectionConfig,
current: i32,
tranche_id: &EventId,
t: &DeclareTranche,
) -> TrancheRow {
let promoted = state.promoted_origins.contains(tranche_id);
let status = if promoted {
TrancheStatus::Promoted
} else {
TrancheStatus::DeclaredZero
};
let mut advisories: Vec<Advisory> = Vec::new();
let pool = tranche_pool(t);
let covering = covering_shortfalls(events, prices, off_cfg, tranche_id, &pool);
let covered_sat: i64 = covering.iter().map(|s| s.short_sat).sum();
if covered_sat > 0 {
if t.sat > covered_sat {
advisories.push(Advisory::OverCovered {
by_sat: t.sat - covered_sat,
});
}
if covering.iter().all(|s| s.short_sat == s.fee_sat) {
advisories.push(Advisory::FeeOnlyPromoteNoop);
}
} else if !promoted {
if let Ok(cf) = filed_basis_for(prices, t.sat, t.window_start, t.window_end) {
if would_displace_if_promoted(
events,
state,
prices,
off_cfg,
tranche_id,
cf.filed_basis,
) {
advisories.push(Advisory::WouldDisplaceIfPromoted);
}
}
}
if promoted {
if let Some(promote_id) = find_live_promote_id(events, tranche_id) {
if now_displacing(events, prices, off_cfg, tranche_id, &promote_id) {
advisories.push(Advisory::NowDisplacing);
}
}
}
if let Ok(as_of) = time::Date::from_calendar_date(current, time::Month::December, 31) {
let wallets = [t.wallet.clone()];
let methods = in_force_methods(events, prices, off_cfg, as_of, &wallets);
if let Some(m) = methods.first() {
if let Some(msg) = method_inversion_advisory(state, &t.wallet, m.method) {
advisories.push(Advisory::MethodInversion(msg));
}
}
}
for d in state.disposals.iter().filter(|d| {
d.legs.iter().any(|l| {
l.lot_id.origin_event_id == *tranche_id
&& l.basis_source == BasisSource::EstimatedConservative
})
}) {
if let Some(msg) = tranche_dip_advisory(d) {
advisories.push(Advisory::TrancheDip(msg));
}
}
let clamped_saving = if promoted {
Vec::new()
} else {
clamped_saving_for(events, state, prices, tables, off_cfg, tranche_id, t)
};
TrancheRow {
target: tranche_id.clone(),
sat: t.sat,
status,
clamped_saving,
advisories,
}
}
#[allow(clippy::too_many_arguments)]
pub fn declare_preview_saving(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
cfg: &ProjectionConfig,
tables: &dyn TaxTables,
sat: i64,
wallet: WalletId,
window_start: crate::TaxDate,
window_end: crate::TaxDate,
year: i32,
profile: Option<&TaxProfile>,
) -> SavingFlavor {
let off_cfg = pseudo_off(cfg);
let cf = match filed_basis_for(prices, sat, window_start, window_end) {
Ok(cf) => cf,
Err(_) => {
return SavingFlavor::Named(
"no preview saving is computable for this window — it lacks full price coverage \
(Coverage::Full) to compute a promotion floor"
.to_string(),
);
}
};
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 synthetic_id = EventId::Decision { seq };
let mut with_declare = events.to_vec();
with_declare.push(LedgerEvent {
id: synthetic_id.clone(),
utc_timestamp: utc,
original_tz: UtcOffset::UTC,
wallet: None,
payload: EventPayload::DeclareTranche(DeclareTranche {
sat,
wallet,
window_start,
window_end,
}),
});
let without_state = project(&with_declare, prices, &off_cfg);
let with_events = with_synthetic_promote(&with_declare, &synthetic_id, cf.filed_basis);
let with_state = project(&with_events, prices, &off_cfg);
match (
compute_tax_year(&with_declare, &with_state, year, profile, tables),
compute_tax_year(&with_declare, &without_state, year, profile, tables),
) {
(TaxOutcome::Computed(_), TaxOutcome::Computed(_)) => {
let delta = clamped_promote_year_saving(
&with_declare,
prices,
&off_cfg,
&synthetic_id,
cf.filed_basis,
year,
profile,
tables,
);
SavingFlavor::ComputedTax { year, delta }
}
_ => {
let gain_for_year = |st: &LedgerState, year: i32| -> Usd {
st.disposals
.iter()
.filter(|d| d.disposed_at.year() == year)
.flat_map(|d| &d.legs)
.map(|l| l.gain)
.sum()
};
let gain_delta = gain_for_year(&without_state, year) - gain_for_year(&with_state, year);
SavingFlavor::Uncomputable { year, gain_delta }
}
}
}
fn still_short_pools(
state: &LedgerState,
live_tranches: &[(EventId, DeclareTranche)],
) -> Vec<PoolShort> {
use std::collections::BTreeMap;
let mut by_pool: BTreeMap<PoolKey, (i64, i64)> = BTreeMap::new();
for sf in shortfalls(state) {
let Some(w) = &sf.wallet else { continue };
let pool = pool_key(sf.date, w);
let matching_tranche_sat: i64 = live_tranches
.iter()
.filter(|(_, t)| tranche_pool(t) == pool && t.window_end <= sf.date)
.map(|(_, t)| t.sat)
.sum();
if matching_tranche_sat > 0 {
let entry = by_pool.entry(pool).or_insert((0, 0));
entry.0 += sf.short_sat;
entry.1 = entry.1.max(matching_tranche_sat);
}
}
by_pool
.into_iter()
.map(|(pool, (short_sat, live_tranche_sat))| PoolShort {
pool,
short_sat,
live_tranche_sat,
})
.collect()
}
pub fn journey_view(
events: &[LedgerEvent],
state: &LedgerState,
prices: &dyn PriceProvider,
tables: &dyn TaxTables,
cfg: &ProjectionConfig,
current: i32,
) -> DefensiveFilingView {
debug_assert!(
!state.pseudo_active(),
"journey_view precondition (DFW-D6): state must not be pseudo-active — the Task 7 dashboard \
entry gate enforces this before ever calling journey_view"
);
let off_cfg = pseudo_off(cfg);
let triaged = triage(events, state);
let candidates: Vec<Shortfall> = triaged
.iter()
.filter_map(|t| match t {
Triage::DeclareCandidate(s) => Some(s.clone()),
_ => None,
})
.collect();
let resolve_first: Vec<Triage> = triaged
.into_iter()
.filter(|t| matches!(t, Triage::ResolveFirst { .. }))
.collect();
let voided = void_targets(events);
let live_tranches: Vec<(EventId, DeclareTranche)> = events
.iter()
.filter_map(|e| match &e.payload {
EventPayload::DeclareTranche(t) if !voided.contains(&e.id) => {
Some((e.id.clone(), t.clone()))
}
_ => None,
})
.collect();
let tranches: Vec<TrancheRow> = live_tranches
.iter()
.map(|(id, t)| build_tranche_row(events, state, prices, tables, &off_cfg, current, id, t))
.collect();
let still_short = still_short_pools(state, &live_tranches);
let flagged = flagged_years(events, state, prices, tables, &off_cfg, current);
let safe_harbor_blocked = in_force_allocation_exists(events) || pre2025_tranche_exists(events);
DefensiveFilingView {
candidates,
resolve_first,
tranches,
still_short,
flagged_years: flagged,
safe_harbor_blocked,
}
}