Compare commits
2 commits
858e9874b4
...
d5459ff39e
| Author | SHA1 | Date | |
|---|---|---|---|
| d5459ff39e | |||
| 635a0557b4 |
14 changed files with 1134 additions and 257 deletions
|
|
@ -26,16 +26,18 @@ Fargo**.
|
||||||
| Broker | How to export | Flag |
|
| Broker | How to export | Flag |
|
||||||
|--------------------------|-------------------------------------------------------------------------------------------------------------|-----------------------|
|
|--------------------------|-------------------------------------------------------------------------------------------------------------|-----------------------|
|
||||||
| **Fidelity** | *Positions* tab -> the three-dot (**⋮**) menu -> **Download** (a CSV) | `--fidelity <CSV>` |
|
| **Fidelity** | *Positions* tab -> the three-dot (**⋮**) menu -> **Download** (a CSV) | `--fidelity <CSV>` |
|
||||||
| **Schwab** (per-account) | *Accounts -> Positions* -> **Export** (one CSV per account) | `--schwab <CSV>` |
|
| **Schwab** (positions) | *Accounts -> Positions*: pick **All Brokerage Accounts** (or one account) in the dropdown -> **Export** | `--schwab <CSV>` |
|
||||||
| **Schwab** (summary) | *Accounts -> Summary*: select the accounts table and copy it ([what to copy](#schwab-summary-what-to-copy)) | `--schwab-summary` |
|
| **Schwab** (summary) | *Accounts -> Summary*: select the accounts table and copy it ([what to copy](#schwab-summary-what-to-copy)) | `--schwab-summary` |
|
||||||
| **Wells Fargo** | Download Type **Portfolio-Expanded Detail**, Portfolio View **Positions**, all brokerage accounts (an `.xls`) | `--wells-fargo <XLS>` |
|
| **Wells Fargo** | Download Type **Portfolio-Expanded Detail**, Portfolio View **Positions**, all brokerage accounts (an `.xls`) | `--wells-fargo <XLS>` |
|
||||||
|
|
||||||
The two Schwab inputs differ in detail: the **per-account CSV** has full
|
The two Schwab inputs differ in detail: the **positions CSV** has full
|
||||||
per-position data (shares, price, value); the **summary paste** carries
|
per-position data (shares, price, value) for whichever accounts you
|
||||||
only each account's cash and total value, so it reconciles *totals*, not
|
picked -- every one of them with **All Brokerage Accounts**, which is
|
||||||
individual holdings. Use the summary for a quick "are my account totals
|
the easy choice; the **summary paste** carries only each account's cash
|
||||||
right?", the CSV for position-level checks. (Fidelity money-market rows
|
and total value, so it reconciles *totals*, not individual holdings.
|
||||||
and Schwab "Cash & Cash Investments" rows are recognized as cash.)
|
Use the summary for a quick "are my account totals right?", the CSV for
|
||||||
|
position-level checks. (Fidelity money-market rows and Schwab "Cash &
|
||||||
|
Cash Investments" rows are recognized as cash.)
|
||||||
|
|
||||||
The Fidelity **Download** isn't a top-level button -- it's behind the
|
The Fidelity **Download** isn't a top-level button -- it's behind the
|
||||||
three-dot (**⋮**) menu at the top-right of the positions panel:
|
three-dot (**⋮**) menu at the top-right of the positions panel:
|
||||||
|
|
@ -86,7 +88,7 @@ the summary reconciles **account totals**, not individual positions.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ZFIN_HOME=~/finance zfin audit --fidelity ~/Downloads/Portfolio_Positions.csv
|
ZFIN_HOME=~/finance zfin audit --fidelity ~/Downloads/Portfolio_Positions.csv
|
||||||
ZFIN_HOME=~/finance zfin audit --schwab ~/Downloads/Positions-Individual.csv
|
ZFIN_HOME=~/finance zfin audit --schwab ~/Downloads/All-Accounts-Positions.csv
|
||||||
ZFIN_HOME=~/finance zfin audit --schwab-summary # then paste the page, Ctrl-D
|
ZFIN_HOME=~/finance zfin audit --schwab-summary # then paste the page, Ctrl-D
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -266,10 +268,16 @@ What it considers:
|
||||||
for ...`, a Schwab summary by `Account number ending in`, a Wells
|
for ...`, a Schwab summary by `Account number ending in`, a Wells
|
||||||
Fargo spreadsheet by its `WFA_Positions` sheet. A renamed file still
|
Fargo spreadsheet by its `WFA_Positions` sheet. A renamed file still
|
||||||
works; an unrelated file is skipped.
|
works; an unrelated file is skipped.
|
||||||
- **A Wells Fargo export with none of your accounts is skipped.** One WF
|
- **Accounts you don't track get one line, not a wall of
|
||||||
download covers a whole household, so if you also manage someone
|
discrepancies.** Fidelity's download, Schwab's All Brokerage Accounts
|
||||||
else's WF accounts, their export in your download folder is noted and
|
export and summary paste, and the Wells Fargo spreadsheet cover every
|
||||||
skipped rather than reported as eight unmapped accounts.
|
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).
|
||||||
|
|
||||||
So with `ZFIN_AUDIT_FILES=~/Downloads`, the workflow collapses to
|
So with `ZFIN_AUDIT_FILES=~/Downloads`, the workflow collapses to
|
||||||
"download from your broker, run `zfin audit`, done."
|
"download from your broker, run `zfin audit`, done."
|
||||||
|
|
@ -282,14 +290,29 @@ portfolio. It does that through
|
||||||
[`accounts.srf`](set-up-accounts.md#2-add-institution-and-account-number-for-auditing):
|
[`accounts.srf`](set-up-accounts.md#2-add-institution-and-account-number-for-auditing):
|
||||||
|
|
||||||
- The export carries an account number -- Schwab's from the "Positions
|
- The export carries an account number -- Schwab's from the "Positions
|
||||||
for account ...1234" title, Fidelity's from the Account Number column,
|
for account ...1234" title (or, in an all-accounts export, the
|
||||||
the summary's from "...ending in 1234", Wells Fargo's from each row's
|
"...1234" line that opens each account's section), Fidelity's from the
|
||||||
`*1234`.
|
Account Number column, the summary's from "...ending in 1234", Wells
|
||||||
|
Fargo's from each row's `*1234`.
|
||||||
- zfin finds the `accounts.srf` entry whose `institution::` (`fidelity`,
|
- zfin finds the `accounts.srf` entry whose `institution::` (`fidelity`,
|
||||||
`schwab`, `wells_fargo`) **and** `account_number::` match, and
|
`schwab`, `wells_fargo`) **and** `account_number::` match, and
|
||||||
compares against that account's lots.
|
compares against that account's lots.
|
||||||
- **No match -> the account is shown as `unmapped`** and flagged as a
|
- **No match -> the account isn't reconciled.** How loudly zfin says so
|
||||||
discrepancy. Fix it by adding `institution::` and `account_number::`
|
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::`
|
||||||
to that account in `accounts.srf` (a placeholder number you recognize
|
to that account in `accounts.srf` (a placeholder number you recognize
|
||||||
is fine -- it just has to match what the export shows).
|
is fine -- it just has to match what the export shows).
|
||||||
|
|
||||||
|
|
@ -370,11 +393,13 @@ so auditing against the export you just imported shows no drift.
|
||||||
|
|
||||||
- The parsers are **broker-specific**. The CSV parsers hardcode each
|
- The parsers are **broker-specific**. The CSV parsers hardcode each
|
||||||
export's column layout -- if Fidelity or Schwab changes their format,
|
export's column layout -- if Fidelity or Schwab changes their format,
|
||||||
parsing can break (both validate their header to catch this). They are
|
parsing can break (both validate their header to catch this, and the
|
||||||
not full RFC-4180 CSV parsers (no escaped quotes or multi-line fields)
|
Schwab parser checks each account's "Positions Total" against the rows
|
||||||
-- fine for the real exports, not for arbitrary CSVs. The Wells Fargo
|
above it). They are not full RFC-4180 CSV parsers (no escaped quotes or
|
||||||
parser finds columns by header name and checks every per-account total
|
multi-line fields) -- fine for the real exports, not for arbitrary
|
||||||
against the lots under it, so a layout change fails loudly.
|
CSVs. The Wells Fargo parser finds columns by header name and checks
|
||||||
|
every per-account total against the lots under it, so a layout change
|
||||||
|
fails loudly.
|
||||||
- Matching is only as good as the `institution::` / `account_number::`
|
- Matching is only as good as the `institution::` / `account_number::`
|
||||||
entries you keep in `accounts.srf`.
|
entries you keep in `accounts.srf`.
|
||||||
- Options, CDs, and cash are reconciled separately from share counts.
|
- Options, CDs, and cash are reconciled separately from share counts.
|
||||||
|
|
@ -385,11 +410,11 @@ manual nudge.
|
||||||
|
|
||||||
### Wells Fargo: one file, every account, real lots
|
### Wells Fargo: one file, every account, real lots
|
||||||
|
|
||||||
The Wells Fargo spreadsheet differs from the CSV exports in two ways
|
Two things about the Wells Fargo spreadsheet are worth knowing:
|
||||||
worth knowing:
|
|
||||||
|
|
||||||
- **It covers the whole household.** Every brokerage account is in one
|
- **It covers the whole household.** Like Fidelity's download and
|
||||||
file, each row tagged with its account as `*1234`.
|
Schwab's **All Brokerage Accounts** export, every brokerage account is
|
||||||
|
in one file, each row tagged with its account as `*1234`.
|
||||||
- **It lists tax lots, not positions.** Audit sums them per account and
|
- **It lists tax lots, not positions.** Audit sums them per account and
|
||||||
symbol before comparing, so it doesn't matter whether your portfolio
|
symbol before comparing, so it doesn't matter whether your portfolio
|
||||||
holds the position as one lot or many. It also means
|
holds the position as one lot or many. It also means
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,18 @@ discrepancies.
|
||||||
| `--verbose` | Show full reconciliation output even when clean. |
|
| `--verbose` | Show full reconciliation output even when clean. |
|
||||||
| `--stale-days <N>` | Manual-price staleness threshold (default 3). |
|
| `--stale-days <N>` | Manual-price staleness threshold (default 3). |
|
||||||
| `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). |
|
| `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). |
|
||||||
| `--schwab <CSV>` | Schwab per-account positions CSV. |
|
| `--schwab <CSV>` | Schwab positions CSV: one account, or "All Brokerage Accounts". |
|
||||||
| `--schwab-summary` | Schwab account summary: paste from the summary page to stdin, then Ctrl-D. |
|
| `--schwab-summary` | Schwab account summary: paste from the summary page to stdin, then Ctrl-D. |
|
||||||
| `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (Download Type "Portfolio-Expanded Detail", "Positions"). |
|
| `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (Download Type "Portfolio-Expanded Detail", "Positions"). |
|
||||||
|
|
||||||
Reconciliation matches export accounts to yours via `institution::` and
|
Reconciliation matches export accounts to yours via `institution::` and
|
||||||
`account_number::` in [`accounts.srf`](../config/accounts-srf.md); an
|
`account_number::` in [`accounts.srf`](../config/accounts-srf.md). An
|
||||||
unmatched account is reported as "unmapped." A discovered Wells Fargo
|
unmatched account in a single-account Schwab export is reported as
|
||||||
export none of whose accounts are mapped is skipped with a note instead,
|
"unmapped." Exports that cover every account (Fidelity, Schwab's All
|
||||||
since it is almost certainly another portfolio's.
|
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).
|
||||||
|
|
||||||
The hygiene check also flags newly-appeared lots worth at least
|
The hygiene check also flags newly-appeared lots worth at least
|
||||||
$10,000 in a **Large new lots - confirm source** section, so you can
|
$10,000 in a **Large new lots - confirm source** section, so you can
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,16 @@ dropped.
|
||||||
|--------------------------|-----------------------------------------------------------------|
|
|--------------------------|-----------------------------------------------------------------|
|
||||||
| `-p, --portfolio <FILE>` | Target file (a single concrete path, not a glob). **Required.** |
|
| `-p, --portfolio <FILE>` | Target file (a single concrete path, not a glob). **Required.** |
|
||||||
| `--fidelity <CSV>` | Fidelity positions CSV. |
|
| `--fidelity <CSV>` | Fidelity positions CSV. |
|
||||||
| `--schwab <CSV>` | Schwab per-account positions CSV. |
|
| `--schwab <CSV>` | Schwab positions CSV (one account or All Brokerage Accounts). |
|
||||||
| `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (`-` for stdin). See below. |
|
| `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (`-` for stdin). See below. |
|
||||||
| `-y, --yes` | Don't prompt before overwriting an existing file. |
|
| `-y, --yes` | Don't prompt before overwriting an existing file. |
|
||||||
|
|
||||||
Account resolution needs an [`accounts.srf`](../config/accounts-srf.md)
|
Account resolution needs an [`accounts.srf`](../config/accounts-srf.md)
|
||||||
next to the target with `institution::` + `account_number::` entries
|
next to the target with `institution::` + `account_number::` entries
|
||||||
matching the export; import refuses to write when an export account is
|
matching the export; import refuses to write when an export account is
|
||||||
unmapped.
|
unmapped. A Schwab **All Brokerage Accounts** export brings every
|
||||||
|
account into the target, so to import just one account, export that
|
||||||
|
account alone.
|
||||||
|
|
||||||
A target that doesn't exist yet is created in the current directory, so
|
A target that doesn't exist yet is created in the current directory, so
|
||||||
run a first import from the portfolio's own directory.
|
run a first import from the portfolio's own directory.
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ pub const cdLotAllowance = common.cdLotAllowance;
|
||||||
pub const accountValueExpectation = common.accountValueExpectation;
|
pub const accountValueExpectation = common.accountValueExpectation;
|
||||||
pub const compareAccounts = common.compareAccounts;
|
pub const compareAccounts = common.compareAccounts;
|
||||||
pub const hasAccountDiscrepancies = common.hasAccountDiscrepancies;
|
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 presentNumbers = common.presentNumbers;
|
||||||
pub const findAbsentAccounts = common.findAbsentAccounts;
|
pub const findAbsentAccounts = common.findAbsentAccounts;
|
||||||
pub const RatioSuggestion = common.RatioSuggestion;
|
pub const RatioSuggestion = common.RatioSuggestion;
|
||||||
|
|
@ -32,6 +35,7 @@ pub const ratioSuggestions = common.ratioSuggestions;
|
||||||
pub const SchwabAccountComparison = schwab.SchwabAccountComparison;
|
pub const SchwabAccountComparison = schwab.SchwabAccountComparison;
|
||||||
pub const compareSchwabSummary = schwab.compareSchwabSummary;
|
pub const compareSchwabSummary = schwab.compareSchwabSummary;
|
||||||
pub const reconcileCsv = schwab.reconcileCsv;
|
pub const reconcileCsv = schwab.reconcileCsv;
|
||||||
|
pub const schwabCsvScope = schwab.csvScope;
|
||||||
pub const reconcileSummary = schwab.reconcileSummary;
|
pub const reconcileSummary = schwab.reconcileSummary;
|
||||||
pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies;
|
pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies;
|
||||||
pub const summaryRatioSuggestions = schwab.summaryRatioSuggestions;
|
pub const summaryRatioSuggestions = schwab.summaryRatioSuggestions;
|
||||||
|
|
|
||||||
|
|
@ -661,6 +661,67 @@ pub fn hasAccountDiscrepancies(results: []const AccountComparison) bool {
|
||||||
return false;
|
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 ────────────────
|
// ── Portfolio accounts absent from the export ────────────────
|
||||||
|
|
||||||
/// A portfolio account that maps to the institution under audit and
|
/// A portfolio account that maps to the institution under audit and
|
||||||
|
|
@ -1977,3 +2038,70 @@ test "collapseOntoProxy: every allocation failure is clean" {
|
||||||
};
|
};
|
||||||
try std.testing.checkAllAllocationFailures(std.testing.allocator, S.run, .{});
|
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
|
//! Schwab has two export shapes, so this module carries more than
|
||||||
//! the Fidelity one:
|
//! the Fidelity one:
|
||||||
//!
|
//!
|
||||||
//! 1. **Per-account positions CSV** (`--schwab`) - same per-account
|
//! 1. **Positions CSV** (`--schwab`) - one account or all of them,
|
||||||
//! positions shape Fidelity uses, so it feeds the shared
|
//! the same per-position shape Fidelity uses, so it feeds the
|
||||||
//! `common.compareAccounts` engine via `reconcileCsv`.
|
//! shared `common.compareAccounts` engine via `reconcileCsv`.
|
||||||
//! 2. **Account summary paste** (`--schwab-summary`) - a
|
//! 2. **Account summary paste** (`--schwab-summary`) - a
|
||||||
//! per-account totals-only view with no per-symbol detail. This
|
//! per-account totals-only view with no per-symbol detail. This
|
||||||
//! is the one genuinely broker-specific reconciler, with its own
|
//! is the one genuinely broker-specific reconciler, with its own
|
||||||
|
|
@ -27,8 +27,12 @@ const AccountSummary = schwab_parser.AccountSummary;
|
||||||
|
|
||||||
/// Account-level comparison result for Schwab summary audit.
|
/// Account-level comparison result for Schwab summary audit.
|
||||||
pub const SchwabAccountComparison = struct {
|
pub const SchwabAccountComparison = struct {
|
||||||
|
/// Portfolio account name; empty when the account is unmapped.
|
||||||
account_name: []const u8,
|
account_name: []const u8,
|
||||||
schwab_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,
|
||||||
account_number: []const u8,
|
account_number: []const u8,
|
||||||
portfolio_cash: f64,
|
portfolio_cash: f64,
|
||||||
schwab_cash: ?f64,
|
schwab_cash: ?f64,
|
||||||
|
|
@ -44,13 +48,14 @@ pub const SchwabAccountComparison = struct {
|
||||||
has_discrepancy: bool,
|
has_discrepancy: bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Per-account positions CSV (--schwab) ─────────────────────
|
// ── Positions CSV (--schwab) ─────────────────────────────────
|
||||||
|
|
||||||
/// Parse a Schwab per-account positions CSV and reconcile it against
|
/// Parse a Schwab positions CSV - either layout, one account or all
|
||||||
/// the portfolio via the shared engine. Returns owned
|
/// of them - and reconcile it against the portfolio via the shared
|
||||||
/// `AccountComparison` results (free each `.comparisons` slice, then
|
/// engine. Returns owned `AccountComparison` results, one per account
|
||||||
/// the results slice). String fields borrow from `csv_data`, which
|
/// in the export (free each `.comparisons` slice, then the results
|
||||||
/// must outlive them. Propagates parser and allocation errors.
|
/// slice). String fields borrow from `csv_data`, which must outlive
|
||||||
|
/// them. Propagates parser and allocation errors.
|
||||||
pub fn reconcileCsv(
|
pub fn reconcileCsv(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
portfolio: zfin.Portfolio,
|
portfolio: zfin.Portfolio,
|
||||||
|
|
@ -59,11 +64,19 @@ pub fn reconcileCsv(
|
||||||
prices: std.StringHashMap(f64),
|
prices: std.StringHashMap(f64),
|
||||||
as_of: Date,
|
as_of: Date,
|
||||||
) ![]common.AccountComparison {
|
) ![]common.AccountComparison {
|
||||||
const parsed = try schwab_parser.parseCsv(allocator, csv_data);
|
const positions = try schwab_parser.parseCsv(allocator, csv_data);
|
||||||
// Result strings borrow from `csv_data`, not the positions slice,
|
// Result strings borrow from `csv_data`, not the positions slice,
|
||||||
// so freeing the slice array here is safe.
|
// so freeing the slice array here is safe.
|
||||||
defer allocator.free(parsed.positions);
|
defer allocator.free(positions);
|
||||||
return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of);
|
return common.compareAccounts(allocator, portfolio, positions, account_map, "schwab", prices, as_of);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Account summary paste (--schwab-summary) ─────────────────
|
// ── Account summary paste (--schwab-summary) ─────────────────
|
||||||
|
|
@ -119,7 +132,7 @@ pub fn compareSchwabSummary(
|
||||||
|
|
||||||
try results.append(allocator, .{
|
try results.append(allocator, .{
|
||||||
.account_name = portfolio_acct orelse "",
|
.account_name = portfolio_acct orelse "",
|
||||||
.schwab_name = sa.account_name,
|
.brokerage_name = sa.account_name,
|
||||||
.account_number = sa.account_number,
|
.account_number = sa.account_number,
|
||||||
.portfolio_cash = pf_cash,
|
.portfolio_cash = pf_cash,
|
||||||
.schwab_cash = sa.cash,
|
.schwab_cash = sa.cash,
|
||||||
|
|
@ -217,7 +230,7 @@ const portfolio_mod = @import("../../models/portfolio.zig");
|
||||||
test "hasSchwabDiscrepancies" {
|
test "hasSchwabDiscrepancies" {
|
||||||
const clean = [_]SchwabAccountComparison{.{
|
const clean = [_]SchwabAccountComparison{.{
|
||||||
.account_name = "IRA",
|
.account_name = "IRA",
|
||||||
.schwab_name = "Roth IRA",
|
.brokerage_name = "Roth IRA",
|
||||||
.account_number = "1234",
|
.account_number = "1234",
|
||||||
.portfolio_cash = 100,
|
.portfolio_cash = 100,
|
||||||
.schwab_cash = 100,
|
.schwab_cash = 100,
|
||||||
|
|
@ -231,7 +244,7 @@ test "hasSchwabDiscrepancies" {
|
||||||
|
|
||||||
const dirty = [_]SchwabAccountComparison{.{
|
const dirty = [_]SchwabAccountComparison{.{
|
||||||
.account_name = "IRA",
|
.account_name = "IRA",
|
||||||
.schwab_name = "Roth IRA",
|
.brokerage_name = "Roth IRA",
|
||||||
.account_number = "1234",
|
.account_number = "1234",
|
||||||
.portfolio_cash = 100,
|
.portfolio_cash = 100,
|
||||||
.schwab_cash = 200,
|
.schwab_cash = 200,
|
||||||
|
|
@ -410,7 +423,7 @@ test "compareSchwabSummary: account_number with no match -> empty account_name"
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||||
try std.testing.expectEqualStrings("", results[0].account_name);
|
try std.testing.expectEqualStrings("", results[0].account_name);
|
||||||
try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name);
|
try std.testing.expectEqualStrings("Unknown Acct", results[0].brokerage_name);
|
||||||
// No portfolio match -> cash and total are zero, schwab values become deltas
|
// 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, 0), results[0].portfolio_cash, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01);
|
||||||
|
|
@ -562,6 +575,72 @@ test "reconcileCsv: parses a Schwab positions CSV and reconciles it" {
|
||||||
try std.testing.expect(found_amzn);
|
try std.testing.expect(found_amzn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "reconcileCsv: an all-accounts CSV reconciles each section as its own account" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const header = "\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n";
|
||||||
|
const csv =
|
||||||
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_IRA ...1234\"\n" ++
|
||||||
|
header ++
|
||||||
|
"\"VTI\",\"VANGUARD TOTAL STOCK MARKET ETF\",\"1.10\",\"0.37%\",\"300.00\",\"100\",\"$110.00\",\"0.37%\",\"$30,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$110.00\",\"0.37%\",\"$30,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
|
||||||
|
"\n\n" ++
|
||||||
|
"\"Sample_Brokerage ...5678\"\n" ++
|
||||||
|
header ++
|
||||||
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"100%\",\"Equity\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
|
||||||
|
"\n\n" ++
|
||||||
|
"\"Sample_Account ...9012\"\n" ++
|
||||||
|
header ++
|
||||||
|
"\"SCHD\",\"SCHWAB US DIVIDEND EQUITY ETF\",\"0.10\",\"0.36%\",\"27.50\",\"200\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"$5,000.00\",\"$500.00\",\"10%\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"$5,000.00\",\"$500.00\",\"10%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
||||||
|
|
||||||
|
var lots = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample IRA" },
|
||||||
|
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
|
||||||
|
// 9012 is deliberately absent: an account in the export that this
|
||||||
|
// portfolio does not track.
|
||||||
|
var entries = [_]analysis.AccountTaxEntry{
|
||||||
|
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" },
|
||||||
|
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "5678" },
|
||||||
|
};
|
||||||
|
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||||
|
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
try prices.put("VTI", 300);
|
||||||
|
try prices.put("AAPL", 200);
|
||||||
|
|
||||||
|
const results = try reconcileCsv(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 10, 6));
|
||||||
|
defer {
|
||||||
|
for (results) |r| allocator.free(r.comparisons);
|
||||||
|
allocator.free(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), results.len);
|
||||||
|
var seen: usize = 0;
|
||||||
|
for (results) |r| {
|
||||||
|
if (std.mem.eql(u8, r.account_number, "1234")) {
|
||||||
|
try std.testing.expectEqualStrings("Sample IRA", r.account_name);
|
||||||
|
try std.testing.expect(!r.has_discrepancies);
|
||||||
|
} else if (std.mem.eql(u8, r.account_number, "5678")) {
|
||||||
|
try std.testing.expectEqualStrings("Sample Brokerage", r.account_name);
|
||||||
|
try std.testing.expect(!r.has_discrepancies);
|
||||||
|
} else {
|
||||||
|
try std.testing.expectEqualStrings("9012", r.account_number);
|
||||||
|
// Unmapped: no portfolio account, broker-side name kept.
|
||||||
|
try std.testing.expectEqualStrings("", r.account_name);
|
||||||
|
try std.testing.expectEqualStrings("Sample_Account", r.brokerage_name);
|
||||||
|
}
|
||||||
|
seen += 1;
|
||||||
|
}
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), seen);
|
||||||
|
}
|
||||||
|
|
||||||
test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" {
|
test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const data =
|
const data =
|
||||||
|
|
@ -634,7 +713,7 @@ test "summaryRatioSuggestions: single-lot direct-indexing yields exact ratio + d
|
||||||
try prices.put("SPY", 500.0);
|
try prices.put("SPY", 500.0);
|
||||||
// current = 100*500*1.0 = 50000; target = 51000; suggested = 1.02, +2%.
|
// current = 100*500*1.0 = 50000; target = 51000; suggested = 1.02, +2%.
|
||||||
const results = [_]SchwabAccountComparison{
|
const results = [_]SchwabAccountComparison{
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
};
|
};
|
||||||
const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am, Date.fromYmd(2026, 6, 19));
|
const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am, Date.fromYmd(2026, 6, 19));
|
||||||
defer allocator.free(sugg);
|
defer allocator.free(sugg);
|
||||||
|
|
@ -655,7 +734,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
|
||||||
defer prices.deinit();
|
defer prices.deinit();
|
||||||
try prices.put("SPY", 500.0);
|
try prices.put("SPY", 500.0);
|
||||||
const results = [_]SchwabAccountComparison{
|
const results = [_]SchwabAccountComparison{
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// null account_map -> empty.
|
// null account_map -> empty.
|
||||||
|
|
@ -681,7 +760,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
|
||||||
};
|
};
|
||||||
const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||||
const tiny = [_]SchwabAccountComparison{
|
const tiny = [_]SchwabAccountComparison{
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
};
|
};
|
||||||
const sugg = try summaryRatioSuggestions(allocator, &tiny, portfolio, prices, am, Date.fromYmd(2026, 6, 19));
|
const sugg = try summaryRatioSuggestions(allocator, &tiny, portfolio, prices, am, Date.fromYmd(2026, 6, 19));
|
||||||
defer allocator.free(sugg);
|
defer allocator.free(sugg);
|
||||||
|
|
@ -703,3 +782,45 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
|
||||||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
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,18 +44,6 @@ pub const Reconciled = struct {
|
||||||
allocator.free(self.results);
|
allocator.free(self.results);
|
||||||
self.workbook.deinit();
|
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
|
/// Decode an export file and reconcile it. Errors are the decode's
|
||||||
|
|
@ -140,7 +128,9 @@ 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);
|
try std.testing.expectEqual(@as(usize, 2), results[0].comparisons.len);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "Reconciled.mapsAnyAccount: false when no export account is in accounts.srf" {
|
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.
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
|
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
|
||||||
var entries = [_]analysis.AccountTaxEntry{
|
var entries = [_]analysis.AccountTaxEntry{
|
||||||
|
|
@ -155,7 +145,7 @@ test "Reconciled.mapsAnyAccount: false when no export account is in accounts.srf
|
||||||
.results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)),
|
.results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)),
|
||||||
};
|
};
|
||||||
defer rec.deinit(allocator);
|
defer rec.deinit(allocator);
|
||||||
try std.testing.expect(!rec.mapsAnyAccount());
|
try std.testing.expect(common.splitUnmapped(common.AccountComparison, rec.results, .all_accounts).skipped());
|
||||||
|
|
||||||
// With the export's account mapped, it is ours.
|
// With the export's account mapped, it is ours.
|
||||||
entries[0].account_number = "1234";
|
entries[0].account_number = "1234";
|
||||||
|
|
@ -164,7 +154,9 @@ test "Reconciled.mapsAnyAccount: false when no export account is in accounts.srf
|
||||||
.results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)),
|
.results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)),
|
||||||
};
|
};
|
||||||
defer mapped.deinit(allocator);
|
defer mapped.deinit(allocator);
|
||||||
try std.testing.expect(mapped.mapsAnyAccount());
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "reconcileXls: decode errors surface with their names" {
|
test "reconcileXls: decode errors surface with their names" {
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,10 @@ pub fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind {
|
||||||
if (std.mem.indexOf(u8, content, "Fidelity Brokerage Services LLC") != null)
|
if (std.mem.indexOf(u8, content, "Fidelity Brokerage Services LLC") != null)
|
||||||
return .fidelity_csv;
|
return .fidelity_csv;
|
||||||
|
|
||||||
// Schwab per-account CSV: starts with a quoted title line like "Positions for ..."
|
// Schwab positions CSV: starts with a quoted title line - "Positions
|
||||||
|
// for account ..." (one account) or "Positions for All-Accounts ..."
|
||||||
|
// (every account). Both are `schwab_csv`; `schwab.parseCsv` reads
|
||||||
|
// either layout.
|
||||||
if (std.mem.startsWith(u8, content, "\"Positions for")) return .schwab_csv;
|
if (std.mem.startsWith(u8, content, "\"Positions for")) return .schwab_csv;
|
||||||
|
|
||||||
// Schwab summary: the "Account number ending in" anchor is exactly what
|
// Schwab summary: the "Account number ending in" anchor is exactly what
|
||||||
|
|
@ -211,6 +214,10 @@ test "detectBrokerFileKind: schwab csv with Positions header" {
|
||||||
const data = "\"Positions for account Brokerage ...1234 as of 11:31 AM ET, 2026/04/25\"\n\nSymbol,Description,Quantity";
|
const data = "\"Positions for account Brokerage ...1234 as of 11:31 AM ET, 2026/04/25\"\n\nSymbol,Description,Quantity";
|
||||||
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?);
|
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?);
|
||||||
}
|
}
|
||||||
|
test "detectBrokerFileKind: schwab all-accounts csv is a schwab csv" {
|
||||||
|
const data = "\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n\"Sample_IRA ...1234\"\n\"Symbol\",\"Description\"";
|
||||||
|
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?);
|
||||||
|
}
|
||||||
test "detectBrokerFileKind: summary-shaped text without the anchor is not detected" {
|
test "detectBrokerFileKind: summary-shaped text without the anchor is not detected" {
|
||||||
// Regression guard against re-introducing the old "account-type label +
|
// Regression guard against re-introducing the old "account-type label +
|
||||||
// $" fallback. This blob looks summary-ish (account-type words, dollar
|
// $" fallback. This blob looks summary-ish (account-type words, dollar
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,47 @@
|
||||||
//!
|
//!
|
||||||
//! Parses two distinct Schwab inputs:
|
//! Parses two distinct Schwab inputs:
|
||||||
//!
|
//!
|
||||||
//! 1. The per-account positions CSV exported from Schwab's website
|
//! 1. The positions CSV exported from Schwab's website (Accounts ->
|
||||||
//! (Accounts -> Positions -> Export). One file per account.
|
//! Positions -> Export). Comes in two layouts, both read by
|
||||||
|
//! `parseCsv`, depending on the account dropdown:
|
||||||
|
//! - one account: a single section, account named in the title;
|
||||||
|
//! - "All Brokerage Accounts": one section per account.
|
||||||
//!
|
//!
|
||||||
//! 2. The freeform account-summary text the user pastes from
|
//! 2. The freeform account-summary text the user pastes from
|
||||||
//! Schwab's Accounts overview page. One paste covers all
|
//! Schwab's Accounts overview page. One paste covers all
|
||||||
//! accounts at once but only carries cash + total-value
|
//! accounts at once but only carries cash + total-value
|
||||||
//! aggregates, no per-position detail.
|
//! aggregates, no per-position detail.
|
||||||
//!
|
//!
|
||||||
|
//! ## Schwab CSV - layout
|
||||||
|
//!
|
||||||
|
//! Single account:
|
||||||
|
//!
|
||||||
|
//! "Positions for account <NAME> ...<NUM> as of <TIME>, <YYYY/MM/DD>"
|
||||||
|
//! <blank>
|
||||||
|
//! "Symbol","Description",...,"Asset Type",
|
||||||
|
//! <position rows>
|
||||||
|
//! "Cash & Cash Investments",...
|
||||||
|
//! "Positions Total",...
|
||||||
|
//!
|
||||||
|
//! All accounts - the same section repeated, each opened by an account
|
||||||
|
//! line, sections separated by blank lines:
|
||||||
|
//!
|
||||||
|
//! "Positions for All-Accounts as of <TIME>, <MM/DD/YYYY>"
|
||||||
|
//! <blank>
|
||||||
|
//! "<NAME> ...<NUM>"
|
||||||
|
//! "Symbol","Description",...,"Asset Type",
|
||||||
|
//! <position rows>
|
||||||
|
//! "Cash & Cash Investments",...
|
||||||
|
//! "Positions Total",...
|
||||||
|
//! <blank>
|
||||||
|
//! <blank>
|
||||||
|
//! "<NAME> ...<NUM>"
|
||||||
|
//! ...
|
||||||
|
//!
|
||||||
|
//! Account names in the all-accounts layout carry underscores where the
|
||||||
|
//! single-account title has spaces. Names are display-only - matching
|
||||||
|
//! against `accounts.srf` is by number - so they are passed through as-is.
|
||||||
|
//!
|
||||||
//! ## Schwab CSV - limitations
|
//! ## Schwab CSV - limitations
|
||||||
//!
|
//!
|
||||||
//! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
|
//! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
|
||||||
|
|
@ -19,17 +52,21 @@
|
||||||
//! quotes ("value with ""quotes"" inside") or multi-line quoted fields.
|
//! quotes ("value with ""quotes"" inside") or multi-line quoted fields.
|
||||||
//! Schwab's export does not use these in practice.
|
//! Schwab's export does not use these in practice.
|
||||||
//!
|
//!
|
||||||
//! 3. The account number and name are extracted from the title line:
|
//! 3. Every position row is stamped with the account of the section it
|
||||||
//! "Positions for account <NAME> ...<NUM> as of ..."
|
//! sits in. The grouping by account happens downstream, in
|
||||||
|
//! `reconcile/common.compareAccounts`.
|
||||||
//!
|
//!
|
||||||
//! 4. Rows with symbol "Cash & Cash Investments" are treated as cash.
|
//! 4. Rows with symbol "Cash & Cash Investments" are treated as cash.
|
||||||
//! The row with symbol "Positions Total" is skipped.
|
//! The row with symbol "Positions Total" is not a position: it is
|
||||||
|
//! checked against the market values summed above it (see
|
||||||
|
//! `checkTotal`), so a mis-split row fails loudly as
|
||||||
|
//! `error.InconsistentExport`.
|
||||||
//!
|
//!
|
||||||
//! 5. Hardcodes the expected column layout via fixed indices. The header
|
//! 5. Hardcodes the expected column layout via fixed indices. Every
|
||||||
//! row is validated (see `validateHeader`) to confirm the columns the
|
//! section's header row is validated (see `validateHeader`) to confirm
|
||||||
//! parser reads are where it expects them, so a Schwab reorder/rename of
|
//! the columns the parser reads are where it expects them, so a Schwab
|
||||||
//! a read-column fails loudly as `error.UnexpectedHeader` instead of
|
//! reorder/rename of a read-column fails loudly as
|
||||||
//! silently mis-reading a value.
|
//! `error.UnexpectedHeader` instead of silently mis-reading a value.
|
||||||
//!
|
//!
|
||||||
//! ## Schwab summary - limitations
|
//! ## Schwab summary - limitations
|
||||||
//!
|
//!
|
||||||
|
|
@ -60,12 +97,16 @@
|
||||||
//! account number line (the last whitespace-separated token).
|
//! account number line (the last whitespace-separated token).
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const builtin = @import("builtin");
|
||||||
|
const Money = @import("../Money.zig");
|
||||||
const portfolio_mod = @import("../models/portfolio.zig");
|
const portfolio_mod = @import("../models/portfolio.zig");
|
||||||
const types = @import("types.zig");
|
const types = @import("types.zig");
|
||||||
|
|
||||||
const BrokeragePosition = types.BrokeragePosition;
|
const BrokeragePosition = types.BrokeragePosition;
|
||||||
const parseDollarAmount = types.parseDollarAmount;
|
const parseDollarAmount = types.parseDollarAmount;
|
||||||
|
|
||||||
|
const log = std.log.scoped(.schwab);
|
||||||
|
|
||||||
const expected_columns = 17;
|
const expected_columns = 17;
|
||||||
|
|
||||||
const Col = struct {
|
const Col = struct {
|
||||||
|
|
@ -149,66 +190,149 @@ fn splitCsvLine(line: []const u8, cols: *[expected_columns][]const u8) usize {
|
||||||
return col_count;
|
return col_count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract account name and number from Schwab title line.
|
/// The account a run of position rows belongs to.
|
||||||
/// Format: "Positions for account <NAME> ...<NUM> as of <TIME>, <DATE>"
|
const Account = struct { name: []const u8, number: []const u8 };
|
||||||
/// Returns {name, number} or null if the line doesn't match.
|
|
||||||
fn parseTitle(line: []const u8) ?struct { name: []const u8, number: []const u8 } {
|
|
||||||
const stripped = std.mem.trim(u8, line, &.{ '"', ' ', '\r' });
|
|
||||||
const prefix = "Positions for account ";
|
|
||||||
if (!std.mem.startsWith(u8, stripped, prefix)) return null;
|
|
||||||
const rest = stripped[prefix.len..];
|
|
||||||
|
|
||||||
// Find "..." which separates name from account number
|
|
||||||
const dots_idx = std.mem.indexOf(u8, rest, "...") orelse return null;
|
|
||||||
const name = std.mem.trimEnd(u8, rest[0..dots_idx], &.{' '});
|
|
||||||
|
|
||||||
// Account number: after "..." until " as of" or end
|
|
||||||
const after_dots = rest[dots_idx + 3 ..];
|
|
||||||
const as_of_idx = std.mem.indexOf(u8, after_dots, " as of") orelse after_dots.len;
|
|
||||||
const number = std.mem.trim(u8, after_dots[0..as_of_idx], &.{' '});
|
|
||||||
|
|
||||||
|
/// Split Schwab's "<NAME> ...<NUM>" account label. The number is what
|
||||||
|
/// follows the last "..." (numbers never contain dots, names might); the
|
||||||
|
/// name is everything before it. Null unless both halves are non-empty
|
||||||
|
/// and the number is a single token.
|
||||||
|
fn parseAccountLabel(text: []const u8) ?Account {
|
||||||
|
const stripped = std.mem.trim(u8, text, " ");
|
||||||
|
const dots_idx = std.mem.lastIndexOf(u8, stripped, "...") orelse return null;
|
||||||
|
const name = std.mem.trimEnd(u8, stripped[0..dots_idx], " ");
|
||||||
|
const number = stripped[dots_idx + 3 ..];
|
||||||
|
if (name.len == 0 or number.len == 0) return null;
|
||||||
|
if (std.mem.indexOfAny(u8, number, " ,\t") != null) return null;
|
||||||
return .{ .name = name, .number = number };
|
return .{ .name = name, .number = number };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed Schwab per-account positions CSV. Returned `positions`
|
/// What the title line says about where account identity comes from.
|
||||||
/// slice is heap-allocated; string fields slice into `data`.
|
const Title = union(enum) {
|
||||||
pub const CsvResult = struct {
|
/// "Positions for account <NAME> ...<NUM> as of ..." - one account,
|
||||||
positions: []BrokeragePosition,
|
/// named here.
|
||||||
account_name: []const u8,
|
account: Account,
|
||||||
account_number: []const u8,
|
/// "Positions for All-Accounts as of ..." - each section names its
|
||||||
|
/// own account on a "<NAME> ...<NUM>" line.
|
||||||
|
all_accounts,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) !CsvResult {
|
/// Classify a Schwab positions title line. Null if it is neither layout.
|
||||||
|
fn parseTitle(line: []const u8) ?Title {
|
||||||
|
const stripped = std.mem.trim(u8, line, &.{ '"', ' ', '\r' });
|
||||||
|
if (std.mem.startsWith(u8, stripped, "Positions for All-Accounts")) return .all_accounts;
|
||||||
|
|
||||||
|
const prefix = "Positions for account ";
|
||||||
|
if (!std.mem.startsWith(u8, stripped, prefix)) return null;
|
||||||
|
const rest = stripped[prefix.len..];
|
||||||
|
const label_end = std.mem.indexOf(u8, rest, " as of") orelse rest.len;
|
||||||
|
return .{ .account = parseAccountLabel(rest[0..label_end]) orelse return null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Check a section's "Positions Total" market value against the rows
|
||||||
|
/// summed above it. Each row is rounded to the cent, so the sum may drift
|
||||||
|
/// from Schwab's total by at most half a cent per row; anything more
|
||||||
|
/// means rows were mis-split or mis-attributed. A total that does not
|
||||||
|
/// parse is not checked.
|
||||||
|
fn checkTotal(account: Account, section: Section, total_raw: []const u8) error{InconsistentExport}!void {
|
||||||
|
const total = parseDollarAmount(total_raw) orelse return;
|
||||||
|
const tolerance = 0.005 * @as(f64, @floatFromInt(section.rows)) + 1e-6;
|
||||||
|
if (@abs(total - section.market_value) <= tolerance) return;
|
||||||
|
if (!builtin.is_test)
|
||||||
|
log.warn("account ...{s}: Positions Total is {f} but its rows sum to {f}", .{ account.number, Money.from(total), Money.from(section.market_value) });
|
||||||
|
return error.InconsistentExport;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ways a Schwab positions CSV can fail to parse.
|
||||||
|
pub const CsvError = error{
|
||||||
|
EmptyFile,
|
||||||
|
/// The title is neither positions layout, or a section's header row
|
||||||
|
/// is missing, short, or has a read-column out of place.
|
||||||
|
UnexpectedHeader,
|
||||||
|
/// An all-accounts export has a row before any "<NAME> ...<NUM>"
|
||||||
|
/// account line, so there is no account to attribute it to.
|
||||||
|
MissingAccountLine,
|
||||||
|
/// An all-accounts export with no account sections at all.
|
||||||
|
NoAccountsFound,
|
||||||
|
/// A "Positions Total" row disagrees with the rows above it.
|
||||||
|
InconsistentExport,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parse a Schwab positions CSV in either layout (see the module doc).
|
||||||
|
/// Every position carries its own account, so callers never need to know
|
||||||
|
/// which layout it was. The returned slice is heap-allocated; string
|
||||||
|
/// fields slice into `data`.
|
||||||
|
pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) (CsvError || std.mem.Allocator.Error)![]BrokeragePosition {
|
||||||
var positions = std.ArrayList(BrokeragePosition).empty;
|
var positions = std.ArrayList(BrokeragePosition).empty;
|
||||||
errdefer positions.deinit(allocator);
|
errdefer positions.deinit(allocator);
|
||||||
|
|
||||||
|
if (std.mem.trim(u8, data, " \r\n").len == 0) return error.EmptyFile;
|
||||||
var lines = std.mem.splitScalar(u8, data, '\n');
|
var lines = std.mem.splitScalar(u8, data, '\n');
|
||||||
|
|
||||||
// Line 1: title with account name and number
|
const title = parseTitle(lines.first()) orelse return error.UnexpectedHeader;
|
||||||
const title_line = lines.next() orelse return error.EmptyFile;
|
|
||||||
const title = parseTitle(title_line) orelse return error.UnexpectedHeader;
|
|
||||||
|
|
||||||
// Line 2: blank (skip)
|
// The account the rows being read belong to: named by the title for
|
||||||
_ = lines.next();
|
// a single-account export, by each section's account line otherwise.
|
||||||
// Line 3: header row - validate the columns we read are where we expect,
|
var account: ?Account = switch (title) {
|
||||||
// so a Schwab format change surfaces as UnexpectedHeader (and, in the
|
.account => |a| a,
|
||||||
// flagless audit, a "could not parse" warning) rather than silently
|
.all_accounts => null,
|
||||||
// reading a shifted column.
|
};
|
||||||
const header_line = lines.next() orelse return error.UnexpectedHeader;
|
// Every section opens with a header row, validated before any of its
|
||||||
try validateHeader(std.mem.trimEnd(u8, header_line, &.{ '\r', ' ' }));
|
// rows are read so a Schwab format change surfaces as
|
||||||
|
// UnexpectedHeader (and, in the flagless audit, a "could not parse"
|
||||||
|
// warning) rather than silently reading a shifted column.
|
||||||
|
var expect_header = account != null;
|
||||||
|
var section: Section = .{};
|
||||||
|
var section_count: usize = 0;
|
||||||
|
|
||||||
// Data rows
|
|
||||||
while (lines.next()) |line| {
|
while (lines.next()) |line| {
|
||||||
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
|
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
|
||||||
if (trimmed.len == 0) continue;
|
if (trimmed.len == 0) continue;
|
||||||
|
|
||||||
var cols: [expected_columns][]const u8 = undefined;
|
var cols: [expected_columns][]const u8 = undefined;
|
||||||
const col_count = splitCsvLine(trimmed, &cols);
|
const col_count = splitCsvLine(trimmed, &cols);
|
||||||
|
|
||||||
|
if (title == .all_accounts and col_count == 1) {
|
||||||
|
if (parseAccountLabel(cols[0])) |a| {
|
||||||
|
account = a;
|
||||||
|
expect_header = true;
|
||||||
|
section = .{};
|
||||||
|
section_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const current = account orelse return error.MissingAccountLine;
|
||||||
|
|
||||||
|
if (expect_header) {
|
||||||
|
try validateHeader(trimmed);
|
||||||
|
expect_header = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (col_count < expected_columns) continue;
|
if (col_count < expected_columns) continue;
|
||||||
|
|
||||||
const symbol = cols[Col.symbol];
|
const symbol = cols[Col.symbol];
|
||||||
if (symbol.len == 0) continue;
|
if (symbol.len == 0) continue;
|
||||||
if (std.mem.eql(u8, symbol, "Positions Total")) continue;
|
if (std.mem.eql(u8, symbol, "Positions Total")) {
|
||||||
|
try checkTotal(current, section, cols[Col.market_value]);
|
||||||
|
section = .{};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// "Cash & Cash Investments" is Schwab's aggregate cash line.
|
// "Cash & Cash Investments" is Schwab's aggregate cash line.
|
||||||
// Actual money-market holdings (SWVXX, etc.) appear as normal rows
|
// Actual money-market holdings (SWVXX, etc.) appear as normal rows
|
||||||
|
|
@ -217,24 +341,25 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) !CsvResult {
|
||||||
// "cash" in the account.
|
// "cash" in the account.
|
||||||
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
|
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
|
||||||
portfolio_mod.isMoneyMarketSymbol(symbol);
|
portfolio_mod.isMoneyMarketSymbol(symbol);
|
||||||
|
const market_value = parseDollarAmount(cols[Col.market_value]);
|
||||||
|
|
||||||
try positions.append(allocator, .{
|
try positions.append(allocator, .{
|
||||||
.account_number = title.number,
|
.account_number = current.number,
|
||||||
.account_name = title.name,
|
.account_name = current.name,
|
||||||
.symbol = symbol,
|
.symbol = symbol,
|
||||||
.description = if (col_count > 1) cols[1] else "",
|
.description = cols[1],
|
||||||
.quantity = if (is_cash) null else parseDollarAmount(cols[Col.quantity]),
|
.quantity = if (is_cash) null else parseDollarAmount(cols[Col.quantity]),
|
||||||
.current_value = parseDollarAmount(cols[Col.market_value]),
|
.current_value = market_value,
|
||||||
.cost_basis = if (is_cash) null else parseDollarAmount(cols[Col.cost_basis]),
|
.cost_basis = if (is_cash) null else parseDollarAmount(cols[Col.cost_basis]),
|
||||||
.is_cash = is_cash,
|
.is_cash = is_cash,
|
||||||
});
|
});
|
||||||
|
section.market_value += market_value orelse 0;
|
||||||
|
section.rows += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return .{
|
if (title == .all_accounts and section_count == 0) return error.NoAccountsFound;
|
||||||
.positions = try positions.toOwnedSlice(allocator),
|
|
||||||
.account_name = title.name,
|
return positions.toOwnedSlice(allocator);
|
||||||
.account_number = title.number,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Account-level summary from a Schwab paste (no per-position detail).
|
/// Account-level summary from a Schwab paste (no per-position detail).
|
||||||
|
|
@ -336,17 +461,41 @@ pub fn parseSummary(allocator: std.mem.Allocator, data: []const u8) ![]AccountSu
|
||||||
// ── Tests ────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
test "parseTitle" {
|
test "parseTitle" {
|
||||||
const t1 = parseTitle("\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"");
|
const t1 = parseTitle("\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"").?;
|
||||||
try std.testing.expect(t1 != null);
|
try std.testing.expectEqualStrings("Sample Trust", t1.account.name);
|
||||||
try std.testing.expectEqualStrings("Sample Trust", t1.?.name);
|
try std.testing.expectEqualStrings("1234", t1.account.number);
|
||||||
try std.testing.expectEqualStrings("1234", t1.?.number);
|
|
||||||
|
|
||||||
const t2 = parseTitle("\"Positions for account Sample IRA ...5678 as of 3:00 PM ET, 2026/04/10\"");
|
const t2 = parseTitle("\"Positions for account Sample IRA ...5678 as of 3:00 PM ET, 2026/04/10\"").?;
|
||||||
try std.testing.expect(t2 != null);
|
try std.testing.expectEqualStrings("Sample IRA", t2.account.name);
|
||||||
try std.testing.expectEqualStrings("Sample IRA", t2.?.name);
|
try std.testing.expectEqualStrings("5678", t2.account.number);
|
||||||
try std.testing.expectEqualStrings("5678", t2.?.number);
|
|
||||||
|
// The all-accounts title names no account; its date is MM/DD/YYYY.
|
||||||
|
try std.testing.expect(parseTitle("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"").? == .all_accounts);
|
||||||
|
|
||||||
try std.testing.expect(parseTitle("some random text") == null);
|
try std.testing.expect(parseTitle("some random text") == null);
|
||||||
|
// Single-account title without the "...NUM" label.
|
||||||
|
try std.testing.expect(parseTitle("\"Positions for account Sample IRA as of 3:00 PM ET, 2026/04/10\"") == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseAccountLabel" {
|
||||||
|
const a = parseAccountLabel("Sample_IRA ...1234").?;
|
||||||
|
try std.testing.expectEqualStrings("Sample_IRA", a.name);
|
||||||
|
try std.testing.expectEqualStrings("1234", a.number);
|
||||||
|
|
||||||
|
// Spaces in names are kept; surrounding space is trimmed.
|
||||||
|
const b = parseAccountLabel(" Sample Brokerage ...Z123 ").?;
|
||||||
|
try std.testing.expectEqualStrings("Sample Brokerage", b.name);
|
||||||
|
try std.testing.expectEqualStrings("Z123", b.number);
|
||||||
|
|
||||||
|
// The number follows the LAST "...", so dots in a name survive.
|
||||||
|
const c = parseAccountLabel("Sample... Trust ...5678").?;
|
||||||
|
try std.testing.expectEqualStrings("Sample... Trust", c.name);
|
||||||
|
try std.testing.expectEqualStrings("5678", c.number);
|
||||||
|
|
||||||
|
try std.testing.expect(parseAccountLabel("Sample IRA") == null); // no dots
|
||||||
|
try std.testing.expect(parseAccountLabel("...1234") == null); // no name
|
||||||
|
try std.testing.expect(parseAccountLabel("Sample IRA ...") == null); // no number
|
||||||
|
try std.testing.expect(parseAccountLabel("Sample IRA ...12 34") == null); // not one token
|
||||||
}
|
}
|
||||||
|
|
||||||
test "splitCsvLine" {
|
test "splitCsvLine" {
|
||||||
|
|
@ -368,29 +517,32 @@ test "parseCsv basic" {
|
||||||
"\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
|
"\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
|
||||||
"\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n" ++
|
"\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n" ++
|
||||||
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$8,271.12\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0.97%\",\"Cash and Money Market\",\n" ++
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$8,271.12\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0.97%\",\"Cash and Money Market\",\n" ++
|
||||||
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$856,805.99\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$364,212.62\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
||||||
|
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const parsed = try parseCsv(allocator, csv);
|
const positions = try parseCsv(allocator, csv);
|
||||||
defer allocator.free(parsed.positions);
|
defer allocator.free(positions);
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("Sample Trust", parsed.account_name);
|
try std.testing.expectEqual(@as(usize, 2), positions.len);
|
||||||
try std.testing.expectEqualStrings("1234", parsed.account_number);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 2), parsed.positions.len);
|
// Every row carries the title's account.
|
||||||
|
for (positions) |p| {
|
||||||
|
try std.testing.expectEqualStrings("Sample Trust", p.account_name);
|
||||||
|
try std.testing.expectEqualStrings("1234", p.account_number);
|
||||||
|
}
|
||||||
|
|
||||||
// Stock position
|
// Stock position
|
||||||
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
|
||||||
try std.testing.expect(!parsed.positions[0].is_cash);
|
try std.testing.expect(!positions[0].is_cash);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 1488), parsed.positions[0].quantity.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 1488), positions[0].quantity.?, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 355941.50), parsed.positions[0].current_value.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 355941.50), positions[0].current_value.?, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), parsed.positions[0].cost_basis.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), positions[0].cost_basis.?, 0.01);
|
||||||
|
|
||||||
// Cash
|
// Cash
|
||||||
try std.testing.expectEqualStrings("Cash & Cash Investments", parsed.positions[1].symbol);
|
try std.testing.expectEqualStrings("Cash & Cash Investments", positions[1].symbol);
|
||||||
try std.testing.expect(parsed.positions[1].is_cash);
|
try std.testing.expect(positions[1].is_cash);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), parsed.positions[1].current_value.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), positions[1].current_value.?, 0.01);
|
||||||
try std.testing.expect(parsed.positions[1].quantity == null);
|
try std.testing.expect(positions[1].quantity == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseCsv accepts the real Short (Long) header labels" {
|
test "parseCsv accepts the real Short (Long) header labels" {
|
||||||
|
|
@ -403,14 +555,14 @@ test "parseCsv accepts the real Short (Long) header labels" {
|
||||||
"\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n" ++
|
"\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n" ++
|
||||||
"\"AMZN\",\"AMAZON.COM INC\",\"5.55\",\"2.38%\",\"239.20\",\"100\",\"$8.00\",\"2.38%\",\"$23,920.00\",\"$10,000.00\",\"$13,920.00\",\"139%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
|
"\"AMZN\",\"AMAZON.COM INC\",\"5.55\",\"2.38%\",\"239.20\",\"100\",\"$8.00\",\"2.38%\",\"$23,920.00\",\"$10,000.00\",\"$13,920.00\",\"139%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const parsed = try parseCsv(allocator, csv);
|
const positions = try parseCsv(allocator, csv);
|
||||||
defer allocator.free(parsed.positions);
|
defer allocator.free(positions);
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), parsed.positions.len);
|
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||||||
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 100), parsed.positions[0].quantity.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), parsed.positions[0].current_value.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), positions[0].current_value.?, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), parsed.positions[0].cost_basis.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[0].cost_basis.?, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseCsv rejects a header whose read-columns are reordered" {
|
test "parseCsv rejects a header whose read-columns are reordered" {
|
||||||
|
|
@ -434,13 +586,13 @@ test "parseCsv tolerates reordering of columns it does not read" {
|
||||||
"\"Symbol\",\"Description\",\"Price Chng % (Price Change %)\",\"Price Chng $ (Price Change $)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n" ++
|
"\"Symbol\",\"Description\",\"Price Chng % (Price Change %)\",\"Price Chng $ (Price Change $)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n" ++
|
||||||
"\"AMZN\",\"AMAZON.COM INC\",\"2.38%\",\"5.55\",\"239.20\",\"100\",\"$8.00\",\"2.38%\",\"$23,920.00\",\"$10,000.00\",\"$13,920.00\",\"139%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
|
"\"AMZN\",\"AMAZON.COM INC\",\"2.38%\",\"5.55\",\"239.20\",\"100\",\"$8.00\",\"2.38%\",\"$23,920.00\",\"$10,000.00\",\"$13,920.00\",\"139%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const parsed = try parseCsv(allocator, csv);
|
const positions = try parseCsv(allocator, csv);
|
||||||
defer allocator.free(parsed.positions);
|
defer allocator.free(positions);
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), parsed.positions.len);
|
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||||||
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 100), parsed.positions[0].quantity.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.01);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), parsed.positions[0].current_value.?, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), positions[0].current_value.?, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseCsv rejects a header with too few columns" {
|
test "parseCsv rejects a header with too few columns" {
|
||||||
|
|
@ -452,6 +604,166 @@ test "parseCsv rejects a header with too few columns" {
|
||||||
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, csv));
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, csv));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "parseCsv rejects an empty file and an unrecognized title" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
try std.testing.expectError(error.EmptyFile, parseCsv(allocator, ""));
|
||||||
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, "\"Positions for nobody\"\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv rejects a single-account file with no header" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, "\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n\n\"AMZN\"\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The real "Short (Long)" header, as every section of an export carries it.
|
||||||
|
const test_header = "\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n";
|
||||||
|
|
||||||
|
/// A three-section all-accounts export (fictional figures). The Trust
|
||||||
|
/// section exercises the two oddities real exports carry: a cost basis
|
||||||
|
/// Schwab reports as "Unavailable", and a holding with no price or value
|
||||||
|
/// ("N/A").
|
||||||
|
const test_all_accounts =
|
||||||
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_IRA ...1234\"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"VTI\",\"VANGUARD TOTAL STOCK MARKET ETF\",\"1.10\",\"0.37%\",\"300.00\",\"100\",\"$110.00\",\"0.37%\",\"$30,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"No\",\"N/A\",\"96.77%\",\"ETFs & Closed End Funds\",\n" ++
|
||||||
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$1,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"3.23%\",\"Cash and Money Market\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$110.00\",\"0.36%\",\"$31,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_Brokerage ...5678\"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n" ++
|
||||||
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$1,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"33.33%\",\"Cash and Money Market\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"0.67%\",\"$3,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_Trust ...9012\"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"SCHD\",\"SCHWAB US DIVIDEND EQUITY ETF\",\"0.10\",\"0.36%\",\"27.50\",\"200\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"Unavailable\",\"--\",\"--\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n" ++
|
||||||
|
"\"123ABC456\",\"SAMPLE CORP ESCROW\",\"--\",\"--\",\"N/A\",\"5\",\"--\",\"--\",\"N/A\",\"$0.00\",\"--\",\"--\",\"--\",\"No\",\"N/A\",\"--\",\"Equity\",\n" ++
|
||||||
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$0.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0%\",\"Cash and Money Market\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"$0.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
||||||
|
|
||||||
|
test "parseCsv all-accounts: every row carries its own section's account" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const positions = try parseCsv(allocator, test_all_accounts);
|
||||||
|
defer allocator.free(positions);
|
||||||
|
|
||||||
|
const Expect = struct { symbol: []const u8, number: []const u8, name: []const u8, is_cash: bool };
|
||||||
|
const expected = [_]Expect{
|
||||||
|
.{ .symbol = "VTI", .number = "1234", .name = "Sample_IRA", .is_cash = false },
|
||||||
|
.{ .symbol = "Cash & Cash Investments", .number = "1234", .name = "Sample_IRA", .is_cash = true },
|
||||||
|
.{ .symbol = "AAPL", .number = "5678", .name = "Sample_Brokerage", .is_cash = false },
|
||||||
|
.{ .symbol = "Cash & Cash Investments", .number = "5678", .name = "Sample_Brokerage", .is_cash = true },
|
||||||
|
.{ .symbol = "SCHD", .number = "9012", .name = "Sample_Trust", .is_cash = false },
|
||||||
|
.{ .symbol = "123ABC456", .number = "9012", .name = "Sample_Trust", .is_cash = false },
|
||||||
|
.{ .symbol = "Cash & Cash Investments", .number = "9012", .name = "Sample_Trust", .is_cash = true },
|
||||||
|
};
|
||||||
|
try std.testing.expectEqual(expected.len, positions.len);
|
||||||
|
for (expected, positions) |e, p| {
|
||||||
|
try std.testing.expectEqualStrings(e.symbol, p.symbol);
|
||||||
|
try std.testing.expectEqualStrings(e.number, p.account_number);
|
||||||
|
try std.testing.expectEqualStrings(e.name, p.account_name);
|
||||||
|
try std.testing.expectEqual(e.is_cash, p.is_cash);
|
||||||
|
}
|
||||||
|
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.001);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 30000), positions[0].current_value.?, 0.001);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 1000), positions[3].current_value.?, 0.001);
|
||||||
|
// "Unavailable" cost basis and "N/A" value read as unknown, not zero.
|
||||||
|
try std.testing.expect(positions[4].cost_basis == null);
|
||||||
|
try std.testing.expect(positions[5].current_value == null);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 5), positions[5].quantity.?, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv all-accounts: a row before any account line has no account" {
|
||||||
|
const csv =
|
||||||
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"Sample_IRA ...1234\"\n";
|
||||||
|
try std.testing.expectError(error.MissingAccountLine, parseCsv(std.testing.allocator, csv));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv all-accounts: every section's header is validated" {
|
||||||
|
// The second section's Symbol and Price are swapped; the first is fine.
|
||||||
|
const csv =
|
||||||
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_IRA ...1234\"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_Brokerage ...5678\"\n" ++
|
||||||
|
"\"Price\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Symbol\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n";
|
||||||
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(std.testing.allocator, csv));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv all-accounts: an account line must be followed by a header" {
|
||||||
|
const csv =
|
||||||
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_IRA ...1234\"\n" ++
|
||||||
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n";
|
||||||
|
try std.testing.expectError(error.UnexpectedHeader, parseCsv(std.testing.allocator, csv));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv all-accounts: no sections is an error, not an empty result" {
|
||||||
|
try std.testing.expectError(
|
||||||
|
error.NoAccountsFound,
|
||||||
|
parseCsv(std.testing.allocator, "\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv: a Positions Total that disagrees with its rows is rejected" {
|
||||||
|
// Both layouts check it. Single account: the total claims $1 more than
|
||||||
|
// the one row.
|
||||||
|
const single =
|
||||||
|
"\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"1%\",\"$2,001.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
||||||
|
try std.testing.expectError(error.InconsistentExport, parseCsv(std.testing.allocator, single));
|
||||||
|
|
||||||
|
// All accounts: the total is only checked against its own section, so
|
||||||
|
// a row attributed to the wrong section shows up. Here the second
|
||||||
|
// section's total is the first section's.
|
||||||
|
const all =
|
||||||
|
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"\"Sample_IRA ...1234\"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
|
||||||
|
"\"Sample_Brokerage ...5678\"\n" ++
|
||||||
|
test_header ++
|
||||||
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$1,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"100%\",\"Cash and Money Market\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$2,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
||||||
|
try std.testing.expectError(error.InconsistentExport, parseCsv(std.testing.allocator, all));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "checkTotal: allows half a cent of rounding per row" {
|
||||||
|
const acct: Account = .{ .name = "Sample IRA", .number = "1234" };
|
||||||
|
// Three rows: up to 1.5 cents of drift is rounding.
|
||||||
|
try checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "$100.01");
|
||||||
|
try checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "$99.99");
|
||||||
|
try std.testing.expectError(error.InconsistentExport, checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "$100.02"));
|
||||||
|
// A total Schwab leaves blank is not checked.
|
||||||
|
try checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "--");
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv all-accounts: allocation failures are handled" {
|
||||||
|
const S = struct {
|
||||||
|
fn run(allocator: std.mem.Allocator) !void {
|
||||||
|
const positions = try parseCsv(allocator, test_all_accounts);
|
||||||
|
allocator.free(positions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try std.testing.checkAllAllocationFailures(std.testing.allocator, S.run, .{});
|
||||||
|
}
|
||||||
|
|
||||||
test "parseSummary basic" {
|
test "parseSummary basic" {
|
||||||
const data =
|
const data =
|
||||||
\\Sample Roth
|
\\Sample Roth
|
||||||
|
|
@ -527,3 +839,12 @@ test "parseSummary no accounts" {
|
||||||
const result = parseSummary(allocator, "some random text\nno accounts here\n");
|
const result = parseSummary(allocator, "some random text\nno accounts here\n");
|
||||||
try std.testing.expectError(error.NoAccountsFound, result);
|
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,8 +21,10 @@
|
||||||
//! pulling in the reconciliation surface.
|
//! pulling in the reconciliation surface.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const zfin = @import("../root.zig");
|
||||||
const cli = @import("common.zig");
|
const cli = @import("common.zig");
|
||||||
const framework = @import("framework.zig");
|
const framework = @import("framework.zig");
|
||||||
|
const analysis = @import("../analytics/analysis.zig");
|
||||||
|
|
||||||
const common = @import("audit/common.zig");
|
const common = @import("audit/common.zig");
|
||||||
const fidelity = @import("../analytics/reconcile/fidelity.zig");
|
const fidelity = @import("../analytics/reconcile/fidelity.zig");
|
||||||
|
|
@ -63,7 +65,8 @@ pub const meta: framework.Meta = .{
|
||||||
\\ --stale-days <N> Manual price staleness threshold (default 3)
|
\\ --stale-days <N> Manual price staleness threshold (default 3)
|
||||||
\\ --fidelity <CSV> Fidelity positions CSV export
|
\\ --fidelity <CSV> Fidelity positions CSV export
|
||||||
\\ ("All accounts" -> Positions tab -> Download)
|
\\ ("All accounts" -> Positions tab -> Download)
|
||||||
\\ --schwab <CSV> Schwab per-account positions CSV export
|
\\ --schwab <CSV> Schwab positions CSV export (one account,
|
||||||
|
\\ or "All Brokerage Accounts")
|
||||||
\\ --schwab-summary Schwab account summary; copy from accounts
|
\\ --schwab-summary Schwab account summary; copy from accounts
|
||||||
\\ summary page, paste to stdin, then ^D
|
\\ summary page, paste to stdin, then ^D
|
||||||
\\ --wells-fargo <XLS> Wells Fargo positions spreadsheet
|
\\ --wells-fargo <XLS> Wells Fargo positions spreadsheet
|
||||||
|
|
@ -209,23 +212,32 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
|
cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
|
||||||
var stdin_reader_buf: [4096]u8 = undefined;
|
var stdin_reader_buf: [4096]u8 = undefined;
|
||||||
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
|
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
|
||||||
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch {
|
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch |err| {
|
||||||
cli.stderrPrint(io, "Error: Cannot read stdin\n");
|
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);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
defer allocator.free(stdin_data);
|
defer allocator.free(stdin_data);
|
||||||
|
|
||||||
const results = schwab.reconcileSummary(allocator, portfolio, stdin_data, account_map, prices, as_of) catch |err| switch (err) {
|
const results = schwab.reconcileSummary(allocator, portfolio, stdin_data, account_map, prices, as_of) catch |err| switch (err) {
|
||||||
error.OutOfMemory => return err,
|
error.OutOfMemory => return err,
|
||||||
else => {
|
else => |e| {
|
||||||
cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n");
|
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);
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
defer allocator.free(results);
|
defer allocator.free(results);
|
||||||
|
|
||||||
try schwab.displaySchwabResults(results, color, out);
|
// A summary paste covers every account on the page.
|
||||||
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out);
|
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);
|
||||||
|
|
||||||
const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
|
const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
|
||||||
defer allocator.free(present);
|
defer allocator.free(present);
|
||||||
|
|
@ -236,9 +248,9 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
|
|
||||||
// Fidelity CSV
|
// Fidelity CSV
|
||||||
if (fidelity_csv) |csv_path| {
|
if (fidelity_csv) |csv_path| {
|
||||||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
|
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
|
||||||
var msg_buf: [256]u8 = undefined;
|
var msg_buf: [512]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";
|
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";
|
||||||
cli.stderrPrint(io, msg);
|
cli.stderrPrint(io, msg);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -246,8 +258,10 @@ 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) {
|
const results = fidelity.reconcile(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) {
|
||||||
error.OutOfMemory => return err,
|
error.OutOfMemory => return err,
|
||||||
else => {
|
else => |e| {
|
||||||
cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n");
|
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);
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -256,8 +270,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
allocator.free(results);
|
allocator.free(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
try common.displayResults(results, color, out);
|
// Fidelity's download covers every account in the login.
|
||||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
try showAccountExport(allocator, "fidelity", results, .all_accounts, portfolio, prices, account_map, color, out);
|
||||||
|
|
||||||
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
|
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
|
||||||
defer allocator.free(present);
|
defer allocator.free(present);
|
||||||
|
|
@ -266,11 +280,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
try common.displayAbsentAccounts(absent, color, "this export", out);
|
try common.displayAbsentAccounts(absent, color, "this export", out);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schwab per-account CSV
|
// Schwab positions CSV (one account, or All Brokerage Accounts)
|
||||||
if (schwab_csv) |csv_path| {
|
if (schwab_csv) |csv_path| {
|
||||||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
|
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
|
||||||
var msg_buf: [256]u8 = undefined;
|
var msg_buf: [512]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";
|
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";
|
||||||
cli.stderrPrint(io, msg);
|
cli.stderrPrint(io, msg);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -278,8 +292,10 @@ 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) {
|
const results = schwab.reconcileCsv(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) {
|
||||||
error.OutOfMemory => return err,
|
error.OutOfMemory => return err,
|
||||||
else => {
|
else => |e| {
|
||||||
cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
|
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);
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -288,8 +304,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
allocator.free(results);
|
allocator.free(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
try common.displayResults(results, color, out);
|
try showAccountExport(allocator, "schwab", results, schwab.csvScope(csv_data), portfolio, prices, account_map, color, out);
|
||||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
|
||||||
|
|
||||||
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
|
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
|
||||||
defer allocator.free(present);
|
defer allocator.free(present);
|
||||||
|
|
@ -320,8 +335,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
};
|
};
|
||||||
defer rec.deinit(allocator);
|
defer rec.deinit(allocator);
|
||||||
|
|
||||||
try common.displayResults(rec.results, color, out);
|
// One WF download covers a whole household.
|
||||||
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out);
|
try showAccountExport(allocator, "wells fargo", rec.results, .all_accounts, portfolio, prices, account_map, color, out);
|
||||||
|
|
||||||
const present = try common.presentNumbers(allocator, common.AccountComparison, rec.results);
|
const present = try common.presentNumbers(allocator, common.AccountComparison, rec.results);
|
||||||
defer allocator.free(present);
|
defer allocator.free(present);
|
||||||
|
|
@ -331,6 +346,31 @@ 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 ────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
test "parseArgs: defaults" {
|
test "parseArgs: defaults" {
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,54 @@ pub const SymbolComparison = reconcile.SymbolComparison;
|
||||||
pub const AccountComparison = reconcile.AccountComparison;
|
pub const AccountComparison = reconcile.AccountComparison;
|
||||||
pub const compareAccounts = reconcile.compareAccounts;
|
pub const compareAccounts = reconcile.compareAccounts;
|
||||||
pub const hasAccountDiscrepancies = reconcile.hasAccountDiscrepancies;
|
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 AbsentAccount = reconcile.AbsentAccount;
|
||||||
pub const presentNumbers = reconcile.presentNumbers;
|
pub const presentNumbers = reconcile.presentNumbers;
|
||||||
pub const findAbsentAccounts = reconcile.findAbsentAccounts;
|
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 ────────────────────────────────────────
|
// ── Ratio suggestions ────────────────────────────────────────
|
||||||
|
|
||||||
/// After displaying audit results, check for price_ratio positions where
|
/// After displaying audit results, check for price_ratio positions where
|
||||||
|
|
@ -547,3 +591,91 @@ 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);
|
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,6 +737,42 @@ fn accumulatePresent(
|
||||||
for (present) |num| try dst.append(allocator, try allocator.dupe(u8, num));
|
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.
|
/// Run the flagless portfolio hygiene check.
|
||||||
pub fn runHygieneCheck(
|
pub fn runHygieneCheck(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
|
|
@ -1156,22 +1192,25 @@ pub fn runHygieneCheck(
|
||||||
};
|
};
|
||||||
defer allocator.free(results);
|
defer allocator.free(results);
|
||||||
|
|
||||||
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
|
// A summary paste covers every account on the page.
|
||||||
try out.print("\n", .{});
|
const view = common.splitUnmapped(schwab.SchwabAccountComparison, results, .all_accounts);
|
||||||
try schwab.displaySchwabResults(results, color, out);
|
if (view.skipped()) {
|
||||||
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out);
|
try common.displayUnmapped(schwab.SchwabAccountComparison, "schwab summary", view, verbose, color, out);
|
||||||
} else {
|
continue;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (verbose or schwab.hasSchwabDiscrepancies(view.shown)) {
|
||||||
|
try out.print("\n", .{});
|
||||||
|
try schwab.displaySchwabResults(view.shown, 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) });
|
||||||
|
}
|
||||||
|
// 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);
|
try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results);
|
||||||
},
|
},
|
||||||
.fidelity_csv => {
|
.fidelity_csv => {
|
||||||
|
|
@ -1184,17 +1223,9 @@ pub fn runHygieneCheck(
|
||||||
allocator.free(results);
|
allocator.free(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (verbose or common.hasAccountDiscrepancies(results)) {
|
// Fidelity's download covers every account in the login.
|
||||||
try out.print("\n", .{});
|
if (try reportAccountExport(allocator, "fidelity", results, .all_accounts, portfolio, prices, account_map, verbose, color, out))
|
||||||
try common.displayResults(results, color, out);
|
try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results);
|
||||||
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 => {
|
.schwab_csv => {
|
||||||
const results = schwab.reconcileCsv(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| {
|
const results = schwab.reconcileCsv(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| {
|
||||||
|
|
@ -1206,16 +1237,8 @@ pub fn runHygieneCheck(
|
||||||
allocator.free(results);
|
allocator.free(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (verbose or common.hasAccountDiscrepancies(results)) {
|
if (try reportAccountExport(allocator, "schwab", results, schwab.csvScope(file_data), portfolio, prices, account_map, verbose, color, out))
|
||||||
try out.print("\n", .{});
|
try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results);
|
||||||
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 => {
|
.wells_fargo_xls => {
|
||||||
var rec = wells_fargo.reconcileXls(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| switch (err) {
|
var rec = wells_fargo.reconcileXls(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| switch (err) {
|
||||||
|
|
@ -1227,25 +1250,9 @@ pub fn runHygieneCheck(
|
||||||
};
|
};
|
||||||
defer rec.deinit(allocator);
|
defer rec.deinit(allocator);
|
||||||
|
|
||||||
// One WF download covers a whole household. If none of
|
// One WF download covers a whole household.
|
||||||
// its accounts is in this portfolio's accounts.srf, it is
|
if (try reportAccountExport(allocator, "wells fargo", rec.results, .all_accounts, portfolio, prices, account_map, verbose, color, out))
|
||||||
// someone else's export sitting in a shared directory;
|
try accumulatePresent(allocator, &wells_fargo_present, common.AccountComparison, rec.results);
|
||||||
// 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);
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2702,3 +2709,99 @@ 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, "transfer::<DATE>,") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "dest_lot::VTI@1970-01-01") != 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,6 +15,7 @@ const reconcile = @import("../../analytics/reconcile.zig");
|
||||||
pub const SchwabAccountComparison = reconcile.SchwabAccountComparison;
|
pub const SchwabAccountComparison = reconcile.SchwabAccountComparison;
|
||||||
pub const compareSchwabSummary = reconcile.compareSchwabSummary;
|
pub const compareSchwabSummary = reconcile.compareSchwabSummary;
|
||||||
pub const reconcileCsv = reconcile.reconcileCsv;
|
pub const reconcileCsv = reconcile.reconcileCsv;
|
||||||
|
pub const csvScope = reconcile.schwabCsvScope;
|
||||||
pub const reconcileSummary = reconcile.reconcileSummary;
|
pub const reconcileSummary = reconcile.reconcileSummary;
|
||||||
pub const hasSchwabDiscrepancies = reconcile.hasSchwabDiscrepancies;
|
pub const hasSchwabDiscrepancies = reconcile.hasSchwabDiscrepancies;
|
||||||
|
|
||||||
|
|
@ -33,7 +34,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
|
||||||
var discrepancy_count: usize = 0;
|
var discrepancy_count: usize = 0;
|
||||||
|
|
||||||
for (results) |r| {
|
for (results) |r| {
|
||||||
const label = if (r.account_name.len > 0) r.account_name else r.schwab_name;
|
const label = if (r.account_name.len > 0) r.account_name else r.brokerage_name;
|
||||||
|
|
||||||
var br_cash_buf: [24]u8 = undefined;
|
var br_cash_buf: [24]u8 = undefined;
|
||||||
var br_total_buf: [24]u8 = undefined;
|
var br_total_buf: [24]u8 = undefined;
|
||||||
|
|
@ -200,14 +201,14 @@ pub fn displaySchwabSummaryRatioSuggestions(
|
||||||
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
|
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
|
||||||
const results = [_]SchwabAccountComparison{
|
const results = [_]SchwabAccountComparison{
|
||||||
// clean mapped -> no status
|
// clean mapped -> no status
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
// cash mismatch -> "Cash +$5.00", counts as a real mismatch
|
// cash mismatch -> "Cash +$5.00", counts as a real mismatch
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
// value delta within the CD band (cash ok) -> muted "Value
|
// value delta within the CD band (cash ok) -> muted "Value
|
||||||
// +$100.00", shown but not counted as a real mismatch
|
// +$100.00", shown but not counted as a real mismatch
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
// unmapped, null broker fields -> "Unmapped" + "--", counts as a real mismatch
|
// unmapped, null broker fields -> "Unmapped" + "--", counts as a real mismatch
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
var buf: [4096]u8 = undefined;
|
var buf: [4096]u8 = undefined;
|
||||||
|
|
@ -220,7 +221,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, "Cash +") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Unmapped") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "Unmapped") != null);
|
||||||
// unmapped row falls back to the schwab_name label
|
// unmapped row falls back to the brokerage_name label
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Brokerage 3456") != null);
|
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, "--") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
|
||||||
|
|
@ -232,7 +233,7 @@ test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals"
|
||||||
|
|
||||||
test "displaySchwabResults: color=true emits ANSI and singular label" {
|
test "displaySchwabResults: color=true emits ANSI and singular label" {
|
||||||
const results = [_]SchwabAccountComparison{
|
const results = [_]SchwabAccountComparison{
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
};
|
};
|
||||||
var buf: [2048]u8 = undefined;
|
var buf: [2048]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
|
|
@ -249,7 +250,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
|
// 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.
|
// off (a missing lot). No CD, no options -> a real, counted warning.
|
||||||
const results = [_]SchwabAccountComparison{
|
const results = [_]SchwabAccountComparison{
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
};
|
};
|
||||||
var buf: [4096]u8 = undefined;
|
var buf: [4096]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
|
|
@ -269,9 +270,9 @@ test "displaySchwabResults: CD-band and option value deltas are muted, shown, an
|
||||||
// is warning-colored and neither counts as a mismatch.
|
// is warning-colored and neither counts as a mismatch.
|
||||||
const results = [_]SchwabAccountComparison{
|
const results = [_]SchwabAccountComparison{
|
||||||
// CD account: $8.70 mark sits within a $154 band -> expected/muted.
|
// CD account: $8.70 mark sits within a $154 band -> expected/muted.
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
// Option account: cost-vs-market gap is unbounded -> muted, drill down.
|
// Option account: cost-vs-market gap is unbounded -> muted, drill down.
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
};
|
};
|
||||||
var buf: [4096]u8 = undefined;
|
var buf: [4096]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
|
|
@ -310,7 +311,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.
|
// total_delta 1000 on a 50000 stock value -> suggested ratio 1.02 vs 1.0.
|
||||||
const results = [_]SchwabAccountComparison{
|
const results = [_]SchwabAccountComparison{
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
var buf: [2048]u8 = undefined;
|
var buf: [2048]u8 = undefined;
|
||||||
|
|
@ -330,7 +331,7 @@ test "displaySchwabSummaryRatioSuggestions: no account_map produces no output" {
|
||||||
defer prices.deinit();
|
defer prices.deinit();
|
||||||
|
|
||||||
const results = [_]SchwabAccountComparison{
|
const results = [_]SchwabAccountComparison{
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
};
|
};
|
||||||
var buf: [512]u8 = undefined;
|
var buf: [512]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
|
|
@ -355,7 +356,7 @@ test "displaySchwabSummaryRatioSuggestions: non-direct-indexing account is skipp
|
||||||
try prices.put("SPY", 500.0);
|
try prices.put("SPY", 500.0);
|
||||||
|
|
||||||
const results = [_]SchwabAccountComparison{
|
const results = [_]SchwabAccountComparison{
|
||||||
.{ .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 },
|
.{ .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 },
|
||||||
};
|
};
|
||||||
var buf: [512]u8 = undefined;
|
var buf: [512]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,8 @@ pub const meta: framework.Meta = .{
|
||||||
\\ concrete path, not a glob). REQUIRED.
|
\\ concrete path, not a glob). REQUIRED.
|
||||||
\\ --fidelity <CSV> Fidelity positions CSV
|
\\ --fidelity <CSV> Fidelity positions CSV
|
||||||
\\ ("All accounts" -> Positions tab -> Download)
|
\\ ("All accounts" -> Positions tab -> Download)
|
||||||
\\ --schwab <CSV> Schwab per-account positions CSV
|
\\ --schwab <CSV> Schwab positions CSV (one account, or
|
||||||
|
\\ "All Brokerage Accounts")
|
||||||
\\ --wells-fargo <XLS> Wells Fargo positions spreadsheet
|
\\ --wells-fargo <XLS> Wells Fargo positions spreadsheet
|
||||||
\\ (Download Type "Portfolio-Expanded
|
\\ (Download Type "Portfolio-Expanded
|
||||||
\\ Detail", Portfolio View "Positions",
|
\\ Detail", Portfolio View "Positions",
|
||||||
|
|
@ -346,10 +347,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
|
|
||||||
const positions: []BrokeragePosition = switch (parsed.source) {
|
const positions: []BrokeragePosition = switch (parsed.source) {
|
||||||
.fidelity => try fidelity.parseCsv(allocator, source_data),
|
.fidelity => try fidelity.parseCsv(allocator, source_data),
|
||||||
.schwab => blk: {
|
.schwab => try schwab.parseCsv(allocator, source_data),
|
||||||
const r = try schwab.parseCsv(allocator, source_data);
|
|
||||||
break :blk r.positions;
|
|
||||||
},
|
|
||||||
.wells_fargo => blk: {
|
.wells_fargo => blk: {
|
||||||
workbook = biff8.Workbook.parse(allocator, source_data) catch |err| return badWellsFargoExport(io, source_path, err);
|
workbook = biff8.Workbook.parse(allocator, source_data) catch |err| return badWellsFargoExport(io, source_path, err);
|
||||||
wf_sheet = wells_fargo.positionsSheet(&workbook.?) catch |err| return badWellsFargoExport(io, source_path, err);
|
wf_sheet = wells_fargo.positionsSheet(&workbook.?) catch |err| return badWellsFargoExport(io, source_path, err);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue