second wells fargo import format
This commit is contained in:
parent
7bfe2d9deb
commit
899da76042
1 changed files with 119 additions and 11 deletions
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue