report if cash only exists in the portfolio, not at broker (probably 0)
This commit is contained in:
parent
d5fd8aa4b6
commit
d434395daa
1 changed files with 161 additions and 0 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue