From 053ff03e6c0072941086a7006a5be4bd7592f19a Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 4 Oct 2026 16:30:01 -0700 Subject: [PATCH] direct indexing on import when brokerage side has positions --- docs/guides/set-up-accounts.md | 6 + docs/reference/cli/import.md | 27 ++ docs/reference/config/accounts-srf.md | 2 +- src/brokerage/wells_fargo.zig | 30 ++ src/commands/import.zig | 664 +++++++++++++++++++++++++- 5 files changed, 727 insertions(+), 2 deletions(-) diff --git a/docs/guides/set-up-accounts.md b/docs/guides/set-up-accounts.md index 0663999..1d00dbc 100644 --- a/docs/guides/set-up-accounts.md +++ b/docs/guides/set-up-accounts.md @@ -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 diff --git a/docs/reference/cli/import.md b/docs/reference/cli/import.md index 6f471e6..d865808 100644 --- a/docs/reference/cli/import.md +++ b/docs/reference/cli/import.md @@ -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 diff --git a/docs/reference/config/accounts-srf.md b/docs/reference/config/accounts-srf.md index 58a0bd7..ca0215d 100644 --- a/docs/reference/config/accounts-srf.md +++ b/docs/reference/config/accounts-srf.md @@ -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). | diff --git a/src/brokerage/wells_fargo.zig b/src/brokerage/wells_fargo.zig index 933ca57..088071f 100644 --- a/src/brokerage/wells_fargo.zig +++ b/src/brokerage/wells_fargo.zig @@ -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); +} diff --git a/src/commands/import.zig b/src/commands/import.zig index 5b201d5..7b0821e 100644 --- a/src/commands/import.zig +++ b/src/commands/import.zig @@ -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::,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 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::,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}); +}