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