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
|
If a lot is priced through a retail-ticker `ticker::` alias while the
|
||||||
account actually holds an institutional class (a different NAV), audit
|
account actually holds an institutional class (a different NAV), audit
|
||||||
compares against the broker's NAV and can **suggest a `price_ratio`** to
|
compares against the broker's NAV and can **suggest a `price_ratio`** to
|
||||||
bridge the gap. Accounts flagged `direct_indexing::true` get the same
|
bridge the gap. A suggestion is only made when it would move the lot's
|
||||||
treatment to track drift. See
|
value by at least a dollar, the same slack the comparison allows. See
|
||||||
[price resolution](../reference/config/portfolio-srf.md#advanced-and-option-fields).
|
[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
|
## Why it's finicky
|
||||||
|
|
||||||
- The parsers are **broker-specific**. The CSV parsers hardcode each
|
- 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. |
|
| `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. |
|
| `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). |
|
| `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). |
|
| `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. |
|
| `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). |
|
| `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 portfolio_mod = @import("../../models/portfolio.zig");
|
||||||
const option = @import("../../models/option.zig");
|
const option = @import("../../models/option.zig");
|
||||||
const Date = @import("../../Date.zig");
|
const Date = @import("../../Date.zig");
|
||||||
|
const directIndexingProxy = @import("../../models/portfolio.zig").directIndexingProxy;
|
||||||
|
|
||||||
const BrokeragePosition = brokerage_types.BrokeragePosition;
|
const BrokeragePosition = brokerage_types.BrokeragePosition;
|
||||||
|
|
||||||
|
|
@ -226,6 +227,52 @@ fn consolidateBySymbol(
|
||||||
return out;
|
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 {
|
fn sumOptional(a: ?f64, b: ?f64) ?f64 {
|
||||||
if (a == null and b == null) return null;
|
if (a == null and b == null) return null;
|
||||||
return (a orelse 0) + (b orelse 0);
|
return (a orelse 0) + (b orelse 0);
|
||||||
|
|
@ -285,7 +332,20 @@ pub fn compareAccounts(
|
||||||
{
|
{
|
||||||
var acct_it = brokerage_accounts.iterator();
|
var acct_it = brokerage_accounts.iterator();
|
||||||
while (acct_it.next()) |kv| {
|
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);
|
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.
|
/// expressed by nudging a ratio that starts at 1.0.
|
||||||
///
|
///
|
||||||
/// Skips unmatched / cash / option rows, non-stock lots, symbol
|
/// Skips unmatched / cash / option rows, non-stock lots, symbol
|
||||||
/// mismatches, missing or zero retail prices, and no-drift lots
|
/// mismatches, missing or zero retail prices, and no-drift lots (a
|
||||||
/// (`current == suggested`). At most one suggestion per comparison row.
|
/// 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.
|
/// Caller owns the returned slice; strings borrow from the inputs.
|
||||||
pub fn ratioSuggestions(
|
pub fn ratioSuggestions(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
|
|
@ -755,7 +816,12 @@ pub fn ratioSuggestions(
|
||||||
|
|
||||||
const current_ratio = lot.price_ratio;
|
const current_ratio = lot.price_ratio;
|
||||||
const suggested_ratio = inst_nav / retail_price;
|
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, .{
|
try out.append(allocator, .{
|
||||||
.account_name = acct.account_name,
|
.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);
|
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" {
|
test "ratioSuggestions: cash/option/only rows and missing prices are skipped" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
var lots = [_]portfolio_mod.Lot{
|
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));
|
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 zfin = @import("../../root.zig");
|
||||||
const analysis = @import("../../analytics/analysis.zig");
|
const analysis = @import("../../analytics/analysis.zig");
|
||||||
const Date = @import("../../Date.zig");
|
const Date = @import("../../Date.zig");
|
||||||
|
const directIndexingProxy = @import("../../models/portfolio.zig").directIndexingProxy;
|
||||||
const common = @import("common.zig");
|
const common = @import("common.zig");
|
||||||
const schwab_parser = @import("../../brokerage/schwab.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)
|
/// suggested_ratio = target_stock_value / (shares * retail_price)
|
||||||
///
|
///
|
||||||
/// Skips non-direct-indexing accounts, unmapped accounts, sub-cent
|
/// 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
|
/// share/price/value. Caller owns the returned slice; strings borrow
|
||||||
/// from the inputs.
|
/// from the inputs.
|
||||||
pub fn summaryRatioSuggestions(
|
pub fn summaryRatioSuggestions(
|
||||||
|
|
@ -167,6 +169,7 @@ pub fn summaryRatioSuggestions(
|
||||||
portfolio: zfin.Portfolio,
|
portfolio: zfin.Portfolio,
|
||||||
prices: std.StringHashMap(f64),
|
prices: std.StringHashMap(f64),
|
||||||
account_map: ?analysis.AccountMap,
|
account_map: ?analysis.AccountMap,
|
||||||
|
as_of: Date,
|
||||||
) ![]common.RatioSuggestion {
|
) ![]common.RatioSuggestion {
|
||||||
var out: std.ArrayList(common.RatioSuggestion) = .empty;
|
var out: std.ArrayList(common.RatioSuggestion) = .empty;
|
||||||
errdefer out.deinit(allocator);
|
errdefer out.deinit(allocator);
|
||||||
|
|
@ -179,18 +182,10 @@ pub fn summaryRatioSuggestions(
|
||||||
const total_delta = r.total_delta orelse continue;
|
const total_delta = r.total_delta orelse continue;
|
||||||
if (@abs(total_delta) < 0.01) continue;
|
if (@abs(total_delta) < 0.01) continue;
|
||||||
|
|
||||||
// Find the single stock lot for this account.
|
const lot = switch (directIndexingProxy(portfolio.lots, r.account_name, as_of)) {
|
||||||
var stock_lot: ?zfin.Lot = null;
|
.found => |p| p.*,
|
||||||
var stock_lot_count: usize = 0;
|
.not_unique => continue,
|
||||||
for (portfolio.lots) |lot| {
|
};
|
||||||
if (lot.security_type != .stock) continue;
|
|
||||||
const lot_acct = lot.account orelse continue;
|
|
||||||
if (!std.mem.eql(u8, lot_acct, r.account_name)) continue;
|
|
||||||
stock_lot = lot;
|
|
||||||
stock_lot_count += 1;
|
|
||||||
}
|
|
||||||
if (stock_lot_count != 1) continue;
|
|
||||||
const lot = stock_lot.?;
|
|
||||||
|
|
||||||
const price_sym = lot.priceSymbol();
|
const price_sym = lot.priceSymbol();
|
||||||
const retail_price = prices.get(price_sym) orelse continue;
|
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{
|
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 },
|
.{ .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);
|
defer allocator.free(sugg);
|
||||||
try std.testing.expectEqual(@as(usize, 1), sugg.len);
|
try std.testing.expectEqual(@as(usize, 1), sugg.len);
|
||||||
try std.testing.expectEqualStrings("SPY", sugg[0].lot_symbol);
|
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.
|
// 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);
|
defer allocator.free(sugg);
|
||||||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
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 },
|
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = false },
|
||||||
};
|
};
|
||||||
const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
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);
|
defer allocator.free(sugg);
|
||||||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
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{
|
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 },
|
.{ .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);
|
defer allocator.free(sugg);
|
||||||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
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 },
|
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true },
|
||||||
};
|
};
|
||||||
const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
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);
|
defer allocator.free(sugg);
|
||||||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
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);
|
defer allocator.free(results);
|
||||||
|
|
||||||
try schwab.displaySchwabResults(results, color, out);
|
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);
|
const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
|
||||||
defer allocator.free(present);
|
defer allocator.free(present);
|
||||||
|
|
|
||||||
|
|
@ -1159,7 +1159,7 @@ pub fn runHygieneCheck(
|
||||||
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
|
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try schwab.displaySchwabResults(results, color, out);
|
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 {
|
} else {
|
||||||
var acct_count: usize = 0;
|
var acct_count: usize = 0;
|
||||||
for (results) |r| {
|
for (results) |r| {
|
||||||
|
|
@ -1169,7 +1169,7 @@ pub fn runHygieneCheck(
|
||||||
// Always show ratio suggestions even in compact
|
// Always show ratio suggestions even in compact
|
||||||
// mode - direct-indexing drift may cause a
|
// mode - direct-indexing drift may cause a
|
||||||
// non-zero delta that still deserves a nudge.
|
// 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);
|
try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results);
|
||||||
|
|
|
||||||
|
|
@ -166,10 +166,11 @@ pub fn displaySchwabSummaryRatioSuggestions(
|
||||||
portfolio: zfin.Portfolio,
|
portfolio: zfin.Portfolio,
|
||||||
prices: std.StringHashMap(f64),
|
prices: std.StringHashMap(f64),
|
||||||
account_map: ?analysis.AccountMap,
|
account_map: ?analysis.AccountMap,
|
||||||
|
as_of: Date,
|
||||||
color: bool,
|
color: bool,
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
) !void {
|
) !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);
|
defer allocator.free(suggestions);
|
||||||
if (suggestions.len == 0) return;
|
if (suggestions.len == 0) return;
|
||||||
|
|
||||||
|
|
@ -314,7 +315,7 @@ test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot dir
|
||||||
|
|
||||||
var buf: [2048]u8 = undefined;
|
var buf: [2048]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
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();
|
const out = w.buffered();
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null);
|
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 buf: [512]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
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);
|
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 buf: [512]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
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);
|
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 Date = @import("../Date.zig");
|
||||||
const Lot = @import("../models/portfolio.zig").Lot;
|
const Lot = @import("../models/portfolio.zig").Lot;
|
||||||
const LotType = @import("../models/portfolio.zig").LotType;
|
const LotType = @import("../models/portfolio.zig").LotType;
|
||||||
|
const directIndexingProxy = @import("../models/portfolio.zig").directIndexingProxy;
|
||||||
const cache = @import("../cache/store.zig");
|
const cache = @import("../cache/store.zig");
|
||||||
const atomic = @import("../atomic.zig");
|
const atomic = @import("../atomic.zig");
|
||||||
const fidelity = @import("../brokerage/fidelity.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
|
// Shares and every other field stay exactly as seeded; the account's cash
|
||||||
// imports normally.
|
// imports normally.
|
||||||
//
|
//
|
||||||
// The proxy is "the account's one open stock lot", the same rule audit's
|
// The proxy is "the account's one open stock lot" (`directIndexingProxy`,
|
||||||
// `summaryRatioSuggestions` uses to put a Schwab summary's delta on a
|
// shared with audit). Anything else is refused with instructions, never
|
||||||
// direct-indexing account. Anything else is refused with instructions,
|
// guessed.
|
||||||
// never guessed.
|
|
||||||
|
|
||||||
/// What the export says about one direct-indexing account.
|
/// What the export says about one direct-indexing account.
|
||||||
const DirectIndexingAccount = struct {
|
const DirectIndexingAccount = struct {
|
||||||
|
|
@ -965,27 +965,6 @@ fn directIndexingAccounts(
|
||||||
return out.toOwnedSlice(allocator);
|
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
|
/// The `price_ratio` that makes `proxy` worth `securities_value` when
|
||||||
/// its ticker closes at `close`. Split-aware via `effectiveShares`, the
|
/// its ticker closes at `close`. Split-aware via `effectiveShares`, the
|
||||||
/// same way `Lot.marketValue` will value it.
|
/// same way `Lot.marketValue` will value it.
|
||||||
|
|
@ -1052,7 +1031,7 @@ fn applyDirectIndexing(
|
||||||
errdefer updates.shrinkRetainingCapacity(updates_start);
|
errdefer updates.shrinkRetainingCapacity(updates_start);
|
||||||
|
|
||||||
for (accounts) |acct| {
|
for (accounts) |acct| {
|
||||||
const proxy = switch (findProxy(prior_lots, acct.account, today)) {
|
const proxy = switch (directIndexingProxy(prior_lots, acct.account, today)) {
|
||||||
.found => |p| p,
|
.found => |p| p,
|
||||||
.not_unique => |count| {
|
.not_unique => |count| {
|
||||||
// An all-cash account with no proxy has nothing to price.
|
// 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);
|
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" {
|
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 };
|
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.
|
// 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.
|
/// A portfolio is a collection of lots.
|
||||||
pub const Portfolio = struct {
|
pub const Portfolio = struct {
|
||||||
lots: []Lot,
|
lots: []Lot,
|
||||||
|
|
@ -1910,6 +1939,26 @@ test "isOpen respects maturity_date" {
|
||||||
try std.testing.expect(stock.isOpen(Date.fromYmd(2026, 5, 8)));
|
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 ────────────────────────────────────────────
|
// ── lotIsOpenAsOf ────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// `isOpen()` asks "is this lot held right now (wall-clock today)?"
|
// `isOpen()` asks "is this lot held right now (wall-clock today)?"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue