handle schwab all accounts csv download
This commit is contained in:
parent
858e9874b4
commit
635a0557b4
8 changed files with 518 additions and 134 deletions
|
|
@ -26,16 +26,18 @@ Fargo**.
|
||||||
| Broker | How to export | Flag |
|
| Broker | How to export | Flag |
|
||||||
|--------------------------|-------------------------------------------------------------------------------------------------------------|-----------------------|
|
|--------------------------|-------------------------------------------------------------------------------------------------------------|-----------------------|
|
||||||
| **Fidelity** | *Positions* tab -> the three-dot (**⋮**) menu -> **Download** (a CSV) | `--fidelity <CSV>` |
|
| **Fidelity** | *Positions* tab -> the three-dot (**⋮**) menu -> **Download** (a CSV) | `--fidelity <CSV>` |
|
||||||
| **Schwab** (per-account) | *Accounts -> Positions* -> **Export** (one CSV per account) | `--schwab <CSV>` |
|
| **Schwab** (positions) | *Accounts -> Positions*: pick **All Brokerage Accounts** (or one account) in the dropdown -> **Export** | `--schwab <CSV>` |
|
||||||
| **Schwab** (summary) | *Accounts -> Summary*: select the accounts table and copy it ([what to copy](#schwab-summary-what-to-copy)) | `--schwab-summary` |
|
| **Schwab** (summary) | *Accounts -> Summary*: select the accounts table and copy it ([what to copy](#schwab-summary-what-to-copy)) | `--schwab-summary` |
|
||||||
| **Wells Fargo** | Download Type **Portfolio-Expanded Detail**, Portfolio View **Positions**, all brokerage accounts (an `.xls`) | `--wells-fargo <XLS>` |
|
| **Wells Fargo** | Download Type **Portfolio-Expanded Detail**, Portfolio View **Positions**, all brokerage accounts (an `.xls`) | `--wells-fargo <XLS>` |
|
||||||
|
|
||||||
The two Schwab inputs differ in detail: the **per-account CSV** has full
|
The two Schwab inputs differ in detail: the **positions CSV** has full
|
||||||
per-position data (shares, price, value); the **summary paste** carries
|
per-position data (shares, price, value) for whichever accounts you
|
||||||
only each account's cash and total value, so it reconciles *totals*, not
|
picked -- every one of them with **All Brokerage Accounts**, which is
|
||||||
individual holdings. Use the summary for a quick "are my account totals
|
the easy choice; the **summary paste** carries only each account's cash
|
||||||
right?", the CSV for position-level checks. (Fidelity money-market rows
|
and total value, so it reconciles *totals*, not individual holdings.
|
||||||
and Schwab "Cash & Cash Investments" rows are recognized as cash.)
|
Use the summary for a quick "are my account totals right?", the CSV for
|
||||||
|
position-level checks. (Fidelity money-market rows and Schwab "Cash &
|
||||||
|
Cash Investments" rows are recognized as cash.)
|
||||||
|
|
||||||
The Fidelity **Download** isn't a top-level button -- it's behind the
|
The Fidelity **Download** isn't a top-level button -- it's behind the
|
||||||
three-dot (**⋮**) menu at the top-right of the positions panel:
|
three-dot (**⋮**) menu at the top-right of the positions panel:
|
||||||
|
|
@ -86,7 +88,7 @@ the summary reconciles **account totals**, not individual positions.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ZFIN_HOME=~/finance zfin audit --fidelity ~/Downloads/Portfolio_Positions.csv
|
ZFIN_HOME=~/finance zfin audit --fidelity ~/Downloads/Portfolio_Positions.csv
|
||||||
ZFIN_HOME=~/finance zfin audit --schwab ~/Downloads/Positions-Individual.csv
|
ZFIN_HOME=~/finance zfin audit --schwab ~/Downloads/All-Accounts-Positions.csv
|
||||||
ZFIN_HOME=~/finance zfin audit --schwab-summary # then paste the page, Ctrl-D
|
ZFIN_HOME=~/finance zfin audit --schwab-summary # then paste the page, Ctrl-D
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -282,9 +284,10 @@ portfolio. It does that through
|
||||||
[`accounts.srf`](set-up-accounts.md#2-add-institution-and-account-number-for-auditing):
|
[`accounts.srf`](set-up-accounts.md#2-add-institution-and-account-number-for-auditing):
|
||||||
|
|
||||||
- The export carries an account number -- Schwab's from the "Positions
|
- The export carries an account number -- Schwab's from the "Positions
|
||||||
for account ...1234" title, Fidelity's from the Account Number column,
|
for account ...1234" title (or, in an all-accounts export, the
|
||||||
the summary's from "...ending in 1234", Wells Fargo's from each row's
|
"...1234" line that opens each account's section), Fidelity's from the
|
||||||
`*1234`.
|
Account Number column, the summary's from "...ending in 1234", Wells
|
||||||
|
Fargo's from each row's `*1234`.
|
||||||
- zfin finds the `accounts.srf` entry whose `institution::` (`fidelity`,
|
- zfin finds the `accounts.srf` entry whose `institution::` (`fidelity`,
|
||||||
`schwab`, `wells_fargo`) **and** `account_number::` match, and
|
`schwab`, `wells_fargo`) **and** `account_number::` match, and
|
||||||
compares against that account's lots.
|
compares against that account's lots.
|
||||||
|
|
@ -370,11 +373,13 @@ so auditing against the export you just imported shows no drift.
|
||||||
|
|
||||||
- The parsers are **broker-specific**. The CSV parsers hardcode each
|
- The parsers are **broker-specific**. The CSV parsers hardcode each
|
||||||
export's column layout -- if Fidelity or Schwab changes their format,
|
export's column layout -- if Fidelity or Schwab changes their format,
|
||||||
parsing can break (both validate their header to catch this). They are
|
parsing can break (both validate their header to catch this, and the
|
||||||
not full RFC-4180 CSV parsers (no escaped quotes or multi-line fields)
|
Schwab parser checks each account's "Positions Total" against the rows
|
||||||
-- fine for the real exports, not for arbitrary CSVs. The Wells Fargo
|
above it). They are not full RFC-4180 CSV parsers (no escaped quotes or
|
||||||
parser finds columns by header name and checks every per-account total
|
multi-line fields) -- fine for the real exports, not for arbitrary
|
||||||
against the lots under it, so a layout change fails loudly.
|
CSVs. The Wells Fargo parser finds columns by header name and checks
|
||||||
|
every per-account total against the lots under it, so a layout change
|
||||||
|
fails loudly.
|
||||||
- Matching is only as good as the `institution::` / `account_number::`
|
- Matching is only as good as the `institution::` / `account_number::`
|
||||||
entries you keep in `accounts.srf`.
|
entries you keep in `accounts.srf`.
|
||||||
- Options, CDs, and cash are reconciled separately from share counts.
|
- Options, CDs, and cash are reconciled separately from share counts.
|
||||||
|
|
@ -385,11 +390,11 @@ manual nudge.
|
||||||
|
|
||||||
### Wells Fargo: one file, every account, real lots
|
### Wells Fargo: one file, every account, real lots
|
||||||
|
|
||||||
The Wells Fargo spreadsheet differs from the CSV exports in two ways
|
Two things about the Wells Fargo spreadsheet are worth knowing:
|
||||||
worth knowing:
|
|
||||||
|
|
||||||
- **It covers the whole household.** Every brokerage account is in one
|
- **It covers the whole household.** Like Fidelity's download and
|
||||||
file, each row tagged with its account as `*1234`.
|
Schwab's **All Brokerage Accounts** export, every brokerage account is
|
||||||
|
in one file, each row tagged with its account as `*1234`.
|
||||||
- **It lists tax lots, not positions.** Audit sums them per account and
|
- **It lists tax lots, not positions.** Audit sums them per account and
|
||||||
symbol before comparing, so it doesn't matter whether your portfolio
|
symbol before comparing, so it doesn't matter whether your portfolio
|
||||||
holds the position as one lot or many. It also means
|
holds the position as one lot or many. It also means
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ discrepancies.
|
||||||
| `--verbose` | Show full reconciliation output even when clean. |
|
| `--verbose` | Show full reconciliation output even when clean. |
|
||||||
| `--stale-days <N>` | Manual-price staleness threshold (default 3). |
|
| `--stale-days <N>` | Manual-price staleness threshold (default 3). |
|
||||||
| `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). |
|
| `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). |
|
||||||
| `--schwab <CSV>` | Schwab per-account positions CSV. |
|
| `--schwab <CSV>` | Schwab positions CSV: one account, or "All Brokerage Accounts". |
|
||||||
| `--schwab-summary` | Schwab account summary: paste from the summary page to stdin, then Ctrl-D. |
|
| `--schwab-summary` | Schwab account summary: paste from the summary page to stdin, then Ctrl-D. |
|
||||||
| `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (Download Type "Portfolio-Expanded Detail", "Positions"). |
|
| `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (Download Type "Portfolio-Expanded Detail", "Positions"). |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,16 @@ dropped.
|
||||||
|--------------------------|-----------------------------------------------------------------|
|
|--------------------------|-----------------------------------------------------------------|
|
||||||
| `-p, --portfolio <FILE>` | Target file (a single concrete path, not a glob). **Required.** |
|
| `-p, --portfolio <FILE>` | Target file (a single concrete path, not a glob). **Required.** |
|
||||||
| `--fidelity <CSV>` | Fidelity positions CSV. |
|
| `--fidelity <CSV>` | Fidelity positions CSV. |
|
||||||
| `--schwab <CSV>` | Schwab per-account positions CSV. |
|
| `--schwab <CSV>` | Schwab positions CSV (one account or All Brokerage Accounts). |
|
||||||
| `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (`-` for stdin). See below. |
|
| `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (`-` for stdin). See below. |
|
||||||
| `-y, --yes` | Don't prompt before overwriting an existing file. |
|
| `-y, --yes` | Don't prompt before overwriting an existing file. |
|
||||||
|
|
||||||
Account resolution needs an [`accounts.srf`](../config/accounts-srf.md)
|
Account resolution needs an [`accounts.srf`](../config/accounts-srf.md)
|
||||||
next to the target with `institution::` + `account_number::` entries
|
next to the target with `institution::` + `account_number::` entries
|
||||||
matching the export; import refuses to write when an export account is
|
matching the export; import refuses to write when an export account is
|
||||||
unmapped.
|
unmapped. A Schwab **All Brokerage Accounts** export brings every
|
||||||
|
account into the target, so to import just one account, export that
|
||||||
|
account alone.
|
||||||
|
|
||||||
A target that doesn't exist yet is created in the current directory, so
|
A target that doesn't exist yet is created in the current directory, so
|
||||||
run a first import from the portfolio's own directory.
|
run a first import from the portfolio's own directory.
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
//! Schwab has two export shapes, so this module carries more than
|
//! Schwab has two export shapes, so this module carries more than
|
||||||
//! the Fidelity one:
|
//! the Fidelity one:
|
||||||
//!
|
//!
|
||||||
//! 1. **Per-account positions CSV** (`--schwab`) - same per-account
|
//! 1. **Positions CSV** (`--schwab`) - one account or all of them,
|
||||||
//! positions shape Fidelity uses, so it feeds the shared
|
//! the same per-position shape Fidelity uses, so it feeds the
|
||||||
//! `common.compareAccounts` engine via `reconcileCsv`.
|
//! shared `common.compareAccounts` engine via `reconcileCsv`.
|
||||||
//! 2. **Account summary paste** (`--schwab-summary`) - a
|
//! 2. **Account summary paste** (`--schwab-summary`) - a
|
||||||
//! per-account totals-only view with no per-symbol detail. This
|
//! per-account totals-only view with no per-symbol detail. This
|
||||||
//! is the one genuinely broker-specific reconciler, with its own
|
//! is the one genuinely broker-specific reconciler, with its own
|
||||||
|
|
@ -44,13 +44,14 @@ pub const SchwabAccountComparison = struct {
|
||||||
has_discrepancy: bool,
|
has_discrepancy: bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Per-account positions CSV (--schwab) ─────────────────────
|
// ── Positions CSV (--schwab) ─────────────────────────────────
|
||||||
|
|
||||||
/// Parse a Schwab per-account positions CSV and reconcile it against
|
/// Parse a Schwab positions CSV - either layout, one account or all
|
||||||
/// the portfolio via the shared engine. Returns owned
|
/// of them - and reconcile it against the portfolio via the shared
|
||||||
/// `AccountComparison` results (free each `.comparisons` slice, then
|
/// engine. Returns owned `AccountComparison` results, one per account
|
||||||
/// the results slice). String fields borrow from `csv_data`, which
|
/// in the export (free each `.comparisons` slice, then the results
|
||||||
/// must outlive them. Propagates parser and allocation errors.
|
/// slice). String fields borrow from `csv_data`, which must outlive
|
||||||
|
/// them. Propagates parser and allocation errors.
|
||||||
pub fn reconcileCsv(
|
pub fn reconcileCsv(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
portfolio: zfin.Portfolio,
|
portfolio: zfin.Portfolio,
|
||||||
|
|
@ -59,11 +60,11 @@ pub fn reconcileCsv(
|
||||||
prices: std.StringHashMap(f64),
|
prices: std.StringHashMap(f64),
|
||||||
as_of: Date,
|
as_of: Date,
|
||||||
) ![]common.AccountComparison {
|
) ![]common.AccountComparison {
|
||||||
const parsed = try schwab_parser.parseCsv(allocator, csv_data);
|
const positions = try schwab_parser.parseCsv(allocator, csv_data);
|
||||||
// Result strings borrow from `csv_data`, not the positions slice,
|
// Result strings borrow from `csv_data`, not the positions slice,
|
||||||
// so freeing the slice array here is safe.
|
// so freeing the slice array here is safe.
|
||||||
defer allocator.free(parsed.positions);
|
defer allocator.free(positions);
|
||||||
return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of);
|
return common.compareAccounts(allocator, portfolio, positions, account_map, "schwab", prices, as_of);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Account summary paste (--schwab-summary) ─────────────────
|
// ── Account summary paste (--schwab-summary) ─────────────────
|
||||||
|
|
@ -562,6 +563,72 @@ test "reconcileCsv: parses a Schwab positions CSV and reconciles it" {
|
||||||
try std.testing.expect(found_amzn);
|
try std.testing.expect(found_amzn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "reconcileCsv: an all-accounts CSV reconciles each section as its own account" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const header = "\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n";
|
||||||
|
const csv =
|
||||||
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_IRA ...1234\"\n" ++
|
||||||
|
header ++
|
||||||
|
"\"VTI\",\"VANGUARD TOTAL STOCK MARKET ETF\",\"1.10\",\"0.37%\",\"300.00\",\"100\",\"$110.00\",\"0.37%\",\"$30,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$110.00\",\"0.37%\",\"$30,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
|
||||||
|
"\n\n" ++
|
||||||
|
"\"Sample_Brokerage ...5678\"\n" ++
|
||||||
|
header ++
|
||||||
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"100%\",\"Equity\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
|
||||||
|
"\n\n" ++
|
||||||
|
"\"Sample_Account ...9012\"\n" ++
|
||||||
|
header ++
|
||||||
|
"\"SCHD\",\"SCHWAB US DIVIDEND EQUITY ETF\",\"0.10\",\"0.36%\",\"27.50\",\"200\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"$5,000.00\",\"$500.00\",\"10%\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"$5,000.00\",\"$500.00\",\"10%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
||||||
|
|
||||||
|
var lots = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample IRA" },
|
||||||
|
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
|
||||||
|
// 9012 is deliberately absent: an account in the export that this
|
||||||
|
// portfolio does not track.
|
||||||
|
var entries = [_]analysis.AccountTaxEntry{
|
||||||
|
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" },
|
||||||
|
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "5678" },
|
||||||
|
};
|
||||||
|
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||||
|
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
try prices.put("VTI", 300);
|
||||||
|
try prices.put("AAPL", 200);
|
||||||
|
|
||||||
|
const results = try reconcileCsv(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 10, 6));
|
||||||
|
defer {
|
||||||
|
for (results) |r| allocator.free(r.comparisons);
|
||||||
|
allocator.free(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), results.len);
|
||||||
|
var seen: usize = 0;
|
||||||
|
for (results) |r| {
|
||||||
|
if (std.mem.eql(u8, r.account_number, "1234")) {
|
||||||
|
try std.testing.expectEqualStrings("Sample IRA", r.account_name);
|
||||||
|
try std.testing.expect(!r.has_discrepancies);
|
||||||
|
} else if (std.mem.eql(u8, r.account_number, "5678")) {
|
||||||
|
try std.testing.expectEqualStrings("Sample Brokerage", r.account_name);
|
||||||
|
try std.testing.expect(!r.has_discrepancies);
|
||||||
|
} else {
|
||||||
|
try std.testing.expectEqualStrings("9012", r.account_number);
|
||||||
|
// Unmapped: no portfolio account, broker-side name kept.
|
||||||
|
try std.testing.expectEqualStrings("", r.account_name);
|
||||||
|
try std.testing.expectEqualStrings("Sample_Account", r.brokerage_name);
|
||||||
|
}
|
||||||
|
seen += 1;
|
||||||
|
}
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), seen);
|
||||||
|
}
|
||||||
|
|
||||||
test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" {
|
test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const data =
|
const data =
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,10 @@ pub fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind {
|
||||||
if (std.mem.indexOf(u8, content, "Fidelity Brokerage Services LLC") != null)
|
if (std.mem.indexOf(u8, content, "Fidelity Brokerage Services LLC") != null)
|
||||||
return .fidelity_csv;
|
return .fidelity_csv;
|
||||||
|
|
||||||
// Schwab per-account CSV: starts with a quoted title line like "Positions for ..."
|
// Schwab positions CSV: starts with a quoted title line - "Positions
|
||||||
|
// for account ..." (one account) or "Positions for All-Accounts ..."
|
||||||
|
// (every account). Both are `schwab_csv`; `schwab.parseCsv` reads
|
||||||
|
// either layout.
|
||||||
if (std.mem.startsWith(u8, content, "\"Positions for")) return .schwab_csv;
|
if (std.mem.startsWith(u8, content, "\"Positions for")) return .schwab_csv;
|
||||||
|
|
||||||
// Schwab summary: the "Account number ending in" anchor is exactly what
|
// Schwab summary: the "Account number ending in" anchor is exactly what
|
||||||
|
|
@ -211,6 +214,10 @@ test "detectBrokerFileKind: schwab csv with Positions header" {
|
||||||
const data = "\"Positions for account Brokerage ...1234 as of 11:31 AM ET, 2026/04/25\"\n\nSymbol,Description,Quantity";
|
const data = "\"Positions for account Brokerage ...1234 as of 11:31 AM ET, 2026/04/25\"\n\nSymbol,Description,Quantity";
|
||||||
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?);
|
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?);
|
||||||
}
|
}
|
||||||
|
test "detectBrokerFileKind: schwab all-accounts csv is a schwab csv" {
|
||||||
|
const data = "\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n\"Sample_IRA ...1234\"\n\"Symbol\",\"Description\"";
|
||||||
|
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?);
|
||||||
|
}
|
||||||
test "detectBrokerFileKind: summary-shaped text without the anchor is not detected" {
|
test "detectBrokerFileKind: summary-shaped text without the anchor is not detected" {
|
||||||
// Regression guard against re-introducing the old "account-type label +
|
// Regression guard against re-introducing the old "account-type label +
|
||||||
// $" fallback. This blob looks summary-ish (account-type words, dollar
|
// $" fallback. This blob looks summary-ish (account-type words, dollar
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,47 @@
|
||||||
//!
|
//!
|
||||||
//! Parses two distinct Schwab inputs:
|
//! Parses two distinct Schwab inputs:
|
||||||
//!
|
//!
|
||||||
//! 1. The per-account positions CSV exported from Schwab's website
|
//! 1. The positions CSV exported from Schwab's website (Accounts ->
|
||||||
//! (Accounts -> Positions -> Export). One file per account.
|
//! Positions -> Export). Comes in two layouts, both read by
|
||||||
|
//! `parseCsv`, depending on the account dropdown:
|
||||||
|
//! - one account: a single section, account named in the title;
|
||||||
|
//! - "All Brokerage Accounts": one section per account.
|
||||||
//!
|
//!
|
||||||
//! 2. The freeform account-summary text the user pastes from
|
//! 2. The freeform account-summary text the user pastes from
|
||||||
//! Schwab's Accounts overview page. One paste covers all
|
//! Schwab's Accounts overview page. One paste covers all
|
||||||
//! accounts at once but only carries cash + total-value
|
//! accounts at once but only carries cash + total-value
|
||||||
//! aggregates, no per-position detail.
|
//! aggregates, no per-position detail.
|
||||||
//!
|
//!
|
||||||
|
//! ## Schwab CSV - layout
|
||||||
|
//!
|
||||||
|
//! Single account:
|
||||||
|
//!
|
||||||
|
//! "Positions for account <NAME> ...<NUM> as of <TIME>, <YYYY/MM/DD>"
|
||||||
|
//! <blank>
|
||||||
|
//! "Symbol","Description",...,"Asset Type",
|
||||||
|
//! <position rows>
|
||||||
|
//! "Cash & Cash Investments",...
|
||||||
|
//! "Positions Total",...
|
||||||
|
//!
|
||||||
|
//! All accounts - the same section repeated, each opened by an account
|
||||||
|
//! line, sections separated by blank lines:
|
||||||
|
//!
|
||||||
|
//! "Positions for All-Accounts as of <TIME>, <MM/DD/YYYY>"
|
||||||
|
//! <blank>
|
||||||
|
//! "<NAME> ...<NUM>"
|
||||||
|
//! "Symbol","Description",...,"Asset Type",
|
||||||
|
//! <position rows>
|
||||||
|
//! "Cash & Cash Investments",...
|
||||||
|
//! "Positions Total",...
|
||||||
|
//! <blank>
|
||||||
|
//! <blank>
|
||||||
|
//! "<NAME> ...<NUM>"
|
||||||
|
//! ...
|
||||||
|
//!
|
||||||
|
//! Account names in the all-accounts layout carry underscores where the
|
||||||
|
//! single-account title has spaces. Names are display-only - matching
|
||||||
|
//! against `accounts.srf` is by number - so they are passed through as-is.
|
||||||
|
//!
|
||||||
//! ## Schwab CSV - limitations
|
//! ## Schwab CSV - limitations
|
||||||
//!
|
//!
|
||||||
//! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
|
//! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
|
||||||
|
|
@ -19,17 +52,21 @@
|
||||||
//! quotes ("value with ""quotes"" inside") or multi-line quoted fields.
|
//! quotes ("value with ""quotes"" inside") or multi-line quoted fields.
|
||||||
//! Schwab's export does not use these in practice.
|
//! Schwab's export does not use these in practice.
|
||||||
//!
|
//!
|
||||||
//! 3. The account number and name are extracted from the title line:
|
//! 3. Every position row is stamped with the account of the section it
|
||||||
//! "Positions for account <NAME> ...<NUM> as of ..."
|
//! sits in. The grouping by account happens downstream, in
|
||||||
|
//! `reconcile/common.compareAccounts`.
|
||||||
//!
|
//!
|
||||||
//! 4. Rows with symbol "Cash & Cash Investments" are treated as cash.
|
//! 4. Rows with symbol "Cash & Cash Investments" are treated as cash.
|
||||||
//! The row with symbol "Positions Total" is skipped.
|
//! The row with symbol "Positions Total" is not a position: it is
|
||||||
|
//! checked against the market values summed above it (see
|
||||||
|
//! `checkTotal`), so a mis-split row fails loudly as
|
||||||
|
//! `error.InconsistentExport`.
|
||||||
//!
|
//!
|
||||||
//! 5. Hardcodes the expected column layout via fixed indices. The header
|
//! 5. Hardcodes the expected column layout via fixed indices. Every
|
||||||
//! row is validated (see `validateHeader`) to confirm the columns the
|
//! section's header row is validated (see `validateHeader`) to confirm
|
||||||
//! parser reads are where it expects them, so a Schwab reorder/rename of
|
//! the columns the parser reads are where it expects them, so a Schwab
|
||||||
//! a read-column fails loudly as `error.UnexpectedHeader` instead of
|
//! reorder/rename of a read-column fails loudly as
|
||||||
//! silently mis-reading a value.
|
//! `error.UnexpectedHeader` instead of silently mis-reading a value.
|
||||||
//!
|
//!
|
||||||
//! ## Schwab summary - limitations
|
//! ## Schwab summary - limitations
|
||||||
//!
|
//!
|
||||||
|
|
@ -60,12 +97,16 @@
|
||||||
//! account number line (the last whitespace-separated token).
|
//! account number line (the last whitespace-separated token).
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const builtin = @import("builtin");
|
||||||
|
const Money = @import("../Money.zig");
|
||||||
const portfolio_mod = @import("../models/portfolio.zig");
|
const portfolio_mod = @import("../models/portfolio.zig");
|
||||||
const types = @import("types.zig");
|
const types = @import("types.zig");
|
||||||
|
|
||||||
const BrokeragePosition = types.BrokeragePosition;
|
const BrokeragePosition = types.BrokeragePosition;
|
||||||
const parseDollarAmount = types.parseDollarAmount;
|
const parseDollarAmount = types.parseDollarAmount;
|
||||||
|
|
||||||
|
const log = std.log.scoped(.schwab);
|
||||||
|
|
||||||
const expected_columns = 17;
|
const expected_columns = 17;
|
||||||
|
|
||||||
const Col = struct {
|
const Col = struct {
|
||||||
|
|
@ -149,66 +190,141 @@ fn splitCsvLine(line: []const u8, cols: *[expected_columns][]const u8) usize {
|
||||||
return col_count;
|
return col_count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract account name and number from Schwab title line.
|
/// The account a run of position rows belongs to.
|
||||||
/// Format: "Positions for account <NAME> ...<NUM> as of <TIME>, <DATE>"
|
const Account = struct { name: []const u8, number: []const u8 };
|
||||||
/// Returns {name, number} or null if the line doesn't match.
|
|
||||||
fn parseTitle(line: []const u8) ?struct { name: []const u8, number: []const u8 } {
|
|
||||||
const stripped = std.mem.trim(u8, line, &.{ '"', ' ', '\r' });
|
|
||||||
const prefix = "Positions for account ";
|
|
||||||
if (!std.mem.startsWith(u8, stripped, prefix)) return null;
|
|
||||||
const rest = stripped[prefix.len..];
|
|
||||||
|
|
||||||
// Find "..." which separates name from account number
|
|
||||||
const dots_idx = std.mem.indexOf(u8, rest, "...") orelse return null;
|
|
||||||
const name = std.mem.trimEnd(u8, rest[0..dots_idx], &.{' '});
|
|
||||||
|
|
||||||
// Account number: after "..." until " as of" or end
|
|
||||||
const after_dots = rest[dots_idx + 3 ..];
|
|
||||||
const as_of_idx = std.mem.indexOf(u8, after_dots, " as of") orelse after_dots.len;
|
|
||||||
const number = std.mem.trim(u8, after_dots[0..as_of_idx], &.{' '});
|
|
||||||
|
|
||||||
|
/// Split Schwab's "<NAME> ...<NUM>" account label. The number is what
|
||||||
|
/// follows the last "..." (numbers never contain dots, names might); the
|
||||||
|
/// name is everything before it. Null unless both halves are non-empty
|
||||||
|
/// and the number is a single token.
|
||||||
|
fn parseAccountLabel(text: []const u8) ?Account {
|
||||||
|
const stripped = std.mem.trim(u8, text, " ");
|
||||||
|
const dots_idx = std.mem.lastIndexOf(u8, stripped, "...") orelse return null;
|
||||||
|
const name = std.mem.trimEnd(u8, stripped[0..dots_idx], " ");
|
||||||
|
const number = stripped[dots_idx + 3 ..];
|
||||||
|
if (name.len == 0 or number.len == 0) return null;
|
||||||
|
if (std.mem.indexOfAny(u8, number, " ,\t") != null) return null;
|
||||||
return .{ .name = name, .number = number };
|
return .{ .name = name, .number = number };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed Schwab per-account positions CSV. Returned `positions`
|
/// What the title line says about where account identity comes from.
|
||||||
/// slice is heap-allocated; string fields slice into `data`.
|
const Title = union(enum) {
|
||||||
pub const CsvResult = struct {
|
/// "Positions for account <NAME> ...<NUM> as of ..." - one account,
|
||||||
positions: []BrokeragePosition,
|
/// named here.
|
||||||
account_name: []const u8,
|
account: Account,
|
||||||
account_number: []const u8,
|
/// "Positions for All-Accounts as of ..." - each section names its
|
||||||
|
/// own account on a "<NAME> ...<NUM>" line.
|
||||||
|
all_accounts,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) !CsvResult {
|
/// Classify a Schwab positions title line. Null if it is neither layout.
|
||||||
|
fn parseTitle(line: []const u8) ?Title {
|
||||||
|
const stripped = std.mem.trim(u8, line, &.{ '"', ' ', '\r' });
|
||||||
|
if (std.mem.startsWith(u8, stripped, "Positions for All-Accounts")) return .all_accounts;
|
||||||
|
|
||||||
|
const prefix = "Positions for account ";
|
||||||
|
if (!std.mem.startsWith(u8, stripped, prefix)) return null;
|
||||||
|
const rest = stripped[prefix.len..];
|
||||||
|
const label_end = std.mem.indexOf(u8, rest, " as of") orelse rest.len;
|
||||||
|
return .{ .account = parseAccountLabel(rest[0..label_end]) orelse return null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Running tally of one section's position rows, checked against the
|
||||||
|
/// section's "Positions Total" row.
|
||||||
|
const Section = struct {
|
||||||
|
market_value: f64 = 0,
|
||||||
|
rows: usize = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Check a section's "Positions Total" market value against the rows
|
||||||
|
/// summed above it. Each row is rounded to the cent, so the sum may drift
|
||||||
|
/// from Schwab's total by at most half a cent per row; anything more
|
||||||
|
/// means rows were mis-split or mis-attributed. A total that does not
|
||||||
|
/// parse is not checked.
|
||||||
|
fn checkTotal(account: Account, section: Section, total_raw: []const u8) error{InconsistentExport}!void {
|
||||||
|
const total = parseDollarAmount(total_raw) orelse return;
|
||||||
|
const tolerance = 0.005 * @as(f64, @floatFromInt(section.rows)) + 1e-6;
|
||||||
|
if (@abs(total - section.market_value) <= tolerance) return;
|
||||||
|
if (!builtin.is_test)
|
||||||
|
log.warn("account ...{s}: Positions Total is {f} but its rows sum to {f}", .{ account.number, Money.from(total), Money.from(section.market_value) });
|
||||||
|
return error.InconsistentExport;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ways a Schwab positions CSV can fail to parse.
|
||||||
|
pub const CsvError = error{
|
||||||
|
EmptyFile,
|
||||||
|
/// The title is neither positions layout, or a section's header row
|
||||||
|
/// is missing, short, or has a read-column out of place.
|
||||||
|
UnexpectedHeader,
|
||||||
|
/// An all-accounts export has a row before any "<NAME> ...<NUM>"
|
||||||
|
/// account line, so there is no account to attribute it to.
|
||||||
|
MissingAccountLine,
|
||||||
|
/// An all-accounts export with no account sections at all.
|
||||||
|
NoAccountsFound,
|
||||||
|
/// A "Positions Total" row disagrees with the rows above it.
|
||||||
|
InconsistentExport,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parse a Schwab positions CSV in either layout (see the module doc).
|
||||||
|
/// Every position carries its own account, so callers never need to know
|
||||||
|
/// which layout it was. The returned slice is heap-allocated; string
|
||||||
|
/// fields slice into `data`.
|
||||||
|
pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) (CsvError || std.mem.Allocator.Error)![]BrokeragePosition {
|
||||||
var positions = std.ArrayList(BrokeragePosition).empty;
|
var positions = std.ArrayList(BrokeragePosition).empty;
|
||||||
errdefer positions.deinit(allocator);
|
errdefer positions.deinit(allocator);
|
||||||
|
|
||||||
|
if (std.mem.trim(u8, data, " \r\n").len == 0) return error.EmptyFile;
|
||||||
var lines = std.mem.splitScalar(u8, data, '\n');
|
var lines = std.mem.splitScalar(u8, data, '\n');
|
||||||
|
|
||||||
// Line 1: title with account name and number
|
const title = parseTitle(lines.first()) orelse return error.UnexpectedHeader;
|
||||||
const title_line = lines.next() orelse return error.EmptyFile;
|
|
||||||
const title = parseTitle(title_line) orelse return error.UnexpectedHeader;
|
|
||||||
|
|
||||||
// Line 2: blank (skip)
|
// The account the rows being read belong to: named by the title for
|
||||||
_ = lines.next();
|
// a single-account export, by each section's account line otherwise.
|
||||||
// Line 3: header row - validate the columns we read are where we expect,
|
var account: ?Account = switch (title) {
|
||||||
// so a Schwab format change surfaces as UnexpectedHeader (and, in the
|
.account => |a| a,
|
||||||
// flagless audit, a "could not parse" warning) rather than silently
|
.all_accounts => null,
|
||||||
// reading a shifted column.
|
};
|
||||||
const header_line = lines.next() orelse return error.UnexpectedHeader;
|
// Every section opens with a header row, validated before any of its
|
||||||
try validateHeader(std.mem.trimEnd(u8, header_line, &.{ '\r', ' ' }));
|
// rows are read so a Schwab format change surfaces as
|
||||||
|
// UnexpectedHeader (and, in the flagless audit, a "could not parse"
|
||||||
|
// warning) rather than silently reading a shifted column.
|
||||||
|
var expect_header = account != null;
|
||||||
|
var section: Section = .{};
|
||||||
|
var section_count: usize = 0;
|
||||||
|
|
||||||
// Data rows
|
|
||||||
while (lines.next()) |line| {
|
while (lines.next()) |line| {
|
||||||
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
|
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
|
||||||
if (trimmed.len == 0) continue;
|
if (trimmed.len == 0) continue;
|
||||||
|
|
||||||
var cols: [expected_columns][]const u8 = undefined;
|
var cols: [expected_columns][]const u8 = undefined;
|
||||||
const col_count = splitCsvLine(trimmed, &cols);
|
const col_count = splitCsvLine(trimmed, &cols);
|
||||||
|
|
||||||
|
if (title == .all_accounts and col_count == 1) {
|
||||||
|
if (parseAccountLabel(cols[0])) |a| {
|
||||||
|
account = a;
|
||||||
|
expect_header = true;
|
||||||
|
section = .{};
|
||||||
|
section_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const current = account orelse return error.MissingAccountLine;
|
||||||
|
|
||||||
|
if (expect_header) {
|
||||||
|
try validateHeader(trimmed);
|
||||||
|
expect_header = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (col_count < expected_columns) continue;
|
if (col_count < expected_columns) continue;
|
||||||
|
|
||||||
const symbol = cols[Col.symbol];
|
const symbol = cols[Col.symbol];
|
||||||
if (symbol.len == 0) continue;
|
if (symbol.len == 0) continue;
|
||||||
if (std.mem.eql(u8, symbol, "Positions Total")) continue;
|
if (std.mem.eql(u8, symbol, "Positions Total")) {
|
||||||
|
try checkTotal(current, section, cols[Col.market_value]);
|
||||||
|
section = .{};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// "Cash & Cash Investments" is Schwab's aggregate cash line.
|
// "Cash & Cash Investments" is Schwab's aggregate cash line.
|
||||||
// Actual money-market holdings (SWVXX, etc.) appear as normal rows
|
// Actual money-market holdings (SWVXX, etc.) appear as normal rows
|
||||||
|
|
@ -217,24 +333,25 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) !CsvResult {
|
||||||
// "cash" in the account.
|
// "cash" in the account.
|
||||||
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
|
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
|
||||||
portfolio_mod.isMoneyMarketSymbol(symbol);
|
portfolio_mod.isMoneyMarketSymbol(symbol);
|
||||||
|
const market_value = parseDollarAmount(cols[Col.market_value]);
|
||||||
|
|
||||||
try positions.append(allocator, .{
|
try positions.append(allocator, .{
|
||||||
.account_number = title.number,
|
.account_number = current.number,
|
||||||
.account_name = title.name,
|
.account_name = current.name,
|
||||||
.symbol = symbol,
|
.symbol = symbol,
|
||||||
.description = if (col_count > 1) cols[1] else "",
|
.description = cols[1],
|
||||||
.quantity = if (is_cash) null else parseDollarAmount(cols[Col.quantity]),
|
.quantity = if (is_cash) null else parseDollarAmount(cols[Col.quantity]),
|
||||||
.current_value = parseDollarAmount(cols[Col.market_value]),
|
.current_value = market_value,
|
||||||
.cost_basis = if (is_cash) null else parseDollarAmount(cols[Col.cost_basis]),
|
.cost_basis = if (is_cash) null else parseDollarAmount(cols[Col.cost_basis]),
|
||||||
.is_cash = is_cash,
|
.is_cash = is_cash,
|
||||||
});
|
});
|
||||||
|
section.market_value += market_value orelse 0;
|
||||||
|
section.rows += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return .{
|
if (title == .all_accounts and section_count == 0) return error.NoAccountsFound;
|
||||||
.positions = try positions.toOwnedSlice(allocator),
|
|
||||||
.account_name = title.name,
|
return positions.toOwnedSlice(allocator);
|
||||||
.account_number = title.number,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Account-level summary from a Schwab paste (no per-position detail).
|
/// Account-level summary from a Schwab paste (no per-position detail).
|
||||||
|
|
@ -336,17 +453,41 @@ pub fn parseSummary(allocator: std.mem.Allocator, data: []const u8) ![]AccountSu
|
||||||
// ── Tests ────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
test "parseTitle" {
|
test "parseTitle" {
|
||||||
const t1 = parseTitle("\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"");
|
const t1 = parseTitle("\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"").?;
|
||||||
try std.testing.expect(t1 != null);
|
try std.testing.expectEqualStrings("Sample Trust", t1.account.name);
|
||||||
try std.testing.expectEqualStrings("Sample Trust", t1.?.name);
|
try std.testing.expectEqualStrings("1234", t1.account.number);
|
||||||
try std.testing.expectEqualStrings("1234", t1.?.number);
|
|
||||||
|
|
||||||
const t2 = parseTitle("\"Positions for account Sample IRA ...5678 as of 3:00 PM ET, 2026/04/10\"");
|
const t2 = parseTitle("\"Positions for account Sample IRA ...5678 as of 3:00 PM ET, 2026/04/10\"").?;
|
||||||
try std.testing.expect(t2 != null);
|
try std.testing.expectEqualStrings("Sample IRA", t2.account.name);
|
||||||
try std.testing.expectEqualStrings("Sample IRA", t2.?.name);
|
try std.testing.expectEqualStrings("5678", t2.account.number);
|
||||||
try std.testing.expectEqualStrings("5678", t2.?.number);
|
|
||||||
|
// The all-accounts title names no account; its date is MM/DD/YYYY.
|
||||||
|
try std.testing.expect(parseTitle("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"").? == .all_accounts);
|
||||||
|
|
||||||
try std.testing.expect(parseTitle("some random text") == null);
|
try std.testing.expect(parseTitle("some random text") == null);
|
||||||
|
// Single-account title without the "...NUM" label.
|
||||||
|
try std.testing.expect(parseTitle("\"Positions for account Sample IRA as of 3:00 PM ET, 2026/04/10\"") == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseAccountLabel" {
|
||||||
|
const a = parseAccountLabel("Sample_IRA ...1234").?;
|
||||||
|
try std.testing.expectEqualStrings("Sample_IRA", a.name);
|
||||||
|
try std.testing.expectEqualStrings("1234", a.number);
|
||||||
|
|
||||||
|
// Spaces in names are kept; surrounding space is trimmed.
|
||||||
|
const b = parseAccountLabel(" Sample Brokerage ...Z123 ").?;
|
||||||
|
try std.testing.expectEqualStrings("Sample Brokerage", b.name);
|
||||||
|
try std.testing.expectEqualStrings("Z123", b.number);
|
||||||
|
|
||||||
|
// The number follows the LAST "...", so dots in a name survive.
|
||||||
|
const c = parseAccountLabel("Sample... Trust ...5678").?;
|
||||||
|
try std.testing.expectEqualStrings("Sample... Trust", c.name);
|
||||||
|
try std.testing.expectEqualStrings("5678", c.number);
|
||||||
|
|
||||||
|
try std.testing.expect(parseAccountLabel("Sample IRA") == null); // no dots
|
||||||
|
try std.testing.expect(parseAccountLabel("...1234") == null); // no name
|
||||||
|
try std.testing.expect(parseAccountLabel("Sample IRA ...") == null); // no number
|
||||||
|
try std.testing.expect(parseAccountLabel("Sample IRA ...12 34") == null); // not one token
|
||||||
}
|
}
|
||||||
|
|
||||||
test "splitCsvLine" {
|
test "splitCsvLine" {
|
||||||
|
|
@ -368,29 +509,32 @@ test "parseCsv basic" {
|
||||||
"\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
|
"\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
|
||||||
"\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n" ++
|
"\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n" ++
|
||||||
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$8,271.12\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0.97%\",\"Cash and Money Market\",\n" ++
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$8,271.12\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0.97%\",\"Cash and Money Market\",\n" ++
|
||||||
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$856,805.99\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$364,212.62\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
||||||
|
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const parsed = try parseCsv(allocator, csv);
|
const positions = try parseCsv(allocator, csv);
|
||||||
defer allocator.free(parsed.positions);
|
defer allocator.free(positions);
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("Sample Trust", parsed.account_name);
|
try std.testing.expectEqual(@as(usize, 2), positions.len);
|
||||||
try std.testing.expectEqualStrings("1234", parsed.account_number);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 2), parsed.positions.len);
|
// Every row carries the title's account.
|
||||||
|
for (positions) |p| {
|
||||||
|
try std.testing.expectEqualStrings("Sample Trust", p.account_name);
|
||||||
|
try std.testing.expectEqualStrings("1234", p.account_number);
|
||||||
|
}
|
||||||
|
|
||||||
// Stock position
|
// Stock position
|
||||||
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
|
||||||
try std.testing.expect(!parsed.positions[0].is_cash);
|
try std.testing.expect(!positions[0].is_cash);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 1488), parsed.positions[0].quantity.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 1488), positions[0].quantity.?, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 355941.50), parsed.positions[0].current_value.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 355941.50), positions[0].current_value.?, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), parsed.positions[0].cost_basis.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), positions[0].cost_basis.?, 0.01);
|
||||||
|
|
||||||
// Cash
|
// Cash
|
||||||
try std.testing.expectEqualStrings("Cash & Cash Investments", parsed.positions[1].symbol);
|
try std.testing.expectEqualStrings("Cash & Cash Investments", positions[1].symbol);
|
||||||
try std.testing.expect(parsed.positions[1].is_cash);
|
try std.testing.expect(positions[1].is_cash);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), parsed.positions[1].current_value.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), positions[1].current_value.?, 0.01);
|
||||||
try std.testing.expect(parsed.positions[1].quantity == null);
|
try std.testing.expect(positions[1].quantity == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseCsv accepts the real Short (Long) header labels" {
|
test "parseCsv accepts the real Short (Long) header labels" {
|
||||||
|
|
@ -403,14 +547,14 @@ test "parseCsv accepts the real Short (Long) header labels" {
|
||||||
"\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n" ++
|
"\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n" ++
|
||||||
"\"AMZN\",\"AMAZON.COM INC\",\"5.55\",\"2.38%\",\"239.20\",\"100\",\"$8.00\",\"2.38%\",\"$23,920.00\",\"$10,000.00\",\"$13,920.00\",\"139%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
|
"\"AMZN\",\"AMAZON.COM INC\",\"5.55\",\"2.38%\",\"239.20\",\"100\",\"$8.00\",\"2.38%\",\"$23,920.00\",\"$10,000.00\",\"$13,920.00\",\"139%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const parsed = try parseCsv(allocator, csv);
|
const positions = try parseCsv(allocator, csv);
|
||||||
defer allocator.free(parsed.positions);
|
defer allocator.free(positions);
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), parsed.positions.len);
|
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||||||
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 100), parsed.positions[0].quantity.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), parsed.positions[0].current_value.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), positions[0].current_value.?, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), parsed.positions[0].cost_basis.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[0].cost_basis.?, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseCsv rejects a header whose read-columns are reordered" {
|
test "parseCsv rejects a header whose read-columns are reordered" {
|
||||||
|
|
@ -434,13 +578,13 @@ test "parseCsv tolerates reordering of columns it does not read" {
|
||||||
"\"Symbol\",\"Description\",\"Price Chng % (Price Change %)\",\"Price Chng $ (Price Change $)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n" ++
|
"\"Symbol\",\"Description\",\"Price Chng % (Price Change %)\",\"Price Chng $ (Price Change $)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n" ++
|
||||||
"\"AMZN\",\"AMAZON.COM INC\",\"2.38%\",\"5.55\",\"239.20\",\"100\",\"$8.00\",\"2.38%\",\"$23,920.00\",\"$10,000.00\",\"$13,920.00\",\"139%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
|
"\"AMZN\",\"AMAZON.COM INC\",\"2.38%\",\"5.55\",\"239.20\",\"100\",\"$8.00\",\"2.38%\",\"$23,920.00\",\"$10,000.00\",\"$13,920.00\",\"139%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const parsed = try parseCsv(allocator, csv);
|
const positions = try parseCsv(allocator, csv);
|
||||||
defer allocator.free(parsed.positions);
|
defer allocator.free(positions);
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), parsed.positions.len);
|
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||||||
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 100), parsed.positions[0].quantity.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), parsed.positions[0].current_value.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), positions[0].current_value.?, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseCsv rejects a header with too few columns" {
|
test "parseCsv rejects a header with too few columns" {
|
||||||
|
|
@ -452,6 +596,166 @@ test "parseCsv rejects a header with too few columns" {
|
||||||
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, csv));
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, csv));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "parseCsv rejects an empty file and an unrecognized title" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
try std.testing.expectError(error.EmptyFile, parseCsv(allocator, ""));
|
||||||
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, "\"Positions for nobody\"\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv rejects a single-account file with no header" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, "\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n\n\"AMZN\"\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The real "Short (Long)" header, as every section of an export carries it.
|
||||||
|
const test_header = "\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n";
|
||||||
|
|
||||||
|
/// A three-section all-accounts export (fictional figures). The Trust
|
||||||
|
/// section exercises the two oddities real exports carry: a cost basis
|
||||||
|
/// Schwab reports as "Unavailable", and a holding with no price or value
|
||||||
|
/// ("N/A").
|
||||||
|
const test_all_accounts =
|
||||||
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_IRA ...1234\"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"VTI\",\"VANGUARD TOTAL STOCK MARKET ETF\",\"1.10\",\"0.37%\",\"300.00\",\"100\",\"$110.00\",\"0.37%\",\"$30,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"No\",\"N/A\",\"96.77%\",\"ETFs & Closed End Funds\",\n" ++
|
||||||
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$1,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"3.23%\",\"Cash and Money Market\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$110.00\",\"0.36%\",\"$31,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_Brokerage ...5678\"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n" ++
|
||||||
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$1,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"33.33%\",\"Cash and Money Market\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"0.67%\",\"$3,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_Trust ...9012\"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"SCHD\",\"SCHWAB US DIVIDEND EQUITY ETF\",\"0.10\",\"0.36%\",\"27.50\",\"200\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"Unavailable\",\"--\",\"--\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n" ++
|
||||||
|
"\"123ABC456\",\"SAMPLE CORP ESCROW\",\"--\",\"--\",\"N/A\",\"5\",\"--\",\"--\",\"N/A\",\"$0.00\",\"--\",\"--\",\"--\",\"No\",\"N/A\",\"--\",\"Equity\",\n" ++
|
||||||
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$0.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0%\",\"Cash and Money Market\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"$0.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
||||||
|
|
||||||
|
test "parseCsv all-accounts: every row carries its own section's account" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const positions = try parseCsv(allocator, test_all_accounts);
|
||||||
|
defer allocator.free(positions);
|
||||||
|
|
||||||
|
const Expect = struct { symbol: []const u8, number: []const u8, name: []const u8, is_cash: bool };
|
||||||
|
const expected = [_]Expect{
|
||||||
|
.{ .symbol = "VTI", .number = "1234", .name = "Sample_IRA", .is_cash = false },
|
||||||
|
.{ .symbol = "Cash & Cash Investments", .number = "1234", .name = "Sample_IRA", .is_cash = true },
|
||||||
|
.{ .symbol = "AAPL", .number = "5678", .name = "Sample_Brokerage", .is_cash = false },
|
||||||
|
.{ .symbol = "Cash & Cash Investments", .number = "5678", .name = "Sample_Brokerage", .is_cash = true },
|
||||||
|
.{ .symbol = "SCHD", .number = "9012", .name = "Sample_Trust", .is_cash = false },
|
||||||
|
.{ .symbol = "123ABC456", .number = "9012", .name = "Sample_Trust", .is_cash = false },
|
||||||
|
.{ .symbol = "Cash & Cash Investments", .number = "9012", .name = "Sample_Trust", .is_cash = true },
|
||||||
|
};
|
||||||
|
try std.testing.expectEqual(expected.len, positions.len);
|
||||||
|
for (expected, positions) |e, p| {
|
||||||
|
try std.testing.expectEqualStrings(e.symbol, p.symbol);
|
||||||
|
try std.testing.expectEqualStrings(e.number, p.account_number);
|
||||||
|
try std.testing.expectEqualStrings(e.name, p.account_name);
|
||||||
|
try std.testing.expectEqual(e.is_cash, p.is_cash);
|
||||||
|
}
|
||||||
|
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.001);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 30000), positions[0].current_value.?, 0.001);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 1000), positions[3].current_value.?, 0.001);
|
||||||
|
// "Unavailable" cost basis and "N/A" value read as unknown, not zero.
|
||||||
|
try std.testing.expect(positions[4].cost_basis == null);
|
||||||
|
try std.testing.expect(positions[5].current_value == null);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 5), positions[5].quantity.?, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv all-accounts: a row before any account line has no account" {
|
||||||
|
const csv =
|
||||||
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"Sample_IRA ...1234\"\n";
|
||||||
|
try std.testing.expectError(error.MissingAccountLine, parseCsv(std.testing.allocator, csv));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv all-accounts: every section's header is validated" {
|
||||||
|
// The second section's Symbol and Price are swapped; the first is fine.
|
||||||
|
const csv =
|
||||||
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_IRA ...1234\"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_Brokerage ...5678\"\n" ++
|
||||||
|
"\"Price\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Symbol\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n";
|
||||||
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(std.testing.allocator, csv));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv all-accounts: an account line must be followed by a header" {
|
||||||
|
const csv =
|
||||||
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_IRA ...1234\"\n" ++
|
||||||
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n";
|
||||||
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(std.testing.allocator, csv));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv all-accounts: no sections is an error, not an empty result" {
|
||||||
|
try std.testing.expectError(
|
||||||
|
error.NoAccountsFound,
|
||||||
|
parseCsv(std.testing.allocator, "\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv: a Positions Total that disagrees with its rows is rejected" {
|
||||||
|
// Both layouts check it. Single account: the total claims $1 more than
|
||||||
|
// the one row.
|
||||||
|
const single =
|
||||||
|
"\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"1%\",\"$2,001.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
||||||
|
try std.testing.expectError(error.InconsistentExport, parseCsv(std.testing.allocator, single));
|
||||||
|
|
||||||
|
// All accounts: the total is only checked against its own section, so
|
||||||
|
// a row attributed to the wrong section shows up. Here the second
|
||||||
|
// section's total is the first section's.
|
||||||
|
const all =
|
||||||
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_IRA ...1234\"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
|
||||||
|
"\"Sample_Brokerage ...5678\"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$1,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"100%\",\"Cash and Money Market\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$2,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
||||||
|
try std.testing.expectError(error.InconsistentExport, parseCsv(std.testing.allocator, all));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "checkTotal: allows half a cent of rounding per row" {
|
||||||
|
const acct: Account = .{ .name = "Sample IRA", .number = "1234" };
|
||||||
|
// Three rows: up to 1.5 cents of drift is rounding.
|
||||||
|
try checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "$100.01");
|
||||||
|
try checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "$99.99");
|
||||||
|
try std.testing.expectError(error.InconsistentExport, checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "$100.02"));
|
||||||
|
// A total Schwab leaves blank is not checked.
|
||||||
|
try checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "--");
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv all-accounts: allocation failures are handled" {
|
||||||
|
const S = struct {
|
||||||
|
fn run(allocator: std.mem.Allocator) !void {
|
||||||
|
const positions = try parseCsv(allocator, test_all_accounts);
|
||||||
|
allocator.free(positions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try std.testing.checkAllAllocationFailures(std.testing.allocator, S.run, .{});
|
||||||
|
}
|
||||||
|
|
||||||
test "parseSummary basic" {
|
test "parseSummary basic" {
|
||||||
const data =
|
const data =
|
||||||
\\Sample Roth
|
\\Sample Roth
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,8 @@ pub const meta: framework.Meta = .{
|
||||||
\\ --stale-days <N> Manual price staleness threshold (default 3)
|
\\ --stale-days <N> Manual price staleness threshold (default 3)
|
||||||
\\ --fidelity <CSV> Fidelity positions CSV export
|
\\ --fidelity <CSV> Fidelity positions CSV export
|
||||||
\\ ("All accounts" -> Positions tab -> Download)
|
\\ ("All accounts" -> Positions tab -> Download)
|
||||||
\\ --schwab <CSV> Schwab per-account positions CSV export
|
\\ --schwab <CSV> Schwab positions CSV export (one account,
|
||||||
|
\\ or "All Brokerage Accounts")
|
||||||
\\ --schwab-summary Schwab account summary; copy from accounts
|
\\ --schwab-summary Schwab account summary; copy from accounts
|
||||||
\\ summary page, paste to stdin, then ^D
|
\\ summary page, paste to stdin, then ^D
|
||||||
\\ --wells-fargo <XLS> Wells Fargo positions spreadsheet
|
\\ --wells-fargo <XLS> Wells Fargo positions spreadsheet
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,8 @@ pub const meta: framework.Meta = .{
|
||||||
\\ concrete path, not a glob). REQUIRED.
|
\\ concrete path, not a glob). REQUIRED.
|
||||||
\\ --fidelity <CSV> Fidelity positions CSV
|
\\ --fidelity <CSV> Fidelity positions CSV
|
||||||
\\ ("All accounts" -> Positions tab -> Download)
|
\\ ("All accounts" -> Positions tab -> Download)
|
||||||
\\ --schwab <CSV> Schwab per-account positions CSV
|
\\ --schwab <CSV> Schwab positions CSV (one account, or
|
||||||
|
\\ "All Brokerage Accounts")
|
||||||
\\ --wells-fargo <XLS> Wells Fargo positions spreadsheet
|
\\ --wells-fargo <XLS> Wells Fargo positions spreadsheet
|
||||||
\\ (Download Type "Portfolio-Expanded
|
\\ (Download Type "Portfolio-Expanded
|
||||||
\\ Detail", Portfolio View "Positions",
|
\\ Detail", Portfolio View "Positions",
|
||||||
|
|
@ -346,10 +347,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
|
|
||||||
const positions: []BrokeragePosition = switch (parsed.source) {
|
const positions: []BrokeragePosition = switch (parsed.source) {
|
||||||
.fidelity => try fidelity.parseCsv(allocator, source_data),
|
.fidelity => try fidelity.parseCsv(allocator, source_data),
|
||||||
.schwab => blk: {
|
.schwab => try schwab.parseCsv(allocator, source_data),
|
||||||
const r = try schwab.parseCsv(allocator, source_data);
|
|
||||||
break :blk r.positions;
|
|
||||||
},
|
|
||||||
.wells_fargo => blk: {
|
.wells_fargo => blk: {
|
||||||
workbook = biff8.Workbook.parse(allocator, source_data) catch |err| return badWellsFargoExport(io, source_path, err);
|
workbook = biff8.Workbook.parse(allocator, source_data) catch |err| return badWellsFargoExport(io, source_path, err);
|
||||||
wf_sheet = wells_fargo.positionsSheet(&workbook.?) catch |err| return badWellsFargoExport(io, source_path, err);
|
wf_sheet = wells_fargo.positionsSheet(&workbook.?) catch |err| return badWellsFargoExport(io, source_path, err);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue