From 8a09d904e2d5d1a2991f71b0752e72154c554a94 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 4 May 2026 15:14:37 -0700 Subject: [PATCH] output ratio suggestions to reconcile Schwab direct indexing accounts --- TODO.md | 81 ++++++-- src/analytics/analysis.zig | 52 ++++++ src/commands/audit.zig | 128 ++++++++++++- src/commands/contributions.zig | 331 ++++++++++++++++++++++++++++++++- 4 files changed, 571 insertions(+), 21 deletions(-) diff --git a/TODO.md b/TODO.md index b6b8a25..f818d96 100644 --- a/TODO.md +++ b/TODO.md @@ -247,20 +247,77 @@ cash additions count correctly. ## Audit: tax-loss account persistent discrepancy (~-$897.59) -The tax-loss account consistently shows a small discrepancy (around --$897.59 at time of writing) that requires manual tweaking in the -ratio-updates section — either adjusting shares or `price_ratio` each -week. The account is a weird one and requires careful hand-updating; -not something the runbook can safely automate. +**Status (2026-05-02):** Resolved via the `direct_indexing::true` +flag in `accounts.srf`. When set on an account: -Consider introducing an **"expected discrepancy tolerance"** field per -account so audit only flags deltas exceeding the known drift. Would -keep the audit output signal-to-noise high for this and any other -accounts with known-unfixable drift. +- Audit now emits a suggested `price_ratio` adjustment even for + lots with ratio == 1.0 (previously skipped). The Schwab-summary + path picks up direct-indexing accounts with a single stock lot + and computes the suggested ratio from the account-level value + delta. Copy the suggestion into `portfolio.srf` and next audit + is clean. +- Contributions swallows sub-1% share drift on lots in the account + as tracking-error noise instead of emitting `rollup_delta` / + `drip_negative`. Real contributions (>1% of account value) still + surface. -Careful: a tolerance should never silently swallow real discrepancies. -Probably render as muted (still visible, just not warning-colored) -until the delta exceeds the tolerance, rather than filtering entirely. +See `src/analytics/analysis.zig` `AccountTaxEntry.direct_indexing` +and `src/commands/audit.zig` `displaySchwabSummaryRatioSuggestions`. + +## Transaction log file (transaction_log.srf) — priority HIGH + +`portfolio.srf` answers "what do I have" — it's a snapshot of +state. Some events that affect contribution attribution aren't +state; they're things that happened. The biggest current gap: +account transfers get double-counted as contributions because the +receiving side's `new_*` lots count toward attribution and the +sending side's `lot_removed` is silently ignored. + +Proposed: new `transaction_log.srf` file alongside `accounts.srf`, +scoped minimally for now: + +``` +transfer::2026-05-02,amount:num:100000,from::Schwab Brokerage,to::Tax Loss[,note::...] +``` + +Contribution pipeline reads entries in the current window and nets +them out of both endpoints' classifications so the grand total and +`compare`'s attribution both reflect only external money flow. +Audit can also surface a warning for any large `new_*` lot that +doesn't have a matching transfer record, nudging the user to +confirm real money vs. add a transfer entry. + +### Scope for first pass + +- Only `transfer::` records (no buys/sells, no dividend logs — + those stay inferred from the portfolio diff). +- File resolution: ZFIN_HOME cascade, same as `portfolio.srf` / + `accounts.srf` / `watchlist.srf`. +- Parser + record type in `src/models/`. +- Wire into `src/commands/contributions.zig` `computeReport`: + after classification, subtract matched transfer pairs from + contribution totals (emit a new `transfer_in` / `transfer_out` + kind or similar). +- Audit surfacing of unmatched large `new_*` lots (optional; can + be a follow-up). +- Doc update: `REPORT.md` workflow and `accounts.srf` sibling + file; contributions classification matrix gets a new row. + +### Out of scope for first pass + +- Buy/sell transaction records (still inferred from diff). +- Dividend/DRIP logs (still inferred from diff). +- Historical reconstruction (transaction log is forward-only — + existing reviews stay as-is). + +### Driver + +User has transfers expected next month. Without this fix, the +next review cycle's contribution total will be inflated by +whatever gets transferred. Related: the `direct_indexing` flag +(done) handles tracking drift on the proxy lot itself; the +transfer log handles the portfolio-total double-count. Different +problems, different fixes. ## Torn SRF files from server sync (recurring bug) diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 510b525..f5573ca 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -54,6 +54,29 @@ pub const AccountTaxEntry = struct { /// contributions (payroll ESPP accrual, direct 401k cash /// deposits). See TODO.md for the design history. cash_is_contribution: bool = false, + /// When true, marks the account as a direct-indexing proxy + /// (lots track a benchmark with tracking-error drift rather + /// than holding the benchmark directly). Two behaviors: + /// + /// 1. Contributions (`zfin contributions` / `zfin compare` + /// attribution): the edit-detection residual tolerance is + /// loosened from 0.01% (noise floor) to 1% — tracking- + /// error share reconciliation no longer lands in + /// `rollup_delta` / `drip_negative` and the attribution + /// total stays clean. + /// + /// 2. Audit (`zfin audit` ratio-suggestions section): lots + /// with `price_ratio == 1.0` in this account get a + /// suggested ratio to bridge the brokerage vs. portfolio + /// value gap. Default audit behavior skips ratio == 1.0 + /// lots since there's nothing to adjust; direct-indexing + /// accounts opt out of that skip. + /// + /// Not a general "ignore drift" flag — use only for accounts + /// whose underlying lots explicitly track a benchmark (e.g. a + /// basket of 500 individual stocks tracked as SPY via `ticker::` + /// alias). + direct_indexing: bool = false, }; /// Update cadence for manual account maintenance. Parsed from accounts.srf. @@ -144,6 +167,18 @@ pub const AccountMap = struct { } return false; } + + /// Is `account` flagged as a direct-indexing proxy? See + /// `AccountTaxEntry.direct_indexing` for the two behaviors this + /// drives. Defaults to false when the account isn't in the map. + pub fn isDirectIndexing(self: AccountMap, account: []const u8) bool { + for (self.entries) |e| { + if (std.mem.eql(u8, e.account, account)) { + return e.direct_indexing; + } + } + return false; + } }; /// Parse an accounts.srf file into an AccountMap. @@ -172,6 +207,7 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun .account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null, .update_cadence = entry.update_cadence, .cash_is_contribution = entry.cash_is_contribution, + .direct_indexing = entry.direct_indexing, }); } @@ -421,6 +457,22 @@ test "parseAccountsFile: cash_is_contribution default false, opt-in true" { try std.testing.expect(!am.cashIsContribution("Nonexistent")); } +test "parseAccountsFile: direct_indexing default false, opt-in true" { + const data = + \\#!srfv1 + \\account::Tax Loss,tax_type::taxable,direct_indexing:bool:true + \\account::Regular Brokerage,tax_type::taxable + ; + const allocator = std.testing.allocator; + var am = try parseAccountsFile(allocator, data); + defer am.deinit(); + + try std.testing.expectEqual(@as(usize, 2), am.entries.len); + try std.testing.expect(am.isDirectIndexing("Tax Loss")); + try std.testing.expect(!am.isDirectIndexing("Regular Brokerage")); + try std.testing.expect(!am.isDirectIndexing("Nonexistent")); +} + test "TaxType.label" { try std.testing.expectEqualStrings("Taxable", TaxType.taxable.label()); try std.testing.expectEqualStrings("Roth (Post-Tax)", TaxType.roth.label()); diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 282c2b2..21db9b5 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -1052,10 +1052,26 @@ pub fn compareAccounts( /// After displaying audit results, check for price_ratio positions where /// the brokerage NAV implies a different ratio than what's configured. /// Outputs actionable suggestions for portfolio.srf updates. +/// +/// Normally only lots with `price_ratio != 1.0` get suggestions — +/// the typical case is an institutional share class where the +/// configured ratio needs to drift toward current retail-vs- +/// institutional NAV. Lots with `price_ratio == 1.0` usually have +/// nothing to adjust. +/// +/// Exception: accounts flagged `direct_indexing::true` in +/// `accounts.srf`. These are proxy baskets whose tracking-error +/// drift is expressed by periodically nudging the ratio even +/// though the starting ratio is 1.0. For those accounts we still +/// emit a suggestion when brokerage and portfolio values disagree +/// — the suggested ratio is just `brokerage_NAV / retail_price` +/// applied against the existing lot share count, same formula as +/// the institutional-class case. fn displayRatioSuggestions( results: []const AccountComparison, portfolio: zfin.Portfolio, prices: std.StringHashMap(f64), + account_map: ?analysis.AccountMap, color: bool, out: *std.Io.Writer, ) !void { @@ -1067,9 +1083,18 @@ fn displayRatioSuggestions( if (cmp.only_in_brokerage or cmp.only_in_portfolio) continue; if (cmp.is_cash or cmp.is_option) continue; + // Is this account flagged direct-indexing? Captured once + // per outer loop so the per-lot gate can skip the + // ratio == 1.0 check for flagged accounts. + const is_direct_indexing = if (account_map) |am| + am.isDirectIndexing(acct.account_name) + else + false; + // Find the portfolio lot(s) for this symbol with price_ratio != 1.0 + // (or any ratio, for direct-indexing accounts). for (portfolio.lots) |lot| { - if (lot.price_ratio == 1.0) continue; + if (lot.price_ratio == 1.0 and !is_direct_indexing) continue; if (lot.security_type != .stock) continue; const lot_acct = lot.account orelse continue; if (!std.mem.eql(u8, lot_acct, acct.account_name)) continue; @@ -1121,6 +1146,89 @@ fn displayRatioSuggestions( if (has_header) try out.print("\n", .{}); } +/// Direct-indexing ratio suggestions from Schwab-summary data. +/// +/// The Schwab summary path only gives us per-account totals, not +/// per-symbol detail. For a direct-indexing account with exactly one +/// stock lot (the common case — the account is the proxy basket, +/// tracked as a single benchmark lot), we can still emit a ratio +/// suggestion from the account-level `total_delta`: +/// +/// current_stock_value = portfolio_total - portfolio_cash +/// target_stock_value = current_stock_value + total_delta +/// suggested_ratio = target_stock_value / (shares × price) +/// +/// Where `price` is `shares × current_cached_price`. The math +/// assumes the full account delta lands on the single tracked lot, +/// which is the semantics of a direct-indexing proxy. +/// +/// Skips accounts with more than one stock lot (can't allocate the +/// delta) or zero stock lots (nothing to adjust). +fn displaySchwabSummaryRatioSuggestions( + results: []const SchwabAccountComparison, + portfolio: zfin.Portfolio, + prices: std.StringHashMap(f64), + account_map: ?analysis.AccountMap, + color: bool, + out: *std.Io.Writer, +) !void { + const am = account_map orelse return; + var has_header = false; + + for (results) |r| { + if (r.account_name.len == 0) continue; + if (!am.isDirectIndexing(r.account_name)) continue; + const total_delta = r.total_delta orelse continue; + if (@abs(total_delta) < 0.01) continue; + + // Find the single stock lot for this account. + var stock_lot: ?zfin.Lot = null; + var stock_lot_count: usize = 0; + for (portfolio.lots) |lot| { + if (lot.security_type != .stock) continue; + const lot_acct = lot.account orelse continue; + if (!std.mem.eql(u8, lot_acct, r.account_name)) continue; + stock_lot = lot; + stock_lot_count += 1; + } + if (stock_lot_count != 1) continue; + const lot = stock_lot.?; + + const price_sym = lot.priceSymbol(); + const retail_price = prices.get(price_sym) orelse continue; + if (retail_price == 0) continue; + if (lot.shares == 0) continue; + + const current_stock_value = lot.shares * retail_price * lot.price_ratio; + if (current_stock_value == 0) continue; + const target_stock_value = current_stock_value + total_delta; + const suggested_ratio = target_stock_value / (lot.shares * retail_price); + const drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0; + + if (!has_header) { + try out.print("\n", .{}); + try cli.printBold(out, color, " Ratio updates", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf; direct-indexing accounts)\n", .{}); + has_header = true; + } + + var cur_buf: [24]u8 = undefined; + var sug_buf: [24]u8 = undefined; + var drift_buf: [16]u8 = undefined; + const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{lot.price_ratio}) catch "?"; + const sug_str = std.fmt.bufPrint(&sug_buf, "{d:.6}", .{suggested_ratio}) catch "?"; + const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.2}%", .{drift_pct}) catch "?"; + + try out.print(" {s:<16} ", .{lot.symbol}); + try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym}); + try out.print(" ratio {s} -> ", .{cur_str}); + try cli.printBold(out, color, "{s}", .{sug_str}); + try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str}); + } + + if (has_header) try out.print("\n", .{}); +} + // ── Display ───────────────────────────────────────────────── fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void { @@ -1880,12 +1988,17 @@ fn runHygieneCheck( if (verbose or hasSchwabDiscrepancies(results)) { try out.print("\n", .{}); try displaySchwabResults(results, color, out); + try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out); } else { var acct_count: usize = 0; for (results) |r| { if (r.account_name.len > 0) acct_count += 1; } try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count}); + // Always show ratio suggestions even in compact + // mode — direct-indexing drift may cause a + // non-zero delta that still deserves a nudge. + try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out); } }, .fidelity_csv => { @@ -1901,11 +2014,11 @@ fn runHygieneCheck( if (verbose or hasAccountDiscrepancies(results)) { try out.print("\n", .{}); try displayResults(results, color, out); - try displayRatioSuggestions(results, portfolio, prices, color, out); + try displayRatioSuggestions(results, portfolio, prices, account_map, color, out); } else { try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len}); // Always show ratio suggestions even in compact mode - try displayRatioSuggestions(results, portfolio, prices, color, out); + try displayRatioSuggestions(results, portfolio, prices, account_map, color, out); } }, .schwab_csv => { @@ -1921,10 +2034,10 @@ fn runHygieneCheck( if (verbose or hasAccountDiscrepancies(results)) { try out.print("\n", .{}); try displayResults(results, color, out); - try displayRatioSuggestions(results, portfolio, prices, color, out); + try displayRatioSuggestions(results, portfolio, prices, account_map, color, out); } else { try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len}); - try displayRatioSuggestions(results, portfolio, prices, color, out); + try displayRatioSuggestions(results, portfolio, prices, account_map, color, out); } }, } @@ -2056,6 +2169,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: defer allocator.free(results); try displaySchwabResults(results, color, out); + try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out); } // Fidelity CSV @@ -2081,7 +2195,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: } try displayResults(results, color, out); - try displayRatioSuggestions(results, portfolio, prices, color, out); + try displayRatioSuggestions(results, portfolio, prices, account_map, color, out); } // Schwab per-account CSV @@ -2107,7 +2221,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: } try displayResults(results, color, out); - try displayRatioSuggestions(results, portfolio, prices, color, out); + try displayRatioSuggestions(results, portfolio, prices, account_map, color, out); } } diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 9389f4b..5cabf25 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -79,6 +79,28 @@ //! (withdraw-as-negative-contribution) is one `if (delta < 0)` in //! `computeReport` if/when the need arises. //! +//! ### Direct-indexing accounts (`direct_indexing::true` in accounts.srf) +//! +//! Direct-indexing proxies hold a basket of underlying stocks +//! tracked as a single benchmark via `ticker::`. The basket +//! naturally drifts against the benchmark week-to-week (tracking +//! error) and the user rebalances periodically — producing small +//! share-count adjustments that aren't real money flow. +//! +//! When a lot in a flagged account goes through `detectEdits` +//! (strict-key broken but secondary key matches), the residual +//! share-delta tolerance is loosened from 0.01% to 1%. The identity +//! match still collapses to `lot_edited`; residuals under 1% are +//! suppressed entirely instead of surfacing as `rollup_delta` / +//! `drip_negative`. Real contributions to direct-indexing accounts +//! (e.g. a $100k buy-in on a multi-million basket = ~1.2%) still +//! surface because they're above the tolerance. +//! +//! The `zfin audit` command uses the same flag for its companion +//! behavior: emit a `price_ratio` adjustment suggestion for these +//! lots even though their ratio is 1.0, bridging the brokerage-vs- +//! portfolio value gap that accumulates from tracking error. +//! //! ## Other architecture notes //! //! Relies on: portfolio.srf being tracked in a git repo, and the `git` @@ -650,6 +672,24 @@ fn secondaryKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 { /// the residual share movement as a distinct change. const edit_residual_tolerance_rel: f64 = 0.0001; // 0.01% +/// Looser tolerance applied to accounts flagged +/// `direct_indexing::true` in `accounts.srf`. Direct-indexing +/// proxies (a basket of underlying stocks tracked as a single +/// benchmark via `ticker::`) have tracking-error drift that legitimately +/// moves the basket's equivalent share count without any real money +/// flowing. A tighter tolerance flags that drift as contribution- +/// adjacent noise; this looser one treats it as edit-only, so the +/// attribution line stays clean of tracking-error reconciliation. +/// +/// 1% is chosen to sit well above typical weekly tracking-error +/// magnitudes (< 0.5% in normal markets) while still catching +/// out-of-band moves — e.g. an actual $100k contribution into a +/// multi-million direct-indexing basket (~1.2% of the account) will +/// fall just over this threshold and surface as a rollup_delta for +/// review. Not configurable per-account today; revisit if anyone's +/// direct-indexing account generates > 1% drift regularly. +const direct_indexing_residual_tolerance_rel: f64 = 0.01; // 1% + /// Identify strict-key pairs that should be reclassified as edits. /// /// Walks the "only in after" and "only in before" strict keys, groups @@ -677,6 +717,7 @@ fn detectEdits( before_map: *const std.StringHashMap(LotAgg), after_map: *const std.StringHashMap(LotAgg), prices: *const std.StringHashMap(f64), + account_map: ?*const analysis.AccountMap, changes: *std.ArrayList(Change), ) !std.StringHashMap(void) { var skip = std.StringHashMap(void).init(allocator); @@ -782,10 +823,22 @@ fn detectEdits( // beyond noise. Mirrors Pass 1's same-key share-delta handling // so the user sees the same classification whether the strict // key was preserved or rewritten. + // + // Direct-indexing accounts (flagged `direct_indexing::true` + // in accounts.srf) use a looser tolerance — tracking-error + // share reconciliation on a proxy basket isn't real money + // flow and shouldn't land in rollup_delta / drip_negative. const delta = after_shares - before_shares; const denom = @max(@abs(after_shares), @abs(before_shares)); const rel = if (denom == 0) 0.0 else @abs(delta) / denom; - if (rel <= edit_residual_tolerance_rel) continue; + const tolerance = if (account_map) |am| + (if (am.isDirectIndexing(rep_lot.account orelse "")) + direct_indexing_residual_tolerance_rel + else + edit_residual_tolerance_rel) + else + edit_residual_tolerance_rel; + if (rel <= tolerance) continue; const unit_value: f64 = blk: { if (rep_lot.security_type == .stock) { @@ -851,7 +904,7 @@ fn computeReport( // strict keys passes 1 and 2 should ignore — each matched group // emits its own `lot_edited` change plus a residual rollup for // any share delta beyond noise. - var skip = try detectEdits(allocator, &before_map, &after_map, prices, &changes); + var skip = try detectEdits(allocator, &before_map, &after_map, prices, opts.account_map, &changes); defer skip.deinit(); // Helper for duping strings into the arena so Change fields have @@ -877,6 +930,21 @@ fn computeReport( const acct = try sdup.of(lot.account orelse ""); const sym = try sdup.of(lot.symbol); if (@abs(delta) > 0.000001) { + // Direct-indexing suppression: for stock lots in + // flagged accounts, sub-1% share drift is tracking- + // error reconciliation, not real money flow. Skip + // emitting a rollup_delta / drip_negative so the + // attribution stays clean. Must apply the same + // logic here as in `detectEdits` or the treatment + // becomes inconsistent depending on whether the + // strict key broke this week. + if (lot.security_type == .stock) { + const denom = @max(@abs(after_agg.shares), @abs(before_agg.shares)); + const rel = if (denom == 0) 0.0 else @abs(delta) / denom; + const is_di = if (opts.account_map) |am| am.isDirectIndexing(lot.account orelse "") else false; + if (is_di and rel <= direct_indexing_residual_tolerance_rel) continue; + } + const before_lot = before_agg.lot; const is_drip = lot.drip or before_lot.drip; const base_kind: ChangeKind = switch (lot.security_type) { @@ -1777,6 +1845,265 @@ test "computeReport: symbol rename with ticker alias collapses to lot_edited" { try std.testing.expect(@abs(residual_value) < 5_000); // nowhere near $330k } +test "computeReport: direct_indexing account suppresses sub-1% residual" { + // Tax-loss direct-indexing proxy: same symbol/account/key but + // small share drift (0.5%) from tracking-error reconciliation. + // With no account_map, the default 0.01% tolerance surfaces this + // as a rollup_delta. With account_map flagging the account as + // direct_indexing, the looser 1% tolerance swallows it — only + // the lot_edited marker is emitted, no residual that would land + // in the attribution total. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SPY", 461.24); + + const before = [_]Lot{ + .{ + .symbol = "SPY", + .shares = 1000.0, + .open_date = Date.fromYmd(2026, 2, 25), + .open_price = 461.240208, + .account = "Tax Loss", + }, + }; + const after = [_]Lot{ + // Broken strict key (symbol+ticker alias rewrite) plus a + // 0.5% share decrease from tracking-error reconciliation. + .{ + .symbol = "DI-SPX", + .ticker = "SPY", + .shares = 995.0, + .open_date = Date.fromYmd(2026, 2, 25), + .open_price = 461.240208, + .account = "Tax Loss", + .price_ratio = 1.0, + }, + }; + + // Build an account_map flagging Tax Loss as direct-indexing. + var am_entries = [_]analysis.AccountTaxEntry{ + .{ + .account = "Tax Loss", + .tax_type = .taxable, + .direct_indexing = true, + }, + }; + const account_map = analysis.AccountMap{ + .entries = &am_entries, + .allocator = allocator, + }; + + const report = try computeReport( + allocator, + &before, + &after, + &prices, + Date.fromYmd(2026, 4, 21), + .{ .account_map = &account_map }, + ); + + var n_edit: usize = 0; + var n_rollup_or_drip: usize = 0; + for (report.changes) |c| switch (c.kind) { + .lot_edited => n_edit += 1, + .rollup_delta, .drip_negative, .drip_confirmed => n_rollup_or_drip += 1, + else => {}, + }; + try std.testing.expectEqual(@as(usize, 1), n_edit); + // Looser tolerance swallows the 0.5% residual — no rollup/drip + // leak into the attribution total. + try std.testing.expectEqual(@as(usize, 0), n_rollup_or_drip); +} + +test "computeReport: direct_indexing same-key drift suppressed in Pass 1" { + // Tax-loss direct-indexing proxy with SAME strict key on both + // sides (no rename this week) but small share drift from + // tracking-error reconciliation. Pass 1 would normally emit a + // drip_negative; with direct_indexing the 1% tolerance suppresses + // it the same way detectEdits does. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SPY", 461.24); + + const before = [_]Lot{ + .{ + .symbol = "DI-SPX", + .ticker = "SPY", + .shares = 1000.0, + .open_date = Date.fromYmd(2026, 2, 25), + .open_price = 461.240208, + .account = "Tax Loss", + }, + }; + const after = [_]Lot{ + // Same strict key, 0.5% share decrease from tracking-error + // reconciliation. + .{ + .symbol = "DI-SPX", + .ticker = "SPY", + .shares = 995.0, + .open_date = Date.fromYmd(2026, 2, 25), + .open_price = 461.240208, + .account = "Tax Loss", + }, + }; + + var am_entries = [_]analysis.AccountTaxEntry{ + .{ + .account = "Tax Loss", + .tax_type = .taxable, + .direct_indexing = true, + }, + }; + const account_map = analysis.AccountMap{ + .entries = &am_entries, + .allocator = allocator, + }; + + const report = try computeReport( + allocator, + &before, + &after, + &prices, + Date.fromYmd(2026, 4, 21), + .{ .account_map = &account_map }, + ); + + // No rollup/drip — the share drift was tracking error and the + // direct_indexing flag suppressed it. + try std.testing.expectEqual(@as(usize, 0), report.changes.len); +} + +test "computeReport: same-key drift without direct_indexing still surfaces (sanity)" { + // Sanity check that the flag is what's doing the suppression: + // same setup as above but without the account_map, so Pass 1 + // emits a drip_negative for the 0.5% drift. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SPY", 461.24); + + const before = [_]Lot{ + .{ + .symbol = "DI-SPX", + .ticker = "SPY", + .shares = 1000.0, + .open_date = Date.fromYmd(2026, 2, 25), + .open_price = 461.240208, + .account = "Tax Loss", + }, + }; + const after = [_]Lot{ + .{ + .symbol = "DI-SPX", + .ticker = "SPY", + .shares = 995.0, + .open_date = Date.fromYmd(2026, 2, 25), + .open_price = 461.240208, + .account = "Tax Loss", + }, + }; + + const report = try computeReport( + allocator, + &before, + &after, + &prices, + Date.fromYmd(2026, 4, 21), + .{}, + ); + + var n_drip_neg: usize = 0; + for (report.changes) |c| switch (c.kind) { + .drip_negative => n_drip_neg += 1, + else => {}, + }; + try std.testing.expectEqual(@as(usize, 1), n_drip_neg); +} + +test "computeReport: direct_indexing tolerance still surfaces real contributions" { + // Regression: the 1% tolerance should still catch a real + // contribution. If the user transfers $100k into a multi-million + // direct-indexing account (~1.2% of value), the residual should + // surface as a rollup_delta so the attribution isn't silent about + // real money flow. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const allocator = arena_state.allocator(); + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("SPY", 461.24); + + // 17,361 shares × $461 ≈ $8M (same scale as the user's real tax + // loss account). A $100k contribution = ~217 shares = ~1.25%. + const before = [_]Lot{ + .{ + .symbol = "DI-SPX", + .ticker = "SPY", + .shares = 17361.0, + .open_date = Date.fromYmd(2026, 2, 25), + .open_price = 461.240208, + .account = "Tax Loss", + .price_ratio = 1.0, + }, + }; + const after = [_]Lot{ + // Strict-key break (different open_date — e.g. user + // redated after a large contribution) AND a 1.25% share + // increase. Tolerance at 1% fails the "swallow" check so + // the residual surfaces. + .{ + .symbol = "DI-SPX", + .ticker = "SPY", + .shares = 17578.0, // +217 shares, ≈ +1.25% + .open_date = Date.fromYmd(2026, 5, 2), + .open_price = 461.240208, + .account = "Tax Loss", + .price_ratio = 1.0, + }, + }; + + var am_entries = [_]analysis.AccountTaxEntry{ + .{ + .account = "Tax Loss", + .tax_type = .taxable, + .direct_indexing = true, + }, + }; + const account_map = analysis.AccountMap{ + .entries = &am_entries, + .allocator = allocator, + }; + + const report = try computeReport( + allocator, + &before, + &after, + &prices, + Date.fromYmd(2026, 5, 2), + .{ .account_map = &account_map }, + ); + + var n_edit: usize = 0; + var n_rollup: usize = 0; + for (report.changes) |c| switch (c.kind) { + .lot_edited => n_edit += 1, + .rollup_delta => n_rollup += 1, + else => {}, + }; + try std.testing.expectEqual(@as(usize, 1), n_edit); + // 1.25% is over the 1% tolerance, so we get a rollup_delta. + try std.testing.expectEqual(@as(usize, 1), n_rollup); +} + test "computeReport: ticker-alias removed (CUSIP-like -> plain ticker) also collapses" { // Reverse direction: before has a CUSIP-style symbol with ticker // alias, after has the plain ticker with no alias. Both resolve