zfin/src/brokerage/wells_fargo.zig

741 lines
32 KiB
Zig

//! 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)
//! <rows>
//! Total ...
//! ETFs, Mutual Funds <- same shape, own headers
//! Total Portfolio
//! <footnotes>
//! ```
//!
//! 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);
}