rollup on audit if detailed data is on broker side but account marked as direct index
All checks were successful
Generic zig build / build (push) Successful in 5m28s
Generic zig build / publish-macos (push) Successful in 11s
Generic zig build / deploy (push) Successful in 19s

This commit is contained in:
Emil Lerch 2026-10-04 17:04:26 -07:00
parent 053ff03e6c
commit 858e9874b4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
9 changed files with 322 additions and 76 deletions

View file

@ -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

View file

@ -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). |

View file

@ -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, .{});
}

View file

@ -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);
}

View file

@ -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);

View file

@ -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);

View file

@ -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);
}

View file

@ -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.

View file

@ -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)?"