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}