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 profile = s.tax_profile(year)?;
44 let prices = s.prices();
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 = s.prices();
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 = session.prices();
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}