second wells fargo import format

This commit is contained in:
Emil Lerch 2026-05-23 12:48:23 -07:00
parent 7bfe2d9deb
commit 899da76042
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -133,21 +133,38 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo
// Description: first non-empty line after the anchor.
const description = nextNonEmpty(staged.items, &cur) orelse break;
// Trade-date column: either `Multiple(N)` or `MM/DD/YYYY`.
// We don't use the value but consuming it advances `cur`
// to the shares line. Single-date format pastes have an
// extra blank line between the description and the date,
// so step past blanks.
_ = nextNonEmpty(staged.items, &cur) orelse break;
// Optional trade-date column: `Multiple(N)` or
// `MM/DD/YYYY`. Some paste shapes (529 plans, mutual-fund
// accounts) omit this column entirely and go straight from
// description to shares. Detect the difference by peeking:
// if the next non-empty line parses as a pure decimal
// (the shares column), don't consume it as trade-date.
var peek_idx = cur;
const peek_line = nextNonEmpty(staged.items, &peek_idx) orelse break;
const peek_is_shares = parseSharesAmount(peek_line) != null;
if (!peek_is_shares) {
// Trade-date column present consume it.
cur = peek_idx;
}
// else: leave `cur` at the original position; the shares
// step below will consume `peek_line` as the shares value.
// Shares: integer with optional thousands commas, no $.
// Shares: integer or decimal with optional thousands
// commas, no $.
const shares_text = nextNonEmpty(staged.items, &cur) orelse break;
const shares = parseSharesAmount(shares_text) orelse continue;
// Avg cost: line starts with `@ $`.
// Avg cost: either `@ $price` (with cost-basis history)
// or `N/A` (529 plans / managed accounts where WF doesn't
// surface a cost basis). When `N/A`, leave avg_cost null
// and let the import path synthesize cost = market value.
const cost_text = nextNonEmpty(staged.items, &cur) orelse break;
if (!std.mem.startsWith(u8, cost_text, "@ ")) continue;
const avg_cost = parseDollarAmount(cost_text[2..]) orelse continue;
const avg_cost: ?f64 = if (std.mem.startsWith(u8, cost_text, "@ "))
parseDollarAmount(cost_text[2..]) orelse continue
else if (std.mem.eql(u8, cost_text, "N/A"))
null
else
continue;
// Last price (skip), day-change-$ (skip), day-change-%
// (skip): three lines of price-detail we don't currently
@ -175,7 +192,13 @@ pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePo
.description = description,
.quantity = shares,
.current_value = market_value,
.cost_basis = shares * avg_cost,
// When avg_cost is null (WF showed `N/A`),
// `synthesizeLots` will fall back to current_value /
// shares, producing a synthetic open_price equal to
// today's price. That's the right behavior for
// managed accounts where WF doesn't surface cost
// basis gain/loss is unknown anyway.
.cost_basis = if (avg_cost) |c| shares * c else null,
.is_cash = portfolio_mod.isMoneyMarketSymbol(symbol),
});
}
@ -1006,6 +1029,91 @@ test "parsePaste: cash section absent is a no-op" {
try testing.expect(!positions[0].is_cash);
}
test "parsePaste: 529-plan layout (no trade-date column, N/A avg cost)" {
// Some WF paste shapes typically 529 plans and managed
// mutual-fund accounts omit the trade-date column entirely
// and report `N/A` where the avg-cost would be. The parser
// must handle both differences:
//
// 1. Description goes directly to shares (no Multiple(N)
// / MM/DD/YYYY line in between).
// 2. Avg-cost line is `N/A`, not `@ $price`. cost_basis
// becomes null; downstream synthesis falls back to
// market_value / shares.
const allocator = testing.allocator;
const data =
"JEFAX, popup\n" ++
"EDUCATION TR ALASKA ^\n" ++
"\t\n" ++
"803.135\n" ++ // shares no trade-date line precedes
"N/A\n" ++ // avg cost: not provided
"\t\n" ++
"$30.22\n" ++ // last price
"+$0.01\n" ++ // day change
"\t\n" ++
"$24,270.74\n" ++ // market value
"+$8.03 (+0.03%)\n" ++
"\t\n" ++
"N/A\n" ++
"N/A\n" ++
"\t\n" ++
"N/A\n";
const positions = try parsePaste(allocator, data);
defer allocator.free(positions);
try testing.expectEqual(@as(usize, 1), positions.len);
try testing.expectEqualStrings("JEFAX", positions[0].symbol);
try testing.expectApproxEqAbs(@as(f64, 803.135), positions[0].quantity.?, 0.001);
try testing.expectApproxEqAbs(@as(f64, 24270.74), positions[0].current_value.?, 0.01);
try testing.expect(positions[0].cost_basis == null);
try testing.expect(!positions[0].is_cash);
}
test "parsePaste: handles two 529-plan records back-to-back" {
// Verify the no-trade-date / N/A-cost path correctly
// resumes the outer scan past the previous record's
// market value, finding the next anchor.
const allocator = testing.allocator;
const data =
"JEFAX, popup\n" ++
"FUND A\n" ++
"\t\n" ++
"803.135\n" ++
"N/A\n" ++
"\t\n" ++
"$30.22\n" ++
"+$0.01\n" ++
"\t\n" ++
"$24,270.74\n" ++
"+$8.03 (+0.03%)\n" ++
"\t\n" ++
"N/A\n" ++
"N/A\n" ++
"\t\n" ++
"N/A\n" ++
"\t\n" ++
"Not Rated\n" ++
"\t\n" ++
"JHPIX, popup\n" ++
"FUND B\n" ++
"\t\n" ++
"131.109\n" ++
"N/A\n" ++
"\t\n" ++
"$18.22\n" ++
"+$0.01\n" ++
"\t\n" ++
"$2,388.81\n";
const positions = try parsePaste(allocator, data);
defer allocator.free(positions);
try testing.expectEqual(@as(usize, 2), positions.len);
try testing.expectEqualStrings("JEFAX", positions[0].symbol);
try testing.expectEqualStrings("JHPIX", positions[1].symbol);
try testing.expect(positions[0].cost_basis == null);
try testing.expect(positions[1].cost_basis == null);
}
test "isPopupAnchor: accepts both compact and spaced forms" {
try testing.expect(isPopupAnchor("XOM,popup"));
try testing.expect(isPopupAnchor("XOM ,popup"));