Compare commits
No commits in common. "d5459ff39e10b5f625112d615d457a74e8f8d16e" and "858e9874b4473f5b50fefd9411b2ef0e88e548e8" have entirely different histories.
d5459ff39e
...
858e9874b4
14 changed files with 253 additions and 1130 deletions
|
|
@ -26,18 +26,16 @@ Fargo**.
|
|||
| Broker | How to export | Flag |
|
||||
|--------------------------|-------------------------------------------------------------------------------------------------------------|-----------------------|
|
||||
| **Fidelity** | *Positions* tab -> the three-dot (**⋮**) menu -> **Download** (a CSV) | `--fidelity <CSV>` |
|
||||
| **Schwab** (positions) | *Accounts -> Positions*: pick **All Brokerage Accounts** (or one account) in the dropdown -> **Export** | `--schwab <CSV>` |
|
||||
| **Schwab** (per-account) | *Accounts -> Positions* -> **Export** (one CSV per account) | `--schwab <CSV>` |
|
||||
| **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>` |
|
||||
|
||||
The two Schwab inputs differ in detail: the **positions CSV** has full
|
||||
per-position data (shares, price, value) for whichever accounts you
|
||||
picked -- every one of them with **All Brokerage Accounts**, which is
|
||||
the easy choice; the **summary paste** carries only each account's cash
|
||||
and total value, so it reconciles *totals*, not individual holdings.
|
||||
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 two Schwab inputs differ in detail: the **per-account CSV** has full
|
||||
per-position data (shares, price, value); the **summary paste** carries
|
||||
only each account's cash and total value, so it reconciles *totals*, not
|
||||
individual holdings. 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
|
||||
three-dot (**⋮**) menu at the top-right of the positions panel:
|
||||
|
|
@ -88,7 +86,7 @@ the summary reconciles **account totals**, not individual positions.
|
|||
|
||||
```bash
|
||||
ZFIN_HOME=~/finance zfin audit --fidelity ~/Downloads/Portfolio_Positions.csv
|
||||
ZFIN_HOME=~/finance zfin audit --schwab ~/Downloads/All-Accounts-Positions.csv
|
||||
ZFIN_HOME=~/finance zfin audit --schwab ~/Downloads/Positions-Individual.csv
|
||||
ZFIN_HOME=~/finance zfin audit --schwab-summary # then paste the page, Ctrl-D
|
||||
```
|
||||
|
||||
|
|
@ -268,16 +266,10 @@ What it considers:
|
|||
for ...`, a Schwab summary by `Account number ending in`, a Wells
|
||||
Fargo spreadsheet by its `WFA_Positions` sheet. A renamed file still
|
||||
works; an unrelated file is skipped.
|
||||
- **Accounts you don't track get one line, not a wall of
|
||||
discrepancies.** Fidelity's download, Schwab's All Brokerage Accounts
|
||||
export and summary paste, and the Wells Fargo spreadsheet cover every
|
||||
account your login can see -- including ones this portfolio
|
||||
deliberately leaves out (a relative's account you manage, one that
|
||||
belongs to another portfolio file). Their unmapped accounts are named
|
||||
on a muted "not in accounts.srf, not reconciled" line instead of
|
||||
being reconciled, and an export with *none* of your accounts is noted
|
||||
and skipped as another portfolio's. See
|
||||
[How accounts are matched](#how-accounts-are-matched).
|
||||
- **A Wells Fargo export with none of your accounts is skipped.** One WF
|
||||
download covers a whole household, so if you also manage someone
|
||||
else's WF accounts, their export in your download folder is noted and
|
||||
skipped rather than reported as eight unmapped accounts.
|
||||
|
||||
So with `ZFIN_AUDIT_FILES=~/Downloads`, the workflow collapses to
|
||||
"download from your broker, run `zfin audit`, done."
|
||||
|
|
@ -290,29 +282,14 @@ portfolio. It does that through
|
|||
[`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
|
||||
for account ...1234" title (or, in an all-accounts export, the
|
||||
"...1234" line that opens each account's section), Fidelity's from the
|
||||
Account Number column, the summary's from "...ending in 1234", Wells
|
||||
Fargo's from each row's `*1234`.
|
||||
for account ...1234" title, Fidelity's from the 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`,
|
||||
`schwab`, `wells_fargo`) **and** `account_number::` match, and
|
||||
compares against that account's lots.
|
||||
- **No match -> the account isn't reconciled.** How loudly zfin says so
|
||||
depends on what you exported:
|
||||
- A **single-account** Schwab export: you picked that account, so it
|
||||
is shown as `unmapped` with every holding, and flagged as a
|
||||
discrepancy.
|
||||
- An export covering **every account** (Fidelity, Schwab's All
|
||||
Brokerage Accounts or summary, Wells Fargo): the account is listed
|
||||
by name and number on one muted "not in accounts.srf, not
|
||||
reconciled" line, and doesn't count as a discrepancy -- so an
|
||||
account you deliberately don't track doesn't keep the audit from
|
||||
coming back clean. If *none* of the export's accounts match, the
|
||||
whole file is skipped as another portfolio's (the flagless audit
|
||||
lists its accounts with `--verbose`; a `--fidelity`/`--schwab`/...
|
||||
flag always does).
|
||||
|
||||
Either way, fix it by adding `institution::` and `account_number::`
|
||||
- **No match -> the account is shown as `unmapped`** and flagged as a
|
||||
discrepancy. Fix it by adding `institution::` and `account_number::`
|
||||
to that account in `accounts.srf` (a placeholder number you recognize
|
||||
is fine -- it just has to match what the export shows).
|
||||
|
||||
|
|
@ -393,13 +370,11 @@ so auditing against the export you just imported shows no drift.
|
|||
|
||||
- The parsers are **broker-specific**. The CSV parsers hardcode each
|
||||
export's column layout -- if Fidelity or Schwab changes their format,
|
||||
parsing can break (both validate their header to catch this, and the
|
||||
Schwab parser checks each account's "Positions Total" against the rows
|
||||
above it). They are not full RFC-4180 CSV parsers (no escaped quotes or
|
||||
multi-line fields) -- fine for the real exports, not for arbitrary
|
||||
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.
|
||||
parsing can break (both validate their header to catch this). They are
|
||||
not full RFC-4180 CSV parsers (no escaped quotes or multi-line fields)
|
||||
-- fine for the real exports, not for arbitrary 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::`
|
||||
entries you keep in `accounts.srf`.
|
||||
- Options, CDs, and cash are reconciled separately from share counts.
|
||||
|
|
@ -410,11 +385,11 @@ manual nudge.
|
|||
|
||||
### Wells Fargo: one file, every account, real lots
|
||||
|
||||
Two things about the Wells Fargo spreadsheet are worth knowing:
|
||||
The Wells Fargo spreadsheet differs from the CSV exports in two ways
|
||||
worth knowing:
|
||||
|
||||
- **It covers the whole household.** Like Fidelity's download and
|
||||
Schwab's **All Brokerage Accounts** export, every brokerage account is
|
||||
in one file, each row tagged with its account as `*1234`.
|
||||
- **It covers the whole household.** 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
|
||||
symbol before comparing, so it doesn't matter whether your portfolio
|
||||
holds the position as one lot or many. It also means
|
||||
|
|
|
|||
|
|
@ -21,18 +21,15 @@ discrepancies.
|
|||
| `--verbose` | Show full reconciliation output even when clean. |
|
||||
| `--stale-days <N>` | Manual-price staleness threshold (default 3). |
|
||||
| `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). |
|
||||
| `--schwab <CSV>` | Schwab positions CSV: one account, or "All Brokerage Accounts". |
|
||||
| `--schwab <CSV>` | Schwab per-account positions CSV. |
|
||||
| `--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"). |
|
||||
|
||||
Reconciliation matches export accounts to yours via `institution::` and
|
||||
`account_number::` in [`accounts.srf`](../config/accounts-srf.md). An
|
||||
unmatched account in a single-account Schwab export is reported as
|
||||
"unmapped." Exports that cover every account (Fidelity, Schwab's All
|
||||
Brokerage Accounts and summary, Wells Fargo) instead name their
|
||||
unmatched accounts on one "not in accounts.srf, not reconciled" line,
|
||||
and one with no matched accounts at all is skipped as another
|
||||
portfolio's. See [how accounts are matched](../../guides/audit-against-brokerage.md#how-accounts-are-matched).
|
||||
`account_number::` in [`accounts.srf`](../config/accounts-srf.md); an
|
||||
unmatched account is reported as "unmapped." A discovered Wells Fargo
|
||||
export none of whose accounts are mapped is skipped with a note instead,
|
||||
since it is almost certainly another portfolio's.
|
||||
|
||||
The hygiene check also flags newly-appeared lots worth at least
|
||||
$10,000 in a **Large new lots - confirm source** section, so you can
|
||||
|
|
|
|||
|
|
@ -33,16 +33,14 @@ dropped.
|
|||
|--------------------------|-----------------------------------------------------------------|
|
||||
| `-p, --portfolio <FILE>` | Target file (a single concrete path, not a glob). **Required.** |
|
||||
| `--fidelity <CSV>` | Fidelity positions CSV. |
|
||||
| `--schwab <CSV>` | Schwab positions CSV (one account or All Brokerage Accounts). |
|
||||
| `--schwab <CSV>` | Schwab per-account positions CSV. |
|
||||
| `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (`-` for stdin). See below. |
|
||||
| `-y, --yes` | Don't prompt before overwriting an existing file. |
|
||||
|
||||
Account resolution needs an [`accounts.srf`](../config/accounts-srf.md)
|
||||
next to the target with `institution::` + `account_number::` entries
|
||||
matching the export; import refuses to write when an export account is
|
||||
unmapped. A Schwab **All Brokerage Accounts** export brings every
|
||||
account into the target, so to import just one account, export that
|
||||
account alone.
|
||||
unmapped.
|
||||
|
||||
A target that doesn't exist yet is created in the current directory, so
|
||||
run a first import from the portfolio's own directory.
|
||||
|
|
|
|||
|
|
@ -24,9 +24,6 @@ pub const cdLotAllowance = common.cdLotAllowance;
|
|||
pub const accountValueExpectation = common.accountValueExpectation;
|
||||
pub const compareAccounts = common.compareAccounts;
|
||||
pub const hasAccountDiscrepancies = common.hasAccountDiscrepancies;
|
||||
pub const ExportScope = common.ExportScope;
|
||||
pub const ExportView = common.ExportView;
|
||||
pub const splitUnmapped = common.splitUnmapped;
|
||||
pub const presentNumbers = common.presentNumbers;
|
||||
pub const findAbsentAccounts = common.findAbsentAccounts;
|
||||
pub const RatioSuggestion = common.RatioSuggestion;
|
||||
|
|
@ -35,7 +32,6 @@ pub const ratioSuggestions = common.ratioSuggestions;
|
|||
pub const SchwabAccountComparison = schwab.SchwabAccountComparison;
|
||||
pub const compareSchwabSummary = schwab.compareSchwabSummary;
|
||||
pub const reconcileCsv = schwab.reconcileCsv;
|
||||
pub const schwabCsvScope = schwab.csvScope;
|
||||
pub const reconcileSummary = schwab.reconcileSummary;
|
||||
pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies;
|
||||
pub const summaryRatioSuggestions = schwab.summaryRatioSuggestions;
|
||||
|
|
|
|||
|
|
@ -661,67 +661,6 @@ pub fn hasAccountDiscrepancies(results: []const AccountComparison) bool {
|
|||
return false;
|
||||
}
|
||||
|
||||
// ── Unmapped accounts in an export ───────────────────────────
|
||||
|
||||
/// How much of a household an export was asked to cover, which decides
|
||||
/// what an unmapped account in it means (see `splitUnmapped`).
|
||||
pub const ExportScope = enum {
|
||||
/// The user picked one account to export - Schwab's single-account
|
||||
/// positions CSV. If that account is unmapped, it is a setup error.
|
||||
one_account,
|
||||
/// Everything the login can see - Fidelity's download, the Wells
|
||||
/// Fargo spreadsheet, Schwab's All Brokerage Accounts export and its
|
||||
/// summary paste. These routinely carry accounts this portfolio
|
||||
/// deliberately doesn't track: a relative's, a view-only one, or
|
||||
/// one belonging to another portfolio file.
|
||||
all_accounts,
|
||||
};
|
||||
|
||||
/// One export's comparison results, split for display. Both slices
|
||||
/// view the caller's results, which `splitUnmapped` reorders in place.
|
||||
pub fn ExportView(comptime T: type) type {
|
||||
return struct {
|
||||
/// Accounts to reconcile in full.
|
||||
shown: []T,
|
||||
/// Unmapped accounts of an all-accounts export, reported on one
|
||||
/// line rather than as discrepancies.
|
||||
unmapped: []T,
|
||||
|
||||
/// True when nothing in an all-accounts export maps: it is almost
|
||||
/// certainly another portfolio's file (one household's download
|
||||
/// in a shared Downloads directory), so it is skipped outright.
|
||||
pub fn skipped(self: @This()) bool {
|
||||
return self.shown.len == 0 and self.unmapped.len > 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Split one export's results into what to reconcile and what to merely
|
||||
/// note. An unmapped account (empty `account_name`, the convention both
|
||||
/// `AccountComparison` and `SchwabAccountComparison` share) in an
|
||||
/// `.all_accounts` export moves to `unmapped`: listing every row of an
|
||||
/// account you deliberately don't track as a discrepancy would mean the
|
||||
/// audit can never come back clean. It is still named, so a newly opened
|
||||
/// account that genuinely needs mapping isn't lost. In a `.one_account`
|
||||
/// export nothing moves - you asked for that account, so its not mapping
|
||||
/// is worth the full unmapped report.
|
||||
///
|
||||
/// Reorders `results` in place - mapped first, each side keeping its
|
||||
/// order - so the caller still owns and frees the same slice.
|
||||
pub fn splitUnmapped(comptime T: type, results: []T, scope: ExportScope) ExportView(T) {
|
||||
if (scope == .one_account) return .{ .shown = results, .unmapped = results[results.len..] };
|
||||
const S = struct {
|
||||
fn mappedFirst(_: void, a: T, b: T) bool {
|
||||
return a.account_name.len > 0 and b.account_name.len == 0;
|
||||
}
|
||||
};
|
||||
// Insertion sort is stable, and an export carries a handful of accounts.
|
||||
std.sort.insertion(T, results, {}, S.mappedFirst);
|
||||
var mapped: usize = 0;
|
||||
while (mapped < results.len and results[mapped].account_name.len > 0) : (mapped += 1) {}
|
||||
return .{ .shown = results[0..mapped], .unmapped = results[mapped..] };
|
||||
}
|
||||
|
||||
// ── Portfolio accounts absent from the export ────────────────
|
||||
|
||||
/// A portfolio account that maps to the institution under audit and
|
||||
|
|
@ -2038,70 +1977,3 @@ test "collapseOntoProxy: every allocation failure is clean" {
|
|||
};
|
||||
try std.testing.checkAllAllocationFailures(std.testing.allocator, S.run, .{});
|
||||
}
|
||||
|
||||
// ── splitUnmapped ────────────────────────────────────────────
|
||||
|
||||
/// An account comparison with no rows; only identity matters here.
|
||||
fn testAcct(account_name: []const u8, number: []const u8) AccountComparison {
|
||||
return .{
|
||||
.account_name = account_name,
|
||||
.brokerage_name = "Sample Account",
|
||||
.account_number = number,
|
||||
.comparisons = &.{},
|
||||
.portfolio_total = 0,
|
||||
.brokerage_total = 0,
|
||||
.total_delta = 0,
|
||||
.option_value_delta = 0,
|
||||
.has_discrepancies = account_name.len == 0,
|
||||
};
|
||||
}
|
||||
|
||||
test "splitUnmapped: an all-accounts export sets unmapped accounts aside, keeping order" {
|
||||
var results = [_]AccountComparison{
|
||||
testAcct("", "9012"),
|
||||
testAcct("Sample IRA", "1234"),
|
||||
testAcct("", "3456"),
|
||||
testAcct("Sample Brokerage", "5678"),
|
||||
};
|
||||
const view = splitUnmapped(AccountComparison, &results, .all_accounts);
|
||||
|
||||
try std.testing.expect(!view.skipped());
|
||||
try std.testing.expectEqual(@as(usize, 2), view.shown.len);
|
||||
try std.testing.expectEqualStrings("1234", view.shown[0].account_number);
|
||||
try std.testing.expectEqualStrings("5678", view.shown[1].account_number);
|
||||
try std.testing.expectEqual(@as(usize, 2), view.unmapped.len);
|
||||
try std.testing.expectEqualStrings("9012", view.unmapped[0].account_number);
|
||||
try std.testing.expectEqualStrings("3456", view.unmapped[1].account_number);
|
||||
// The set aside accounts were the discrepancies; what is shown is clean.
|
||||
try std.testing.expect(!hasAccountDiscrepancies(view.shown));
|
||||
}
|
||||
|
||||
test "splitUnmapped: a one-account export keeps its unmapped account in full view" {
|
||||
var results = [_]AccountComparison{testAcct("", "9012")};
|
||||
const view = splitUnmapped(AccountComparison, &results, .one_account);
|
||||
try std.testing.expect(!view.skipped());
|
||||
try std.testing.expectEqual(@as(usize, 1), view.shown.len);
|
||||
try std.testing.expectEqual(@as(usize, 0), view.unmapped.len);
|
||||
try std.testing.expect(hasAccountDiscrepancies(view.shown));
|
||||
}
|
||||
|
||||
test "splitUnmapped: an all-accounts export with nothing mapped is skipped" {
|
||||
var results = [_]AccountComparison{ testAcct("", "9012"), testAcct("", "3456") };
|
||||
const view = splitUnmapped(AccountComparison, &results, .all_accounts);
|
||||
try std.testing.expect(view.skipped());
|
||||
try std.testing.expectEqual(@as(usize, 0), view.shown.len);
|
||||
try std.testing.expectEqual(@as(usize, 2), view.unmapped.len);
|
||||
}
|
||||
|
||||
test "splitUnmapped: everything mapped, or nothing at all, sets nothing aside" {
|
||||
var mapped = [_]AccountComparison{ testAcct("Sample IRA", "1234"), testAcct("Sample Brokerage", "5678") };
|
||||
const view = splitUnmapped(AccountComparison, &mapped, .all_accounts);
|
||||
try std.testing.expect(!view.skipped());
|
||||
try std.testing.expectEqual(@as(usize, 2), view.shown.len);
|
||||
try std.testing.expectEqual(@as(usize, 0), view.unmapped.len);
|
||||
|
||||
// An empty export is not "skipped as another portfolio's" - there is
|
||||
// nothing to attribute to anyone.
|
||||
var none = [_]AccountComparison{};
|
||||
try std.testing.expect(!splitUnmapped(AccountComparison, &none, .all_accounts).skipped());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
//! Schwab has two export shapes, so this module carries more than
|
||||
//! the Fidelity one:
|
||||
//!
|
||||
//! 1. **Positions CSV** (`--schwab`) - one account or all of them,
|
||||
//! the same per-position shape Fidelity uses, so it feeds the
|
||||
//! shared `common.compareAccounts` engine via `reconcileCsv`.
|
||||
//! 1. **Per-account positions CSV** (`--schwab`) - same per-account
|
||||
//! positions shape Fidelity uses, so it feeds the shared
|
||||
//! `common.compareAccounts` engine via `reconcileCsv`.
|
||||
//! 2. **Account summary paste** (`--schwab-summary`) - a
|
||||
//! per-account totals-only view with no per-symbol detail. This
|
||||
//! is the one genuinely broker-specific reconciler, with its own
|
||||
|
|
@ -27,12 +27,8 @@ const AccountSummary = schwab_parser.AccountSummary;
|
|||
|
||||
/// Account-level comparison result for Schwab summary audit.
|
||||
pub const SchwabAccountComparison = struct {
|
||||
/// Portfolio account name; empty when the account is unmapped.
|
||||
account_name: []const u8,
|
||||
/// The account's name as Schwab shows it. Named like
|
||||
/// `AccountComparison.brokerage_name` so code handling either result
|
||||
/// type (`common.splitUnmapped`) reads the same field.
|
||||
brokerage_name: []const u8,
|
||||
schwab_name: []const u8,
|
||||
account_number: []const u8,
|
||||
portfolio_cash: f64,
|
||||
schwab_cash: ?f64,
|
||||
|
|
@ -48,14 +44,13 @@ pub const SchwabAccountComparison = struct {
|
|||
has_discrepancy: bool,
|
||||
};
|
||||
|
||||
// ── Positions CSV (--schwab) ─────────────────────────────────
|
||||
// ── Per-account positions CSV (--schwab) ─────────────────────
|
||||
|
||||
/// Parse a Schwab positions CSV - either layout, one account or all
|
||||
/// of them - and reconcile it against the portfolio via the shared
|
||||
/// engine. Returns owned `AccountComparison` results, one per account
|
||||
/// in the export (free each `.comparisons` slice, then the results
|
||||
/// slice). String fields borrow from `csv_data`, which must outlive
|
||||
/// them. Propagates parser and allocation errors.
|
||||
/// Parse a Schwab per-account positions CSV and reconcile it against
|
||||
/// the portfolio via the shared engine. Returns owned
|
||||
/// `AccountComparison` results (free each `.comparisons` slice, then
|
||||
/// the results slice). String fields borrow from `csv_data`, which
|
||||
/// must outlive them. Propagates parser and allocation errors.
|
||||
pub fn reconcileCsv(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
|
|
@ -64,19 +59,11 @@ pub fn reconcileCsv(
|
|||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]common.AccountComparison {
|
||||
const positions = try schwab_parser.parseCsv(allocator, csv_data);
|
||||
const parsed = try schwab_parser.parseCsv(allocator, csv_data);
|
||||
// Result strings borrow from `csv_data`, not the positions slice,
|
||||
// so freeing the slice array here is safe.
|
||||
defer allocator.free(positions);
|
||||
return common.compareAccounts(allocator, portfolio, positions, account_map, "schwab", prices, as_of);
|
||||
}
|
||||
|
||||
/// Which `common.ExportScope` a Schwab positions CSV has: an "All
|
||||
/// Brokerage Accounts" export covers the whole login, while a
|
||||
/// single-account export is one the user picked on purpose, so its
|
||||
/// account being unmapped is worth the full report.
|
||||
pub fn csvScope(csv_data: []const u8) common.ExportScope {
|
||||
return if (schwab_parser.isAllAccounts(csv_data)) .all_accounts else .one_account;
|
||||
defer allocator.free(parsed.positions);
|
||||
return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of);
|
||||
}
|
||||
|
||||
// ── Account summary paste (--schwab-summary) ─────────────────
|
||||
|
|
@ -132,7 +119,7 @@ pub fn compareSchwabSummary(
|
|||
|
||||
try results.append(allocator, .{
|
||||
.account_name = portfolio_acct orelse "",
|
||||
.brokerage_name = sa.account_name,
|
||||
.schwab_name = sa.account_name,
|
||||
.account_number = sa.account_number,
|
||||
.portfolio_cash = pf_cash,
|
||||
.schwab_cash = sa.cash,
|
||||
|
|
@ -230,7 +217,7 @@ const portfolio_mod = @import("../../models/portfolio.zig");
|
|||
test "hasSchwabDiscrepancies" {
|
||||
const clean = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.brokerage_name = "Roth IRA",
|
||||
.schwab_name = "Roth IRA",
|
||||
.account_number = "1234",
|
||||
.portfolio_cash = 100,
|
||||
.schwab_cash = 100,
|
||||
|
|
@ -244,7 +231,7 @@ test "hasSchwabDiscrepancies" {
|
|||
|
||||
const dirty = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.brokerage_name = "Roth IRA",
|
||||
.schwab_name = "Roth IRA",
|
||||
.account_number = "1234",
|
||||
.portfolio_cash = 100,
|
||||
.schwab_cash = 200,
|
||||
|
|
@ -423,7 +410,7 @@ test "compareSchwabSummary: account_number with no match -> empty account_name"
|
|||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectEqualStrings("", results[0].account_name);
|
||||
try std.testing.expectEqualStrings("Unknown Acct", results[0].brokerage_name);
|
||||
try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name);
|
||||
// No portfolio match -> cash and total are zero, schwab values become deltas
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01);
|
||||
|
|
@ -575,72 +562,6 @@ test "reconcileCsv: parses a Schwab positions CSV and reconciles it" {
|
|||
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" {
|
||||
const allocator = std.testing.allocator;
|
||||
const data =
|
||||
|
|
@ -713,7 +634,7 @@ test "summaryRatioSuggestions: single-lot direct-indexing yields exact ratio + d
|
|||
try prices.put("SPY", 500.0);
|
||||
// current = 100*500*1.0 = 50000; target = 51000; suggested = 1.02, +2%.
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
};
|
||||
const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am, Date.fromYmd(2026, 6, 19));
|
||||
defer allocator.free(sugg);
|
||||
|
|
@ -734,7 +655,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
|
|||
defer prices.deinit();
|
||||
try prices.put("SPY", 500.0);
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
};
|
||||
|
||||
// null account_map -> empty.
|
||||
|
|
@ -760,7 +681,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
|
|||
};
|
||||
const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
const tiny = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 50000.005, .total_delta = 0.005, .has_discrepancy = false },
|
||||
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 50000.005, .total_delta = 0.005, .has_discrepancy = false },
|
||||
};
|
||||
const sugg = try summaryRatioSuggestions(allocator, &tiny, portfolio, prices, am, Date.fromYmd(2026, 6, 19));
|
||||
defer allocator.free(sugg);
|
||||
|
|
@ -782,45 +703,3 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
|
|||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Export scope ─────────────────────────────────────────────
|
||||
|
||||
test "csvScope: only an All Brokerage Accounts export covers the whole login" {
|
||||
try std.testing.expectEqual(common.ExportScope.all_accounts, csvScope("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n\"Sample_IRA ...1234\"\n"));
|
||||
try std.testing.expectEqual(common.ExportScope.one_account, csvScope("\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n"));
|
||||
// Unparseable input never reaches here (reconcileCsv fails first), but
|
||||
// the conservative answer is the one that keeps unmapped accounts loud.
|
||||
try std.testing.expectEqual(common.ExportScope.one_account, csvScope("garbage"));
|
||||
}
|
||||
|
||||
test "splitUnmapped: a summary paste sets its unmapped accounts aside" {
|
||||
const allocator = std.testing.allocator;
|
||||
const data =
|
||||
\\Sample Roth
|
||||
\\Account number ending in 1234 ...1234
|
||||
\\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
|
||||
\\Sample Account
|
||||
\\Account number ending in 9012 ...9012
|
||||
\\Type Brokerage $10.00 $5,000.00 +$1.00 +0.02%
|
||||
;
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "", .shares = 46.44, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1, .account = "Sample Roth IRA", .security_type = .cash },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Roth IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = try reconcileSummary(allocator, portfolio, data, acct_map, prices, Date.fromYmd(2026, 10, 6));
|
||||
defer allocator.free(results);
|
||||
|
||||
const view = common.splitUnmapped(SchwabAccountComparison, results, .all_accounts);
|
||||
try std.testing.expectEqual(@as(usize, 1), view.shown.len);
|
||||
try std.testing.expectEqualStrings("Sample Roth IRA", view.shown[0].account_name);
|
||||
try std.testing.expectEqual(@as(usize, 1), view.unmapped.len);
|
||||
try std.testing.expectEqualStrings("Sample Account", view.unmapped[0].brokerage_name);
|
||||
try std.testing.expectEqualStrings("9012", view.unmapped[0].account_number);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,18 @@ pub const Reconciled = struct {
|
|||
allocator.free(self.results);
|
||||
self.workbook.deinit();
|
||||
}
|
||||
|
||||
/// True when at least one of the export's accounts is in
|
||||
/// `accounts.srf`. An export with none is almost certainly another
|
||||
/// portfolio's (one household's download sitting in a shared
|
||||
/// Downloads directory), so auto-discovery skips it rather than
|
||||
/// reporting every account as a discrepancy.
|
||||
pub fn mapsAnyAccount(self: Reconciled) bool {
|
||||
for (self.results) |r| {
|
||||
if (r.account_name.len > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/// Decode an export file and reconcile it. Errors are the decode's
|
||||
|
|
@ -128,9 +140,7 @@ test "reconcile: per-lot rows and cash rows sum per account and match the portfo
|
|||
try std.testing.expectEqual(@as(usize, 2), results[0].comparisons.len);
|
||||
}
|
||||
|
||||
test "reconcile: a household export with none of its accounts mapped is skipped" {
|
||||
// One WF download covers a whole household, so one sitting in a
|
||||
// shared Downloads directory may be another portfolio's entirely.
|
||||
test "Reconciled.mapsAnyAccount: false when no export account is in accounts.srf" {
|
||||
const allocator = std.testing.allocator;
|
||||
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
|
|
@ -145,7 +155,7 @@ test "reconcile: a household export with none of its accounts mapped is skipped"
|
|||
.results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)),
|
||||
};
|
||||
defer rec.deinit(allocator);
|
||||
try std.testing.expect(common.splitUnmapped(common.AccountComparison, rec.results, .all_accounts).skipped());
|
||||
try std.testing.expect(!rec.mapsAnyAccount());
|
||||
|
||||
// With the export's account mapped, it is ours.
|
||||
entries[0].account_number = "1234";
|
||||
|
|
@ -154,9 +164,7 @@ test "reconcile: a household export with none of its accounts mapped is skipped"
|
|||
.results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)),
|
||||
};
|
||||
defer mapped.deinit(allocator);
|
||||
const view = common.splitUnmapped(common.AccountComparison, mapped.results, .all_accounts);
|
||||
try std.testing.expect(!view.skipped());
|
||||
try std.testing.expectEqual(@as(usize, 1), view.shown.len);
|
||||
try std.testing.expect(mapped.mapsAnyAccount());
|
||||
}
|
||||
|
||||
test "reconcileXls: decode errors surface with their names" {
|
||||
|
|
|
|||
|
|
@ -71,10 +71,7 @@ pub fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind {
|
|||
if (std.mem.indexOf(u8, content, "Fidelity Brokerage Services LLC") != null)
|
||||
return .fidelity_csv;
|
||||
|
||||
// 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.
|
||||
// Schwab per-account CSV: starts with a quoted title line like "Positions for ..."
|
||||
if (std.mem.startsWith(u8, content, "\"Positions for")) return .schwab_csv;
|
||||
|
||||
// Schwab summary: the "Account number ending in" anchor is exactly what
|
||||
|
|
@ -214,10 +211,6 @@ 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";
|
||||
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" {
|
||||
// Regression guard against re-introducing the old "account-type label +
|
||||
// $" fallback. This blob looks summary-ish (account-type words, dollar
|
||||
|
|
|
|||
|
|
@ -2,47 +2,14 @@
|
|||
//!
|
||||
//! Parses two distinct Schwab inputs:
|
||||
//!
|
||||
//! 1. The positions CSV exported from Schwab's website (Accounts ->
|
||||
//! 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.
|
||||
//! 1. The per-account positions CSV exported from Schwab's website
|
||||
//! (Accounts -> Positions -> Export). One file per account.
|
||||
//!
|
||||
//! 2. The freeform account-summary text the user pastes from
|
||||
//! Schwab's Accounts overview page. One paste covers all
|
||||
//! accounts at once but only carries cash + total-value
|
||||
//! 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
|
||||
//!
|
||||
//! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
|
||||
|
|
@ -52,21 +19,17 @@
|
|||
//! quotes ("value with ""quotes"" inside") or multi-line quoted fields.
|
||||
//! Schwab's export does not use these in practice.
|
||||
//!
|
||||
//! 3. Every position row is stamped with the account of the section it
|
||||
//! sits in. The grouping by account happens downstream, in
|
||||
//! `reconcile/common.compareAccounts`.
|
||||
//! 3. The account number and name are extracted from the title line:
|
||||
//! "Positions for account <NAME> ...<NUM> as of ..."
|
||||
//!
|
||||
//! 4. Rows with symbol "Cash & Cash Investments" are treated as cash.
|
||||
//! 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`.
|
||||
//! The row with symbol "Positions Total" is skipped.
|
||||
//!
|
||||
//! 5. Hardcodes the expected column layout via fixed indices. Every
|
||||
//! section's header row is validated (see `validateHeader`) to confirm
|
||||
//! the columns the parser reads are where it expects them, so a Schwab
|
||||
//! reorder/rename of a read-column fails loudly as
|
||||
//! `error.UnexpectedHeader` instead of silently mis-reading a value.
|
||||
//! 5. Hardcodes the expected column layout via fixed indices. The header
|
||||
//! row is validated (see `validateHeader`) to confirm the columns the
|
||||
//! parser reads are where it expects them, so a Schwab reorder/rename of
|
||||
//! a read-column fails loudly as `error.UnexpectedHeader` instead of
|
||||
//! silently mis-reading a value.
|
||||
//!
|
||||
//! ## Schwab summary - limitations
|
||||
//!
|
||||
|
|
@ -97,16 +60,12 @@
|
|||
//! account number line (the last whitespace-separated token).
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const Money = @import("../Money.zig");
|
||||
const portfolio_mod = @import("../models/portfolio.zig");
|
||||
const types = @import("types.zig");
|
||||
|
||||
const BrokeragePosition = types.BrokeragePosition;
|
||||
const parseDollarAmount = types.parseDollarAmount;
|
||||
|
||||
const log = std.log.scoped(.schwab);
|
||||
|
||||
const expected_columns = 17;
|
||||
|
||||
const Col = struct {
|
||||
|
|
@ -190,149 +149,66 @@ fn splitCsvLine(line: []const u8, cols: *[expected_columns][]const u8) usize {
|
|||
return col_count;
|
||||
}
|
||||
|
||||
/// The account a run of position rows belongs to.
|
||||
const Account = struct { name: []const u8, number: []const u8 };
|
||||
|
||||
/// 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 };
|
||||
}
|
||||
|
||||
/// What the title line says about where account identity comes from.
|
||||
const Title = union(enum) {
|
||||
/// "Positions for account <NAME> ...<NUM> as of ..." - one account,
|
||||
/// named here.
|
||||
account: Account,
|
||||
/// "Positions for All-Accounts as of ..." - each section names its
|
||||
/// own account on a "<NAME> ...<NUM>" line.
|
||||
all_accounts,
|
||||
};
|
||||
|
||||
/// Classify a Schwab positions title line. Null if it is neither layout.
|
||||
fn parseTitle(line: []const u8) ?Title {
|
||||
/// Extract account name and number from Schwab title line.
|
||||
/// Format: "Positions for account <NAME> ...<NUM> as of <TIME>, <DATE>"
|
||||
/// 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' });
|
||||
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 };
|
||||
|
||||
// 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], &.{' '});
|
||||
|
||||
return .{ .name = name, .number = number };
|
||||
}
|
||||
|
||||
/// True for an "All Brokerage Accounts" export - one that covers every
|
||||
/// account the login can see, rather than one the user picked. Reads only
|
||||
/// the title line.
|
||||
pub fn isAllAccounts(data: []const u8) bool {
|
||||
const title = parseTitle(data[0 .. std.mem.indexOfScalar(u8, data, '\n') orelse data.len]) orelse return false;
|
||||
return title == .all_accounts;
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Parsed Schwab per-account positions CSV. Returned `positions`
|
||||
/// slice is heap-allocated; string fields slice into `data`.
|
||||
pub const CsvResult = struct {
|
||||
positions: []BrokeragePosition,
|
||||
account_name: []const u8,
|
||||
account_number: []const u8,
|
||||
};
|
||||
|
||||
/// 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 {
|
||||
pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) !CsvResult {
|
||||
var positions = std.ArrayList(BrokeragePosition).empty;
|
||||
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');
|
||||
|
||||
const title = parseTitle(lines.first()) orelse return error.UnexpectedHeader;
|
||||
// Line 1: title with account name and number
|
||||
const title_line = lines.next() orelse return error.EmptyFile;
|
||||
const title = parseTitle(title_line) orelse return error.UnexpectedHeader;
|
||||
|
||||
// The account the rows being read belong to: named by the title for
|
||||
// a single-account export, by each section's account line otherwise.
|
||||
var account: ?Account = switch (title) {
|
||||
.account => |a| a,
|
||||
.all_accounts => null,
|
||||
};
|
||||
// Every section opens with a header row, validated before any of its
|
||||
// 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;
|
||||
// Line 2: blank (skip)
|
||||
_ = lines.next();
|
||||
// Line 3: header row - validate the columns we read are where we expect,
|
||||
// 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.
|
||||
const header_line = lines.next() orelse return error.UnexpectedHeader;
|
||||
try validateHeader(std.mem.trimEnd(u8, header_line, &.{ '\r', ' ' }));
|
||||
|
||||
// Data rows
|
||||
while (lines.next()) |line| {
|
||||
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
|
||||
if (trimmed.len == 0) continue;
|
||||
|
||||
var cols: [expected_columns][]const u8 = undefined;
|
||||
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;
|
||||
|
||||
const symbol = cols[Col.symbol];
|
||||
if (symbol.len == 0) continue;
|
||||
if (std.mem.eql(u8, symbol, "Positions Total")) {
|
||||
try checkTotal(current, section, cols[Col.market_value]);
|
||||
section = .{};
|
||||
continue;
|
||||
}
|
||||
if (std.mem.eql(u8, symbol, "Positions Total")) continue;
|
||||
|
||||
// "Cash & Cash Investments" is Schwab's aggregate cash line.
|
||||
// Actual money-market holdings (SWVXX, etc.) appear as normal rows
|
||||
|
|
@ -341,25 +217,24 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) (CsvError || std
|
|||
// "cash" in the account.
|
||||
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
|
||||
portfolio_mod.isMoneyMarketSymbol(symbol);
|
||||
const market_value = parseDollarAmount(cols[Col.market_value]);
|
||||
|
||||
try positions.append(allocator, .{
|
||||
.account_number = current.number,
|
||||
.account_name = current.name,
|
||||
.account_number = title.number,
|
||||
.account_name = title.name,
|
||||
.symbol = symbol,
|
||||
.description = cols[1],
|
||||
.description = if (col_count > 1) cols[1] else "",
|
||||
.quantity = if (is_cash) null else parseDollarAmount(cols[Col.quantity]),
|
||||
.current_value = market_value,
|
||||
.current_value = parseDollarAmount(cols[Col.market_value]),
|
||||
.cost_basis = if (is_cash) null else parseDollarAmount(cols[Col.cost_basis]),
|
||||
.is_cash = is_cash,
|
||||
});
|
||||
section.market_value += market_value orelse 0;
|
||||
section.rows += 1;
|
||||
}
|
||||
|
||||
if (title == .all_accounts and section_count == 0) return error.NoAccountsFound;
|
||||
|
||||
return positions.toOwnedSlice(allocator);
|
||||
return .{
|
||||
.positions = try positions.toOwnedSlice(allocator),
|
||||
.account_name = title.name,
|
||||
.account_number = title.number,
|
||||
};
|
||||
}
|
||||
|
||||
/// Account-level summary from a Schwab paste (no per-position detail).
|
||||
|
|
@ -461,41 +336,17 @@ pub fn parseSummary(allocator: std.mem.Allocator, data: []const u8) ![]AccountSu
|
|||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
test "parseTitle" {
|
||||
const t1 = parseTitle("\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"").?;
|
||||
try std.testing.expectEqualStrings("Sample Trust", t1.account.name);
|
||||
try std.testing.expectEqualStrings("1234", t1.account.number);
|
||||
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.?.name);
|
||||
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\"").?;
|
||||
try std.testing.expectEqualStrings("Sample IRA", t2.account.name);
|
||||
try std.testing.expectEqualStrings("5678", t2.account.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);
|
||||
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.?.name);
|
||||
try std.testing.expectEqualStrings("5678", t2.?.number);
|
||||
|
||||
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" {
|
||||
|
|
@ -517,32 +368,29 @@ 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" ++
|
||||
"\"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" ++
|
||||
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$364,212.62\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
||||
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$856,805.99\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
||||
|
||||
const allocator = std.testing.allocator;
|
||||
const positions = try parseCsv(allocator, csv);
|
||||
defer allocator.free(positions);
|
||||
const parsed = try parseCsv(allocator, csv);
|
||||
defer allocator.free(parsed.positions);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), positions.len);
|
||||
try std.testing.expectEqualStrings("Sample Trust", parsed.account_name);
|
||||
try std.testing.expectEqualStrings("1234", parsed.account_number);
|
||||
|
||||
// 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);
|
||||
}
|
||||
try std.testing.expectEqual(@as(usize, 2), parsed.positions.len);
|
||||
|
||||
// Stock position
|
||||
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
|
||||
try std.testing.expect(!positions[0].is_cash);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1488), positions[0].quantity.?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 355941.50), positions[0].current_value.?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), positions[0].cost_basis.?, 0.01);
|
||||
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
||||
try std.testing.expect(!parsed.positions[0].is_cash);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1488), parsed.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, 110243.38), parsed.positions[0].cost_basis.?, 0.01);
|
||||
|
||||
// Cash
|
||||
try std.testing.expectEqualStrings("Cash & Cash Investments", positions[1].symbol);
|
||||
try std.testing.expect(positions[1].is_cash);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), positions[1].current_value.?, 0.01);
|
||||
try std.testing.expect(positions[1].quantity == null);
|
||||
try std.testing.expectEqualStrings("Cash & Cash Investments", parsed.positions[1].symbol);
|
||||
try std.testing.expect(parsed.positions[1].is_cash);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), parsed.positions[1].current_value.?, 0.01);
|
||||
try std.testing.expect(parsed.positions[1].quantity == null);
|
||||
}
|
||||
|
||||
test "parseCsv accepts the real Short (Long) header labels" {
|
||||
|
|
@ -555,14 +403,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" ++
|
||||
"\"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 positions = try parseCsv(allocator, csv);
|
||||
defer allocator.free(positions);
|
||||
const parsed = try parseCsv(allocator, csv);
|
||||
defer allocator.free(parsed.positions);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||||
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), positions[0].current_value.?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[0].cost_basis.?, 0.01);
|
||||
try std.testing.expectEqual(@as(usize, 1), parsed.positions.len);
|
||||
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 100), parsed.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, 10000.00), parsed.positions[0].cost_basis.?, 0.01);
|
||||
}
|
||||
|
||||
test "parseCsv rejects a header whose read-columns are reordered" {
|
||||
|
|
@ -586,13 +434,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" ++
|
||||
"\"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 positions = try parseCsv(allocator, csv);
|
||||
defer allocator.free(positions);
|
||||
const parsed = try parseCsv(allocator, csv);
|
||||
defer allocator.free(parsed.positions);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||||
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), positions[0].current_value.?, 0.01);
|
||||
try std.testing.expectEqual(@as(usize, 1), parsed.positions.len);
|
||||
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 100), parsed.positions[0].quantity.?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), parsed.positions[0].current_value.?, 0.01);
|
||||
}
|
||||
|
||||
test "parseCsv rejects a header with too few columns" {
|
||||
|
|
@ -604,166 +452,6 @@ test "parseCsv rejects a header with too few columns" {
|
|||
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" {
|
||||
const data =
|
||||
\\Sample Roth
|
||||
|
|
@ -839,12 +527,3 @@ test "parseSummary no accounts" {
|
|||
const result = parseSummary(allocator, "some random text\nno accounts here\n");
|
||||
try std.testing.expectError(error.NoAccountsFound, result);
|
||||
}
|
||||
|
||||
test "isAllAccounts: keys on the title line only" {
|
||||
try std.testing.expect(isAllAccounts("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n\"Sample_IRA ...1234\"\n"));
|
||||
try std.testing.expect(isAllAccounts("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\""));
|
||||
try std.testing.expect(!isAllAccounts("\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n"));
|
||||
// A later line mentioning the phrase does not count.
|
||||
try std.testing.expect(!isAllAccounts("something else\n\"Positions for All-Accounts\"\n"));
|
||||
try std.testing.expect(!isAllAccounts(""));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,8 @@
|
|||
//! pulling in the reconciliation surface.
|
||||
|
||||
const std = @import("std");
|
||||
const zfin = @import("../root.zig");
|
||||
const cli = @import("common.zig");
|
||||
const framework = @import("framework.zig");
|
||||
const analysis = @import("../analytics/analysis.zig");
|
||||
|
||||
const common = @import("audit/common.zig");
|
||||
const fidelity = @import("../analytics/reconcile/fidelity.zig");
|
||||
|
|
@ -65,8 +63,7 @@ pub const meta: framework.Meta = .{
|
|||
\\ --stale-days <N> Manual price staleness threshold (default 3)
|
||||
\\ --fidelity <CSV> Fidelity positions CSV export
|
||||
\\ ("All accounts" -> Positions tab -> Download)
|
||||
\\ --schwab <CSV> Schwab positions CSV export (one account,
|
||||
\\ or "All Brokerage Accounts")
|
||||
\\ --schwab <CSV> Schwab per-account positions CSV export
|
||||
\\ --schwab-summary Schwab account summary; copy from accounts
|
||||
\\ summary page, paste to stdin, then ^D
|
||||
\\ --wells-fargo <XLS> Wells Fargo positions spreadsheet
|
||||
|
|
@ -212,32 +209,23 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
|
||||
var stdin_reader_buf: [4096]u8 = undefined;
|
||||
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
|
||||
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch |err| {
|
||||
var msg_buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read stdin: {s}\n", .{@errorName(err)}) catch "Error: Cannot read stdin\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch {
|
||||
cli.stderrPrint(io, "Error: Cannot read stdin\n");
|
||||
return;
|
||||
};
|
||||
defer allocator.free(stdin_data);
|
||||
|
||||
const results = schwab.reconcileSummary(allocator, portfolio, stdin_data, account_map, prices, as_of) catch |err| switch (err) {
|
||||
error.OutOfMemory => return err,
|
||||
else => |e| {
|
||||
var msg_buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse Schwab summary: {s} (no 'Account number ending in' lines found?)\n", .{@errorName(e)}) catch "Error: Cannot parse Schwab summary\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
else => {
|
||||
cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n");
|
||||
return;
|
||||
},
|
||||
};
|
||||
defer allocator.free(results);
|
||||
|
||||
// A summary paste covers every account on the page.
|
||||
const view = common.splitUnmapped(schwab.SchwabAccountComparison, results, .all_accounts);
|
||||
if (!view.skipped()) {
|
||||
try schwab.displaySchwabResults(view.shown, color, out);
|
||||
try schwab.displaySchwabSummaryRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, as_of, color, out);
|
||||
}
|
||||
try common.displayUnmapped(schwab.SchwabAccountComparison, "schwab summary", view, true, color, out);
|
||||
try schwab.displaySchwabResults(results, color, out);
|
||||
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out);
|
||||
|
||||
const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
|
||||
defer allocator.free(present);
|
||||
|
|
@ -248,9 +236,9 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
|
||||
// Fidelity CSV
|
||||
if (fidelity_csv) |csv_path| {
|
||||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file {s}: {s}\n", .{ csv_path, @errorName(err) }) catch "Error: Cannot read CSV file\n";
|
||||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
var msg_buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
return;
|
||||
};
|
||||
|
|
@ -258,10 +246,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
|
||||
const results = fidelity.reconcile(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) {
|
||||
error.OutOfMemory => return err,
|
||||
else => |e| {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse Fidelity CSV {s}: {s}\n", .{ csv_path, @errorName(e) }) catch "Error: Cannot parse Fidelity CSV\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
else => {
|
||||
cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
|
@ -270,8 +256,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
allocator.free(results);
|
||||
}
|
||||
|
||||
// Fidelity's download covers every account in the login.
|
||||
try showAccountExport(allocator, "fidelity", results, .all_accounts, portfolio, prices, account_map, color, out);
|
||||
try common.displayResults(results, color, out);
|
||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||
|
||||
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
|
||||
defer allocator.free(present);
|
||||
|
|
@ -280,11 +266,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
try common.displayAbsentAccounts(absent, color, "this export", out);
|
||||
}
|
||||
|
||||
// Schwab positions CSV (one account, or All Brokerage Accounts)
|
||||
// Schwab per-account CSV
|
||||
if (schwab_csv) |csv_path| {
|
||||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file {s}: {s}\n", .{ csv_path, @errorName(err) }) catch "Error: Cannot read CSV file\n";
|
||||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
var msg_buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
return;
|
||||
};
|
||||
|
|
@ -292,10 +278,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
|
||||
const results = schwab.reconcileCsv(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) {
|
||||
error.OutOfMemory => return err,
|
||||
else => |e| {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse Schwab CSV {s}: {s}\n", .{ csv_path, @errorName(e) }) catch "Error: Cannot parse Schwab CSV\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
else => {
|
||||
cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
|
@ -304,7 +288,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
allocator.free(results);
|
||||
}
|
||||
|
||||
try showAccountExport(allocator, "schwab", results, schwab.csvScope(csv_data), portfolio, prices, account_map, color, out);
|
||||
try common.displayResults(results, color, out);
|
||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||
|
||||
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
|
||||
defer allocator.free(present);
|
||||
|
|
@ -335,8 +320,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
};
|
||||
defer rec.deinit(allocator);
|
||||
|
||||
// One WF download covers a whole household.
|
||||
try showAccountExport(allocator, "wells fargo", rec.results, .all_accounts, portfolio, prices, account_map, color, out);
|
||||
try common.displayResults(rec.results, color, out);
|
||||
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out);
|
||||
|
||||
const present = try common.presentNumbers(allocator, common.AccountComparison, rec.results);
|
||||
defer allocator.free(present);
|
||||
|
|
@ -346,31 +331,6 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
}
|
||||
}
|
||||
|
||||
/// Show one explicitly-requested per-position export: the comparison
|
||||
/// and ratio suggestions for its mapped accounts, then its unmapped ones
|
||||
/// (see `common.splitUnmapped`). The unmapped list is printed even for an
|
||||
/// export skipped as another portfolio's - you pointed audit at this
|
||||
/// file, so the account numbers to map are worth seeing. Reorders
|
||||
/// `results` in place.
|
||||
fn showAccountExport(
|
||||
allocator: std.mem.Allocator,
|
||||
label: []const u8,
|
||||
results: []common.AccountComparison,
|
||||
scope: common.ExportScope,
|
||||
portfolio: zfin.Portfolio,
|
||||
prices: std.StringHashMap(f64),
|
||||
account_map: analysis.AccountMap,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
) !void {
|
||||
const view = common.splitUnmapped(common.AccountComparison, results, scope);
|
||||
if (!view.skipped()) {
|
||||
try common.displayResults(view.shown, color, out);
|
||||
try common.displayRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, color, out);
|
||||
}
|
||||
try common.displayUnmapped(common.AccountComparison, label, view, true, color, out);
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
test "parseArgs: defaults" {
|
||||
|
|
|
|||
|
|
@ -24,54 +24,10 @@ pub const SymbolComparison = reconcile.SymbolComparison;
|
|||
pub const AccountComparison = reconcile.AccountComparison;
|
||||
pub const compareAccounts = reconcile.compareAccounts;
|
||||
pub const hasAccountDiscrepancies = reconcile.hasAccountDiscrepancies;
|
||||
pub const ExportScope = reconcile.ExportScope;
|
||||
pub const ExportView = reconcile.ExportView;
|
||||
pub const splitUnmapped = reconcile.splitUnmapped;
|
||||
pub const AbsentAccount = reconcile.AbsentAccount;
|
||||
pub const presentNumbers = reconcile.presentNumbers;
|
||||
pub const findAbsentAccounts = reconcile.findAbsentAccounts;
|
||||
|
||||
// ── Unmapped accounts ────────────────────────────────────────
|
||||
|
||||
/// Report an export's unmapped accounts (see `splitUnmapped`) in muted
|
||||
/// text: a line with the count, then one line per account so the number
|
||||
/// to map is right there. For an export skipped outright, the
|
||||
/// per-account list is printed only when `list_skipped`: someone else's
|
||||
/// whole household is noise on every routine audit, but worth seeing
|
||||
/// when you point audit at the file yourself. Prints nothing when every
|
||||
/// account is mapped. `T` is `AccountComparison` or
|
||||
/// `SchwabAccountComparison`; both carry `brokerage_name` and
|
||||
/// `account_number`.
|
||||
pub fn displayUnmapped(
|
||||
comptime T: type,
|
||||
label: []const u8,
|
||||
view: ExportView(T),
|
||||
list_skipped: bool,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
) !void {
|
||||
const n = view.unmapped.len;
|
||||
if (n == 0) return;
|
||||
if (view.skipped()) {
|
||||
if (n == 1) {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s}: its only account is not in accounts.srf; skipped (another portfolio's export?)\n", .{label});
|
||||
} else {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s}: none of its {d} accounts are in accounts.srf; skipped (another portfolio's export?)\n", .{ label, n });
|
||||
}
|
||||
if (!list_skipped) return;
|
||||
} else {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s}: {d} {s} not in accounts.srf, not reconciled\n", .{ label, n, accountsNoun(n) });
|
||||
}
|
||||
for (view.unmapped) |r| {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s} #{s}\n", .{ r.brokerage_name, r.account_number });
|
||||
}
|
||||
}
|
||||
|
||||
/// "account" or "accounts", for a count of them.
|
||||
pub fn accountsNoun(n: usize) []const u8 {
|
||||
return if (n == 1) "account" else "accounts";
|
||||
}
|
||||
|
||||
// ── Ratio suggestions ────────────────────────────────────────
|
||||
|
||||
/// After displaying audit results, check for price_ratio positions where
|
||||
|
|
@ -591,91 +547,3 @@ test "displayAbsentAccounts: silent when empty, renders names + totals otherwise
|
|||
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "not found in any export") != null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── displayUnmapped ──────────────────────────────────────────
|
||||
|
||||
fn testComparison(account_name: []const u8, brokerage_name: []const u8, number: []const u8) AccountComparison {
|
||||
return .{
|
||||
.account_name = account_name,
|
||||
.brokerage_name = brokerage_name,
|
||||
.account_number = number,
|
||||
.comparisons = &.{},
|
||||
.portfolio_total = 0,
|
||||
.brokerage_total = 0,
|
||||
.total_delta = 0,
|
||||
.option_value_delta = 0,
|
||||
.has_discrepancies = account_name.len == 0,
|
||||
};
|
||||
}
|
||||
|
||||
fn renderUnmapped(buf: []u8, results: []AccountComparison, scope: ExportScope, list_skipped: bool) ![]const u8 {
|
||||
var w: std.Io.Writer = .fixed(buf);
|
||||
try displayUnmapped(AccountComparison, "schwab", splitUnmapped(AccountComparison, results, scope), list_skipped, false, &w);
|
||||
return w.buffered();
|
||||
}
|
||||
|
||||
test "displayUnmapped: names each unmapped account of an all-accounts export" {
|
||||
var results = [_]AccountComparison{
|
||||
testComparison("Sample IRA", "Sample_IRA", "1234"),
|
||||
testComparison("", "Sample_Account", "9012"),
|
||||
};
|
||||
var buf: [512]u8 = undefined;
|
||||
const out = try renderUnmapped(&buf, &results, .all_accounts, false);
|
||||
try std.testing.expectEqualStrings(
|
||||
" schwab: 1 account not in accounts.srf, not reconciled\n" ++
|
||||
" Sample_Account #9012\n",
|
||||
out,
|
||||
);
|
||||
|
||||
// Plural, and the list follows the count.
|
||||
var two = [_]AccountComparison{
|
||||
testComparison("Sample IRA", "Sample_IRA", "1234"),
|
||||
testComparison("", "Sample_Account", "9012"),
|
||||
testComparison("", "Sample_Trust", "3456"),
|
||||
};
|
||||
const out2 = try renderUnmapped(&buf, &two, .all_accounts, false);
|
||||
try std.testing.expect(std.mem.startsWith(u8, out2, " schwab: 2 accounts not in accounts.srf, not reconciled\n"));
|
||||
try std.testing.expect(std.mem.indexOf(u8, out2, " Sample_Trust #3456\n") != null);
|
||||
}
|
||||
|
||||
test "displayUnmapped: a skipped export lists its accounts only when asked" {
|
||||
var buf: [512]u8 = undefined;
|
||||
|
||||
var one = [_]AccountComparison{testComparison("", "Sample_Account", "9012")};
|
||||
try std.testing.expectEqualStrings(
|
||||
" schwab: its only account is not in accounts.srf; skipped (another portfolio's export?)\n",
|
||||
try renderUnmapped(&buf, &one, .all_accounts, false),
|
||||
);
|
||||
|
||||
var two = [_]AccountComparison{ testComparison("", "Sample_Account", "9012"), testComparison("", "Sample_Trust", "3456") };
|
||||
try std.testing.expectEqualStrings(
|
||||
" schwab: none of its 2 accounts are in accounts.srf; skipped (another portfolio's export?)\n",
|
||||
try renderUnmapped(&buf, &two, .all_accounts, false),
|
||||
);
|
||||
const listed = try renderUnmapped(&buf, &two, .all_accounts, true);
|
||||
try std.testing.expect(std.mem.indexOf(u8, listed, " Sample_Account #9012\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, listed, " Sample_Trust #3456\n") != null);
|
||||
}
|
||||
|
||||
test "displayUnmapped: silent when every account maps, and for a one-account export" {
|
||||
var buf: [512]u8 = undefined;
|
||||
var mapped = [_]AccountComparison{testComparison("Sample IRA", "Sample_IRA", "1234")};
|
||||
try std.testing.expectEqualStrings("", try renderUnmapped(&buf, &mapped, .all_accounts, true));
|
||||
// A one-account export's unmapped account gets the full report from
|
||||
// displayResults instead.
|
||||
var picked = [_]AccountComparison{testComparison("", "Sample_Account", "9012")};
|
||||
try std.testing.expectEqualStrings("", try renderUnmapped(&buf, &picked, .one_account, true));
|
||||
}
|
||||
|
||||
test "displayUnmapped: color mutes the note" {
|
||||
var results = [_]AccountComparison{
|
||||
testComparison("Sample IRA", "Sample_IRA", "1234"),
|
||||
testComparison("", "Sample_Account", "9012"),
|
||||
};
|
||||
var buf: [512]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try displayUnmapped(AccountComparison, "schwab", splitUnmapped(AccountComparison, &results, .all_accounts), false, true, &w);
|
||||
var seqbuf: [32]u8 = undefined;
|
||||
const mute_seq = try std.fmt.bufPrint(&seqbuf, "\x1b[38;2;{d};{d};{d}m", .{ cli.CLR_MUTED[0], cli.CLR_MUTED[1], cli.CLR_MUTED[2] });
|
||||
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), mute_seq) != null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -737,42 +737,6 @@ fn accumulatePresent(
|
|||
for (present) |num| try dst.append(allocator, try allocator.dupe(u8, num));
|
||||
}
|
||||
|
||||
/// Report one discovered per-position export (Fidelity, a Schwab CSV,
|
||||
/// Wells Fargo): the full comparison when `verbose` or anything is off,
|
||||
/// else a one-line all-clear; then the export's unmapped accounts (see
|
||||
/// `common.splitUnmapped`). Returns false when the export was skipped as
|
||||
/// another portfolio's, so the caller leaves its accounts out of the
|
||||
/// "not found in any export" present-set. Reorders `results` in place.
|
||||
fn reportAccountExport(
|
||||
allocator: std.mem.Allocator,
|
||||
label: []const u8,
|
||||
results: []common.AccountComparison,
|
||||
scope: common.ExportScope,
|
||||
portfolio: zfin.Portfolio,
|
||||
prices: std.StringHashMap(f64),
|
||||
account_map: analysis.AccountMap,
|
||||
verbose: bool,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
) !bool {
|
||||
const view = common.splitUnmapped(common.AccountComparison, results, scope);
|
||||
if (view.skipped()) {
|
||||
try common.displayUnmapped(common.AccountComparison, label, view, verbose, color, out);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (verbose or common.hasAccountDiscrepancies(view.shown)) {
|
||||
try out.print("\n", .{});
|
||||
try common.displayResults(view.shown, color, out);
|
||||
} else {
|
||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}: {d} {s}, no discrepancies\n", .{ label, view.shown.len, common.accountsNoun(view.shown.len) });
|
||||
}
|
||||
// Always show ratio suggestions, even in compact mode.
|
||||
try common.displayRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, color, out);
|
||||
try common.displayUnmapped(common.AccountComparison, label, view, verbose, color, out);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Run the flagless portfolio hygiene check.
|
||||
pub fn runHygieneCheck(
|
||||
io: std.Io,
|
||||
|
|
@ -1192,24 +1156,21 @@ pub fn runHygieneCheck(
|
|||
};
|
||||
defer allocator.free(results);
|
||||
|
||||
// A summary paste covers every account on the page.
|
||||
const view = common.splitUnmapped(schwab.SchwabAccountComparison, results, .all_accounts);
|
||||
if (view.skipped()) {
|
||||
try common.displayUnmapped(schwab.SchwabAccountComparison, "schwab summary", view, verbose, color, out);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (verbose or schwab.hasSchwabDiscrepancies(view.shown)) {
|
||||
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
|
||||
try out.print("\n", .{});
|
||||
try schwab.displaySchwabResults(view.shown, color, out);
|
||||
try schwab.displaySchwabResults(results, color, out);
|
||||
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out);
|
||||
} else {
|
||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} {s}, no discrepancies\n", .{ view.shown.len, common.accountsNoun(view.shown.len) });
|
||||
var acct_count: usize = 0;
|
||||
for (results) |r| {
|
||||
if (r.account_name.len > 0) acct_count += 1;
|
||||
}
|
||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count});
|
||||
// Always show ratio suggestions even in compact
|
||||
// mode - direct-indexing drift may cause a
|
||||
// non-zero delta that still deserves a nudge.
|
||||
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out);
|
||||
}
|
||||
// Always show ratio suggestions, even in compact mode -
|
||||
// direct-indexing drift may cause a non-zero delta that
|
||||
// still deserves a nudge.
|
||||
try schwab.displaySchwabSummaryRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, as_of, color, out);
|
||||
try common.displayUnmapped(schwab.SchwabAccountComparison, "schwab summary", view, verbose, color, out);
|
||||
|
||||
try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results);
|
||||
},
|
||||
|
|
@ -1223,9 +1184,17 @@ pub fn runHygieneCheck(
|
|||
allocator.free(results);
|
||||
}
|
||||
|
||||
// Fidelity's download covers every account in the login.
|
||||
if (try reportAccountExport(allocator, "fidelity", results, .all_accounts, portfolio, prices, account_map, verbose, color, out))
|
||||
try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results);
|
||||
if (verbose or common.hasAccountDiscrepancies(results)) {
|
||||
try out.print("\n", .{});
|
||||
try common.displayResults(results, color, out);
|
||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||
} else {
|
||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len});
|
||||
// Always show ratio suggestions even in compact mode
|
||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||
}
|
||||
|
||||
try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results);
|
||||
},
|
||||
.schwab_csv => {
|
||||
const results = schwab.reconcileCsv(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| {
|
||||
|
|
@ -1237,8 +1206,16 @@ pub fn runHygieneCheck(
|
|||
allocator.free(results);
|
||||
}
|
||||
|
||||
if (try reportAccountExport(allocator, "schwab", results, schwab.csvScope(file_data), portfolio, prices, account_map, verbose, color, out))
|
||||
try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results);
|
||||
if (verbose or common.hasAccountDiscrepancies(results)) {
|
||||
try out.print("\n", .{});
|
||||
try common.displayResults(results, color, out);
|
||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||
} else {
|
||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len});
|
||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||
}
|
||||
|
||||
try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results);
|
||||
},
|
||||
.wells_fargo_xls => {
|
||||
var rec = wells_fargo.reconcileXls(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| switch (err) {
|
||||
|
|
@ -1250,9 +1227,25 @@ pub fn runHygieneCheck(
|
|||
};
|
||||
defer rec.deinit(allocator);
|
||||
|
||||
// One WF download covers a whole household.
|
||||
if (try reportAccountExport(allocator, "wells fargo", rec.results, .all_accounts, portfolio, prices, account_map, verbose, color, out))
|
||||
try accumulatePresent(allocator, &wells_fargo_present, common.AccountComparison, rec.results);
|
||||
// One WF download covers a whole household. If none of
|
||||
// its accounts is in this portfolio's accounts.srf, it is
|
||||
// someone else's export sitting in a shared directory;
|
||||
// listing every account as a discrepancy would be noise.
|
||||
if (!rec.mapsAnyAccount()) {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " wells fargo: none of its {d} accounts are in accounts.srf; skipped (another portfolio's export?)\n", .{rec.results.len});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (verbose or common.hasAccountDiscrepancies(rec.results)) {
|
||||
try out.print("\n", .{});
|
||||
try common.displayResults(rec.results, color, out);
|
||||
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out);
|
||||
} else {
|
||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " wells fargo: {d} accounts, no discrepancies\n", .{rec.results.len});
|
||||
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out);
|
||||
}
|
||||
|
||||
try accumulatePresent(allocator, &wells_fargo_present, common.AccountComparison, rec.results);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -2709,99 +2702,3 @@ test "printLargeLotWarning: an undated stock lot keeps the literal date in dest_
|
|||
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::<DATE>,") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "dest_lot::VTI@1970-01-01") != null);
|
||||
}
|
||||
|
||||
test "runHygieneCheck: discovered exports set unmapped accounts aside unless one was picked on purpose" {
|
||||
const allocator = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "portfolio.srf",
|
||||
.data = "#!srfv1\nsymbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:200,price:num:300,account::Sample IRA\n",
|
||||
});
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "accounts.srf",
|
||||
.data = "#!srfv1\naccount::Sample IRA,tax_type::traditional,institution::schwab,account_number::1234\n",
|
||||
});
|
||||
|
||||
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 vti = "\"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";
|
||||
const aapl = "\"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";
|
||||
const all_title = "\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n";
|
||||
try tmp.dir.createDirPath(io, "audit");
|
||||
// Every account: ours (1234), plus one this portfolio doesn't track.
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "audit/all.csv",
|
||||
.data = all_title ++ "\"Sample_IRA ...1234\"\n" ++ header ++ vti ++ "\n\n\"Sample_Account ...9012\"\n" ++ header ++ aapl,
|
||||
});
|
||||
// One account, picked on purpose - and unmapped, which is a setup error.
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "audit/one.csv",
|
||||
.data = "\"Positions for account Sample Brokerage ...5678 as of 10:00 AM ET, 2026/06/27\"\n\n" ++ header ++ aapl,
|
||||
});
|
||||
// Every account of a login none of whose accounts are ours.
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "audit/other.csv",
|
||||
.data = all_title ++ "\"Sample_Other ...3456\"\n" ++ header ++ aapl,
|
||||
});
|
||||
// Same rule for other brokers' all-accounts exports: a Fidelity
|
||||
// download of someone else's login is skipped...
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "audit/fidelity.csv",
|
||||
.data = "Account number,Account name,Symbol,Description,Quantity,Last price,Last price change,Current value,Today's gain/loss dollar,Today's gain/loss percent,Total gain/loss dollar,Total gain/loss percent,Percent of account,Cost basis total,Average cost basis,Type\n" ++
|
||||
"Z123,Sample Fid,AAPL,APPLE INC,10,$200.00,+$2.00,$2000.00,+$20.00,+1%,+$500.00,+33%,100%,$1500.00,$150.00,Margin,\n" ++
|
||||
"\n" ++
|
||||
"\"Brokerage services are provided by Fidelity Brokerage Services LLC (FBS), 900 Salem Street, Smithfield, RI 02917. ...\"\n",
|
||||
});
|
||||
// ...and a summary paste reconciles our account, noting the other.
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "audit/summary.txt",
|
||||
.data = "Sample IRA\nAccount number ending in 1234 ...1234\nType IRA $0.00 $30,000.00 +$0.00 +0%\n" ++
|
||||
"Sample Account\nAccount number ending in 9012 ...9012\nType Brokerage $0.00 $2,000.00 +$0.00 +0%\n",
|
||||
});
|
||||
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir = path_buf[0..dir_len];
|
||||
|
||||
// No API keys / no server / no ZFIN_AUDIT_FILES -> hermetic; only
|
||||
// the portfolio's own audit/ directory is searched.
|
||||
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
|
||||
defer svc.deinit();
|
||||
var env = try std.testing.environ.createMap(allocator);
|
||||
defer env.deinit();
|
||||
|
||||
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
||||
defer allocator.free(pf_path);
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||
defer aw.deinit();
|
||||
|
||||
// wall-clock required: discovery keeps only exports modified in the
|
||||
// last 24 hours, judged against the real mtimes of the files above.
|
||||
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
||||
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 10, 6), now_s, false, .never, &aw.writer);
|
||||
const out = aw.written();
|
||||
|
||||
// all.csv: the mapped account reconciles clean; the other is named
|
||||
// on one muted line, not reported as a discrepancy.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, " schwab: 1 account, no discrepancies\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, " schwab: 1 account not in accounts.srf, not reconciled\n Sample_Account #9012\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Sample_Account #9012 (unmapped") == null);
|
||||
|
||||
// one.csv: the full unmapped report, as before.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Brokerage #5678 (unmapped - add account_number to accounts.srf)") != null);
|
||||
|
||||
// other.csv: skipped, and its account not listed in a routine audit.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, " schwab: its only account is not in accounts.srf; skipped (another portfolio's export?)\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Sample_Other") == null);
|
||||
|
||||
// fidelity.csv: likewise skipped.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, " fidelity: its only account is not in accounts.srf; skipped (another portfolio's export?)\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Fid") == null);
|
||||
|
||||
// summary.txt: totals reconcile for ours; the other is noted.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, " schwab summary: 1 account, no discrepancies\n") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, " schwab summary: 1 account not in accounts.srf, not reconciled\n Sample Account #9012\n") != null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ const reconcile = @import("../../analytics/reconcile.zig");
|
|||
pub const SchwabAccountComparison = reconcile.SchwabAccountComparison;
|
||||
pub const compareSchwabSummary = reconcile.compareSchwabSummary;
|
||||
pub const reconcileCsv = reconcile.reconcileCsv;
|
||||
pub const csvScope = reconcile.schwabCsvScope;
|
||||
pub const reconcileSummary = reconcile.reconcileSummary;
|
||||
pub const hasSchwabDiscrepancies = reconcile.hasSchwabDiscrepancies;
|
||||
|
||||
|
|
@ -34,7 +33,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
|
|||
var discrepancy_count: usize = 0;
|
||||
|
||||
for (results) |r| {
|
||||
const label = if (r.account_name.len > 0) r.account_name else r.brokerage_name;
|
||||
const label = if (r.account_name.len > 0) r.account_name else r.schwab_name;
|
||||
|
||||
var br_cash_buf: [24]u8 = undefined;
|
||||
var br_total_buf: [24]u8 = undefined;
|
||||
|
|
@ -201,14 +200,14 @@ pub fn displaySchwabSummaryRatioSuggestions(
|
|||
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
|
||||
const results = [_]SchwabAccountComparison{
|
||||
// clean mapped -> no status
|
||||
.{ .account_name = "Sample Roth", .brokerage_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false },
|
||||
.{ .account_name = "Sample Roth", .schwab_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false },
|
||||
// cash mismatch -> "Cash +$5.00", counts as a real mismatch
|
||||
.{ .account_name = "Sample Trust", .brokerage_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true },
|
||||
.{ .account_name = "Sample Trust", .schwab_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true },
|
||||
// value delta within the CD band (cash ok) -> muted "Value
|
||||
// +$100.00", shown but not counted as a real mismatch
|
||||
.{ .account_name = "Sample HSA", .brokerage_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .cd_allowance = 200, .has_discrepancy = false },
|
||||
.{ .account_name = "Sample HSA", .schwab_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .cd_allowance = 200, .has_discrepancy = false },
|
||||
// unmapped, null broker fields -> "Unmapped" + "--", counts as a real mismatch
|
||||
.{ .account_name = "", .brokerage_name = "Sample Brokerage 3456", .account_number = "3456", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
|
||||
.{ .account_name = "", .schwab_name = "Sample Brokerage 3456", .account_number = "3456", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
|
||||
};
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
|
|
@ -221,7 +220,7 @@ test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals"
|
|||
try std.testing.expect(std.mem.indexOf(u8, out, "Cash +") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Unmapped") != null);
|
||||
// unmapped row falls back to the brokerage_name label
|
||||
// unmapped row falls back to the schwab_name label
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Brokerage 3456") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "--") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
|
||||
|
|
@ -233,7 +232,7 @@ test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals"
|
|||
|
||||
test "displaySchwabResults: color=true emits ANSI and singular label" {
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "", .brokerage_name = "Sample Brokerage 9999", .account_number = "9999", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
|
||||
.{ .account_name = "", .schwab_name = "Sample Brokerage 9999", .account_number = "9999", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
|
||||
};
|
||||
var buf: [2048]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
@ -250,7 +249,7 @@ test "displaySchwabResults: value delta with no CD or options warns and counts"
|
|||
// The Kelly-IRA shape: cash matches to the penny, but the total is
|
||||
// off (a missing lot). No CD, no options -> a real, counted warning.
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample IRA", .brokerage_name = "IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 250000, .schwab_total = 251740.29, .total_delta = 1740.29, .has_discrepancy = true },
|
||||
.{ .account_name = "Sample IRA", .schwab_name = "IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 250000, .schwab_total = 251740.29, .total_delta = 1740.29, .has_discrepancy = true },
|
||||
};
|
||||
var buf: [4096]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
@ -270,9 +269,9 @@ test "displaySchwabResults: CD-band and option value deltas are muted, shown, an
|
|||
// is warning-colored and neither counts as a mismatch.
|
||||
const results = [_]SchwabAccountComparison{
|
||||
// CD account: $8.70 mark sits within a $154 band -> expected/muted.
|
||||
.{ .account_name = "Sample IRA", .brokerage_name = "IRA", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 100000, .schwab_total = 99991.30, .total_delta = -8.70, .cd_allowance = 154, .has_discrepancy = true },
|
||||
.{ .account_name = "Sample IRA", .schwab_name = "IRA", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 100000, .schwab_total = 99991.30, .total_delta = -8.70, .cd_allowance = 154, .has_discrepancy = true },
|
||||
// Option account: cost-vs-market gap is unbounded -> muted, drill down.
|
||||
.{ .account_name = "Sample Roth", .brokerage_name = "Roth", .account_number = "5678", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 55000, .total_delta = 5000, .has_options = true, .has_discrepancy = true },
|
||||
.{ .account_name = "Sample Roth", .schwab_name = "Roth", .account_number = "5678", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 55000, .total_delta = 5000, .has_options = true, .has_discrepancy = true },
|
||||
};
|
||||
var buf: [4096]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
@ -311,7 +310,7 @@ test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot dir
|
|||
|
||||
// total_delta 1000 on a 50000 stock value -> suggested ratio 1.02 vs 1.0.
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
};
|
||||
|
||||
var buf: [2048]u8 = undefined;
|
||||
|
|
@ -331,7 +330,7 @@ test "displaySchwabSummaryRatioSuggestions: no account_map produces no output" {
|
|||
defer prices.deinit();
|
||||
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
};
|
||||
var buf: [512]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
@ -356,7 +355,7 @@ test "displaySchwabSummaryRatioSuggestions: non-direct-indexing account is skipp
|
|||
try prices.put("SPY", 500.0);
|
||||
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
};
|
||||
var buf: [512]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
|
|||
|
|
@ -223,8 +223,7 @@ pub const meta: framework.Meta = .{
|
|||
\\ concrete path, not a glob). REQUIRED.
|
||||
\\ --fidelity <CSV> Fidelity positions CSV
|
||||
\\ ("All accounts" -> Positions tab -> Download)
|
||||
\\ --schwab <CSV> Schwab positions CSV (one account, or
|
||||
\\ "All Brokerage Accounts")
|
||||
\\ --schwab <CSV> Schwab per-account positions CSV
|
||||
\\ --wells-fargo <XLS> Wells Fargo positions spreadsheet
|
||||
\\ (Download Type "Portfolio-Expanded
|
||||
\\ Detail", Portfolio View "Positions",
|
||||
|
|
@ -347,7 +346,10 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
|
||||
const positions: []BrokeragePosition = switch (parsed.source) {
|
||||
.fidelity => try fidelity.parseCsv(allocator, source_data),
|
||||
.schwab => try schwab.parseCsv(allocator, source_data),
|
||||
.schwab => blk: {
|
||||
const r = try schwab.parseCsv(allocator, source_data);
|
||||
break :blk r.positions;
|
||||
},
|
||||
.wells_fargo => blk: {
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue