direct indexing on import when brokerage side has positions
This commit is contained in:
parent
2876358c5a
commit
053ff03e6c
5 changed files with 727 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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). |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue