output ratio suggestions to reconcile Schwab direct indexing accounts
This commit is contained in:
parent
2238733de7
commit
ea1a88442a
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)
|
## Audit: tax-loss account persistent discrepancy (~-$897.59)
|
||||||
|
|
||||||
The tax-loss account consistently shows a small discrepancy (around
|
**Status (2026-05-02):** Resolved via the `direct_indexing::true`
|
||||||
-$897.59 at time of writing) that requires manual tweaking in the
|
flag in `accounts.srf`. When set on an account:
|
||||||
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.
|
|
||||||
|
|
||||||
Consider introducing an **"expected discrepancy tolerance"** field per
|
- Audit now emits a suggested `price_ratio` adjustment even for
|
||||||
account so audit only flags deltas exceeding the known drift. Would
|
lots with ratio == 1.0 (previously skipped). The Schwab-summary
|
||||||
keep the audit output signal-to-noise high for this and any other
|
path picks up direct-indexing accounts with a single stock lot
|
||||||
accounts with known-unfixable drift.
|
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.
|
See `src/analytics/analysis.zig` `AccountTaxEntry.direct_indexing`
|
||||||
Probably render as muted (still visible, just not warning-colored)
|
and `src/commands/audit.zig` `displaySchwabSummaryRatioSuggestions`.
|
||||||
until the delta exceeds the tolerance, rather than filtering entirely.
|
|
||||||
|
## 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)
|
## Torn SRF files from server sync (recurring bug)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,29 @@ pub const AccountTaxEntry = struct {
|
||||||
/// contributions (payroll ESPP accrual, direct 401k cash
|
/// contributions (payroll ESPP accrual, direct 401k cash
|
||||||
/// deposits). See TODO.md for the design history.
|
/// deposits). See TODO.md for the design history.
|
||||||
cash_is_contribution: bool = false,
|
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.
|
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
|
||||||
|
|
@ -144,6 +167,18 @@ pub const AccountMap = struct {
|
||||||
}
|
}
|
||||||
return false;
|
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.
|
/// 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,
|
.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null,
|
||||||
.update_cadence = entry.update_cadence,
|
.update_cadence = entry.update_cadence,
|
||||||
.cash_is_contribution = entry.cash_is_contribution,
|
.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"));
|
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" {
|
test "TaxType.label" {
|
||||||
try std.testing.expectEqualStrings("Taxable", TaxType.taxable.label());
|
try std.testing.expectEqualStrings("Taxable", TaxType.taxable.label());
|
||||||
try std.testing.expectEqualStrings("Roth (Post-Tax)", TaxType.roth.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
|
/// After displaying audit results, check for price_ratio positions where
|
||||||
/// the brokerage NAV implies a different ratio than what's configured.
|
/// the brokerage NAV implies a different ratio than what's configured.
|
||||||
/// Outputs actionable suggestions for portfolio.srf updates.
|
/// 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(
|
fn displayRatioSuggestions(
|
||||||
results: []const AccountComparison,
|
results: []const AccountComparison,
|
||||||
portfolio: zfin.Portfolio,
|
portfolio: zfin.Portfolio,
|
||||||
prices: std.StringHashMap(f64),
|
prices: std.StringHashMap(f64),
|
||||||
|
account_map: ?analysis.AccountMap,
|
||||||
color: bool,
|
color: bool,
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
) !void {
|
) !void {
|
||||||
|
|
@ -1067,9 +1083,18 @@ fn displayRatioSuggestions(
|
||||||
if (cmp.only_in_brokerage or cmp.only_in_portfolio) continue;
|
if (cmp.only_in_brokerage or cmp.only_in_portfolio) continue;
|
||||||
if (cmp.is_cash or cmp.is_option) 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
|
// Find the portfolio lot(s) for this symbol with price_ratio != 1.0
|
||||||
|
// (or any ratio, for direct-indexing accounts).
|
||||||
for (portfolio.lots) |lot| {
|
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;
|
if (lot.security_type != .stock) continue;
|
||||||
const lot_acct = lot.account orelse continue;
|
const lot_acct = lot.account orelse continue;
|
||||||
if (!std.mem.eql(u8, lot_acct, acct.account_name)) 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", .{});
|
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 ─────────────────────────────────────────────────
|
// ── Display ─────────────────────────────────────────────────
|
||||||
|
|
||||||
fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void {
|
fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void {
|
||||||
|
|
@ -1880,12 +1988,17 @@ fn runHygieneCheck(
|
||||||
if (verbose or hasSchwabDiscrepancies(results)) {
|
if (verbose or hasSchwabDiscrepancies(results)) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try displaySchwabResults(results, color, out);
|
try displaySchwabResults(results, color, out);
|
||||||
|
try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||||||
} else {
|
} else {
|
||||||
var acct_count: usize = 0;
|
var acct_count: usize = 0;
|
||||||
for (results) |r| {
|
for (results) |r| {
|
||||||
if (r.account_name.len > 0) acct_count += 1;
|
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});
|
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 => {
|
.fidelity_csv => {
|
||||||
|
|
@ -1901,11 +2014,11 @@ fn runHygieneCheck(
|
||||||
if (verbose or hasAccountDiscrepancies(results)) {
|
if (verbose or hasAccountDiscrepancies(results)) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try displayResults(results, color, out);
|
try displayResults(results, color, out);
|
||||||
try displayRatioSuggestions(results, portfolio, prices, color, out);
|
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||||||
} else {
|
} else {
|
||||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len});
|
try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len});
|
||||||
// Always show ratio suggestions even in compact mode
|
// 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 => {
|
.schwab_csv => {
|
||||||
|
|
@ -1921,10 +2034,10 @@ fn runHygieneCheck(
|
||||||
if (verbose or hasAccountDiscrepancies(results)) {
|
if (verbose or hasAccountDiscrepancies(results)) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try displayResults(results, color, out);
|
try displayResults(results, color, out);
|
||||||
try displayRatioSuggestions(results, portfolio, prices, color, out);
|
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||||||
} else {
|
} else {
|
||||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len});
|
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);
|
defer allocator.free(results);
|
||||||
|
|
||||||
try displaySchwabResults(results, color, out);
|
try displaySchwabResults(results, color, out);
|
||||||
|
try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fidelity CSV
|
// Fidelity CSV
|
||||||
|
|
@ -2081,7 +2195,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path:
|
||||||
}
|
}
|
||||||
|
|
||||||
try displayResults(results, color, out);
|
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
|
// 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 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
|
//! (withdraw-as-negative-contribution) is one `if (delta < 0)` in
|
||||||
//! `computeReport` if/when the need arises.
|
//! `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
|
//! ## Other architecture notes
|
||||||
//!
|
//!
|
||||||
//! Relies on: portfolio.srf being tracked in a git repo, and the `git`
|
//! 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.
|
/// the residual share movement as a distinct change.
|
||||||
const edit_residual_tolerance_rel: f64 = 0.0001; // 0.01%
|
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.
|
/// Identify strict-key pairs that should be reclassified as edits.
|
||||||
///
|
///
|
||||||
/// Walks the "only in after" and "only in before" strict keys, groups
|
/// Walks the "only in after" and "only in before" strict keys, groups
|
||||||
|
|
@ -677,6 +717,7 @@ fn detectEdits(
|
||||||
before_map: *const std.StringHashMap(LotAgg),
|
before_map: *const std.StringHashMap(LotAgg),
|
||||||
after_map: *const std.StringHashMap(LotAgg),
|
after_map: *const std.StringHashMap(LotAgg),
|
||||||
prices: *const std.StringHashMap(f64),
|
prices: *const std.StringHashMap(f64),
|
||||||
|
account_map: ?*const analysis.AccountMap,
|
||||||
changes: *std.ArrayList(Change),
|
changes: *std.ArrayList(Change),
|
||||||
) !std.StringHashMap(void) {
|
) !std.StringHashMap(void) {
|
||||||
var skip = std.StringHashMap(void).init(allocator);
|
var skip = std.StringHashMap(void).init(allocator);
|
||||||
|
|
@ -782,10 +823,22 @@ fn detectEdits(
|
||||||
// beyond noise. Mirrors Pass 1's same-key share-delta handling
|
// beyond noise. Mirrors Pass 1's same-key share-delta handling
|
||||||
// so the user sees the same classification whether the strict
|
// so the user sees the same classification whether the strict
|
||||||
// key was preserved or rewritten.
|
// 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 delta = after_shares - before_shares;
|
||||||
const denom = @max(@abs(after_shares), @abs(before_shares));
|
const denom = @max(@abs(after_shares), @abs(before_shares));
|
||||||
const rel = if (denom == 0) 0.0 else @abs(delta) / denom;
|
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: {
|
const unit_value: f64 = blk: {
|
||||||
if (rep_lot.security_type == .stock) {
|
if (rep_lot.security_type == .stock) {
|
||||||
|
|
@ -851,7 +904,7 @@ fn computeReport(
|
||||||
// strict keys passes 1 and 2 should ignore — each matched group
|
// strict keys passes 1 and 2 should ignore — each matched group
|
||||||
// emits its own `lot_edited` change plus a residual rollup for
|
// emits its own `lot_edited` change plus a residual rollup for
|
||||||
// any share delta beyond noise.
|
// 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();
|
defer skip.deinit();
|
||||||
|
|
||||||
// Helper for duping strings into the arena so Change fields have
|
// 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 acct = try sdup.of(lot.account orelse "");
|
||||||
const sym = try sdup.of(lot.symbol);
|
const sym = try sdup.of(lot.symbol);
|
||||||
if (@abs(delta) > 0.000001) {
|
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 before_lot = before_agg.lot;
|
||||||
const is_drip = lot.drip or before_lot.drip;
|
const is_drip = lot.drip or before_lot.drip;
|
||||||
const base_kind: ChangeKind = switch (lot.security_type) {
|
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
|
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" {
|
test "computeReport: ticker-alias removed (CUSIP-like -> plain ticker) also collapses" {
|
||||||
// Reverse direction: before has a CUSIP-style symbol with ticker
|
// Reverse direction: before has a CUSIP-style symbol with ticker
|
||||||
// alias, after has the plain ticker with no alias. Both resolve
|
// alias, after has the plain ticker with no alias. Both resolve
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue