From 858e9874b4473f5b50fefd9411b2ef0e88e548e8 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 4 Oct 2026 17:04:26 -0700 Subject: [PATCH] rollup on audit if detailed data is on broker side but account marked as direct index --- docs/guides/audit-against-brokerage.md | 21 ++- docs/reference/config/accounts-srf.md | 2 +- src/analytics/reconcile/common.zig | 231 ++++++++++++++++++++++++- src/analytics/reconcile/schwab.zig | 31 ++-- src/commands/audit.zig | 2 +- src/commands/audit/hygiene.zig | 4 +- src/commands/audit/schwab.zig | 9 +- src/commands/import.zig | 49 +----- src/models/portfolio.zig | 49 ++++++ 9 files changed, 322 insertions(+), 76 deletions(-) diff --git a/docs/guides/audit-against-brokerage.md b/docs/guides/audit-against-brokerage.md index 5279493..4fc6a2b 100644 --- a/docs/guides/audit-against-brokerage.md +++ b/docs/guides/audit-against-brokerage.md @@ -345,10 +345,27 @@ Portfolio Audit (brokerage is source of truth) If a lot is priced through a retail-ticker `ticker::` alias while the account actually holds an institutional class (a different NAV), audit compares against the broker's NAV and can **suggest a `price_ratio`** to -bridge the gap. Accounts flagged `direct_indexing::true` get the same -treatment to track drift. See +bridge the gap. A suggestion is only made when it would move the lot's +value by at least a dollar, the same slack the comparison allows. See [price resolution](../reference/config/portfolio-srf.md#advanced-and-option-fields). +### Direct-indexing accounts + +An account flagged `direct_indexing:bool:true` in `accounts.srf` is held +in the portfolio as one proxy lot (see +[tracking a synthetic account](set-up-accounts.md#5-track-tax-loss-harvested-on-a-synthetic-account)), +while the export lists every real holding. Audit compares the two as +**one row**: all of the account's non-cash export rows are summed onto +the proxy, and cash is compared as usual. The row's value delta is the +proxy's drift, and the `price_ratio` suggestion is exactly the ratio +that closes it. + +This needs the account's one open stock lot to be its proxy. With zero +or several, audit compares holding by holding, which shows the mismatch +plainly instead of guessing. For a Wells Fargo account the proxy is +kept current by [`zfin import`](../reference/cli/import.md#direct-indexing-accounts), +so auditing against the export you just imported shows no drift. + ## Why it's finicky - The parsers are **broker-specific**. The CSV parsers hardcode each diff --git a/docs/reference/config/accounts-srf.md b/docs/reference/config/accounts-srf.md index ca0215d..37a8441 100644 --- a/docs/reference/config/accounts-srf.md +++ b/docs/reference/config/accounts-srf.md @@ -32,7 +32,7 @@ account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT0 | `account_number` | string | No | -- | Matched with `institution` against export rows (WF: `1234` for `*1234`). Use a placeholder, not a full real number. | | `update_cadence` | string | No | `weekly` | How often you refresh this account's manual data: `weekly`, `monthly`, `quarterly`, or `none`. Drives the audit staleness nag. | | `cash_is_contribution` | bool | No | `false` | When `true`, raw cash-balance increases on this account count as real external contributions (see below). | -| `direct_indexing` | bool | No | `false` | Marks an account tracked as one benchmark proxy lot (loosens contribution/audit tolerances; import re-prices the proxy). | +| `direct_indexing` | bool | No | `false` | Account held as one benchmark proxy lot: audit compares it as one row, import re-prices it, contributions loosen tolerances. | | `shielded` | bool | No | (derived) | Umbrella-exposure override (see below). | | `audit_large_lot_threshold` | num | No | `10000` | Per-account dollar cutoff for the audit "Large new lots" nudge (see below). Must be positive. | | `harvested` | num | No | -- | Hand-declared cumulative tax-loss-harvested figure, for accounts whose realized P&L zfin cannot derive (see below). | diff --git a/src/analytics/reconcile/common.zig b/src/analytics/reconcile/common.zig index 9c85096..741dc91 100644 --- a/src/analytics/reconcile/common.zig +++ b/src/analytics/reconcile/common.zig @@ -23,6 +23,7 @@ const brokerage_types = @import("../../brokerage/types.zig"); const portfolio_mod = @import("../../models/portfolio.zig"); const option = @import("../../models/option.zig"); const Date = @import("../../Date.zig"); +const directIndexingProxy = @import("../../models/portfolio.zig").directIndexingProxy; const BrokeragePosition = brokerage_types.BrokeragePosition; @@ -226,6 +227,52 @@ fn consolidateBySymbol( return out; } +/// Replace a direct-indexing account's non-cash rows with one row for +/// its proxy lot: the account's securities total, at the proxy's share +/// count. Cash rows stay as they are. +/// +/// The portfolio holds such an account as one proxy lot (a `ticker::` +/// alias to a fund that tracks it, priced through `price_ratio`), so a +/// holding-by-holding comparison would report every real holding as +/// brokerage-only and the proxy as portfolio-only. One row checks what +/// can be checked - the account's total - and its implied price per +/// proxy share (`brokerage_value / brokerage_shares`) is exactly what +/// `ratioSuggestions` turns into a corrected `price_ratio`. +/// +/// No securities rows (an all-cash account) leaves the rows untouched, +/// so the proxy shows up as portfolio-only: it no longer has anything +/// to stand for. +fn collapseOntoProxy(allocator: std.mem.Allocator, rows: *std.ArrayList(BrokeragePosition), proxy: zfin.Lot) !void { + if (rows.items.len == 0) return; + const account_number = rows.items[0].account_number; + const account_name = rows.items[0].account_name; + + var total: f64 = 0; + var cost: ?f64 = null; + var kept: usize = 0; + for (rows.items) |row| { + if (row.is_cash) { + rows.items[kept] = row; + kept += 1; + } else { + total += row.current_value orelse 0; + cost = sumOptional(cost, row.cost_basis); + } + } + if (kept == rows.items.len) return; + rows.shrinkRetainingCapacity(kept); + try rows.append(allocator, .{ + .account_number = account_number, + .account_name = account_name, + .symbol = proxy.symbol, + .description = "direct-indexing holdings", + .quantity = proxy.effectiveShares(), + .current_value = total, + .cost_basis = cost, + .is_cash = false, + }); +} + fn sumOptional(a: ?f64, b: ?f64) ?f64 { if (a == null and b == null) return null; return (a orelse 0) + (b orelse 0); @@ -285,7 +332,20 @@ pub fn compareAccounts( { var acct_it = brokerage_accounts.iterator(); while (acct_it.next()) |kv| { - const consolidated = try consolidateBySymbol(allocator, kv.value_ptr.items); + var consolidated = try consolidateBySymbol(allocator, kv.value_ptr.items); + errdefer consolidated.deinit(allocator); + // A direct-indexing account is one proxy lot in the + // portfolio, so compare its holdings as one row against it. + if (account_map.findByInstitutionAccount(institution, kv.key_ptr.*)) |account| { + if (account_map.isDirectIndexing(account)) { + switch (directIndexingProxy(portfolio.lots, account, as_of)) { + .found => |proxy| try collapseOntoProxy(allocator, &consolidated, proxy.*), + // No unique proxy: compare holding by holding, + // which surfaces the mismatch plainly. + .not_unique => {}, + } + } + } try consolidated_accounts.put(kv.key_ptr.*, consolidated); } } @@ -715,8 +775,9 @@ pub const RatioSuggestion = struct { /// expressed by nudging a ratio that starts at 1.0. /// /// Skips unmatched / cash / option rows, non-stock lots, symbol -/// mismatches, missing or zero retail prices, and no-drift lots -/// (`current == suggested`). At most one suggestion per comparison row. +/// mismatches, missing or zero retail prices, and no-drift lots (a +/// ratio change worth less than `value_tolerance` of the lot's value). +/// At most one suggestion per comparison row. /// Caller owns the returned slice; strings borrow from the inputs. pub fn ratioSuggestions( allocator: std.mem.Allocator, @@ -755,7 +816,12 @@ pub fn ratioSuggestions( const current_ratio = lot.price_ratio; const suggested_ratio = inst_nav / retail_price; - if (current_ratio == suggested_ratio) break; // no drift + // No drift worth a suggestion: the change would move the + // lot's value by less than the `value_tolerance` slack the + // comparison itself allows. Catches rounding, e.g. a + // direct-indexing ratio that import just set from this + // same export, one ulp away from what audit recomputes. + if (@abs(suggested_ratio - current_ratio) * lot.effectiveShares() * retail_price < value_tolerance) break; try out.append(allocator, .{ .account_name = acct.account_name, @@ -1696,6 +1762,31 @@ test "ratioSuggestions: zero drift produces no suggestion" { try std.testing.expectEqual(@as(usize, 0), sugg.len); } +test "ratioSuggestions: sub-dollar drift is rounding, not a suggestion" { + const allocator = std.testing.allocator; + var lots = [_]zfin.Lot{ + .{ .symbol = "VTTHX", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price_ratio = 5.0 }, + }; + const portfolio = zfin.Portfolio{ .lots = &lots, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("VTTHX", 27.0); + + // Ratio one ulp off 5.0, and then a change worth $0.27 on a $13,500 + // lot: both inside the $1 the comparison already tolerates. + for ([_]f64{ std.math.nextAfter(f64, 5.0, 6.0) * 27.0, 5.0001 * 27.0 }) |nav| { + const cmps = [_]SymbolComparison{ + .{ .symbol = "VTTHX", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 135, .brokerage_price = nav, .portfolio_value = 13500, .brokerage_value = 100 * nav, .shares_delta = 0, .value_delta = 100 * nav - 13500, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false }, + }; + const results = [_]AccountComparison{ + .{ .account_name = "Sample IRA", .brokerage_name = "IRA", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 13500, .brokerage_total = 100 * nav, .total_delta = 100 * nav - 13500, .option_value_delta = 0, .has_discrepancies = false }, + }; + const sugg = try ratioSuggestions(allocator, &results, portfolio, prices, null); + defer allocator.free(sugg); + try std.testing.expectEqual(@as(usize, 0), sugg.len); + } +} + test "ratioSuggestions: cash/option/only rows and missing prices are skipped" { const allocator = std.testing.allocator; var lots = [_]portfolio_mod.Lot{ @@ -1754,3 +1845,135 @@ test "compareAccounts: a cash lot closed in the portfolio is not flagged portfol try std.testing.expect(!(cmp.is_cash and cmp.only_in_portfolio)); } } + +// ---- Direct-indexing accounts ---- + +const DirectIndexingAudit = struct { + /// Proxy for a Roth whose real holdings are AAPL and SMPLX. + fn proxy(price_ratio: f64) zfin.Lot { + return .{ .symbol = "DI-ROTH", .ticker = "VTI", .shares = 10, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 250, .account = "Sample Roth", .price_ratio = price_ratio }; + } + + const cash_lot: zfin.Lot = .{ .symbol = "", .shares = 50, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 1, .account = "Sample Roth", .security_type = .cash }; + + const export_rows = [_]BrokeragePosition{ + .{ .account_number = "1234", .account_name = "*1234", .symbol = "", .description = "Cash Balance", .quantity = null, .current_value = 50, .cost_basis = null, .is_cash = true }, + .{ .account_number = "1234", .account_name = "*1234", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false }, + .{ .account_number = "1234", .account_name = "*1234", .symbol = "SMPLX", .description = "", .quantity = 100, .current_value = 1000, .cost_basis = 900, .is_cash = false }, + }; + + fn entries() [1]analysis.AccountTaxEntry { + return .{.{ .account = "Sample Roth", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234", .direct_indexing = true }}; + } + + fn compare(lots: []zfin.Lot, rows: []const BrokeragePosition, map: analysis.AccountMap, prices: std.StringHashMap(f64)) ![]AccountComparison { + const portfolio = zfin.Portfolio{ .lots = lots, .allocator = std.testing.allocator }; + return compareAccounts(std.testing.allocator, portfolio, rows, map, "wells_fargo", prices, Date.fromYmd(2026, 10, 4)); + } + + fn free(results: []AccountComparison) void { + for (results) |r| std.testing.allocator.free(r.comparisons); + std.testing.allocator.free(results); + } + + fn find(results: []const AccountComparison, symbol: []const u8) ?SymbolComparison { + for (results[0].comparisons) |c| { + if (std.mem.eql(u8, c.symbol, symbol)) return c; + } + return null; + } +}; + +test "compareAccounts: a direct-indexing account's holdings are compared as one row against its proxy" { + const allocator = std.testing.allocator; + var lots = [_]zfin.Lot{ DirectIndexingAudit.proxy(1.0), DirectIndexingAudit.cash_lot }; + var entries = DirectIndexingAudit.entries(); + const map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("VTI", 300); + + const results = try DirectIndexingAudit.compare(&lots, &DirectIndexingAudit.export_rows, map, prices); + defer DirectIndexingAudit.free(results); + + // Cash plus the one proxy row: no AAPL / SMPLX brokerage-only rows, + // no proxy portfolio-only row. + try std.testing.expectEqual(@as(usize, 2), results[0].comparisons.len); + const row = DirectIndexingAudit.find(results, "DI-ROTH").?; + try std.testing.expectEqual(@as(f64, 3000), row.brokerage_value.?); + try std.testing.expectEqual(@as(f64, 10), row.brokerage_shares.?); + try std.testing.expectApproxEqAbs(@as(f64, 0), row.value_delta.?, 1e-9); + try std.testing.expect(!row.only_in_brokerage and !row.only_in_portfolio); + // 10 x $300 x 1.0 = $3000 of holdings, and the cash matches. + try std.testing.expect(!results[0].has_discrepancies); +} + +test "compareAccounts: a drifted proxy shows the delta, and ratioSuggestions corrects it" { + const allocator = std.testing.allocator; + var lots = [_]zfin.Lot{ DirectIndexingAudit.proxy(0.9), DirectIndexingAudit.cash_lot }; + var entries = DirectIndexingAudit.entries(); + const map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("VTI", 300); + + const results = try DirectIndexingAudit.compare(&lots, &DirectIndexingAudit.export_rows, map, prices); + defer DirectIndexingAudit.free(results); + const row = DirectIndexingAudit.find(results, "DI-ROTH").?; + // Portfolio says 10 x $300 x 0.9 = $2700; the account holds $3000. + try std.testing.expectApproxEqAbs(@as(f64, 300), row.value_delta.?, 1e-9); + try std.testing.expect(results[0].has_discrepancies); + + const portfolio = zfin.Portfolio{ .lots = &lots, .allocator = allocator }; + const sugg = try ratioSuggestions(allocator, results, portfolio, prices, map); + defer allocator.free(sugg); + try std.testing.expectEqual(@as(usize, 1), sugg.len); + try std.testing.expectEqualStrings("DI-ROTH", sugg[0].lot_symbol); + try std.testing.expectApproxEqAbs(@as(f64, 1.0), sugg[0].suggested_ratio, 1e-12); +} + +test "compareAccounts: without a unique proxy, a direct-indexing account is compared holding by holding" { + const allocator = std.testing.allocator; + var second = DirectIndexingAudit.proxy(1.0); + second.symbol = "DI-ROTH2"; + var lots = [_]zfin.Lot{ DirectIndexingAudit.proxy(1.0), second, DirectIndexingAudit.cash_lot }; + var entries = DirectIndexingAudit.entries(); + const map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("VTI", 300); + + const results = try DirectIndexingAudit.compare(&lots, &DirectIndexingAudit.export_rows, map, prices); + defer DirectIndexingAudit.free(results); + try std.testing.expect(DirectIndexingAudit.find(results, "AAPL").?.only_in_brokerage); + // Both lots price through VTI, so they surface as one VTI position. + try std.testing.expect(DirectIndexingAudit.find(results, "VTI").?.only_in_portfolio); + try std.testing.expect(results[0].has_discrepancies); +} + +test "compareAccounts: an all-cash direct-indexing account leaves its proxy portfolio-only" { + const allocator = std.testing.allocator; + var lots = [_]zfin.Lot{ DirectIndexingAudit.proxy(1.0), DirectIndexingAudit.cash_lot }; + var entries = DirectIndexingAudit.entries(); + const map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("VTI", 300); + + const results = try DirectIndexingAudit.compare(&lots, DirectIndexingAudit.export_rows[0..1], map, prices); + defer DirectIndexingAudit.free(results); + try std.testing.expect(DirectIndexingAudit.find(results, "VTI").?.only_in_portfolio); +} + +test "collapseOntoProxy: every allocation failure is clean" { + const S = struct { + fn run(allocator: std.mem.Allocator) !void { + var rows: std.ArrayList(BrokeragePosition) = .empty; + defer rows.deinit(allocator); + try rows.appendSlice(allocator, &DirectIndexingAudit.export_rows); + try collapseOntoProxy(allocator, &rows, DirectIndexingAudit.proxy(1.0)); + try std.testing.expectEqual(@as(usize, 2), rows.items.len); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, S.run, .{}); +} diff --git a/src/analytics/reconcile/schwab.zig b/src/analytics/reconcile/schwab.zig index a922de0..00a00ff 100644 --- a/src/analytics/reconcile/schwab.zig +++ b/src/analytics/reconcile/schwab.zig @@ -19,6 +19,7 @@ const std = @import("std"); const zfin = @import("../../root.zig"); const analysis = @import("../../analytics/analysis.zig"); const Date = @import("../../Date.zig"); +const directIndexingProxy = @import("../../models/portfolio.zig").directIndexingProxy; const common = @import("common.zig"); const schwab_parser = @import("../../brokerage/schwab.zig"); @@ -158,7 +159,8 @@ pub fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool { /// suggested_ratio = target_stock_value / (shares * retail_price) /// /// Skips non-direct-indexing accounts, unmapped accounts, sub-cent -/// deltas, accounts without exactly one stock lot, and zero +/// deltas, accounts without a unique proxy lot (`directIndexingProxy`: +/// exactly one open stock lot as of `as_of`), and zero /// share/price/value. Caller owns the returned slice; strings borrow /// from the inputs. pub fn summaryRatioSuggestions( @@ -167,6 +169,7 @@ pub fn summaryRatioSuggestions( portfolio: zfin.Portfolio, prices: std.StringHashMap(f64), account_map: ?analysis.AccountMap, + as_of: Date, ) ![]common.RatioSuggestion { var out: std.ArrayList(common.RatioSuggestion) = .empty; errdefer out.deinit(allocator); @@ -179,18 +182,10 @@ pub fn summaryRatioSuggestions( 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 lot = switch (directIndexingProxy(portfolio.lots, r.account_name, as_of)) { + .found => |p| p.*, + .not_unique => continue, + }; const price_sym = lot.priceSymbol(); const retail_price = prices.get(price_sym) orelse continue; @@ -641,7 +636,7 @@ test "summaryRatioSuggestions: single-lot direct-indexing yields exact ratio + d const results = [_]SchwabAccountComparison{ .{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true }, }; - const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am); + const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am, Date.fromYmd(2026, 6, 19)); defer allocator.free(sugg); try std.testing.expectEqual(@as(usize, 1), sugg.len); try std.testing.expectEqualStrings("SPY", sugg[0].lot_symbol); @@ -665,7 +660,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s // null account_map -> empty. { - const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, null); + const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, null, Date.fromYmd(2026, 6, 19)); defer allocator.free(sugg); try std.testing.expectEqual(@as(usize, 0), sugg.len); } @@ -675,7 +670,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = false }, }; const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am); + const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am, Date.fromYmd(2026, 6, 19)); defer allocator.free(sugg); try std.testing.expectEqual(@as(usize, 0), sugg.len); } @@ -688,7 +683,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s const tiny = [_]SchwabAccountComparison{ .{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 50000.005, .total_delta = 0.005, .has_discrepancy = false }, }; - const sugg = try summaryRatioSuggestions(allocator, &tiny, portfolio, prices, am); + const sugg = try summaryRatioSuggestions(allocator, &tiny, portfolio, prices, am, Date.fromYmd(2026, 6, 19)); defer allocator.free(sugg); try std.testing.expectEqual(@as(usize, 0), sugg.len); } @@ -703,7 +698,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s .{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true }, }; const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; - const sugg = try summaryRatioSuggestions(allocator, &results, pf2, prices, am); + const sugg = try summaryRatioSuggestions(allocator, &results, pf2, prices, am, Date.fromYmd(2026, 6, 19)); defer allocator.free(sugg); try std.testing.expectEqual(@as(usize, 0), sugg.len); } diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 3ec0aa9..1e2e757 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -225,7 +225,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer allocator.free(results); try schwab.displaySchwabResults(results, color, out); - try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); + try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out); const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results); defer allocator.free(present); diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index 3177611..304b050 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -1159,7 +1159,7 @@ pub fn runHygieneCheck( if (verbose or schwab.hasSchwabDiscrepancies(results)) { try out.print("\n", .{}); try schwab.displaySchwabResults(results, color, out); - try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); + try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out); } else { var acct_count: usize = 0; for (results) |r| { @@ -1169,7 +1169,7 @@ pub fn runHygieneCheck( // Always show ratio suggestions even in compact // mode - direct-indexing drift may cause a // non-zero delta that still deserves a nudge. - try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); + try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out); } try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results); diff --git a/src/commands/audit/schwab.zig b/src/commands/audit/schwab.zig index edd24ea..eda2897 100644 --- a/src/commands/audit/schwab.zig +++ b/src/commands/audit/schwab.zig @@ -166,10 +166,11 @@ pub fn displaySchwabSummaryRatioSuggestions( portfolio: zfin.Portfolio, prices: std.StringHashMap(f64), account_map: ?analysis.AccountMap, + as_of: Date, color: bool, out: *std.Io.Writer, ) !void { - const suggestions = try reconcile.summaryRatioSuggestions(allocator, results, portfolio, prices, account_map); + const suggestions = try reconcile.summaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of); defer allocator.free(suggestions); if (suggestions.len == 0) return; @@ -314,7 +315,7 @@ test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot dir var buf: [2048]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); - try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, acct_map, false, &w); + try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, acct_map, Date.fromYmd(2026, 6, 19), false, &w); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null); @@ -333,7 +334,7 @@ test "displaySchwabSummaryRatioSuggestions: no account_map produces no output" { }; var buf: [512]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); - try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, null, false, &w); + try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, null, Date.fromYmd(2026, 6, 19), false, &w); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } @@ -358,6 +359,6 @@ test "displaySchwabSummaryRatioSuggestions: non-direct-indexing account is skipp }; var buf: [512]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); - try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, acct_map, false, &w); + try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, acct_map, Date.fromYmd(2026, 6, 19), false, &w); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } diff --git a/src/commands/import.zig b/src/commands/import.zig index 7b0821e..52ea9ac 100644 --- a/src/commands/import.zig +++ b/src/commands/import.zig @@ -148,6 +148,7 @@ const framework = @import("framework.zig"); const Date = @import("../Date.zig"); const Lot = @import("../models/portfolio.zig").Lot; const LotType = @import("../models/portfolio.zig").LotType; +const directIndexingProxy = @import("../models/portfolio.zig").directIndexingProxy; const cache = @import("../cache/store.zig"); const atomic = @import("../atomic.zig"); const fidelity = @import("../brokerage/fidelity.zig"); @@ -908,10 +909,9 @@ fn inheritFromPrior( // Shares and every other field stay exactly as seeded; the account's cash // imports normally. // -// The proxy is "the account's one open stock lot", the same rule audit's -// `summaryRatioSuggestions` uses to put a Schwab summary's delta on a -// direct-indexing account. Anything else is refused with instructions, -// never guessed. +// The proxy is "the account's one open stock lot" (`directIndexingProxy`, +// shared with audit). Anything else is refused with instructions, never +// guessed. /// What the export says about one direct-indexing account. const DirectIndexingAccount = struct { @@ -965,27 +965,6 @@ fn directIndexingAccounts( return out.toOwnedSlice(allocator); } -/// The proxy for `account` in the existing file: its one open stock lot. -const ProxySearch = union(enum) { - found: *const Lot, - /// How many open stock lots the account has instead (0 or 2+). - not_unique: usize, -}; - -fn findProxy(prior_lots: []const Lot, account: []const u8, today: Date) ProxySearch { - var found: ?*const Lot = null; - var count: usize = 0; - for (prior_lots) |*lot| { - if (lot.security_type != .stock) continue; - if (!lot.lotIsOpenAsOf(today)) continue; - const lot_account = lot.account orelse continue; - if (!std.mem.eql(u8, lot_account, account)) continue; - found = lot; - count += 1; - } - return if (count == 1) .{ .found = found.? } else .{ .not_unique = count }; -} - /// The `price_ratio` that makes `proxy` worth `securities_value` when /// its ticker closes at `close`. Split-aware via `effectiveShares`, the /// same way `Lot.marketValue` will value it. @@ -1052,7 +1031,7 @@ fn applyDirectIndexing( errdefer updates.shrinkRetainingCapacity(updates_start); for (accounts) |acct| { - const proxy = switch (findProxy(prior_lots, acct.account, today)) { + const proxy = switch (directIndexingProxy(prior_lots, acct.account, today)) { .found => |p| p, .not_unique => |count| { // An all-cash account with no proxy has nothing to price. @@ -2131,24 +2110,6 @@ test "directIndexingAccounts: an all-cash direct-indexing account still gets an try testing.expectEqual(@as(f64, 0), accounts[0].securities_value); } -test "findProxy: the account's one open stock lot, else how many there are" { - const today = Date.fromYmd(2026, 10, 4); - const lots = [_]Lot{ - .{ .symbol = "DI-ROTH", .ticker = "VTI", .shares = 100, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 30, .account = "Sample Roth" }, - // Not candidates: cash, a closed lot, another account. - .{ .symbol = "", .shares = 50, .open_date = Date.epoch, .open_price = 1, .account = "Sample Roth", .security_type = .cash }, - .{ .symbol = "OLD", .shares = 1, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 1, .close_date = Date.fromYmd(2025, 1, 2), .close_price = 2, .account = "Sample Roth" }, - .{ .symbol = "VTI", .shares = 5, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 200, .account = "Sample IRA" }, - }; - switch (findProxy(&lots, "Sample Roth", today)) { - .found => |p| try testing.expectEqualStrings("DI-ROTH", p.symbol), - .not_unique => return error.TestUnexpectedResult, - } - try testing.expectEqual(ProxySearch{ .not_unique = 0 }, findProxy(&lots, "Sample HSA", today)); - const two = [_]Lot{ lots[0], lots[0] }; - try testing.expectEqual(ProxySearch{ .not_unique = 2 }, findProxy(&two, "Sample Roth", today)); -} - test "proxyRatio: values the proxy at the account's securities, split-aware" { const proxy: Lot = .{ .symbol = "DI-ROTH", .shares = 100, .open_date = Date.epoch, .open_price = 1, .split_factor = 2 }; // 200 effective shares at $15 = $3000 at ratio 1; $3300 needs 1.1. diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index 009a228..d670655 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -843,6 +843,35 @@ pub const Position = struct { } }; +/// The result of looking for a direct-indexing account's proxy lot. +pub const DirectIndexingProxy = union(enum) { + found: *const Lot, + /// How many open stock lots the account has instead (0 or 2+). + not_unique: usize, +}; + +/// The proxy lot of a direct-indexing account (`direct_indexing` in +/// accounts.srf): its ONE open stock lot, a `ticker::` alias whose +/// `price_ratio` stands in for every holding the account really has. +/// +/// The single definition of "which lot is the proxy", shared by import +/// (which re-prices it from an export) and audit (which compares the +/// account's export total against it and suggests a ratio). Zero or +/// several candidates is reported, never resolved by guessing. +pub fn directIndexingProxy(lots: []const Lot, account: []const u8, as_of: Date) DirectIndexingProxy { + var found: ?*const Lot = null; + var count: usize = 0; + for (lots) |*lot| { + if (lot.security_type != .stock) continue; + if (!lot.lotIsOpenAsOf(as_of)) continue; + const lot_account = lot.account orelse continue; + if (!std.mem.eql(u8, lot_account, account)) continue; + found = lot; + count += 1; + } + return if (count == 1) .{ .found = found.? } else .{ .not_unique = count }; +} + /// A portfolio is a collection of lots. pub const Portfolio = struct { lots: []Lot, @@ -1910,6 +1939,26 @@ test "isOpen respects maturity_date" { try std.testing.expect(stock.isOpen(Date.fromYmd(2026, 5, 8))); } +// ---- directIndexingProxy ---- + +test "directIndexingProxy: the account's one open stock lot, else how many there are" { + const as_of = Date.fromYmd(2026, 10, 4); + const lots = [_]Lot{ + .{ .symbol = "DI-ROTH", .ticker = "VTI", .shares = 100, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 30, .account = "Sample Roth" }, + // Not candidates: cash, a closed lot, another account's lot. + .{ .symbol = "", .shares = 50, .open_date = Date.epoch, .open_price = 1, .account = "Sample Roth", .security_type = .cash }, + .{ .symbol = "OLD", .shares = 1, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 1, .close_date = Date.fromYmd(2025, 1, 2), .close_price = 2, .account = "Sample Roth" }, + .{ .symbol = "VTI", .shares = 5, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 200, .account = "Sample IRA" }, + }; + switch (directIndexingProxy(&lots, "Sample Roth", as_of)) { + .found => |p| try std.testing.expectEqualStrings("DI-ROTH", p.symbol), + .not_unique => return error.TestUnexpectedResult, + } + try std.testing.expectEqual(DirectIndexingProxy{ .not_unique = 0 }, directIndexingProxy(&lots, "Sample HSA", as_of)); + const two = [_]Lot{ lots[0], lots[0] }; + try std.testing.expectEqual(DirectIndexingProxy{ .not_unique = 2 }, directIndexingProxy(&two, "Sample Roth", as_of)); +} + // ── lotIsOpenAsOf ──────────────────────────────────────────── // // `isOpen()` asks "is this lot held right now (wall-clock today)?"