output ratio suggestions to reconcile Schwab direct indexing accounts

This commit is contained in:
Emil Lerch 2026-05-04 15:14:37 -07:00
parent f007a1d350
commit 8a09d904e2
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 571 additions and 21 deletions

81
TODO.md
View file

@ -247,20 +247,77 @@ cash additions count correctly.
## Audit: tax-loss account persistent discrepancy (~-$897.59)
The tax-loss account consistently shows a small discrepancy (around
-$897.59 at time of writing) that requires manual tweaking in the
ratio-updates section — either adjusting shares or `price_ratio` each
week. The account is a weird one and requires careful hand-updating;
not something the runbook can safely automate.
**Status (2026-05-02):** Resolved via the `direct_indexing::true`
flag in `accounts.srf`. When set on an account:
Consider introducing an **"expected discrepancy tolerance"** field per
account so audit only flags deltas exceeding the known drift. Would
keep the audit output signal-to-noise high for this and any other
accounts with known-unfixable drift.
- Audit now emits a suggested `price_ratio` adjustment even for
lots with ratio == 1.0 (previously skipped). The Schwab-summary
path picks up direct-indexing accounts with a single stock lot
and computes the suggested ratio from the account-level value
delta. Copy the suggestion into `portfolio.srf` and next audit
is clean.
- Contributions swallows sub-1% share drift on lots in the account
as tracking-error noise instead of emitting `rollup_delta` /
`drip_negative`. Real contributions (>1% of account value) still
surface.
Careful: a tolerance should never silently swallow real discrepancies.
Probably render as muted (still visible, just not warning-colored)
until the delta exceeds the tolerance, rather than filtering entirely.
See `src/analytics/analysis.zig` `AccountTaxEntry.direct_indexing`
and `src/commands/audit.zig` `displaySchwabSummaryRatioSuggestions`.
## Transaction log file (transaction_log.srf) — priority HIGH
`portfolio.srf` answers "what do I have" — it's a snapshot of
state. Some events that affect contribution attribution aren't
state; they're things that happened. The biggest current gap:
account transfers get double-counted as contributions because the
receiving side's `new_*` lots count toward attribution and the
sending side's `lot_removed` is silently ignored.
Proposed: new `transaction_log.srf` file alongside `accounts.srf`,
scoped minimally for now:
```
transfer::2026-05-02,amount:num:100000,from::Schwab Brokerage,to::Tax Loss[,note::...]
```
Contribution pipeline reads entries in the current window and nets
them out of both endpoints' classifications so the grand total and
`compare`'s attribution both reflect only external money flow.
Audit can also surface a warning for any large `new_*` lot that
doesn't have a matching transfer record, nudging the user to
confirm real money vs. add a transfer entry.
### Scope for first pass
- Only `transfer::` records (no buys/sells, no dividend logs —
those stay inferred from the portfolio diff).
- File resolution: ZFIN_HOME cascade, same as `portfolio.srf` /
`accounts.srf` / `watchlist.srf`.
- Parser + record type in `src/models/`.
- Wire into `src/commands/contributions.zig` `computeReport`:
after classification, subtract matched transfer pairs from
contribution totals (emit a new `transfer_in` / `transfer_out`
kind or similar).
- Audit surfacing of unmatched large `new_*` lots (optional; can
be a follow-up).
- Doc update: `REPORT.md` workflow and `accounts.srf` sibling
file; contributions classification matrix gets a new row.
### Out of scope for first pass
- Buy/sell transaction records (still inferred from diff).
- Dividend/DRIP logs (still inferred from diff).
- Historical reconstruction (transaction log is forward-only —
existing reviews stay as-is).
### Driver
User has transfers expected next month. Without this fix, the
next review cycle's contribution total will be inflated by
whatever gets transferred. Related: the `direct_indexing` flag
(done) handles tracking drift on the proxy lot itself; the
transfer log handles the portfolio-total double-count. Different
problems, different fixes.
## Torn SRF files from server sync (recurring bug)

View file

@ -54,6 +54,29 @@ pub const AccountTaxEntry = struct {
/// contributions (payroll ESPP accrual, direct 401k cash
/// deposits). See TODO.md for the design history.
cash_is_contribution: bool = false,
/// When true, marks the account as a direct-indexing proxy
/// (lots track a benchmark with tracking-error drift rather
/// than holding the benchmark directly). Two behaviors:
///
/// 1. Contributions (`zfin contributions` / `zfin compare`
/// attribution): the edit-detection residual tolerance is
/// loosened from 0.01% (noise floor) to 1% tracking-
/// error share reconciliation no longer lands in
/// `rollup_delta` / `drip_negative` and the attribution
/// total stays clean.
///
/// 2. Audit (`zfin audit` ratio-suggestions section): lots
/// with `price_ratio == 1.0` in this account get a
/// suggested ratio to bridge the brokerage vs. portfolio
/// value gap. Default audit behavior skips ratio == 1.0
/// lots since there's nothing to adjust; direct-indexing
/// accounts opt out of that skip.
///
/// Not a general "ignore drift" flag use only for accounts
/// whose underlying lots explicitly track a benchmark (e.g. a
/// basket of 500 individual stocks tracked as SPY via `ticker::`
/// alias).
direct_indexing: bool = false,
};
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
@ -144,6 +167,18 @@ pub const AccountMap = struct {
}
return false;
}
/// Is `account` flagged as a direct-indexing proxy? See
/// `AccountTaxEntry.direct_indexing` for the two behaviors this
/// drives. Defaults to false when the account isn't in the map.
pub fn isDirectIndexing(self: AccountMap, account: []const u8) bool {
for (self.entries) |e| {
if (std.mem.eql(u8, e.account, account)) {
return e.direct_indexing;
}
}
return false;
}
};
/// Parse an accounts.srf file into an AccountMap.
@ -172,6 +207,7 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null,
.update_cadence = entry.update_cadence,
.cash_is_contribution = entry.cash_is_contribution,
.direct_indexing = entry.direct_indexing,
});
}
@ -421,6 +457,22 @@ test "parseAccountsFile: cash_is_contribution default false, opt-in true" {
try std.testing.expect(!am.cashIsContribution("Nonexistent"));
}
test "parseAccountsFile: direct_indexing default false, opt-in true" {
const data =
\\#!srfv1
\\account::Tax Loss,tax_type::taxable,direct_indexing:bool:true
\\account::Regular Brokerage,tax_type::taxable
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
try std.testing.expect(am.isDirectIndexing("Tax Loss"));
try std.testing.expect(!am.isDirectIndexing("Regular Brokerage"));
try std.testing.expect(!am.isDirectIndexing("Nonexistent"));
}
test "TaxType.label" {
try std.testing.expectEqualStrings("Taxable", TaxType.taxable.label());
try std.testing.expectEqualStrings("Roth (Post-Tax)", TaxType.roth.label());

View file

@ -1052,10 +1052,26 @@ pub fn compareAccounts(
/// After displaying audit results, check for price_ratio positions where
/// the brokerage NAV implies a different ratio than what's configured.
/// Outputs actionable suggestions for portfolio.srf updates.
///
/// Normally only lots with `price_ratio != 1.0` get suggestions
/// the typical case is an institutional share class where the
/// configured ratio needs to drift toward current retail-vs-
/// institutional NAV. Lots with `price_ratio == 1.0` usually have
/// nothing to adjust.
///
/// Exception: accounts flagged `direct_indexing::true` in
/// `accounts.srf`. These are proxy baskets whose tracking-error
/// drift is expressed by periodically nudging the ratio even
/// though the starting ratio is 1.0. For those accounts we still
/// emit a suggestion when brokerage and portfolio values disagree
/// the suggested ratio is just `brokerage_NAV / retail_price`
/// applied against the existing lot share count, same formula as
/// the institutional-class case.
fn displayRatioSuggestions(
results: []const AccountComparison,
portfolio: zfin.Portfolio,
prices: std.StringHashMap(f64),
account_map: ?analysis.AccountMap,
color: bool,
out: *std.Io.Writer,
) !void {
@ -1067,9 +1083,18 @@ fn displayRatioSuggestions(
if (cmp.only_in_brokerage or cmp.only_in_portfolio) continue;
if (cmp.is_cash or cmp.is_option) continue;
// Is this account flagged direct-indexing? Captured once
// per outer loop so the per-lot gate can skip the
// ratio == 1.0 check for flagged accounts.
const is_direct_indexing = if (account_map) |am|
am.isDirectIndexing(acct.account_name)
else
false;
// Find the portfolio lot(s) for this symbol with price_ratio != 1.0
// (or any ratio, for direct-indexing accounts).
for (portfolio.lots) |lot| {
if (lot.price_ratio == 1.0) continue;
if (lot.price_ratio == 1.0 and !is_direct_indexing) continue;
if (lot.security_type != .stock) continue;
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, acct.account_name)) continue;
@ -1121,6 +1146,89 @@ fn displayRatioSuggestions(
if (has_header) try out.print("\n", .{});
}
/// Direct-indexing ratio suggestions from Schwab-summary data.
///
/// The Schwab summary path only gives us per-account totals, not
/// per-symbol detail. For a direct-indexing account with exactly one
/// stock lot (the common case the account is the proxy basket,
/// tracked as a single benchmark lot), we can still emit a ratio
/// suggestion from the account-level `total_delta`:
///
/// current_stock_value = portfolio_total - portfolio_cash
/// target_stock_value = current_stock_value + total_delta
/// suggested_ratio = target_stock_value / (shares × price)
///
/// Where `price` is `shares × current_cached_price`. The math
/// assumes the full account delta lands on the single tracked lot,
/// which is the semantics of a direct-indexing proxy.
///
/// Skips accounts with more than one stock lot (can't allocate the
/// delta) or zero stock lots (nothing to adjust).
fn displaySchwabSummaryRatioSuggestions(
results: []const SchwabAccountComparison,
portfolio: zfin.Portfolio,
prices: std.StringHashMap(f64),
account_map: ?analysis.AccountMap,
color: bool,
out: *std.Io.Writer,
) !void {
const am = account_map orelse return;
var has_header = false;
for (results) |r| {
if (r.account_name.len == 0) continue;
if (!am.isDirectIndexing(r.account_name)) continue;
const total_delta = r.total_delta orelse continue;
if (@abs(total_delta) < 0.01) continue;
// Find the single stock lot for this account.
var stock_lot: ?zfin.Lot = null;
var stock_lot_count: usize = 0;
for (portfolio.lots) |lot| {
if (lot.security_type != .stock) continue;
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, r.account_name)) continue;
stock_lot = lot;
stock_lot_count += 1;
}
if (stock_lot_count != 1) continue;
const lot = stock_lot.?;
const price_sym = lot.priceSymbol();
const retail_price = prices.get(price_sym) orelse continue;
if (retail_price == 0) continue;
if (lot.shares == 0) continue;
const current_stock_value = lot.shares * retail_price * lot.price_ratio;
if (current_stock_value == 0) continue;
const target_stock_value = current_stock_value + total_delta;
const suggested_ratio = target_stock_value / (lot.shares * retail_price);
const drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0;
if (!has_header) {
try out.print("\n", .{});
try cli.printBold(out, color, " Ratio updates", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf; direct-indexing accounts)\n", .{});
has_header = true;
}
var cur_buf: [24]u8 = undefined;
var sug_buf: [24]u8 = undefined;
var drift_buf: [16]u8 = undefined;
const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{lot.price_ratio}) catch "?";
const sug_str = std.fmt.bufPrint(&sug_buf, "{d:.6}", .{suggested_ratio}) catch "?";
const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.2}%", .{drift_pct}) catch "?";
try out.print(" {s:<16} ", .{lot.symbol});
try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym});
try out.print(" ratio {s} -> ", .{cur_str});
try cli.printBold(out, color, "{s}", .{sug_str});
try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str});
}
if (has_header) try out.print("\n", .{});
}
// Display
fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void {
@ -1880,12 +1988,17 @@ fn runHygieneCheck(
if (verbose or hasSchwabDiscrepancies(results)) {
try out.print("\n", .{});
try displaySchwabResults(results, color, out);
try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
} else {
var acct_count: usize = 0;
for (results) |r| {
if (r.account_name.len > 0) acct_count += 1;
}
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count});
// Always show ratio suggestions even in compact
// mode direct-indexing drift may cause a
// non-zero delta that still deserves a nudge.
try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
},
.fidelity_csv => {
@ -1901,11 +2014,11 @@ fn runHygieneCheck(
if (verbose or hasAccountDiscrepancies(results)) {
try out.print("\n", .{});
try displayResults(results, color, out);
try displayRatioSuggestions(results, portfolio, prices, color, out);
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
} else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len});
// Always show ratio suggestions even in compact mode
try displayRatioSuggestions(results, portfolio, prices, color, out);
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
},
.schwab_csv => {
@ -1921,10 +2034,10 @@ fn runHygieneCheck(
if (verbose or hasAccountDiscrepancies(results)) {
try out.print("\n", .{});
try displayResults(results, color, out);
try displayRatioSuggestions(results, portfolio, prices, color, out);
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
} else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len});
try displayRatioSuggestions(results, portfolio, prices, color, out);
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
},
}
@ -2056,6 +2169,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path:
defer allocator.free(results);
try displaySchwabResults(results, color, out);
try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
// Fidelity CSV
@ -2081,7 +2195,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path:
}
try displayResults(results, color, out);
try displayRatioSuggestions(results, portfolio, prices, color, out);
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
// Schwab per-account CSV
@ -2107,7 +2221,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path:
}
try displayResults(results, color, out);
try displayRatioSuggestions(results, portfolio, prices, color, out);
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
}

View file

@ -79,6 +79,28 @@
//! (withdraw-as-negative-contribution) is one `if (delta < 0)` in
//! `computeReport` if/when the need arises.
//!
//! ### Direct-indexing accounts (`direct_indexing::true` in accounts.srf)
//!
//! Direct-indexing proxies hold a basket of underlying stocks
//! tracked as a single benchmark via `ticker::`. The basket
//! naturally drifts against the benchmark week-to-week (tracking
//! error) and the user rebalances periodically producing small
//! share-count adjustments that aren't real money flow.
//!
//! When a lot in a flagged account goes through `detectEdits`
//! (strict-key broken but secondary key matches), the residual
//! share-delta tolerance is loosened from 0.01% to 1%. The identity
//! match still collapses to `lot_edited`; residuals under 1% are
//! suppressed entirely instead of surfacing as `rollup_delta` /
//! `drip_negative`. Real contributions to direct-indexing accounts
//! (e.g. a $100k buy-in on a multi-million basket = ~1.2%) still
//! surface because they're above the tolerance.
//!
//! The `zfin audit` command uses the same flag for its companion
//! behavior: emit a `price_ratio` adjustment suggestion for these
//! lots even though their ratio is 1.0, bridging the brokerage-vs-
//! portfolio value gap that accumulates from tracking error.
//!
//! ## Other architecture notes
//!
//! Relies on: portfolio.srf being tracked in a git repo, and the `git`
@ -650,6 +672,24 @@ fn secondaryKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 {
/// the residual share movement as a distinct change.
const edit_residual_tolerance_rel: f64 = 0.0001; // 0.01%
/// Looser tolerance applied to accounts flagged
/// `direct_indexing::true` in `accounts.srf`. Direct-indexing
/// proxies (a basket of underlying stocks tracked as a single
/// benchmark via `ticker::`) have tracking-error drift that legitimately
/// moves the basket's equivalent share count without any real money
/// flowing. A tighter tolerance flags that drift as contribution-
/// adjacent noise; this looser one treats it as edit-only, so the
/// attribution line stays clean of tracking-error reconciliation.
///
/// 1% is chosen to sit well above typical weekly tracking-error
/// magnitudes (< 0.5% in normal markets) while still catching
/// out-of-band moves e.g. an actual $100k contribution into a
/// multi-million direct-indexing basket (~1.2% of the account) will
/// fall just over this threshold and surface as a rollup_delta for
/// review. Not configurable per-account today; revisit if anyone's
/// direct-indexing account generates > 1% drift regularly.
const direct_indexing_residual_tolerance_rel: f64 = 0.01; // 1%
/// Identify strict-key pairs that should be reclassified as edits.
///
/// Walks the "only in after" and "only in before" strict keys, groups
@ -677,6 +717,7 @@ fn detectEdits(
before_map: *const std.StringHashMap(LotAgg),
after_map: *const std.StringHashMap(LotAgg),
prices: *const std.StringHashMap(f64),
account_map: ?*const analysis.AccountMap,
changes: *std.ArrayList(Change),
) !std.StringHashMap(void) {
var skip = std.StringHashMap(void).init(allocator);
@ -782,10 +823,22 @@ fn detectEdits(
// beyond noise. Mirrors Pass 1's same-key share-delta handling
// so the user sees the same classification whether the strict
// key was preserved or rewritten.
//
// Direct-indexing accounts (flagged `direct_indexing::true`
// in accounts.srf) use a looser tolerance tracking-error
// share reconciliation on a proxy basket isn't real money
// flow and shouldn't land in rollup_delta / drip_negative.
const delta = after_shares - before_shares;
const denom = @max(@abs(after_shares), @abs(before_shares));
const rel = if (denom == 0) 0.0 else @abs(delta) / denom;
if (rel <= edit_residual_tolerance_rel) continue;
const tolerance = if (account_map) |am|
(if (am.isDirectIndexing(rep_lot.account orelse ""))
direct_indexing_residual_tolerance_rel
else
edit_residual_tolerance_rel)
else
edit_residual_tolerance_rel;
if (rel <= tolerance) continue;
const unit_value: f64 = blk: {
if (rep_lot.security_type == .stock) {
@ -851,7 +904,7 @@ fn computeReport(
// strict keys passes 1 and 2 should ignore each matched group
// emits its own `lot_edited` change plus a residual rollup for
// any share delta beyond noise.
var skip = try detectEdits(allocator, &before_map, &after_map, prices, &changes);
var skip = try detectEdits(allocator, &before_map, &after_map, prices, opts.account_map, &changes);
defer skip.deinit();
// Helper for duping strings into the arena so Change fields have
@ -877,6 +930,21 @@ fn computeReport(
const acct = try sdup.of(lot.account orelse "");
const sym = try sdup.of(lot.symbol);
if (@abs(delta) > 0.000001) {
// Direct-indexing suppression: for stock lots in
// flagged accounts, sub-1% share drift is tracking-
// error reconciliation, not real money flow. Skip
// emitting a rollup_delta / drip_negative so the
// attribution stays clean. Must apply the same
// logic here as in `detectEdits` or the treatment
// becomes inconsistent depending on whether the
// strict key broke this week.
if (lot.security_type == .stock) {
const denom = @max(@abs(after_agg.shares), @abs(before_agg.shares));
const rel = if (denom == 0) 0.0 else @abs(delta) / denom;
const is_di = if (opts.account_map) |am| am.isDirectIndexing(lot.account orelse "") else false;
if (is_di and rel <= direct_indexing_residual_tolerance_rel) continue;
}
const before_lot = before_agg.lot;
const is_drip = lot.drip or before_lot.drip;
const base_kind: ChangeKind = switch (lot.security_type) {
@ -1777,6 +1845,265 @@ test "computeReport: symbol rename with ticker alias collapses to lot_edited" {
try std.testing.expect(@abs(residual_value) < 5_000); // nowhere near $330k
}
test "computeReport: direct_indexing account suppresses sub-1% residual" {
// Tax-loss direct-indexing proxy: same symbol/account/key but
// small share drift (0.5%) from tracking-error reconciliation.
// With no account_map, the default 0.01% tolerance surfaces this
// as a rollup_delta. With account_map flagging the account as
// direct_indexing, the looser 1% tolerance swallows it only
// the lot_edited marker is emitted, no residual that would land
// in the attribution total.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 461.24);
const before = [_]Lot{
.{
.symbol = "SPY",
.shares = 1000.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
},
};
const after = [_]Lot{
// Broken strict key (symbol+ticker alias rewrite) plus a
// 0.5% share decrease from tracking-error reconciliation.
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 995.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
.price_ratio = 1.0,
},
};
// Build an account_map flagging Tax Loss as direct-indexing.
var am_entries = [_]analysis.AccountTaxEntry{
.{
.account = "Tax Loss",
.tax_type = .taxable,
.direct_indexing = true,
},
};
const account_map = analysis.AccountMap{
.entries = &am_entries,
.allocator = allocator,
};
const report = try computeReport(
allocator,
&before,
&after,
&prices,
Date.fromYmd(2026, 4, 21),
.{ .account_map = &account_map },
);
var n_edit: usize = 0;
var n_rollup_or_drip: usize = 0;
for (report.changes) |c| switch (c.kind) {
.lot_edited => n_edit += 1,
.rollup_delta, .drip_negative, .drip_confirmed => n_rollup_or_drip += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_edit);
// Looser tolerance swallows the 0.5% residual no rollup/drip
// leak into the attribution total.
try std.testing.expectEqual(@as(usize, 0), n_rollup_or_drip);
}
test "computeReport: direct_indexing same-key drift suppressed in Pass 1" {
// Tax-loss direct-indexing proxy with SAME strict key on both
// sides (no rename this week) but small share drift from
// tracking-error reconciliation. Pass 1 would normally emit a
// drip_negative; with direct_indexing the 1% tolerance suppresses
// it the same way detectEdits does.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 461.24);
const before = [_]Lot{
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 1000.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
},
};
const after = [_]Lot{
// Same strict key, 0.5% share decrease from tracking-error
// reconciliation.
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 995.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
},
};
var am_entries = [_]analysis.AccountTaxEntry{
.{
.account = "Tax Loss",
.tax_type = .taxable,
.direct_indexing = true,
},
};
const account_map = analysis.AccountMap{
.entries = &am_entries,
.allocator = allocator,
};
const report = try computeReport(
allocator,
&before,
&after,
&prices,
Date.fromYmd(2026, 4, 21),
.{ .account_map = &account_map },
);
// No rollup/drip the share drift was tracking error and the
// direct_indexing flag suppressed it.
try std.testing.expectEqual(@as(usize, 0), report.changes.len);
}
test "computeReport: same-key drift without direct_indexing still surfaces (sanity)" {
// Sanity check that the flag is what's doing the suppression:
// same setup as above but without the account_map, so Pass 1
// emits a drip_negative for the 0.5% drift.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 461.24);
const before = [_]Lot{
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 1000.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
},
};
const after = [_]Lot{
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 995.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
},
};
const report = try computeReport(
allocator,
&before,
&after,
&prices,
Date.fromYmd(2026, 4, 21),
.{},
);
var n_drip_neg: usize = 0;
for (report.changes) |c| switch (c.kind) {
.drip_negative => n_drip_neg += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_drip_neg);
}
test "computeReport: direct_indexing tolerance still surfaces real contributions" {
// Regression: the 1% tolerance should still catch a real
// contribution. If the user transfers $100k into a multi-million
// direct-indexing account (~1.2% of value), the residual should
// surface as a rollup_delta so the attribution isn't silent about
// real money flow.
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 461.24);
// 17,361 shares × $461 $8M (same scale as the user's real tax
// loss account). A $100k contribution = ~217 shares = ~1.25%.
const before = [_]Lot{
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 17361.0,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 461.240208,
.account = "Tax Loss",
.price_ratio = 1.0,
},
};
const after = [_]Lot{
// Strict-key break (different open_date e.g. user
// redated after a large contribution) AND a 1.25% share
// increase. Tolerance at 1% fails the "swallow" check so
// the residual surfaces.
.{
.symbol = "DI-SPX",
.ticker = "SPY",
.shares = 17578.0, // +217 shares, +1.25%
.open_date = Date.fromYmd(2026, 5, 2),
.open_price = 461.240208,
.account = "Tax Loss",
.price_ratio = 1.0,
},
};
var am_entries = [_]analysis.AccountTaxEntry{
.{
.account = "Tax Loss",
.tax_type = .taxable,
.direct_indexing = true,
},
};
const account_map = analysis.AccountMap{
.entries = &am_entries,
.allocator = allocator,
};
const report = try computeReport(
allocator,
&before,
&after,
&prices,
Date.fromYmd(2026, 5, 2),
.{ .account_map = &account_map },
);
var n_edit: usize = 0;
var n_rollup: usize = 0;
for (report.changes) |c| switch (c.kind) {
.lot_edited => n_edit += 1,
.rollup_delta => n_rollup += 1,
else => {},
};
try std.testing.expectEqual(@as(usize, 1), n_edit);
// 1.25% is over the 1% tolerance, so we get a rollup_delta.
try std.testing.expectEqual(@as(usize, 1), n_rollup);
}
test "computeReport: ticker-alias removed (CUSIP-like -> plain ticker) also collapses" {
// Reverse direction: before has a CUSIP-style symbol with ticker
// alias, after has the plain ticker with no alias. Both resolve