fidelity detection fix (part 1 - see below)

In early July fidelity changed the casing on the CSV headers. This broke
the file detection (Schwab/Fidelity) because zfin was doing a case
sensitive match on these headers. This quick fix addresses this problem,
and a wider fix to make detection less fragile will follow shortly.
This commit is contained in:
Emil Lerch 2026-07-18 12:14:48 -07:00
parent 4d185f9502
commit 6e48954190
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 60 additions and 5 deletions

View file

@ -81,11 +81,14 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosi
var lines = std.mem.splitScalar(u8, content, '\n');
// Validate header row
// Validate header row. Case-insensitive: Fidelity switched the export
// header from Title Case to sentence case ("Account number,Account
// name,..."). The column order is unchanged, so the positional parsing
// below still holds; only this guard needed to loosen.
const header_line = lines.next() orelse return error.EmptyFile;
const header_trimmed = std.mem.trimEnd(u8, header_line, &.{ '\r', ' ' });
if (header_trimmed.len == 0) return error.EmptyFile;
if (!std.mem.startsWith(u8, header_trimmed, "Account Number")) {
if (!std.ascii.startsWithIgnoreCase(header_trimmed, "Account Number")) {
return error.UnexpectedHeader;
}
@ -247,3 +250,36 @@ test "parseCsv cash account type is not cash position" {
try std.testing.expect(!positions[0].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01);
}
test "parseCsv accepts Fidelity's lowercase (sentence-case) header" {
// Regression: Fidelity switched the export header from Title Case to
// sentence case ("Account number,Account name,...,Last price,...,Cost
// basis total,Average cost basis,Type"). Column order is unchanged, so
// positional parsing must still work. The trailing comma after the Type
// value and the blank-line-separated legal footer mirror the real export.
const csv =
"Account number,Account name,Symbol,Description,Quantity,Last price,Last price change,Current value,Today's gain/loss dollar,Today's gain/loss percent,Total gain/loss dollar,Total gain/loss percent,Percent of account,Cost basis total,Average cost basis,Type\n" ++
"Z123,Individual - TOD,FZFXX**,HELD IN MONEY MARKET,,,,$24.03,,,,,0.12%,,,Cash,\n" ++
"Z123,Individual - TOD,AAPL,APPLE INC,100,$150.00,-$2.00,$15000.00,-$200.00,-1.3%,+$5000.00,+50%,99.88%,$10000.00,$100.00,Margin,\n" ++
"\n" ++
"\"Brokerage services are provided by Fidelity Brokerage Services LLC (FBS)...\"\n";
const allocator = std.testing.allocator;
const positions = try parseCsv(allocator, csv);
defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 2), positions.len);
// Money-market row -> cash, no quantity.
try std.testing.expectEqualStrings("FZFXX", positions[0].symbol);
try std.testing.expect(positions[0].is_cash);
try std.testing.expect(positions[0].quantity == null);
try std.testing.expectApproxEqAbs(@as(f64, 24.03), positions[0].current_value.?, 0.01);
// Stock row -> positional columns still resolve correctly.
try std.testing.expectEqualStrings("AAPL", positions[1].symbol);
try std.testing.expect(!positions[1].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[1].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 15000.00), positions[1].current_value.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[1].cost_basis.?, 0.01);
}

View file

@ -59,9 +59,14 @@ fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind {
else
data;
// Fidelity CSV: first line starts with "Account Number" or "Account Name"
if (std.mem.startsWith(u8, content, "Account Number") or
std.mem.startsWith(u8, content, "Account Name"))
// Fidelity CSV: first line starts with "Account Number"/"Account Name".
// Case-insensitive: Fidelity switched the export header from Title Case
// to sentence case ("Account number,Account name,...") and could change
// it again. Matching this loosely also keeps the file from falling
// through to the broad schwab-summary heuristic below, which would
// otherwise misclassify it via the "Brokerage services..." legal footer.
if (std.ascii.startsWithIgnoreCase(content, "Account Number") or
std.ascii.startsWithIgnoreCase(content, "Account Name"))
return .fidelity_csv;
// Schwab per-account CSV: starts with a quoted title line like "Positions for ..."
@ -1077,6 +1082,20 @@ test "detectBrokerFileKind: fidelity csv with BOM" {
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_bom).?);
}
test "detectBrokerFileKind: fidelity csv with lowercase header is not misclassified as schwab" {
// Regression: Fidelity switched the header from Title Case to sentence
// case ("Account number,..."). The old case-sensitive check failed and
// the file fell through to the schwab-summary heuristic, which
// false-matched on the "Brokerage services are provided by..." legal
// footer plus the "$" amounts. Must classify as fidelity_csv.
const data =
"Account number,Account name,Symbol,Description,Quantity,Last price,Last price change,Current value,Today's gain/loss dollar,Today's gain/loss percent,Total gain/loss dollar,Total gain/loss percent,Percent of account,Cost basis total,Average cost basis,Type\n" ++
"Z123,Individual - TOD,AAPL,APPLE INC,100,$150.00,-$2.00,$15000.00,,,,,99.88%,$10000.00,$100.00,Margin,\n" ++
"\n" ++
"\"Brokerage services are provided by Fidelity Brokerage Services LLC (FBS)...\" $\n";
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(data).?);
}
test "detectBrokerFileKind: schwab csv" {
const schwab_header = "\"Positions for account Roth IRA ...1234 as of\"";
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(schwab_header).?);