direct indexing on import when brokerage side has positions

This commit is contained in:
Emil Lerch 2026-10-04 16:30:01 -07:00
parent 2876358c5a
commit 053ff03e6c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 727 additions and 2 deletions

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` | Marks an account tracked as one benchmark proxy lot (loosens contribution/audit tolerances; import re-prices the proxy). |
| `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

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

@ -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
@ -147,6 +155,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 +254,8 @@ pub const meta: framework.Meta = .{
CannotReadAccountsFile,
UnmappedAccount,
InvalidExport,
ProxyLotRequired,
ProxyLotUnpriceable,
UserDeclined,
WriteFailed,
},
@ -404,12 +415,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 +502,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 +895,311 @@ 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", the same rule audit's
// `summaryRatioSuggestions` uses to put a Schwab summary's delta on a
// direct-indexing account. Anything else is refused with instructions,
// never guessed.
/// 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 proxy for `account` in the existing file: its one open stock lot.
const ProxySearch = union(enum) {
found: *const Lot,
/// How many open stock lots the account has instead (0 or 2+).
not_unique: usize,
};
fn findProxy(prior_lots: []const Lot, account: []const u8, today: Date) ProxySearch {
var found: ?*const Lot = null;
var count: usize = 0;
for (prior_lots) |*lot| {
if (lot.security_type != .stock) continue;
if (!lot.lotIsOpenAsOf(today)) continue;
const lot_account = lot.account orelse continue;
if (!std.mem.eql(u8, lot_account, account)) continue;
found = lot;
count += 1;
}
return if (count == 1) .{ .found = found.? } else .{ .not_unique = count };
}
/// The `price_ratio` that makes `proxy` worth `securities_value` when
/// its ticker closes at `close`. Split-aware via `effectiveShares`, the
/// same way `Lot.marketValue` will value it.
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 (findProxy(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 +2088,311 @@ 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 "findProxy: the account's one open stock lot, else how many there are" {
const today = Date.fromYmd(2026, 10, 4);
const lots = [_]Lot{
.{ .symbol = "DI-ROTH", .ticker = "VTI", .shares = 100, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 30, .account = "Sample Roth" },
// Not candidates: cash, a closed lot, another account.
.{ .symbol = "", .shares = 50, .open_date = Date.epoch, .open_price = 1, .account = "Sample Roth", .security_type = .cash },
.{ .symbol = "OLD", .shares = 1, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 1, .close_date = Date.fromYmd(2025, 1, 2), .close_price = 2, .account = "Sample Roth" },
.{ .symbol = "VTI", .shares = 5, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 200, .account = "Sample IRA" },
};
switch (findProxy(&lots, "Sample Roth", today)) {
.found => |p| try testing.expectEqualStrings("DI-ROTH", p.symbol),
.not_unique => return error.TestUnexpectedResult,
}
try testing.expectEqual(ProxySearch{ .not_unique = 0 }, findProxy(&lots, "Sample HSA", today));
const two = [_]Lot{ lots[0], lots[0] };
try testing.expectEqual(ProxySearch{ .not_unique = 2 }, findProxy(&two, "Sample Roth", today));
}
test "proxyRatio: values the proxy at the account's securities, split-aware" {
const proxy: Lot = .{ .symbol = "DI-ROTH", .shares = 100, .open_date = Date.epoch, .open_price = 1, .split_factor = 2 };
// 200 effective shares at $15 = $3000 at ratio 1; $3300 needs 1.1.
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});
}