//! Wells Fargo Advisors positions export. //! //! The WF portal's spreadsheet download, with "Download Type" set to //! **Portfolio-Expanded Detail**, "Portfolio View" set to **Positions**, //! for all brokerage accounts. It is a legacy binary `.xls` (BIFF8), //! decoded with the `biff8` package, holding one sheet, `WFA_Positions`, //! for the whole household: //! //! ``` //! Priced as of Close on MM/DD/YYYY //! Account Number: HOUSEHOLD //! Cash/Cash Alternatives & Margin <- cash section //! Description | Account Number | Market Value //! Cash Balance | *1234 | ... //! Bank Deposit Sweep | *1234 | ... //! Accrued Money Market & Sweep Interest | Detail | ... (household subtotal) //! Accrued Money Market & Sweep Interest | *1234 | ... //! Total Cash/Cash Alternatives & Margin //! Stocks / Common Stock <- section titles //! Description | Symbol | Account Number | ... (34 columns) //! //! Total ... //! ETFs, Mutual Funds <- same shape, own headers //! Total Portfolio //! //! ``` //! //! Security rows nest three levels deep, distinguished by two cells: //! //! | Account Number | Trade Date | Row is | //! |----------------|-----------------|----------------------------------------| //! | `Detail` | (any) | household total for a symbol held in several accounts | //! | `*1234` | `Detail...` | one account's total for a multi-lot symbol | //! | `*1234` | a date, ... | one tax lot (the only rows that count) | //! //! A symbol held as a single lot in an account has only the lot row. //! Every per-account total is checked against the lots under it, so a //! layout change (or a "Collapsed Detail" download, which has totals but //! no lots) fails loudly instead of importing the wrong shares. //! //! Lot trade dates are `MM/DD/YYYY`, suffixed `nc` for lots whose cost //! WF does not report to the IRS. Funds with same-day activity show //! `Intra-Day` instead, with no trade date or cost; those lots get //! `Date.epoch` (import's "unknown" sentinel) and no cost basis, so //! import falls back exactly as it does for an export without dates. //! //! Columns are located by header name, not position. WF suffixes some //! headers with a footnote digit (`Trade Date1`); those are stripped //! before matching. //! //! Account identity: each row carries the account as `*NNNN`, the last //! four digits. `accounts.srf` entries with `institution::wells_fargo` //! map them through `account_number::NNNN`. const std = @import("std"); const builtin = @import("builtin"); const biff8 = @import("biff8"); const Date = @import("../Date.zig"); const Lot = @import("../models/portfolio.zig").Lot; const LotType = @import("../models/portfolio.zig").LotType; const isMoneyMarketSymbol = @import("../models/portfolio.zig").isMoneyMarketSymbol; const types = @import("types.zig"); const analysis = @import("../analytics/analysis.zig"); const BrokeragePosition = types.BrokeragePosition; const Cell = biff8.Cell; const Sheet = biff8.Sheet; const log = std.log.scoped(.wells_fargo); /// Institution name used for `accounts.srf` lookups /// (`institution::wells_fargo`). pub const institution = "wells_fargo"; /// The one sheet in a positions export. pub const sheet_name = "WFA_Positions"; pub const Error = error{ /// No `WFA_Positions` sheet: some other spreadsheet, or a WF /// download other than the positions view. NotPositionsExport, /// A section header is missing a column the parser reads. UnexpectedHeader, /// An account cell is not `*` followed by digits. UnexpectedAccountNumber, /// A lot's trade-date cell is neither a date nor a placeholder WF is /// known to print (`Intra-Day`, `N/A`). UnexpectedTradeDate, /// A lot or cash row lacks its share count or market value. MissingValue, /// A per-account total disagrees with the lots listed under it. InconsistentExport, OutOfMemory, }; /// The positions sheet of a parsed workbook. pub fn positionsSheet(wb: *const biff8.Workbook) Error!*const Sheet { return wb.sheet(sheet_name) orelse error.NotPositionsExport; } /// The date the export's values are priced at: the close named in its /// "Priced as of Close on MM/DD/YYYY" banner. A download made over a /// weekend is still priced at Friday's close, so this, not the /// download date, is the date to price anything against. Null when the /// banner is missing or unreadable. pub fn pricedAsOf(sheet: *const Sheet) ?Date { // The banner sits in the first few rows, above every section. const scan_rows = @min(sheet.rows.len, 10); for (0..scan_rows) |r| { const text = trimmedText(sheet.cell(r, 0)); if (!std.mem.startsWith(u8, text, "Priced as of")) continue; if (text.len < "MM/DD/YYYY".len) return null; return Date.parseMdy(text[text.len - "MM/DD/YYYY".len ..]) catch null; } return null; } /// Anything reading an export file can fail with: the spreadsheet /// decode, then the sheet layout. pub const ExportError = biff8.ParseError || Error; /// What to tell the user about a failed export, beyond the error's /// name. Most failures mean a different choice on WF's download page, /// so name the right one. Lines are indented to sit under an /// `Error: ...` line; empty when there is nothing useful to add. pub fn exportHint(err: ExportError) []const u8 { return switch (err) { error.NotCompoundFile, error.NoWorkbookStream, error.UnsupportedBiffVersion, error.NotPositionsExport, => " Expected the WF positions spreadsheet: Download Type \"Portfolio-Expanded Detail\",\n" ++ " Portfolio View \"Positions\", all brokerage accounts.\n", error.InconsistentExport, error.UnexpectedTradeDate, => " Download Type must be \"Portfolio-Expanded Detail\" (per-lot rows), not \"Collapsed\".\n", error.Truncated => " The file ends early; download it again.\n", else => "", }; } /// One `BrokeragePosition` per tax lot, plus one per cash row (cash /// balance, sweep, accrued interest: `symbol` empty, `is_cash`). Rows /// for the same account and symbol are left separate; the reconciler /// consolidates them. /// /// `account_number` is the digits (`1234`); `account_name` is WF's own /// `*1234`, which is all the export says about the account. Strings /// borrow from `sheet`. Caller frees the returned slice. pub fn parsePositions(allocator: std.mem.Allocator, sheet: *const Sheet) Error![]BrokeragePosition { var positions: std.ArrayList(BrokeragePosition) = .empty; errdefer positions.deinit(allocator); try walk(allocator, sheet, &positions, null); return positions.toOwnedSlice(allocator); } /// The export as portfolio lots: one per WF tax lot, with its real /// trade date and cost, and one cash lot per account summing every /// cash row. Built with `types.lotFromPosition`, so the cash and /// cost conventions match every other import source. Lots with no /// trade date keep `Date.epoch`. /// /// `note` is left null for the caller. Every string is allocated /// against `allocator`; free each lot's `symbol` and `account`, then /// the slice. pub fn parseLots( allocator: std.mem.Allocator, sheet: *const Sheet, account_map: analysis.AccountMap, ) (Error || error{UnmappedAccount})![]Lot { var positions: std.ArrayList(BrokeragePosition) = .empty; defer positions.deinit(allocator); var open_dates: std.ArrayList(?Date) = .empty; defer open_dates.deinit(allocator); try walk(allocator, sheet, &positions, &open_dates); var lots: std.ArrayList(Lot) = .empty; errdefer { for (lots.items) |lot| { allocator.free(lot.symbol); if (lot.account) |a| allocator.free(a); } lots.deinit(allocator); } for (positions.items, open_dates.items) |pos, open_date| { const account = account_map.findByInstitutionAccount(institution, pos.account_number) orelse return error.UnmappedAccount; // Cash-section rows fold into one cash lot per account, kept // where that account's first cash row appeared. if (pos.is_cash and pos.symbol.len == 0) { const value = pos.current_value orelse 0; for (lots.items) |*lot| { if (lot.security_type == .cash and lot.symbol.len == 0 and std.mem.eql(u8, lot.account.?, account)) { lot.shares += value; break; } } else try appendOwned(allocator, &lots, types.lotFromPosition(pos, account)); continue; } var lot = types.lotFromPosition(pos, account); if (open_date) |d| lot.open_date = d; try appendOwned(allocator, &lots, lot); } return lots.toOwnedSlice(allocator); } fn appendOwned(allocator: std.mem.Allocator, lots: *std.ArrayList(Lot), lot: Lot) !void { var owned = lot; owned.symbol = try allocator.dupe(u8, lot.symbol); errdefer allocator.free(owned.symbol); owned.account = try allocator.dupe(u8, lot.account.?); errdefer allocator.free(owned.account.?); try lots.append(allocator, owned); } // ---- Sheet walk ---- /// Column indices of a securities section, from its header row. const Columns = struct { description: usize, symbol: usize, account: usize, shares: usize, market_value: usize, total_cost: usize, trade_date: usize, }; /// A per-account total row, held until the lots under it are summed. const OpenTotal = struct { row: usize, account: []const u8, symbol: []const u8, shares: f64, lot_shares: f64 = 0, }; const Section = union(enum) { none, cash: struct { account: usize, market_value: usize }, securities: Columns, }; /// Visit every lot and cash row in sheet order, appending a position /// for each (and its trade date, when `open_dates` is given). fn walk( allocator: std.mem.Allocator, sheet: *const Sheet, positions: *std.ArrayList(BrokeragePosition), open_dates: ?*std.ArrayList(?Date), ) Error!void { var section: Section = .none; var open_total: ?OpenTotal = null; for (0..sheet.rows.len) |r| { const first = trimmedText(sheet.cell(r, 0)); if (std.mem.eql(u8, first, "Description")) { try closeTotal(&open_total); section = try parseHeader(sheet, r); continue; } if (std.mem.startsWith(u8, first, "Total")) { try closeTotal(&open_total); section = .none; continue; } switch (section) { .none => {}, .cash => |c| { const account = trimmedText(sheet.cell(r, c.account)); // Blank lines, and "Detail": the household subtotal // of a cash line that appears in several accounts. if (account.len == 0 or std.mem.eql(u8, account, "Detail")) continue; try positions.append(allocator, .{ .account_number = try accountDigits(account, r), .account_name = account, .symbol = "", .description = first, .quantity = null, .current_value = try requireNumber(sheet.cell(r, c.market_value), r, "Market Value"), .cost_basis = null, .is_cash = true, }); if (open_dates) |d| try d.append(allocator, null); }, .securities => |cols| { const symbol = trimmedText(sheet.cell(r, cols.symbol)); const account = trimmedText(sheet.cell(r, cols.account)); // Section titles ("Common Stock", "Open End") and blank // lines have no symbol. if (symbol.len == 0 or account.len == 0) continue; // Household total: the per-account rows that follow // carry everything it sums. if (std.mem.eql(u8, account, "Detail")) { try closeTotal(&open_total); continue; } const trade = sheet.cell(r, cols.trade_date); const shares = try requireNumber(sheet.cell(r, cols.shares), r, "Shares"); if (std.mem.startsWith(u8, trimmedText(trade), "Detail")) { try closeTotal(&open_total); open_total = .{ .row = r, .account = account, .symbol = symbol, .shares = shares }; continue; } // A lot. It belongs to the open total only if it is the // same account and symbol; anything else ends that total. if (open_total) |*t| { if (std.mem.eql(u8, t.account, account) and std.mem.eql(u8, t.symbol, symbol)) { t.lot_shares += shares; } else try closeTotal(&open_total); } const is_cash = isMoneyMarketSymbol(symbol); try positions.append(allocator, .{ .account_number = try accountDigits(account, r), .account_name = account, .symbol = symbol, .description = trimmedText(sheet.cell(r, cols.description)), // Money-market rows follow the cash convention every // other parser uses: dollars, not shares. .quantity = if (is_cash) null else shares, .current_value = try requireNumber(sheet.cell(r, cols.market_value), r, "Market Value"), .cost_basis = if (is_cash) null else sheet.cell(r, cols.total_cost).asNumber(), .is_cash = is_cash, }); if (open_dates) |d| try d.append(allocator, try parseTradeDate(trade, r)); }, } } try closeTotal(&open_total); } /// Classify a header row: securities sections have a `Symbol` column, /// the cash section does not. fn parseHeader(sheet: *const Sheet, r: usize) Error!Section { const has_symbol = findColumn(sheet, r, "Symbol") != null; if (!has_symbol) { return .{ .cash = .{ .account = try requireColumn(sheet, r, "Account Number"), .market_value = try requireColumn(sheet, r, "Market Value"), } }; } return .{ .securities = .{ .description = try requireColumn(sheet, r, "Description"), .symbol = try requireColumn(sheet, r, "Symbol"), .account = try requireColumn(sheet, r, "Account Number"), .shares = try requireColumn(sheet, r, "Shares"), .market_value = try requireColumn(sheet, r, "Market Value"), .total_cost = try requireColumn(sheet, r, "Total Cost"), .trade_date = try requireColumn(sheet, r, "Trade Date"), } }; } fn findColumn(sheet: *const Sheet, r: usize, name: []const u8) ?usize { for (sheet.rows[r], 0..) |cell, c| { // Footnote markers are digits glued onto the header text. const header = std.mem.trimEnd(u8, trimmedText(cell), "0123456789"); if (std.mem.eql(u8, header, name)) return c; } return null; } fn requireColumn(sheet: *const Sheet, r: usize, name: []const u8) Error!usize { return findColumn(sheet, r, name) orelse { if (!builtin.is_test) log.warn("header on row {d} has no '{s}' column", .{ r + 1, name }); return error.UnexpectedHeader; }; } fn requireNumber(cell: Cell, r: usize, column: []const u8) Error!f64 { return cell.asNumber() orelse { if (!builtin.is_test) log.warn("row {d} has no number in '{s}'", .{ r + 1, column }); return error.MissingValue; }; } /// `*1234` -> `1234`. fn accountDigits(account: []const u8, r: usize) Error![]const u8 { if (account.len > 1 and account[0] == '*') { const digits = account[1..]; for (digits) |c| { if (!std.ascii.isDigit(c)) break; } else return digits; } if (!builtin.is_test) log.warn("row {d}: account '{s}' is not '*' followed by digits", .{ r + 1, account }); return error.UnexpectedAccountNumber; } /// A lot's trade date: `MM/DD/YYYY`, optionally suffixed `nc` /// (noncovered). `Intra-Day` and `N/A` mean WF is not showing one. fn parseTradeDate(cell: Cell, r: usize) Error!?Date { const raw = trimmedText(cell); if (std.mem.eql(u8, raw, "Intra-Day") or std.mem.eql(u8, raw, "N/A")) return null; const date_text = if (std.mem.endsWith(u8, raw, "nc")) raw[0 .. raw.len - 2] else raw; return Date.parseMdy(date_text) catch { if (!builtin.is_test) log.warn("row {d}: unrecognized trade date '{s}'", .{ r + 1, raw }); return error.UnexpectedTradeDate; }; } /// Check a per-account total against the lots summed under it. fn closeTotal(open_total: *?OpenTotal) Error!void { const t = open_total.* orelse return; open_total.* = null; if (@abs(t.lot_shares - t.shares) <= 1e-6 * @max(1.0, @abs(t.shares))) return; if (!builtin.is_test) { log.warn("row {d}: {s} {s} totals {d} shares but its lots sum to {d}", .{ t.row + 1, t.account, t.symbol, t.shares, t.lot_shares }); if (t.lot_shares == 0) log.warn("no lots follow it: zfin needs the 'Portfolio-Expanded Detail' download, not 'Collapsed'", .{}); } return error.InconsistentExport; } fn trimmedText(cell: Cell) []const u8 { return std.mem.trim(u8, cell.asText() orelse "", " "); } // ---- Tests ---- const testing = std.testing; fn txt(s: []const u8) Cell { return .{ .text = s }; } fn num(v: f64) Cell { return .{ .number = v }; } /// Header with the real export's names, a footnote-suffixed one among /// them, in a different order from the real file (columns are found /// by name). const header_row = [_]Cell{ txt("Description"), txt("Symbol"), txt("Account Number"), txt("Cash/Margin"), txt("Market Value"), txt("Shares"), txt("Tax Term"), txt("Total Cost1"), txt("Trade Date1") }; /// Placeholder household: accounts *1234 and *5678. /// /// - cash: *1234 balance + sweep + accrued (with a household subtotal /// row), *5678 sweep only /// - stocks: a two-lot AAPL in *1234 (total + lots) and a single-lot /// MSFT in *5678 /// - ETFs: VTI in both accounts (household total, then *1234's two-lot /// total, then *5678's single lot), and a money-market fund /// - mutual funds: an Intra-Day lot and a noncovered lot const fixture_rows = [_][]const Cell{ &.{txt("Priced as of Close on 10/02/2026")}, &.{txt("Account Number: HOUSEHOLD")}, &.{}, &.{txt("Cash/Cash Alternatives & Margin")}, &.{ txt("Description"), txt("Account Number"), txt("Market Value") }, &.{ txt("Cash Balance"), txt("*1234"), num(10) }, &.{ txt("Bank Deposit Sweep "), txt("*1234"), num(500) }, &.{ txt("Bank Deposit Sweep "), txt("*5678"), num(250) }, &.{ txt("Accrued Money Market & Sweep Interest"), txt("Detail"), num(0.75) }, &.{ txt("Accrued Money Market & Sweep Interest"), txt("*1234"), num(0.5) }, &.{ txt("Accrued Money Market & Sweep Interest"), txt("*5678"), num(0.25) }, &.{ txt("Total Cash/Cash Alternatives & Margin"), .empty, num(761.0) }, &.{}, &.{txt("Stocks")}, &header_row, &.{txt("Common Stock")}, &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(3000), num(15), txt("Detail"), num(2000), txt("Detail") }, &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(2000), num(10), txt("LONG"), num(1200), txt("03/15/2021") }, &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(1000), num(5), txt("SHORT"), num(800), txt("01/10/2026") }, &.{ txt("MICROSOFT CORP"), txt("MSFT"), txt("*5678"), txt("Cash"), num(4000), num(10), txt("LONG"), num(3000), txt("06/01/2020") }, &.{ txt("Total Stocks"), .empty, .empty, .empty, num(7000) }, &.{}, &.{txt("ETFs")}, &header_row, &.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("Detail"), txt("Cash"), num(9000), num(30), txt("Detail"), num(7000), txt("Detail") }, &.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*1234"), txt("Cash"), num(6000), num(20), txt("Detail"), num(4500), txt("Detail") }, &.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*1234"), txt("Cash"), num(3600), num(12), txt("LONG"), num(2400), txt("02/03/2022") }, &.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*1234"), txt("Cash"), num(2400), num(8), txt("SHORT"), num(2100), txt("09/09/2026") }, &.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*5678"), txt("Cash"), num(3000), num(10), txt("LONG"), num(2500), txt("04/04/2023") }, &.{ txt("SAMPLE MONEY MARKET"), txt("WMPXX"), txt("*5678"), txt("Cash"), num(100), num(100), txt("SHORT"), num(100), txt("05/05/2026") }, &.{ txt("Total ETFs"), .empty, .empty, .empty, num(9100) }, &.{}, &.{txt("Mutual Funds")}, &header_row, &.{txt("Open End")}, &.{ txt("SAMPLE GROWTH FUND"), txt("SMPLX"), txt("*1234"), txt("Network Fund"), num(5000), num(250), txt("N/A"), txt("N/A"), txt("Intra-Day") }, &.{ txt("SAMPLE INCOME FUND"), txt("SMPIX"), txt("*5678"), txt("Special Product"), num(1100), num(100), txt("N/A"), num(1000), txt("07/07/2015nc") }, &.{ txt("Total Mutual Funds"), .empty, .empty, .empty, num(6100) }, &.{}, &.{txt("Total Portfolio")}, &.{ .empty, txt("Market Value") }, &.{ txt("Total"), num(23961) }, &.{txt("1 Securities with intra-day activity will not display information in the following data fields: Trade Date, ...")}, }; const fixture: Sheet = .{ .name = sheet_name, .rows = &fixture_rows }; fn sheetWith(rows: []const []const Cell) Sheet { return .{ .name = sheet_name, .rows = rows }; } /// accounts.srf entries for the fixture's two accounts. fn fixtureAccounts() [2]analysis.AccountTaxEntry { return .{ .{ .account = "Sample IRA *1234", .tax_type = .traditional, .institution = "wells_fargo", .account_number = "1234" }, .{ .account = "Sample Brokerage *5678", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "5678" }, }; } fn freeLotsForTest(allocator: std.mem.Allocator, lots: []const Lot) void { for (lots) |lot| { allocator.free(lot.symbol); allocator.free(lot.account.?); } allocator.free(lots); } test "parsePositions: every lot and cash row, nothing else" { const positions = try parsePositions(testing.allocator, &fixture); defer testing.allocator.free(positions); // 5 cash rows (subtotal skipped), 3 stock lots, 4 ETF lots (both // totals skipped), 2 fund lots. try testing.expectEqual(@as(usize, 14), positions.len); const cash = positions[0]; try testing.expectEqualStrings("1234", cash.account_number); try testing.expectEqualStrings("*1234", cash.account_name); try testing.expectEqualStrings("", cash.symbol); try testing.expectEqualStrings("Cash Balance", cash.description); try testing.expect(cash.is_cash); try testing.expect(cash.quantity == null); try testing.expectEqual(@as(f64, 10), cash.current_value.?); // Trailing padding is trimmed. try testing.expectEqualStrings("Bank Deposit Sweep", positions[1].description); const lot = positions[5]; try testing.expectEqualStrings("AAPL", lot.symbol); try testing.expectEqualStrings("APPLE INC", lot.description); try testing.expectEqual(@as(f64, 10), lot.quantity.?); try testing.expectEqual(@as(f64, 2000), lot.current_value.?); try testing.expectEqual(@as(f64, 1200), lot.cost_basis.?); try testing.expect(!lot.is_cash); // Per-symbol sums equal what WF's own totals say. var vti: f64 = 0; for (positions) |p| { if (std.mem.eql(u8, p.symbol, "VTI")) vti += p.quantity.?; } try testing.expectEqual(@as(f64, 30), vti); // A money-market holding is cash, valued in dollars. const mm = positions[11]; try testing.expectEqualStrings("WMPXX", mm.symbol); try testing.expect(mm.is_cash); try testing.expect(mm.quantity == null); try testing.expect(mm.cost_basis == null); // A fund with no reported cost. try testing.expectEqualStrings("SMPLX", positions[12].symbol); try testing.expect(positions[12].cost_basis == null); } test "parseLots: real trade dates and costs; one cash lot per account" { const allocator = testing.allocator; var entries = fixtureAccounts(); const account_map: analysis.AccountMap = .{ .entries = &entries, .allocator = allocator }; const lots = try parseLots(allocator, &fixture, account_map); defer freeLotsForTest(allocator, lots); // 2 cash lots (one per account) + 3 + 4 + 2. try testing.expectEqual(@as(usize, 11), lots.len); // Cash: balance + sweep + accrued for *1234; the household // subtotal row is not double counted. try testing.expectEqual(LotType.cash, lots[0].security_type); try testing.expectEqualStrings("Sample IRA *1234", lots[0].account.?); try testing.expectApproxEqAbs(@as(f64, 510.5), lots[0].shares, 1e-9); try testing.expectEqual(@as(f64, 1.0), lots[0].open_price); try testing.expectEqualStrings("Sample Brokerage *5678", lots[1].account.?); try testing.expectApproxEqAbs(@as(f64, 250.25), lots[1].shares, 1e-9); // A dated lot: real trade date, cost per share. const aapl = lots[2]; try testing.expectEqualStrings("AAPL", aapl.symbol); try testing.expectEqual(@as(f64, 10), aapl.shares); try testing.expect(Date.fromYmd(2021, 3, 15).eql(aapl.open_date)); try testing.expectEqual(@as(f64, 120), aapl.open_price); try testing.expect(aapl.note == null); // Money market: a cash lot keeping its symbol. const mm = lots[8]; try testing.expectEqualStrings("WMPXX", mm.symbol); try testing.expectEqual(LotType.cash, mm.security_type); try testing.expectEqual(@as(f64, 100), mm.shares); // Intra-Day fund: unknown date, priced from market value. const intraday = lots[9]; try testing.expectEqualStrings("SMPLX", intraday.symbol); try testing.expect(Date.epoch.eql(intraday.open_date)); try testing.expectEqual(@as(f64, 20), intraday.open_price); // 5000 / 250 // Noncovered lot: the `nc` suffix does not hide the date. try testing.expect(Date.fromYmd(2015, 7, 7).eql(lots[10].open_date)); try testing.expectEqual(@as(f64, 10), lots[10].open_price); } test "parseLots: an account missing from accounts.srf is UnmappedAccount" { const account_map: analysis.AccountMap = .{ .entries = &.{}, .allocator = testing.allocator }; try testing.expectError(error.UnmappedAccount, parseLots(testing.allocator, &fixture, account_map)); } test "parseLots: every allocation failure is clean" { const S = struct { fn run(allocator: std.mem.Allocator, map: analysis.AccountMap) !void { freeLotsForTest(allocator, try parseLots(allocator, &fixture, map)); } }; var entries = fixtureAccounts(); const map: analysis.AccountMap = .{ .entries = &entries, .allocator = testing.allocator }; try testing.checkAllAllocationFailures(testing.allocator, S.run, .{map}); } test "positionsSheet: finds the sheet by name" { var wb: biff8.Workbook = .{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .sheets = &.{fixture} }; defer wb.deinit(); try testing.expectEqualStrings(sheet_name, (try positionsSheet(&wb)).name); var other: biff8.Workbook = .{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .sheets = &.{.{ .name = "Sheet1", .rows = &.{} }} }; defer other.deinit(); try testing.expectError(error.NotPositionsExport, positionsSheet(&other)); } test "a header missing a needed column is UnexpectedHeader" { const no_trade_date = sheetWith(&.{ &.{ txt("Description"), txt("Symbol"), txt("Account Number"), txt("Market Value"), txt("Shares"), txt("Total Cost") }, }); try testing.expectError(error.UnexpectedHeader, parsePositions(testing.allocator, &no_trade_date)); const cash_no_value = sheetWith(&.{&.{ txt("Description"), txt("Account Number") }}); try testing.expectError(error.UnexpectedHeader, parsePositions(testing.allocator, &cash_no_value)); } test "malformed rows are reported, not skipped" { const bad_account = sheetWith(&.{ &header_row, &.{ txt("APPLE INC"), txt("AAPL"), txt("1234"), txt("Cash"), num(10), num(1), txt("LONG"), num(5), txt("03/15/2021") }, }); try testing.expectError(error.UnexpectedAccountNumber, parsePositions(testing.allocator, &bad_account)); const bad_cash_account = sheetWith(&.{ &.{ txt("Description"), txt("Account Number"), txt("Market Value") }, &.{ txt("Cash Balance"), txt("*12a4"), num(10) }, }); try testing.expectError(error.UnexpectedAccountNumber, parsePositions(testing.allocator, &bad_cash_account)); const no_shares = sheetWith(&.{ &header_row, &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(10), txt("N/A"), txt("LONG"), num(5), txt("03/15/2021") }, }); try testing.expectError(error.MissingValue, parsePositions(testing.allocator, &no_shares)); const no_value = sheetWith(&.{ &.{ txt("Description"), txt("Account Number"), txt("Market Value") }, &.{ txt("Cash Balance"), txt("*1234"), txt("N/A") }, }); try testing.expectError(error.MissingValue, parsePositions(testing.allocator, &no_value)); } test "an unknown trade date fails parseLots but not parsePositions" { // "Multiple" is the kind of thing a different download type might // print in place of per-lot dates. const sheet = sheetWith(&.{ &header_row, &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(10), num(1), txt("LONG"), num(5), txt("Multiple") }, }); const positions = try parsePositions(testing.allocator, &sheet); testing.allocator.free(positions); var entries = fixtureAccounts(); const account_map: analysis.AccountMap = .{ .entries = &entries, .allocator = testing.allocator }; try testing.expectError(error.UnexpectedTradeDate, parseLots(testing.allocator, &sheet, account_map)); } test "an account total that disagrees with its lots is InconsistentExport" { const short = sheetWith(&.{ &header_row, &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(30), num(15), txt("Detail"), num(20), txt("Detail") }, &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(20), num(10), txt("LONG"), num(12), txt("03/15/2021") }, &.{ txt("MICROSOFT CORP"), txt("MSFT"), txt("*1234"), txt("Cash"), num(40), num(1), txt("LONG"), num(30), txt("06/01/2020") }, }); try testing.expectError(error.InconsistentExport, parsePositions(testing.allocator, &short)); // Totals with no lots under them: a Collapsed Detail download. const collapsed = sheetWith(&.{ &header_row, &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(30), num(15), txt("Detail"), num(20), txt("Detail") }, &.{ txt("Total Stocks"), .empty, .empty, .empty, num(30) }, }); try testing.expectError(error.InconsistentExport, parsePositions(testing.allocator, &collapsed)); // An open total is still checked when the sheet just ends. const unterminated = sheetWith(&.{ &header_row, &.{ txt("APPLE INC"), txt("AAPL"), txt("*1234"), txt("Cash"), num(30), num(15), txt("Detail"), num(20), txt("Detail") }, }); try testing.expectError(error.InconsistentExport, parsePositions(testing.allocator, &unterminated)); } test "rows outside any section are ignored" { const sheet = sheetWith(&.{ &.{ txt("Stray"), txt("AAPL"), txt("*1234"), num(1) }, &.{txt("Total Portfolio")}, }); const positions = try parsePositions(testing.allocator, &sheet); defer testing.allocator.free(positions); try testing.expectEqual(@as(usize, 0), positions.len); } test "exportHint: names the right download for each failure" { try testing.expect(std.mem.indexOf(u8, exportHint(error.NotCompoundFile), "Portfolio-Expanded Detail") != null); try testing.expect(std.mem.indexOf(u8, exportHint(error.NotPositionsExport), "Positions") != null); try testing.expect(std.mem.indexOf(u8, exportHint(error.InconsistentExport), "Collapsed") != null); try testing.expect(std.mem.indexOf(u8, exportHint(error.Truncated), "again") != null); try testing.expectEqualStrings("", exportHint(error.MissingValue)); } test "pricedAsOf: reads the banner date" { try testing.expect(Date.fromYmd(2026, 10, 2).eql(pricedAsOf(&fixture).?)); } test "pricedAsOf: null without a readable banner" { const no_banner = sheetWith(&.{&.{txt("Account Number: HOUSEHOLD")}}); try testing.expect(pricedAsOf(&no_banner) == null); const bad_date = sheetWith(&.{&.{txt("Priced as of Close on 13/45/2026")}}); try testing.expect(pricedAsOf(&bad_date) == null); const too_short = sheetWith(&.{&.{txt("Priced as of")}}); try testing.expect(pricedAsOf(&too_short) == null); }