Skip to main content

btctax_cli/cmd/
optimize.rs

1//! `optimize run` (Task 9) — §C.2 Mode-1 what-if proposal. READ-ONLY: opens the vault, projects,
2//! optimizes, and returns the proposal. Appends / persists NOTHING.
3//!
4//! `optimize accept` (Task 10) — §C.2 gated persistence. The ONLY Mode-1 path that writes. It
5//! RECOMPUTES the same deterministic optimum (never trusts a stale proposal — NFR4), then for each
6//! disposal applies the §1.1012-1(j) gate: persist the proposed `LotSelection` ONLY when it is
7//! genuinely contemporaneous (made ≤ sale) OR — for an already-executed disposal within the own-books
8//! envelope — behind a NARROW per-disposal `--attest`; a 2027+ broker-held pick is CATEGORICALLY
9//! refused (own-books is insufficient; no attestation can cure it). When attested, the proposed
10//! `LotSelection` decision AND the attestation side-table row are co-persisted ATOMICALLY (both land
11//! in the same in-memory DB and are flushed by a SINGLE `session.save()`), so the persisted selection
12//! == the attested selection == the new baseline (closes the Task-8 operational note; R2-I1 holds on a
13//! later re-run). Revocation reuses the existing `reconcile void` on the returned decision id.
14//!
15//! `optimize consult` (Task 11) — §C.3 Mode-2 read-only pre-trade what-if. Opens the vault,
16//! projects, calls `consult_sale`, returns a `ConsultReport`. READ-ONLY: appends NOTHING, no
17//! decision, no side-table write (Mode-2 produces nothing). Tax decision-support (consequences),
18//! NOT buy/sell advice.
19use crate::{CliError, Session};
20use btctax_adapters::BundledTaxTables;
21use btctax_core::conventions::tax_date;
22use btctax_core::persistence::append_decision;
23use btctax_core::{
24    consult_sale, optimize_year, ConsultReport, ConsultRequest, DisposeKind, EvaluateError,
25    EventId, EventPayload, LotPick, LotSelection, OptimizeError, OptimizeProposal, Persistability,
26    TaxDate, TaxTables, Usd, WalletId,
27};
28use btctax_store::Passphrase;
29use std::path::Path;
30use time::{OffsetDateTime, UtcOffset};
31
32/// `optimize run` — Mode 1 what-if. READ-ONLY: opens the vault, projects, optimizes, returns the
33/// proposal. Appends/persists NOTHING. `now` is the CLI clock seam → the proposed picks' made-date
34/// (R0-C2: core stays clock-free; the proposal the user reads is judged against the REAL made-date).
35pub fn run(
36    vault: &Path,
37    pp: &Passphrase,
38    year: i32,
39    now: OffsetDateTime,
40) -> Result<OptimizeProposal, CliError> {
41    let s = Session::open(vault, pp)?;
42    let (events, state, cfg) = s.load_events_and_project()?;
43    let prices = s.prices();
44    let tables = BundledTaxTables::load();
45    // Single resolver + fail-closed screening (SPEC §4.12): pick up a ReturnInputs-derived profile too.
46    let profile = s.resolve_screened_profile(&state, year, &tables)?;
47    let attested = s.optimize_attested_set()?;
48    let proposal_made = tax_date(now, UtcOffset::UTC); // R0-C2: real made-date threaded into core
49    let p = optimize_year(
50        &events,
51        prices,
52        &cfg,
53        year,
54        profile.as_ref(),
55        &tables,
56        &attested,
57        proposal_made,
58    )
59    .map_err(map_opt_err)?;
60    // R0-C1: core has no logger — log the cap/why HERE (CLI seam) when the result is approximate.
61    if p.approximate {
62        eprintln!(
63            "warning: optimize result is APPROXIMATE (not a guaranteed global minimum): {:?}",
64            p.approx_reason
65        );
66    }
67    Ok(p)
68}
69
70pub(crate) fn map_opt_err(e: OptimizeError) -> CliError {
71    match e {
72        OptimizeError::YearNotComputable(b) => CliError::Usage(format!(
73            "year not computable — resolve the blocker first: [{:?}] {}",
74            b.kind, b.detail
75        )),
76        OptimizeError::PreTransitionYear(y) => CliError::Usage(format!(
77            "{y} is pre-2025: a pre-2025 selection restates a closed year — not an optimization (M7)"
78        )),
79        OptimizeError::NoDisposals => {
80            CliError::Usage("no method-honoring disposals in that year".into())
81        }
82        OptimizeError::NoLots => CliError::Usage("no lots available to sell".into()),
83        OptimizeError::Evaluate(EvaluateError::ProceedsRequired) => CliError::Usage(
84            "--proceeds <usd> is required for a date with no bundled dataset price \
85             (--fmv alone cannot resolve proceeds for a future or off-dataset date)"
86                .into(),
87        ),
88        OptimizeError::Evaluate(ev) => CliError::Usage(format!("evaluate error: {ev:?}")),
89    }
90}
91
92/// `optimize consult` — §C.3 Mode-2 READ-ONLY pre-trade what-if.
93///
94/// Opens the vault, runs the pure deterministic projection, and calls `consult_sale` with the
95/// synthetic `ConsultRequest`. Returns a `ConsultReport` with the tax-minimizing lot selection,
96/// the resulting ST/LT split, the federal tax attributable to the hypothetical sale, and — when
97/// present — the ST→LT timing insight (crossover + saving). **Appends NOTHING, writes NOTHING,
98/// calls no `session.save()`.** The result is tax decision-support (consequences of a contemplated
99/// sale), NOT buy/sell/hold advice (§C.2 scope invariant).
100pub fn consult(
101    vault: &Path,
102    pp: &Passphrase,
103    sell_sat: i64,
104    wallet: WalletId,
105    at: TaxDate,
106    proceeds: Option<Usd>,
107    kind: DisposeKind,
108) -> Result<ConsultReport, CliError> {
109    let s = Session::open(vault, pp)?;
110    let (events, state, cfg) = s.load_events_and_project()?;
111    let prices = s.prices();
112    let tables = BundledTaxTables::load();
113    let profile = s.resolve_screened_profile(&state, at.year(), &tables)?;
114    let req = ConsultRequest {
115        sell_sat,
116        wallet,
117        at,
118        proceeds,
119        kind,
120    };
121    // consult_sale is READ-ONLY (clone-fold-discard on every call); no save() is ever called.
122    consult_sale(&events, prices, &cfg, profile.as_ref(), &tables, &req).map_err(map_opt_err)
123}
124
125/// The result of `optimize accept` — what was persisted vs skipped (for rendering). `persisted` carries
126/// `(disposal, decision, basis)`: the disposal whose pick was adopted, the appended `LotSelection`
127/// decision id (pass it to `reconcile void` to revoke), and the §A.5 basis label
128/// (`"Contemporaneous"` / `"AttestedRecording"`). `skipped` carries `(disposal, reason)`.
129#[derive(Debug)]
130pub struct AcceptOutcome {
131    pub persisted: Vec<(EventId, EventId, &'static str)>,
132    pub skipped: Vec<(EventId, String)>,
133}
134
135/// `optimize accept` — apply the recomputed optimum, gated per disposal (§C.2 / §1.1012-1(j)).
136///
137/// `only`: if `Some(disposal)`, restrict to that one disposal (the form that carries `--attest`).
138/// `attestation`: the user's narrow contemporaneous-ID statement, REQUIRED to persist an
139/// already-executed disposal; the app NEVER fabricates it and refuses to persist a post-hoc selection
140/// without it. A bare `accept` (no `--attest`) persists only genuinely-contemporaneous picks; it
141/// remains what-if for already-executed ones. `now` is the CLI clock seam → the proposed picks'
142/// made-date (core stays clock-free; the persisted decision is judged against the REAL made-date).
143pub fn accept(
144    vault: &Path,
145    pp: &Passphrase,
146    year: i32,
147    only: Option<&str>,
148    attestation: Option<&str>,
149    now: OffsetDateTime,
150) -> Result<AcceptOutcome, CliError> {
151    accept_with_tables(
152        vault,
153        pp,
154        year,
155        only,
156        attestation,
157        now,
158        &BundledTaxTables::load(),
159    )
160}
161
162/// Implementation seam for [`accept`] with injectable tax tables. The public `accept` uses the bundled
163/// tables (TY2024 and TY2025); tests inject a table for a later year to exercise the 2027+ broker
164/// refusal end-to-end (a 2027 disposal is otherwise `YearNotComputable` under the bundled-tables-only
165/// path). Not part of the stable surface.
166#[doc(hidden)]
167#[allow(clippy::too_many_arguments)]
168pub fn accept_with_tables(
169    vault: &Path,
170    pp: &Passphrase,
171    year: i32,
172    only: Option<&str>,
173    attestation: Option<&str>,
174    now: OffsetDateTime,
175    tables: &dyn TaxTables,
176) -> Result<AcceptOutcome, CliError> {
177    let mut session = Session::open(vault, pp)?;
178    let (events, state, cfg) = session.load_events_and_project()?;
179    // `accept` injects `tables` (a test may pass a later-year table); screen against it.
180    let profile = session.resolve_screened_profile(&state, year, tables)?;
181    let prices = session.prices();
182    let attested = session.optimize_attested_set()?;
183    let made = tax_date(now, UtcOffset::UTC); // the LotSelection's made-date (decisions are UTC)
184    let only_id = only.map(crate::eventref::parse_event_id).transpose()?;
185
186    // R2-M5/R0-M5: validate the --attest/--disposal precondition BEFORE recomputing or appending
187    // ANYTHING — `--attest` requires a single `--disposal` scope (the app never invites a blanket false
188    // attestation across all disposals). Hoisting this guard ABOVE the loop guarantees no disposal is
189    // appended before it fires (no partial/abandoned writes on the rejected path).
190    if attestation.is_some() && only_id.is_none() {
191        return Err(CliError::Usage(
192            "--attest must be scoped to ONE disposal via --disposal (no blanket attestation)"
193                .into(),
194        ));
195    }
196
197    // RECOMPUTE the same deterministic optimum (NFR4) — never trust a stale proposal. R0-C2: judge the
198    // proposal against the REAL made-date (`made`) so `run` and `accept` agree on persistability.
199    let proposal = optimize_year(
200        &events,
201        prices,
202        &cfg,
203        year,
204        profile.as_ref(),
205        tables,
206        &attested,
207        made,
208    )
209    .map_err(map_opt_err)?;
210
211    let mut out = AcceptOutcome {
212        persisted: vec![],
213        skipped: vec![],
214    };
215    for d in &proposal.per_disposal {
216        if let Some(target) = &only_id {
217            if &d.disposal != target {
218                continue;
219            }
220        }
221        // Nothing to persist if the proposed selection equals the current one (no-change row).
222        if d.proposed_selection == d.current_selection {
223            out.skipped.push((
224                d.disposal.clone(),
225                "already optimal under current identification".into(),
226            ));
227            continue;
228        }
229        // The §C.2 gate. `d.persistable` was computed by `optimize_year` against the SAME `made`
230        // (== `persistability(&d.wallet, d.date, made)`), so it is the per-disposal verdict here.
231        match d.persistable {
232            Persistability::ForbiddenBroker2027 => {
233                // NEVER persist — own-books is insufficient for 2027+ broker-held units, and no
234                // attestation can cure it (categorical refusal; FIFO is the defensible position).
235                out.skipped.push((
236                    d.disposal.clone(),
237                    "2027+ broker-held: own-books is insufficient; cannot persist \
238                     (FIFO is the defensible position)"
239                        .into(),
240                ));
241            }
242            Persistability::ContemporaneousNow => {
243                // Made ≤ sale → genuinely contemporaneous; persist freely (no attestation needed).
244                let id = persist_selection(&mut session, &d.disposal, &d.proposed_selection, now)?;
245                out.persisted
246                    .push((d.disposal.clone(), id, "Contemporaneous"));
247            }
248            Persistability::NeedsAttestation => {
249                // Already executed within the own-books envelope: refuse WITHOUT a narrow per-disposal
250                // attestation (the app NEVER auto-attests a post-hoc selection).
251                let Some(att) = attestation else {
252                    out.skipped.push((
253                        d.disposal.clone(),
254                        "already executed — re-run \
255                         `optimize accept --disposal <ref> --attest \"<genuine contemporaneous ID>\"`"
256                            .into(),
257                    ));
258                    continue;
259                };
260                // Blanket-attest is already rejected up-front (above the loop), so here
261                // `only_id == Some(d.disposal)` — no append-before-guard can occur on any error path.
262                // Co-persist the LotSelection decision AND the attestation row ATOMICALLY: both land in
263                // the same in-memory DB and are flushed together by the single `session.save()` below,
264                // so the persisted selection == the attested selection == the new baseline.
265                let id = persist_selection(&mut session, &d.disposal, &d.proposed_selection, now)?;
266                crate::optimize_attest::set(session.conn(), &d.disposal, att, &made.to_string())?;
267                out.persisted
268                    .push((d.disposal.clone(), id, "AttestedRecording"));
269            }
270        }
271    }
272    session.save()?;
273    Ok(out)
274}
275
276/// Append the `LotSelection` decision for one disposal (no save; the caller batches the single save so
277/// the decision + any attestation row are flushed atomically). `fingerprint = None`, consistent with
278/// all decisions (`append_decision` passes `None`).
279fn persist_selection(
280    session: &mut Session,
281    disposal: &EventId,
282    picks: &[LotPick],
283    now: OffsetDateTime,
284) -> Result<EventId, CliError> {
285    let payload = EventPayload::LotSelection(LotSelection {
286        disposal_event: disposal.clone(),
287        lots: picks.to_vec(),
288    });
289    Ok(append_decision(
290        session.conn(),
291        payload,
292        now,
293        UtcOffset::UTC,
294        None,
295    )?)
296}