Compare commits

...

2 commits

Author SHA1 Message Date
858e9874b4
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
2026-10-04 17:04:26 -07:00
053ff03e6c
direct indexing on import when brokerage side has positions 2026-10-04 16:30:01 -07:00
12 changed files with 1004 additions and 33 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

@ -116,6 +116,12 @@ a `ticker::` alias standing in for the whole sleeve:
symbol::DI-SPX,ticker::SPY,shares:num:1000,open_date::2024-01-15,open_price:num:400,account::Sample Tax Loss
```
If the account comes in through [`zfin import`](../reference/cli/import.md)
(and is flagged `direct_indexing:bool:true`, below), you only seed this
lot once: each import keeps it and re-sets its `price_ratio` so it is
worth what the export says the account holds. See
[Direct-indexing accounts](../reference/cli/import.md#direct-indexing-accounts).
That keeps the file readable, but it means zfin has no closed lots to
work from, so it can't compute realized gain/loss -- and the harvested
total is the whole reason the account exists. Record it by hand

View file

@ -57,6 +57,33 @@ all brokerage accounts. It saves as `WFA_Portfolio_Positions_*.xls`.
- Cash is one lot per account: cash balance, sweep, and accrued
interest combined, matching WF's own cash total.
## Direct-indexing accounts
An account flagged `direct_indexing:bool:true` in
[`accounts.srf`](../config/accounts-srf.md) churns too much to track lot
by lot (a tax-loss-harvesting sleeve, a mechanical rotation strategy).
Import never writes its individual holdings. Instead the account lives
as **one proxy lot** that you seed once by hand in the target file: a
`ticker::` alias to a fund that tracks the account, e.g.
```srf
symbol::DI-ROTH,ticker::VTI,shares:num:1000,open_date::2025-01-02,open_price:num:250,account::Sample Roth IRA,note::direct indexing proxy
```
On every import, the proxy is carried forward unchanged except for
`price_ratio`, which is set so that shares x the ticker's close x
`price_ratio` equals the account's securities in the export. The close
used is the export's own price date (Wells Fargo's "Priced as of" date;
today for other sources). The account's cash still imports as a normal
cash lot, and the summary reports each re-pricing.
- Any share count works; pick `open_price` so shares x `open_price` is
the account's cost basis if you want gain/loss to mean something.
- Import refuses, with a ready-to-paste line, when the account has no
stock lot or more than one in the target file, and when the proxy
cannot be priced (no shares, no close for its ticker, or an account
that now holds only cash).
## Example
```bash

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 whose lots track a benchmark with tracking-error drift (loosens contribution/audit tolerances). |
| `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

@ -97,6 +97,23 @@ pub fn positionsSheet(wb: *const biff8.Workbook) Error!*const Sheet {
return wb.sheet(sheet_name) orelse error.NotPositionsExport;
}
/// The date the export's values are priced at: the close named in its
/// "Priced as of Close on MM/DD/YYYY" banner. A download made over a
/// weekend is still priced at Friday's close, so this, not the
/// download date, is the date to price anything against. Null when the
/// banner is missing or unreadable.
pub fn pricedAsOf(sheet: *const Sheet) ?Date {
// The banner sits in the first few rows, above every section.
const scan_rows = @min(sheet.rows.len, 10);
for (0..scan_rows) |r| {
const text = trimmedText(sheet.cell(r, 0));
if (!std.mem.startsWith(u8, text, "Priced as of")) continue;
if (text.len < "MM/DD/YYYY".len) return null;
return Date.parseMdy(text[text.len - "MM/DD/YYYY".len ..]) catch null;
}
return null;
}
/// Anything reading an export file can fail with: the spreadsheet
/// decode, then the sheet layout.
pub const ExportError = biff8.ParseError || Error;
@ -709,3 +726,16 @@ test "exportHint: names the right download for each failure" {
try testing.expect(std.mem.indexOf(u8, exportHint(error.Truncated), "again") != null);
try testing.expectEqualStrings("", exportHint(error.MissingValue));
}
test "pricedAsOf: reads the banner date" {
try testing.expect(Date.fromYmd(2026, 10, 2).eql(pricedAsOf(&fixture).?));
}
test "pricedAsOf: null without a readable banner" {
const no_banner = sheetWith(&.{&.{txt("Account Number: HOUSEHOLD")}});
try testing.expect(pricedAsOf(&no_banner) == null);
const bad_date = sheetWith(&.{&.{txt("Priced as of Close on 13/45/2026")}});
try testing.expect(pricedAsOf(&bad_date) == null);
const too_short = sheetWith(&.{&.{txt("Priced as of")}});
try testing.expect(pricedAsOf(&too_short) == null);
}

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

@ -45,6 +45,14 @@
//! back to the prior lot as below. Hand-edited fields and the note are
//! inherited the same way for every source.
//!
//! ## Direct-indexing accounts
//!
//! An account flagged `direct_indexing` in accounts.srf is never written
//! lot by lot. Its one hand-seeded proxy lot is carried forward from the
//! existing file with only `price_ratio` re-set, so the proxy is worth
//! the account's securities on the export's price date. See
//! `applyDirectIndexing`.
//!
//! ## Re-import merge
//!
//! When the target portfolio file already exists, `import` reads
@ -140,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");
@ -147,6 +156,7 @@ const schwab = @import("../brokerage/schwab.zig");
const wells_fargo = @import("../brokerage/wells_fargo.zig");
const brokerage_types = @import("../brokerage/types.zig");
const analysis = @import("../analytics/analysis.zig");
const format = @import("../format.zig");
const BrokeragePosition = brokerage_types.BrokeragePosition;
@ -245,6 +255,8 @@ pub const meta: framework.Meta = .{
CannotReadAccountsFile,
UnmappedAccount,
InvalidExport,
ProxyLotRequired,
ProxyLotUnpriceable,
UserDeclined,
WriteFailed,
},
@ -404,12 +416,38 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
//
// `UnmappedAccount` arrives with the offending account numbers
// already printed to stderr.
const lots = switch (parsed.source) {
var lots = switch (parsed.source) {
.wells_fargo => try wellsFargoLots(io, allocator, wf_sheet.?, source_path, positions, account_map, ctx.today, prior_lookup_opt),
else => try synthesizeLots(io, allocator, positions, account_map, parsed.source, ctx.today, prior_lookup_opt),
};
defer freeLots(allocator, lots);
// Direct-indexing accounts: one re-priced proxy lot each.
//
// Priced at the export's own date when it states one (a weekend
// Wells Fargo download is priced at Friday's close), else today.
var proxy_updates: std.ArrayList(ProxyUpdate) = .empty;
defer proxy_updates.deinit(allocator);
const price_date: Date = switch (parsed.source) {
.wells_fargo => wells_fargo.pricedAsOf(wf_sheet.?) orelse ctx.today,
else => ctx.today,
};
lots = try applyDirectIndexing(
io,
allocator,
svc,
cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy),
lots,
positions,
account_map,
parsed.source.label(),
if (prior_portfolio_opt) |pf| pf.lots else &.{},
ctx.today,
price_date,
target_path,
&proxy_updates,
);
// ── Confirm overwrite when target exists ──────────────────
//
// Inline access() check: a single call site doesn't earn its
@ -465,6 +503,18 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
parsed.source.label(),
},
);
for (proxy_updates.items) |u| {
var pct_buf: [16]u8 = undefined;
try out.print(" {s}: proxy {s} ({s} close {f}) price_ratio {d:.6} -> {d:.6} ({s})\n", .{
u.account,
u.symbol,
u.ticker,
u.priced_on,
u.old_ratio,
u.new_ratio,
format.fmtPct(&pct_buf, (u.new_ratio - u.old_ratio) / u.old_ratio, .{ .decimals = 2, .signed = true }),
});
}
try out.flush();
}
@ -846,6 +896,289 @@ fn inheritFromPrior(
}
}
// ---- Direct-indexing accounts ----
//
// An account flagged `direct_indexing:bool:true` in accounts.srf churns
// too much to track lot by lot: a tax-loss-harvesting sleeve, a mechanical
// rotation strategy. In the portfolio it is ONE hand-seeded proxy lot, a
// `ticker::` alias to a benchmark that tracks it, whose `price_ratio`
// absorbs the tracking drift. Import never writes such an account's
// individual holdings. It carries the proxy forward from the existing file
// and re-sets only its `price_ratio`, so the proxy is worth what the export
// says the account's securities are worth on the export's price date.
// 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" (`directIndexingProxy`,
// shared with audit). Anything else is refused with instructions, never
// guessed.
/// What the export says about one direct-indexing account.
const DirectIndexingAccount = struct {
/// Portfolio account name (borrowed from the account map).
account: []const u8,
/// Market value of the account's non-cash rows.
securities_value: f64 = 0,
/// Their total cost, using current value where a row has no cost.
/// Feeds the suggested proxy line when none exists yet.
cost: f64 = 0,
};
/// One proxy re-pricing, for the import summary.
const ProxyUpdate = struct {
account: []const u8,
symbol: []const u8,
ticker: []const u8,
old_ratio: f64,
new_ratio: f64,
/// The close actually used. Earlier than the export's price date
/// when the candle cache has nothing newer.
priced_on: Date,
};
/// Every direct-indexing account in the export, with its securities
/// totals, in order of first appearance. An account whose rows are all
/// cash still gets an entry (with zero securities), so a proxy that can
/// no longer be priced is noticed instead of silently dropped.
fn directIndexingAccounts(
allocator: std.mem.Allocator,
positions: []const BrokeragePosition,
account_map: analysis.AccountMap,
institution: []const u8,
) ![]DirectIndexingAccount {
var out: std.ArrayList(DirectIndexingAccount) = .empty;
errdefer out.deinit(allocator);
for (positions) |pos| {
const account = account_map.findByInstitutionAccount(institution, pos.account_number) orelse continue;
if (!account_map.isDirectIndexing(account)) continue;
const entry = for (out.items) |*e| {
if (std.mem.eql(u8, e.account, account)) break e;
} else blk: {
try out.append(allocator, .{ .account = account });
break :blk &out.items[out.items.len - 1];
};
if (pos.is_cash) continue;
const value = pos.current_value orelse 0;
entry.securities_value += value;
entry.cost += pos.cost_basis orelse value;
}
return out.toOwnedSlice(allocator);
}
/// 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.
fn proxyRatio(securities_value: f64, proxy: Lot, close: f64) f64 {
return securities_value / (proxy.effectiveShares() * close);
}
fn isDirectIndexingSecurity(lot: Lot, account_map: analysis.AccountMap) bool {
if (lot.security_type == .cash) return false;
const account = lot.account orelse return false;
return account_map.isDirectIndexing(account);
}
/// Copy of `lot` whose strings are owned by `allocator` (the set
/// `freeLot` frees).
fn dupeLot(allocator: std.mem.Allocator, lot: Lot) !Lot {
var copy = lot;
copy.symbol = try allocator.dupe(u8, lot.symbol);
errdefer allocator.free(copy.symbol);
copy.account = if (lot.account) |a| try allocator.dupe(u8, a) else null;
errdefer if (copy.account) |a| allocator.free(a);
copy.note = if (lot.note) |n| try allocator.dupe(u8, n) else null;
errdefer if (copy.note) |n| allocator.free(n);
copy.label = if (lot.label) |l| try allocator.dupe(u8, l) else null;
errdefer if (copy.label) |l| allocator.free(l);
copy.ticker = if (lot.ticker) |t| try allocator.dupe(u8, t) else null;
errdefer if (copy.ticker) |t| allocator.free(t);
copy.underlying = if (lot.underlying) |u| try allocator.dupe(u8, u) else null;
return copy;
}
/// Replace each direct-indexing account's securities in `lots` with its
/// proxy from `prior_lots`, re-priced so it is worth the account's
/// securities on `price_date`. Records each re-pricing in `updates`.
///
/// On success the old `lots` slice is consumed and a new one returned;
/// with no direct-indexing account in the export, `lots` itself comes
/// back untouched. On error `lots` is left exactly as it was (every
/// proxy is resolved before anything is moved), so the caller's
/// `freeLots` still applies.
fn applyDirectIndexing(
io: std.Io,
allocator: std.mem.Allocator,
svc: *zfin.DataService,
fetch_options: zfin.FetchOptions,
lots: []Lot,
positions: []const BrokeragePosition,
account_map: analysis.AccountMap,
institution: []const u8,
prior_lots: []const Lot,
today: Date,
price_date: Date,
target_path: []const u8,
updates: *std.ArrayList(ProxyUpdate),
) ![]Lot {
const accounts = try directIndexingAccounts(allocator, positions, account_map, institution);
defer allocator.free(accounts);
if (accounts.len == 0) return lots;
var proxies: std.ArrayList(Lot) = .empty;
defer proxies.deinit(allocator);
errdefer for (proxies.items) |p| freeLot(allocator, p);
const updates_start = updates.items.len;
errdefer updates.shrinkRetainingCapacity(updates_start);
for (accounts) |acct| {
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.
if (count == 0 and acct.securities_value == 0) continue;
reportProxyRequired(io, target_path, acct, count, earliestOpenDate(lots, acct.account));
return error.ProxyLotRequired;
},
};
if (acct.securities_value <= 0) {
reportProxyProblem(io, acct.account, proxy.symbol, "holds no securities in this export, so its proxy cannot be priced. If the account is now all cash, delete the proxy lot.");
return error.ProxyLotUnpriceable;
}
if (proxy.effectiveShares() <= 0) {
reportProxyProblem(io, acct.account, proxy.symbol, "has a proxy lot with no shares. Give it any positive share count; import sets price_ratio.");
return error.ProxyLotUnpriceable;
}
const ticker = proxy.priceSymbol();
const close = try proxyClose(io, svc, fetch_options, ticker, price_date, acct.account);
var copy = try dupeLot(allocator, proxy.*);
copy.price_ratio = proxyRatio(acct.securities_value, copy, close.close);
proxies.append(allocator, copy) catch |err| {
freeLot(allocator, copy);
return err;
};
try updates.append(allocator, .{
.account = acct.account,
.symbol = proxy.symbol,
.ticker = ticker,
.old_ratio = proxy.price_ratio,
.new_ratio = copy.price_ratio,
.priced_on = close.date,
});
}
// Everything that can fail is done except this one allocation; from
// here ownership only moves.
var kept: usize = 0;
for (lots) |lot| {
if (!isDirectIndexingSecurity(lot, account_map)) kept += 1;
}
const result = try allocator.alloc(Lot, kept + proxies.items.len);
var i: usize = 0;
for (lots) |lot| {
if (isDirectIndexingSecurity(lot, account_map)) {
freeLot(allocator, lot);
} else {
result[i] = lot;
i += 1;
}
}
@memcpy(result[i..], proxies.items);
proxies.clearRetainingCapacity();
allocator.free(lots);
return result;
}
/// The proxy ticker's close on (or the last trading day before)
/// `price_date`.
fn proxyClose(
io: std.Io,
svc: *zfin.DataService,
fetch_options: zfin.FetchOptions,
ticker: []const u8,
price_date: Date,
account: []const u8,
) !zfin.valuation.CandleAtDate {
const candles = svc.getCandles(ticker, fetch_options) catch |err| {
if (err == error.Canceled) return err;
if (!builtin.is_test) {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot price {s}, the proxy for {s}: {s}\n", .{ ticker, account, @errorName(err) }) catch "Error: Cannot price a direct-indexing proxy\n";
cli.stderrPrint(io, msg);
}
return error.ProxyLotUnpriceable;
};
defer candles.deinit();
return zfin.valuation.candleCloseOnOrBefore(candles.data, price_date) orelse {
if (!builtin.is_test) {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: No {s} close on or before {f} to price the proxy for {s}\n", .{ ticker, price_date, account }) catch "Error: No close to price a direct-indexing proxy\n";
cli.stderrPrint(io, msg);
}
return error.ProxyLotUnpriceable;
};
}
/// Earliest real buy date among `account`'s non-cash lots, for the
/// suggested proxy line.
fn earliestOpenDate(lots: []const Lot, account: []const u8) ?Date {
var earliest: ?Date = null;
for (lots) |lot| {
if (lot.security_type == .cash) continue;
const lot_account = lot.account orelse continue;
if (!std.mem.eql(u8, lot_account, account)) continue;
if (lot.open_date.eql(Date.epoch)) continue;
if (earliest == null or lot.open_date.lessThan(earliest.?)) earliest = lot.open_date;
}
return earliest;
}
/// Shares in the suggested proxy line. Arbitrary: import sets the ratio.
const suggested_proxy_shares: f64 = 1000;
/// The proxy line a user can paste to seed a direct-indexing account:
/// cost basis preserved via `open_price`, ticker left for them to pick.
fn writeSuggestedProxyLine(w: *std.Io.Writer, acct: DirectIndexingAccount, earliest: ?Date) !void {
try w.print("symbol::DI-PROXY,ticker::<PROXY>,shares:num:{d},open_date::{f},open_price:num:{d:.4},account::{s},note::direct indexing proxy", .{
suggested_proxy_shares,
earliest orelse Date.epoch,
acct.cost / suggested_proxy_shares,
acct.account,
});
}
fn reportProxyRequired(io: std.Io, target_path: []const u8, acct: DirectIndexingAccount, count: usize, earliest: ?Date) void {
if (builtin.is_test) return;
var buf: [2048]u8 = undefined;
var w = std.Io.File.stderr().writer(io, &buf);
printProxyRequired(&w.interface, target_path, acct, count, earliest) catch |err| {
std.log.debug("direct-indexing message write failed: {t}", .{err});
return;
};
w.interface.flush() catch |err| std.log.debug("direct-indexing message flush failed: {t}", .{err});
}
fn printProxyRequired(w: *std.Io.Writer, target_path: []const u8, acct: DirectIndexingAccount, count: usize, earliest: ?Date) !void {
try w.print("Error: {s} is direct_indexing in accounts.srf, so import keeps it as one proxy lot,\n", .{acct.account});
if (count == 0) {
try w.print(" but {s} has no stock lot for it. Add one:\n", .{target_path});
} else {
try w.print(" but {s} has {d} stock lots for it. Replace them with one:\n", .{ target_path, count });
}
try w.writeAll(" ");
try writeSuggestedProxyLine(w, acct, earliest);
try w.writeAll("\n <PROXY> is a ticker that tracks the account (e.g. VTI). Any share count works:\n" ++
" import sets price_ratio so the lot is worth the account's securities.\n");
}
fn reportProxyProblem(io: std.Io, account: []const u8, symbol: []const u8, problem: []const u8) void {
if (builtin.is_test) return;
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: {s} (proxy {s}) {s}\n", .{ account, symbol, problem }) catch "Error: direct-indexing proxy problem\n";
cli.stderrPrint(io, msg);
}
/// Free per-lot allocator-owned strings + the slice. Mirror of the
/// internal cleanup in `Portfolio.deinit` (which we'd use directly
/// except we don't construct a Portfolio here - `serializePortfolio`
@ -1734,3 +2067,293 @@ test "badWellsFargoExport: allocation failure stays an allocation failure" {
try testing.expectError(error.OutOfMemory, @as(error{ InvalidExport, OutOfMemory }!void, badWellsFargoExport(testing.io, "wf.xls", error.OutOfMemory)));
try testing.expectError(error.InvalidExport, @as(error{ InvalidExport, OutOfMemory }!void, badWellsFargoExport(testing.io, "wf.xls", error.NotCompoundFile)));
}
// ---- Direct-indexing accounts ----
fn directIndexingTestMap(entries: []analysis.AccountTaxEntry) analysis.AccountMap {
return .{ .entries = entries, .allocator = testing.allocator };
}
fn directIndexingTestEntries() [2]analysis.AccountTaxEntry {
return .{
.{ .account = "Sample Roth", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234", .direct_indexing = true },
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "wells_fargo", .account_number = "5678" },
};
}
/// A Roth with two holdings and cash, plus another account.
const direct_indexing_positions = [_]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 = null, .is_cash = false },
.{ .account_number = "5678", .account_name = "*5678", .symbol = "VTI", .description = "", .quantity = 5, .current_value = 1500, .cost_basis = 1000, .is_cash = false },
};
test "directIndexingAccounts: totals securities per direct-indexing account only" {
var entries = directIndexingTestEntries();
const accounts = try directIndexingAccounts(testing.allocator, &direct_indexing_positions, directIndexingTestMap(&entries), "wells_fargo");
defer testing.allocator.free(accounts);
try testing.expectEqual(@as(usize, 1), accounts.len);
try testing.expectEqualStrings("Sample Roth", accounts[0].account);
// Cash excluded; SMPLX has no cost, so its value stands in.
try testing.expectEqual(@as(f64, 3000), accounts[0].securities_value);
try testing.expectEqual(@as(f64, 2500), accounts[0].cost);
}
test "directIndexingAccounts: an all-cash direct-indexing account still gets an entry" {
var entries = directIndexingTestEntries();
const cash_only = [_]BrokeragePosition{direct_indexing_positions[0]};
const accounts = try directIndexingAccounts(testing.allocator, &cash_only, directIndexingTestMap(&entries), "wells_fargo");
defer testing.allocator.free(accounts);
try testing.expectEqual(@as(usize, 1), accounts.len);
try testing.expectEqual(@as(f64, 0), accounts[0].securities_value);
}
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.
try testing.expectApproxEqAbs(@as(f64, 1.1), proxyRatio(3300, proxy, 15), 1e-12);
}
test "printProxyRequired: names the problem and suggests a pasteable line" {
var buf: [1024]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const acct: DirectIndexingAccount = .{ .account = "Sample Roth", .securities_value = 3000, .cost = 2500 };
try printProxyRequired(&w, "portfolio_wells_fargo.srf", acct, 2, Date.fromYmd(2025, 3, 15));
const text = w.buffered();
try testing.expect(std.mem.indexOf(u8, text, "Sample Roth is direct_indexing") != null);
try testing.expect(std.mem.indexOf(u8, text, "has 2 stock lots for it") != null);
// Cost basis carried by open_price: 2500 / 1000 shares.
try testing.expect(std.mem.indexOf(u8, text, "symbol::DI-PROXY,ticker::<PROXY>,shares:num:1000,open_date::2025-03-15,open_price:num:2.5000,account::Sample Roth") != null);
var none_buf: [1024]u8 = undefined;
var none: std.Io.Writer = .fixed(&none_buf);
try printProxyRequired(&none, "portfolio_wells_fargo.srf", acct, 0, null);
try testing.expect(std.mem.indexOf(u8, none.buffered(), "has no stock lot for it") != null);
}
/// Owned lots as `synthesizeLots` / `wells_fargo.parseLots` would
/// produce for `direct_indexing_positions`.
fn directIndexingTestLots(allocator: std.mem.Allocator) ![]Lot {
const lots = try allocator.alloc(Lot, 4);
var n: usize = 0;
errdefer {
for (lots[0..n]) |l| freeLot(allocator, l);
allocator.free(lots);
}
const plain = [_]Lot{
.{ .symbol = "", .shares = 50, .open_date = Date.epoch, .open_price = 1, .account = "Sample Roth", .security_type = .cash },
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2025, 3, 15), .open_price = 150, .account = "Sample Roth" },
.{ .symbol = "SMPLX", .shares = 100, .open_date = Date.epoch, .open_price = 10, .account = "Sample Roth" },
.{ .symbol = "VTI", .shares = 5, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 200, .account = "Sample IRA" },
};
for (plain) |p| {
lots[n] = try dupeLot(allocator, p);
n += 1;
}
return lots;
}
/// A cache directory holding VTI candles that end on 2026-10-01.
const DirectIndexingFixture = struct {
tmp: std.testing.TmpDir,
/// Sentinel-terminated, as `realPathFileAlloc` returns it, so the
/// free matches the allocation.
dir_path: [:0]const u8,
svc: zfin.DataService,
fn init(self: *DirectIndexingFixture, with_candles: bool) !void {
const io = testing.io;
self.tmp = std.testing.tmpDir(.{});
self.dir_path = try self.tmp.dir.realPathFileAlloc(io, ".", testing.allocator);
self.svc = zfin.DataService.init(io, testing.allocator, .{ .cache_dir = self.dir_path });
if (with_candles) {
const candles = [_]zfin.Candle{
.{ .date = Date.fromYmd(2026, 9, 30), .open = 290, .high = 300, .low = 290, .close = 295, .adj_close = 295, .volume = 1 },
.{ .date = Date.fromYmd(2026, 10, 1), .open = 295, .high = 301, .low = 294, .close = 300, .adj_close = 300, .volume = 1 },
};
var store = cache.Store.init(io, testing.allocator, self.dir_path);
store.cacheCandles("VTI", &candles, .{ .provider = .tiingo }, 9_999_999_999);
}
}
fn deinit(self: *DirectIndexingFixture) void {
self.svc.deinit();
testing.allocator.free(self.dir_path);
self.tmp.cleanup();
}
fn apply(self: *DirectIndexingFixture, lots: []Lot, prior: []const Lot, price_date: Date, updates: *std.ArrayList(ProxyUpdate)) ![]Lot {
var entries = directIndexingTestEntries();
return applyDirectIndexing(
testing.io,
testing.allocator,
&self.svc,
.{ .skip_network = true },
lots,
&direct_indexing_positions,
directIndexingTestMap(&entries),
"wells_fargo",
prior,
Date.fromYmd(2026, 10, 4),
price_date,
"portfolio_wells_fargo.srf",
updates,
);
}
};
const roth_proxy: Lot = .{
.symbol = "DI-ROTH",
.ticker = "VTI",
.shares = 10,
.open_date = Date.fromYmd(2025, 1, 2),
.open_price = 250,
.account = "Sample Roth",
.note = "direct indexing proxy",
.label = "Roth sleeve",
};
test "applyDirectIndexing: the account's securities become its re-priced proxy; cash and other accounts untouched" {
var fx: DirectIndexingFixture = undefined;
try fx.init(true);
defer fx.deinit();
var updates: std.ArrayList(ProxyUpdate) = .empty;
defer updates.deinit(testing.allocator);
// Priced on a Saturday: the Friday (2026-10-01 here) close is used.
const lots = try fx.apply(try directIndexingTestLots(testing.allocator), &.{roth_proxy}, Date.fromYmd(2026, 10, 3), &updates);
defer freeLots(testing.allocator, lots);
try testing.expectEqual(@as(usize, 3), lots.len);
try testing.expectEqual(LotType.cash, lots[0].security_type);
try testing.expectEqualStrings("Sample Roth", lots[0].account.?);
try testing.expectEqualStrings("Sample IRA", lots[1].account.?);
try testing.expectEqualStrings("VTI", lots[1].symbol);
const proxy = lots[2];
try testing.expectEqualStrings("DI-ROTH", proxy.symbol);
// Worth the account's $3000 of securities at the $300 close used.
try testing.expectApproxEqAbs(@as(f64, 3000), proxy.marketValue(300, false), 1e-9);
// Everything else exactly as seeded.
try testing.expectEqual(@as(f64, 10), proxy.shares);
try testing.expect(Date.fromYmd(2025, 1, 2).eql(proxy.open_date));
try testing.expectEqual(@as(f64, 250), proxy.open_price);
try testing.expectEqualStrings("direct indexing proxy", proxy.note.?);
try testing.expectEqualStrings("Roth sleeve", proxy.label.?);
try testing.expectEqual(@as(usize, 1), updates.items.len);
try testing.expectEqual(@as(f64, 1.0), updates.items[0].old_ratio);
try testing.expect(Date.fromYmd(2026, 10, 1).eql(updates.items[0].priced_on));
}
test "applyDirectIndexing: a drifted proxy gets a new ratio" {
var fx: DirectIndexingFixture = undefined;
try fx.init(true);
defer fx.deinit();
var updates: std.ArrayList(ProxyUpdate) = .empty;
defer updates.deinit(testing.allocator);
var drifted = roth_proxy;
drifted.price_ratio = 0.9;
// Priced on 2026-09-30: $295 close.
const lots = try fx.apply(try directIndexingTestLots(testing.allocator), &.{drifted}, Date.fromYmd(2026, 9, 30), &updates);
defer freeLots(testing.allocator, lots);
try testing.expectApproxEqAbs(@as(f64, 3000.0 / (10.0 * 295.0)), lots[2].price_ratio, 1e-12);
try testing.expectEqual(@as(f64, 0.9), updates.items[0].old_ratio);
}
test "applyDirectIndexing: no direct-indexing account leaves the lots as they are" {
var fx: DirectIndexingFixture = undefined;
try fx.init(true);
defer fx.deinit();
var updates: std.ArrayList(ProxyUpdate) = .empty;
defer updates.deinit(testing.allocator);
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Roth", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
};
const lots = try directIndexingTestLots(testing.allocator);
defer freeLots(testing.allocator, lots);
const same = try applyDirectIndexing(testing.io, testing.allocator, &fx.svc, .{ .skip_network = true }, lots, &direct_indexing_positions, directIndexingTestMap(&entries), "wells_fargo", &.{}, Date.fromYmd(2026, 10, 4), Date.fromYmd(2026, 10, 3), "p.srf", &updates);
try testing.expectEqual(lots.ptr, same.ptr);
try testing.expectEqual(@as(usize, 0), updates.items.len);
}
test "applyDirectIndexing: refusals leave the lots intact" {
var fx: DirectIndexingFixture = undefined;
try fx.init(true);
defer fx.deinit();
var updates: std.ArrayList(ProxyUpdate) = .empty;
defer updates.deinit(testing.allocator);
const lots = try directIndexingTestLots(testing.allocator);
// Ownership stays with this test on every refusal; freed once.
defer freeLots(testing.allocator, lots);
// No proxy in the existing file.
try testing.expectError(error.ProxyLotRequired, fx.apply(lots, &.{}, Date.fromYmd(2026, 10, 3), &updates));
// Two candidates: ambiguous.
try testing.expectError(error.ProxyLotRequired, fx.apply(lots, &.{ roth_proxy, roth_proxy }, Date.fromYmd(2026, 10, 3), &updates));
// A proxy with no shares cannot be priced.
var empty = roth_proxy;
empty.shares = 0;
try testing.expectError(error.ProxyLotUnpriceable, fx.apply(lots, &.{empty}, Date.fromYmd(2026, 10, 3), &updates));
// No close on or before the price date.
try testing.expectError(error.ProxyLotUnpriceable, fx.apply(lots, &.{roth_proxy}, Date.fromYmd(2026, 1, 2), &updates));
try testing.expectEqual(@as(usize, 0), updates.items.len);
try testing.expectEqual(@as(usize, 4), lots.len);
}
test "applyDirectIndexing: a proxy whose ticker has no candles is refused" {
var fx: DirectIndexingFixture = undefined;
try fx.init(false);
defer fx.deinit();
var updates: std.ArrayList(ProxyUpdate) = .empty;
defer updates.deinit(testing.allocator);
const lots = try directIndexingTestLots(testing.allocator);
defer freeLots(testing.allocator, lots);
try testing.expectError(error.ProxyLotUnpriceable, fx.apply(lots, &.{roth_proxy}, Date.fromYmd(2026, 10, 3), &updates));
}
test "applyDirectIndexing: an all-cash account is fine without a proxy, refused with one" {
var fx: DirectIndexingFixture = undefined;
try fx.init(true);
defer fx.deinit();
var updates: std.ArrayList(ProxyUpdate) = .empty;
defer updates.deinit(testing.allocator);
var entries = directIndexingTestEntries();
const cash_only = [_]BrokeragePosition{direct_indexing_positions[0]};
const cash_lot: Lot = .{ .symbol = "", .shares = 50, .open_date = Date.epoch, .open_price = 1, .account = "Sample Roth", .security_type = .cash };
const lots = try testing.allocator.alloc(Lot, 1);
lots[0] = try dupeLot(testing.allocator, cash_lot);
const kept = try applyDirectIndexing(testing.io, testing.allocator, &fx.svc, .{ .skip_network = true }, lots, &cash_only, directIndexingTestMap(&entries), "wells_fargo", &.{}, Date.fromYmd(2026, 10, 4), Date.fromYmd(2026, 10, 3), "p.srf", &updates);
defer freeLots(testing.allocator, kept);
try testing.expectEqual(@as(usize, 1), kept.len);
try testing.expectEqual(LotType.cash, kept[0].security_type);
try testing.expectError(error.ProxyLotUnpriceable, applyDirectIndexing(testing.io, testing.allocator, &fx.svc, .{ .skip_network = true }, kept, &cash_only, directIndexingTestMap(&entries), "wells_fargo", &.{roth_proxy}, Date.fromYmd(2026, 10, 4), Date.fromYmd(2026, 10, 3), "p.srf", &updates));
}
test "applyDirectIndexing: every allocation failure is clean" {
var fx: DirectIndexingFixture = undefined;
try fx.init(true);
defer fx.deinit();
const S = struct {
fn run(allocator: std.mem.Allocator, svc: *zfin.DataService) !void {
var entries = directIndexingTestEntries();
var updates: std.ArrayList(ProxyUpdate) = .empty;
defer updates.deinit(allocator);
const lots = try directIndexingTestLots(allocator);
const result = applyDirectIndexing(testing.io, allocator, svc, .{ .skip_network = true }, lots, &direct_indexing_positions, directIndexingTestMap(&entries), "wells_fargo", &.{roth_proxy}, Date.fromYmd(2026, 10, 4), Date.fromYmd(2026, 10, 3), "p.srf", &updates) catch |err| {
freeLots(allocator, lots);
return err;
};
freeLots(allocator, result);
}
};
try testing.checkAllAllocationFailures(testing.allocator, S.run, .{&fx.svc});
}

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