rollup on audit if detailed data is on broker side but account marked as direct index
This commit is contained in:
parent
053ff03e6c
commit
858e9874b4
9 changed files with 322 additions and 76 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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). |
|
||||
|
|
|
|||
|
|
@ -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, .{});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)?"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue