output ratio suggestions to reconcile Schwab direct indexing accounts
This commit is contained in:
parent
f007a1d350
commit
8a09d904e2
4 changed files with 571 additions and 21 deletions
81
TODO.md
81
TODO.md
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue