From d434395daa2b7cfbd48ede5a7beb12e9b7eecb85 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 3 Aug 2026 13:07:19 -0700 Subject: [PATCH] report if cash only exists in the portfolio, not at broker (probably 0) --- src/analytics/reconcile/common.zig | 161 +++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/src/analytics/reconcile/common.zig b/src/analytics/reconcile/common.zig index 4f17fd8..4ef4bea 100644 --- a/src/analytics/reconcile/common.zig +++ b/src/analytics/reconcile/common.zig @@ -307,6 +307,11 @@ pub fn compareAccounts( var brokerage_total: f64 = 0; var option_value_delta: f64 = 0; var has_discrepancies = false; + // Did the export carry a cash row for this account? A broker drops a + // money-market position from the export entirely once it reaches + // zero, and the comparison below is driven by brokerage rows - so + // without this we would never look at the account's cash at all. + var saw_cash_row = false; // Track which portfolio symbols we've matched var matched_symbols = std.StringHashMap(void).init(allocator); @@ -347,6 +352,7 @@ pub fn compareAccounts( var cd_allowance: f64 = 0; if (bp.is_cash) { + saw_cash_row = true; pf_shares = portfolio.cashForAccount(portfolio_acct_name.?); pf_value = pf_shares; } else { @@ -532,6 +538,43 @@ pub fn compareAccounts( }); } } + + // Cash the broker no longer reports. + // + // A money-market position is dropped from the export once it + // reaches zero, so an account can appear with holdings but no cash + // row. Because the comparison above iterates brokerage rows, "no + // row" silently meant "no comparison" - a portfolio cash lot could + // sit stale indefinitely while a stock or CD in the same situation + // was flagged by the sweeps above. Observed in the wild: a $39.08 + // BrokerageLink cash lot survived three weekly reconciliations + // after the money had actually been invested. + // + // This reports the discrepancy; it deliberately does not assert + // the balance is zero. Absence is strong evidence, not proof, and + // `audit` exists to surface things for a human to decide. + if (!saw_cash_row) { + const pf_cash = portfolio.cashForAccount(pa); + if (@abs(pf_cash) > cash_tolerance) { + portfolio_total += pf_cash; + has_discrepancies = true; + try comparisons.append(allocator, .{ + .symbol = "CASH", + .portfolio_shares = pf_cash, + .brokerage_shares = null, + .portfolio_price = 1.0, + .brokerage_price = null, + .portfolio_value = pf_cash, + .brokerage_value = null, + .shares_delta = null, + .value_delta = null, + .is_cash = true, + .is_option = false, + .only_in_brokerage = false, + .only_in_portfolio = true, + }); + } + } } try results.append(allocator, .{ @@ -1078,6 +1121,124 @@ test "compareAccounts: unmapped brokerage account is reported brokerage-only" { try std.testing.expectApproxEqAbs(@as(f64, 200), cmp.brokerage_price.?, 0.01); } +test "compareAccounts: cash the broker no longer reports is flagged portfolio-only" { + const allocator = std.testing.allocator; + + // The real BrokerageLink incident. The $39.08 money-market balance had + // already been invested, so Fidelity dropped the FDRXX row from the + // export entirely - a zero position isn't exported. The account still + // appears (it holds FDSCX), but with no cash row. Because the comparison + // is driven by brokerage rows, "no row" used to mean "no comparison", and + // the stale cash lot survived three weekly reconciliations unnoticed. + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "FDRXX", .shares = 39.08, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 1.0, .security_type = .cash, .account = "Sample 401k BL" }, + .{ .symbol = "FDSCX", .shares = 3018.809, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 33.82, .account = "Sample 401k BL" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + // Export carries the holding but NO cash row. + var brokerage = [_]BrokeragePosition{ + .{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDSCX", .description = "FIDELITY STOCK SELECTOR SMALL CAP", .quantity = 3018.809, .current_value = 152963.05, .cost_basis = 104700.15, .is_cash = false }, + }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("FDSCX", 50.67); + + const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 8, 1)); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 1), results.len); + try std.testing.expect(results[0].has_discrepancies); + + var found = false; + for (results[0].comparisons) |cmp| { + if (!cmp.is_cash or !cmp.only_in_portfolio) continue; + found = true; + try std.testing.expectApproxEqAbs(@as(f64, 39.08), cmp.portfolio_value, 0.001); + try std.testing.expect(cmp.brokerage_value == null); + try std.testing.expect(cmp.brokerage_shares == null); + } + try std.testing.expect(found); +} + +test "compareAccounts: an export cash row suppresses the portfolio-only cash flag" { + const allocator = std.testing.allocator; + + // Same account, but the broker DOES report cash. The normal comparison + // path owns it; there must be no duplicate portfolio-only cash row. + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "FDRXX", .shares = 39.08, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 1.0, .security_type = .cash, .account = "Sample 401k BL" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var brokerage = [_]BrokeragePosition{ + .{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDRXX", .description = "HELD IN MONEY MARKET", .quantity = null, .current_value = 39.08, .cost_basis = null, .is_cash = true }, + }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 8, 1)); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 1), results.len); + for (results[0].comparisons) |cmp| { + try std.testing.expect(!(cmp.is_cash and cmp.only_in_portfolio)); + } + try std.testing.expect(!results[0].has_discrepancies); +} + +test "compareAccounts: no cash row and no portfolio cash stays silent" { + const allocator = std.testing.allocator; + + // Absence on both sides is agreement, not a discrepancy. + var lots = [_]portfolio_mod.Lot{ + .{ .symbol = "FDSCX", .shares = 3018.809, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 33.82, .account = "Sample 401k BL" }, + }; + const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator }; + + var brokerage = [_]BrokeragePosition{ + .{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDSCX", .description = "FIDELITY STOCK SELECTOR SMALL CAP", .quantity = 3018.809, .current_value = 152963.05, .cost_basis = 104700.15, .is_cash = false }, + }; + + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" }, + }; + const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator }; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + try prices.put("FDSCX", 50.67); + + const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 8, 1)); + defer { + for (results) |r| allocator.free(r.comparisons); + allocator.free(results); + } + + try std.testing.expectEqual(@as(usize, 1), results.len); + for (results[0].comparisons) |cmp| { + try std.testing.expect(!(cmp.is_cash and cmp.only_in_portfolio)); + } +} + test "compareAccounts: portfolio-only stock position is flagged only_in_portfolio" { const allocator = std.testing.allocator;