audit: better handling of unmapped accounts in multi-account file imports
This commit is contained in:
parent
635a0557b4
commit
d5459ff39e
11 changed files with 616 additions and 123 deletions
|
|
@ -268,10 +268,16 @@ What it considers:
|
|||
for ...`, a Schwab summary by `Account number ending in`, a Wells
|
||||
Fargo spreadsheet by its `WFA_Positions` sheet. A renamed file still
|
||||
works; an unrelated file is skipped.
|
||||
- **A Wells Fargo export with none of your accounts is skipped.** One WF
|
||||
download covers a whole household, so if you also manage someone
|
||||
else's WF accounts, their export in your download folder is noted and
|
||||
skipped rather than reported as eight unmapped accounts.
|
||||
- **Accounts you don't track get one line, not a wall of
|
||||
discrepancies.** Fidelity's download, Schwab's All Brokerage Accounts
|
||||
export and summary paste, and the Wells Fargo spreadsheet cover every
|
||||
account your login can see -- including ones this portfolio
|
||||
deliberately leaves out (a relative's account you manage, one that
|
||||
belongs to another portfolio file). Their unmapped accounts are named
|
||||
on a muted "not in accounts.srf, not reconciled" line instead of
|
||||
being reconciled, and an export with *none* of your accounts is noted
|
||||
and skipped as another portfolio's. See
|
||||
[How accounts are matched](#how-accounts-are-matched).
|
||||
|
||||
So with `ZFIN_AUDIT_FILES=~/Downloads`, the workflow collapses to
|
||||
"download from your broker, run `zfin audit`, done."
|
||||
|
|
@ -291,8 +297,22 @@ portfolio. It does that through
|
|||
- zfin finds the `accounts.srf` entry whose `institution::` (`fidelity`,
|
||||
`schwab`, `wells_fargo`) **and** `account_number::` match, and
|
||||
compares against that account's lots.
|
||||
- **No match -> the account is shown as `unmapped`** and flagged as a
|
||||
discrepancy. Fix it by adding `institution::` and `account_number::`
|
||||
- **No match -> the account isn't reconciled.** How loudly zfin says so
|
||||
depends on what you exported:
|
||||
- A **single-account** Schwab export: you picked that account, so it
|
||||
is shown as `unmapped` with every holding, and flagged as a
|
||||
discrepancy.
|
||||
- An export covering **every account** (Fidelity, Schwab's All
|
||||
Brokerage Accounts or summary, Wells Fargo): the account is listed
|
||||
by name and number on one muted "not in accounts.srf, not
|
||||
reconciled" line, and doesn't count as a discrepancy -- so an
|
||||
account you deliberately don't track doesn't keep the audit from
|
||||
coming back clean. If *none* of the export's accounts match, the
|
||||
whole file is skipped as another portfolio's (the flagless audit
|
||||
lists its accounts with `--verbose`; a `--fidelity`/`--schwab`/...
|
||||
flag always does).
|
||||
|
||||
Either way, fix it by adding `institution::` and `account_number::`
|
||||
to that account in `accounts.srf` (a placeholder number you recognize
|
||||
is fine -- it just has to match what the export shows).
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,13 @@ discrepancies.
|
|||
| `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (Download Type "Portfolio-Expanded Detail", "Positions"). |
|
||||
|
||||
Reconciliation matches export accounts to yours via `institution::` and
|
||||
`account_number::` in [`accounts.srf`](../config/accounts-srf.md); an
|
||||
unmatched account is reported as "unmapped." A discovered Wells Fargo
|
||||
export none of whose accounts are mapped is skipped with a note instead,
|
||||
since it is almost certainly another portfolio's.
|
||||
`account_number::` in [`accounts.srf`](../config/accounts-srf.md). An
|
||||
unmatched account in a single-account Schwab export is reported as
|
||||
"unmapped." Exports that cover every account (Fidelity, Schwab's All
|
||||
Brokerage Accounts and summary, Wells Fargo) instead name their
|
||||
unmatched accounts on one "not in accounts.srf, not reconciled" line,
|
||||
and one with no matched accounts at all is skipped as another
|
||||
portfolio's. See [how accounts are matched](../../guides/audit-against-brokerage.md#how-accounts-are-matched).
|
||||
|
||||
The hygiene check also flags newly-appeared lots worth at least
|
||||
$10,000 in a **Large new lots - confirm source** section, so you can
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ pub const cdLotAllowance = common.cdLotAllowance;
|
|||
pub const accountValueExpectation = common.accountValueExpectation;
|
||||
pub const compareAccounts = common.compareAccounts;
|
||||
pub const hasAccountDiscrepancies = common.hasAccountDiscrepancies;
|
||||
pub const ExportScope = common.ExportScope;
|
||||
pub const ExportView = common.ExportView;
|
||||
pub const splitUnmapped = common.splitUnmapped;
|
||||
pub const presentNumbers = common.presentNumbers;
|
||||
pub const findAbsentAccounts = common.findAbsentAccounts;
|
||||
pub const RatioSuggestion = common.RatioSuggestion;
|
||||
|
|
@ -32,6 +35,7 @@ pub const ratioSuggestions = common.ratioSuggestions;
|
|||
pub const SchwabAccountComparison = schwab.SchwabAccountComparison;
|
||||
pub const compareSchwabSummary = schwab.compareSchwabSummary;
|
||||
pub const reconcileCsv = schwab.reconcileCsv;
|
||||
pub const schwabCsvScope = schwab.csvScope;
|
||||
pub const reconcileSummary = schwab.reconcileSummary;
|
||||
pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies;
|
||||
pub const summaryRatioSuggestions = schwab.summaryRatioSuggestions;
|
||||
|
|
|
|||
|
|
@ -661,6 +661,67 @@ pub fn hasAccountDiscrepancies(results: []const AccountComparison) bool {
|
|||
return false;
|
||||
}
|
||||
|
||||
// ── Unmapped accounts in an export ───────────────────────────
|
||||
|
||||
/// How much of a household an export was asked to cover, which decides
|
||||
/// what an unmapped account in it means (see `splitUnmapped`).
|
||||
pub const ExportScope = enum {
|
||||
/// The user picked one account to export - Schwab's single-account
|
||||
/// positions CSV. If that account is unmapped, it is a setup error.
|
||||
one_account,
|
||||
/// Everything the login can see - Fidelity's download, the Wells
|
||||
/// Fargo spreadsheet, Schwab's All Brokerage Accounts export and its
|
||||
/// summary paste. These routinely carry accounts this portfolio
|
||||
/// deliberately doesn't track: a relative's, a view-only one, or
|
||||
/// one belonging to another portfolio file.
|
||||
all_accounts,
|
||||
};
|
||||
|
||||
/// One export's comparison results, split for display. Both slices
|
||||
/// view the caller's results, which `splitUnmapped` reorders in place.
|
||||
pub fn ExportView(comptime T: type) type {
|
||||
return struct {
|
||||
/// Accounts to reconcile in full.
|
||||
shown: []T,
|
||||
/// Unmapped accounts of an all-accounts export, reported on one
|
||||
/// line rather than as discrepancies.
|
||||
unmapped: []T,
|
||||
|
||||
/// True when nothing in an all-accounts export maps: it is almost
|
||||
/// certainly another portfolio's file (one household's download
|
||||
/// in a shared Downloads directory), so it is skipped outright.
|
||||
pub fn skipped(self: @This()) bool {
|
||||
return self.shown.len == 0 and self.unmapped.len > 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Split one export's results into what to reconcile and what to merely
|
||||
/// note. An unmapped account (empty `account_name`, the convention both
|
||||
/// `AccountComparison` and `SchwabAccountComparison` share) in an
|
||||
/// `.all_accounts` export moves to `unmapped`: listing every row of an
|
||||
/// account you deliberately don't track as a discrepancy would mean the
|
||||
/// audit can never come back clean. It is still named, so a newly opened
|
||||
/// account that genuinely needs mapping isn't lost. In a `.one_account`
|
||||
/// export nothing moves - you asked for that account, so its not mapping
|
||||
/// is worth the full unmapped report.
|
||||
///
|
||||
/// Reorders `results` in place - mapped first, each side keeping its
|
||||
/// order - so the caller still owns and frees the same slice.
|
||||
pub fn splitUnmapped(comptime T: type, results: []T, scope: ExportScope) ExportView(T) {
|
||||
if (scope == .one_account) return .{ .shown = results, .unmapped = results[results.len..] };
|
||||
const S = struct {
|
||||
fn mappedFirst(_: void, a: T, b: T) bool {
|
||||
return a.account_name.len > 0 and b.account_name.len == 0;
|
||||
}
|
||||
};
|
||||
// Insertion sort is stable, and an export carries a handful of accounts.
|
||||
std.sort.insertion(T, results, {}, S.mappedFirst);
|
||||
var mapped: usize = 0;
|
||||
while (mapped < results.len and results[mapped].account_name.len > 0) : (mapped += 1) {}
|
||||
return .{ .shown = results[0..mapped], .unmapped = results[mapped..] };
|
||||
}
|
||||
|
||||
// ── Portfolio accounts absent from the export ────────────────
|
||||
|
||||
/// A portfolio account that maps to the institution under audit and
|
||||
|
|
@ -1977,3 +2038,70 @@ test "collapseOntoProxy: every allocation failure is clean" {
|
|||
};
|
||||
try std.testing.checkAllAllocationFailures(std.testing.allocator, S.run, .{});
|
||||
}
|
||||
|
||||
// ── splitUnmapped ────────────────────────────────────────────
|
||||
|
||||
/// An account comparison with no rows; only identity matters here.
|
||||
fn testAcct(account_name: []const u8, number: []const u8) AccountComparison {
|
||||
return .{
|
||||
.account_name = account_name,
|
||||
.brokerage_name = "Sample Account",
|
||||
.account_number = number,
|
||||
.comparisons = &.{},
|
||||
.portfolio_total = 0,
|
||||
.brokerage_total = 0,
|
||||
.total_delta = 0,
|
||||
.option_value_delta = 0,
|
||||
.has_discrepancies = account_name.len == 0,
|
||||
};
|
||||
}
|
||||
|
||||
test "splitUnmapped: an all-accounts export sets unmapped accounts aside, keeping order" {
|
||||
var results = [_]AccountComparison{
|
||||
testAcct("", "9012"),
|
||||
testAcct("Sample IRA", "1234"),
|
||||
testAcct("", "3456"),
|
||||
testAcct("Sample Brokerage", "5678"),
|
||||
};
|
||||
const view = splitUnmapped(AccountComparison, &results, .all_accounts);
|
||||
|
||||
try std.testing.expect(!view.skipped());
|
||||
try std.testing.expectEqual(@as(usize, 2), view.shown.len);
|
||||
try std.testing.expectEqualStrings("1234", view.shown[0].account_number);
|
||||
try std.testing.expectEqualStrings("5678", view.shown[1].account_number);
|
||||
try std.testing.expectEqual(@as(usize, 2), view.unmapped.len);
|
||||
try std.testing.expectEqualStrings("9012", view.unmapped[0].account_number);
|
||||
try std.testing.expectEqualStrings("3456", view.unmapped[1].account_number);
|
||||
// The set aside accounts were the discrepancies; what is shown is clean.
|
||||
try std.testing.expect(!hasAccountDiscrepancies(view.shown));
|
||||
}
|
||||
|
||||
test "splitUnmapped: a one-account export keeps its unmapped account in full view" {
|
||||
var results = [_]AccountComparison{testAcct("", "9012")};
|
||||
const view = splitUnmapped(AccountComparison, &results, .one_account);
|
||||
try std.testing.expect(!view.skipped());
|
||||
try std.testing.expectEqual(@as(usize, 1), view.shown.len);
|
||||
try std.testing.expectEqual(@as(usize, 0), view.unmapped.len);
|
||||
try std.testing.expect(hasAccountDiscrepancies(view.shown));
|
||||
}
|
||||
|
||||
test "splitUnmapped: an all-accounts export with nothing mapped is skipped" {
|
||||
var results = [_]AccountComparison{ testAcct("", "9012"), testAcct("", "3456") };
|
||||
const view = splitUnmapped(AccountComparison, &results, .all_accounts);
|
||||
try std.testing.expect(view.skipped());
|
||||
try std.testing.expectEqual(@as(usize, 0), view.shown.len);
|
||||
try std.testing.expectEqual(@as(usize, 2), view.unmapped.len);
|
||||
}
|
||||
|
||||
test "splitUnmapped: everything mapped, or nothing at all, sets nothing aside" {
|
||||
var mapped = [_]AccountComparison{ testAcct("Sample IRA", "1234"), testAcct("Sample Brokerage", "5678") };
|
||||
const view = splitUnmapped(AccountComparison, &mapped, .all_accounts);
|
||||
try std.testing.expect(!view.skipped());
|
||||
try std.testing.expectEqual(@as(usize, 2), view.shown.len);
|
||||
try std.testing.expectEqual(@as(usize, 0), view.unmapped.len);
|
||||
|
||||
// An empty export is not "skipped as another portfolio's" - there is
|
||||
// nothing to attribute to anyone.
|
||||
var none = [_]AccountComparison{};
|
||||
try std.testing.expect(!splitUnmapped(AccountComparison, &none, .all_accounts).skipped());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,8 +27,12 @@ const AccountSummary = schwab_parser.AccountSummary;
|
|||
|
||||
/// Account-level comparison result for Schwab summary audit.
|
||||
pub const SchwabAccountComparison = struct {
|
||||
/// Portfolio account name; empty when the account is unmapped.
|
||||
account_name: []const u8,
|
||||
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,
|
||||
portfolio_cash: f64,
|
||||
schwab_cash: ?f64,
|
||||
|
|
@ -67,6 +71,14 @@ pub fn reconcileCsv(
|
|||
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) ─────────────────
|
||||
|
||||
/// Parse a Schwab account summary and reconcile its per-account
|
||||
|
|
@ -120,7 +132,7 @@ pub fn compareSchwabSummary(
|
|||
|
||||
try results.append(allocator, .{
|
||||
.account_name = portfolio_acct orelse "",
|
||||
.schwab_name = sa.account_name,
|
||||
.brokerage_name = sa.account_name,
|
||||
.account_number = sa.account_number,
|
||||
.portfolio_cash = pf_cash,
|
||||
.schwab_cash = sa.cash,
|
||||
|
|
@ -218,7 +230,7 @@ const portfolio_mod = @import("../../models/portfolio.zig");
|
|||
test "hasSchwabDiscrepancies" {
|
||||
const clean = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.schwab_name = "Roth IRA",
|
||||
.brokerage_name = "Roth IRA",
|
||||
.account_number = "1234",
|
||||
.portfolio_cash = 100,
|
||||
.schwab_cash = 100,
|
||||
|
|
@ -232,7 +244,7 @@ test "hasSchwabDiscrepancies" {
|
|||
|
||||
const dirty = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.schwab_name = "Roth IRA",
|
||||
.brokerage_name = "Roth IRA",
|
||||
.account_number = "1234",
|
||||
.portfolio_cash = 100,
|
||||
.schwab_cash = 200,
|
||||
|
|
@ -411,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.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
|
||||
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);
|
||||
|
|
@ -701,7 +713,7 @@ test "summaryRatioSuggestions: single-lot direct-indexing yields exact ratio + d
|
|||
try prices.put("SPY", 500.0);
|
||||
// current = 100*500*1.0 = 50000; target = 51000; suggested = 1.02, +2%.
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .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));
|
||||
defer allocator.free(sugg);
|
||||
|
|
@ -722,7 +734,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
|
|||
defer prices.deinit();
|
||||
try prices.put("SPY", 500.0);
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .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.
|
||||
|
|
@ -748,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 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));
|
||||
defer allocator.free(sugg);
|
||||
|
|
@ -770,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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
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 portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
|
||||
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)),
|
||||
};
|
||||
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.
|
||||
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)),
|
||||
};
|
||||
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" {
|
||||
|
|
|
|||
|
|
@ -229,6 +229,14 @@ fn parseTitle(line: []const u8) ?Title {
|
|||
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 {
|
||||
|
|
@ -831,3 +839,12 @@ test "parseSummary no accounts" {
|
|||
const result = parseSummary(allocator, "some random text\nno accounts here\n");
|
||||
try std.testing.expectError(error.NoAccountsFound, result);
|
||||
}
|
||||
|
||||
test "isAllAccounts: keys on the title line only" {
|
||||
try std.testing.expect(isAllAccounts("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n\"Sample_IRA ...1234\"\n"));
|
||||
try std.testing.expect(isAllAccounts("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\""));
|
||||
try std.testing.expect(!isAllAccounts("\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n"));
|
||||
// A later line mentioning the phrase does not count.
|
||||
try std.testing.expect(!isAllAccounts("something else\n\"Positions for All-Accounts\"\n"));
|
||||
try std.testing.expect(!isAllAccounts(""));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@
|
|||
//! pulling in the reconciliation surface.
|
||||
|
||||
const std = @import("std");
|
||||
const zfin = @import("../root.zig");
|
||||
const cli = @import("common.zig");
|
||||
const framework = @import("framework.zig");
|
||||
const analysis = @import("../analytics/analysis.zig");
|
||||
|
||||
const common = @import("audit/common.zig");
|
||||
const fidelity = @import("../analytics/reconcile/fidelity.zig");
|
||||
|
|
@ -210,23 +212,32 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
|
||||
var stdin_reader_buf: [4096]u8 = undefined;
|
||||
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
|
||||
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch {
|
||||
cli.stderrPrint(io, "Error: Cannot read stdin\n");
|
||||
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch |err| {
|
||||
var msg_buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read stdin: {s}\n", .{@errorName(err)}) catch "Error: Cannot read stdin\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
return;
|
||||
};
|
||||
defer allocator.free(stdin_data);
|
||||
|
||||
const results = schwab.reconcileSummary(allocator, portfolio, stdin_data, account_map, prices, as_of) catch |err| switch (err) {
|
||||
error.OutOfMemory => return err,
|
||||
else => {
|
||||
cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n");
|
||||
else => |e| {
|
||||
var msg_buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse Schwab summary: {s} (no 'Account number ending in' lines found?)\n", .{@errorName(e)}) catch "Error: Cannot parse Schwab summary\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
return;
|
||||
},
|
||||
};
|
||||
defer allocator.free(results);
|
||||
|
||||
try schwab.displaySchwabResults(results, color, out);
|
||||
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out);
|
||||
// A summary paste covers every account on the page.
|
||||
const view = common.splitUnmapped(schwab.SchwabAccountComparison, results, .all_accounts);
|
||||
if (!view.skipped()) {
|
||||
try schwab.displaySchwabResults(view.shown, color, out);
|
||||
try schwab.displaySchwabSummaryRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, as_of, color, out);
|
||||
}
|
||||
try common.displayUnmapped(schwab.SchwabAccountComparison, "schwab summary", view, true, color, out);
|
||||
|
||||
const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
|
||||
defer allocator.free(present);
|
||||
|
|
@ -237,9 +248,9 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
|
||||
// Fidelity CSV
|
||||
if (fidelity_csv) |csv_path| {
|
||||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
var msg_buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
|
||||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file {s}: {s}\n", .{ csv_path, @errorName(err) }) catch "Error: Cannot read CSV file\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
return;
|
||||
};
|
||||
|
|
@ -247,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) {
|
||||
error.OutOfMemory => return err,
|
||||
else => {
|
||||
cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n");
|
||||
else => |e| {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse Fidelity CSV {s}: {s}\n", .{ csv_path, @errorName(e) }) catch "Error: Cannot parse Fidelity CSV\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
|
@ -257,8 +270,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
allocator.free(results);
|
||||
}
|
||||
|
||||
try common.displayResults(results, color, out);
|
||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||
// Fidelity's download covers every account in the login.
|
||||
try showAccountExport(allocator, "fidelity", results, .all_accounts, portfolio, prices, account_map, color, out);
|
||||
|
||||
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
|
||||
defer allocator.free(present);
|
||||
|
|
@ -267,11 +280,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
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| {
|
||||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
|
||||
var msg_buf: [256]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
|
||||
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file {s}: {s}\n", .{ csv_path, @errorName(err) }) catch "Error: Cannot read CSV file\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
return;
|
||||
};
|
||||
|
|
@ -279,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) {
|
||||
error.OutOfMemory => return err,
|
||||
else => {
|
||||
cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
|
||||
else => |e| {
|
||||
var msg_buf: [512]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse Schwab CSV {s}: {s}\n", .{ csv_path, @errorName(e) }) catch "Error: Cannot parse Schwab CSV\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
|
@ -289,8 +304,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
allocator.free(results);
|
||||
}
|
||||
|
||||
try common.displayResults(results, color, out);
|
||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||
try showAccountExport(allocator, "schwab", results, schwab.csvScope(csv_data), portfolio, prices, account_map, color, out);
|
||||
|
||||
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
|
||||
defer allocator.free(present);
|
||||
|
|
@ -321,8 +335,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
};
|
||||
defer rec.deinit(allocator);
|
||||
|
||||
try common.displayResults(rec.results, color, out);
|
||||
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out);
|
||||
// One WF download covers a whole household.
|
||||
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);
|
||||
defer allocator.free(present);
|
||||
|
|
@ -332,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 ────────────────────────────────────────────────────
|
||||
|
||||
test "parseArgs: defaults" {
|
||||
|
|
|
|||
|
|
@ -24,10 +24,54 @@ pub const SymbolComparison = reconcile.SymbolComparison;
|
|||
pub const AccountComparison = reconcile.AccountComparison;
|
||||
pub const compareAccounts = reconcile.compareAccounts;
|
||||
pub const hasAccountDiscrepancies = reconcile.hasAccountDiscrepancies;
|
||||
pub const ExportScope = reconcile.ExportScope;
|
||||
pub const ExportView = reconcile.ExportView;
|
||||
pub const splitUnmapped = reconcile.splitUnmapped;
|
||||
pub const AbsentAccount = reconcile.AbsentAccount;
|
||||
pub const presentNumbers = reconcile.presentNumbers;
|
||||
pub const findAbsentAccounts = reconcile.findAbsentAccounts;
|
||||
|
||||
// ── Unmapped accounts ────────────────────────────────────────
|
||||
|
||||
/// Report an export's unmapped accounts (see `splitUnmapped`) in muted
|
||||
/// text: a line with the count, then one line per account so the number
|
||||
/// to map is right there. For an export skipped outright, the
|
||||
/// per-account list is printed only when `list_skipped`: someone else's
|
||||
/// whole household is noise on every routine audit, but worth seeing
|
||||
/// when you point audit at the file yourself. Prints nothing when every
|
||||
/// account is mapped. `T` is `AccountComparison` or
|
||||
/// `SchwabAccountComparison`; both carry `brokerage_name` and
|
||||
/// `account_number`.
|
||||
pub fn displayUnmapped(
|
||||
comptime T: type,
|
||||
label: []const u8,
|
||||
view: ExportView(T),
|
||||
list_skipped: bool,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
) !void {
|
||||
const n = view.unmapped.len;
|
||||
if (n == 0) return;
|
||||
if (view.skipped()) {
|
||||
if (n == 1) {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s}: its only account is not in accounts.srf; skipped (another portfolio's export?)\n", .{label});
|
||||
} else {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s}: none of its {d} accounts are in accounts.srf; skipped (another portfolio's export?)\n", .{ label, n });
|
||||
}
|
||||
if (!list_skipped) return;
|
||||
} else {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s}: {d} {s} not in accounts.srf, not reconciled\n", .{ label, n, accountsNoun(n) });
|
||||
}
|
||||
for (view.unmapped) |r| {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s} #{s}\n", .{ r.brokerage_name, r.account_number });
|
||||
}
|
||||
}
|
||||
|
||||
/// "account" or "accounts", for a count of them.
|
||||
pub fn accountsNoun(n: usize) []const u8 {
|
||||
return if (n == 1) "account" else "accounts";
|
||||
}
|
||||
|
||||
// ── Ratio suggestions ────────────────────────────────────────
|
||||
|
||||
/// After displaying audit results, check for price_ratio positions where
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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));
|
||||
}
|
||||
|
||||
/// Report one discovered per-position export (Fidelity, a Schwab CSV,
|
||||
/// Wells Fargo): the full comparison when `verbose` or anything is off,
|
||||
/// else a one-line all-clear; then the export's unmapped accounts (see
|
||||
/// `common.splitUnmapped`). Returns false when the export was skipped as
|
||||
/// another portfolio's, so the caller leaves its accounts out of the
|
||||
/// "not found in any export" present-set. Reorders `results` in place.
|
||||
fn reportAccountExport(
|
||||
allocator: std.mem.Allocator,
|
||||
label: []const u8,
|
||||
results: []common.AccountComparison,
|
||||
scope: common.ExportScope,
|
||||
portfolio: zfin.Portfolio,
|
||||
prices: std.StringHashMap(f64),
|
||||
account_map: analysis.AccountMap,
|
||||
verbose: bool,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
) !bool {
|
||||
const view = common.splitUnmapped(common.AccountComparison, results, scope);
|
||||
if (view.skipped()) {
|
||||
try common.displayUnmapped(common.AccountComparison, label, view, verbose, color, out);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (verbose or common.hasAccountDiscrepancies(view.shown)) {
|
||||
try out.print("\n", .{});
|
||||
try common.displayResults(view.shown, color, out);
|
||||
} else {
|
||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}: {d} {s}, no discrepancies\n", .{ label, view.shown.len, common.accountsNoun(view.shown.len) });
|
||||
}
|
||||
// Always show ratio suggestions, even in compact mode.
|
||||
try common.displayRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, color, out);
|
||||
try common.displayUnmapped(common.AccountComparison, label, view, verbose, color, out);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Run the flagless portfolio hygiene check.
|
||||
pub fn runHygieneCheck(
|
||||
io: std.Io,
|
||||
|
|
@ -1156,22 +1192,25 @@ pub fn runHygieneCheck(
|
|||
};
|
||||
defer allocator.free(results);
|
||||
|
||||
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
|
||||
try out.print("\n", .{});
|
||||
try schwab.displaySchwabResults(results, color, out);
|
||||
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out);
|
||||
} else {
|
||||
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);
|
||||
// A summary paste covers every account on the page.
|
||||
const view = common.splitUnmapped(schwab.SchwabAccountComparison, results, .all_accounts);
|
||||
if (view.skipped()) {
|
||||
try common.displayUnmapped(schwab.SchwabAccountComparison, "schwab summary", view, verbose, color, out);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (verbose or schwab.hasSchwabDiscrepancies(view.shown)) {
|
||||
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);
|
||||
},
|
||||
.fidelity_csv => {
|
||||
|
|
@ -1184,17 +1223,9 @@ pub fn runHygieneCheck(
|
|||
allocator.free(results);
|
||||
}
|
||||
|
||||
if (verbose or common.hasAccountDiscrepancies(results)) {
|
||||
try out.print("\n", .{});
|
||||
try common.displayResults(results, color, out);
|
||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||
} else {
|
||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len});
|
||||
// Always show ratio suggestions even in compact mode
|
||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||
}
|
||||
|
||||
try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results);
|
||||
// Fidelity's download covers every account in the login.
|
||||
if (try reportAccountExport(allocator, "fidelity", results, .all_accounts, portfolio, prices, account_map, verbose, color, out))
|
||||
try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results);
|
||||
},
|
||||
.schwab_csv => {
|
||||
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);
|
||||
}
|
||||
|
||||
if (verbose or common.hasAccountDiscrepancies(results)) {
|
||||
try out.print("\n", .{});
|
||||
try common.displayResults(results, color, out);
|
||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||
} else {
|
||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len});
|
||||
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||
}
|
||||
|
||||
try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results);
|
||||
if (try reportAccountExport(allocator, "schwab", results, schwab.csvScope(file_data), portfolio, prices, account_map, verbose, color, out))
|
||||
try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results);
|
||||
},
|
||||
.wells_fargo_xls => {
|
||||
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);
|
||||
|
||||
// One WF download covers a whole household. If none of
|
||||
// its accounts is in this portfolio's accounts.srf, it is
|
||||
// someone else's export sitting in a shared directory;
|
||||
// listing every account as a discrepancy would be noise.
|
||||
if (!rec.mapsAnyAccount()) {
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " wells fargo: none of its {d} accounts are in accounts.srf; skipped (another portfolio's export?)\n", .{rec.results.len});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (verbose or common.hasAccountDiscrepancies(rec.results)) {
|
||||
try out.print("\n", .{});
|
||||
try common.displayResults(rec.results, color, out);
|
||||
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out);
|
||||
} else {
|
||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " wells fargo: {d} accounts, no discrepancies\n", .{rec.results.len});
|
||||
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out);
|
||||
}
|
||||
|
||||
try accumulatePresent(allocator, &wells_fargo_present, common.AccountComparison, rec.results);
|
||||
// One WF download covers a whole household.
|
||||
if (try reportAccountExport(allocator, "wells fargo", rec.results, .all_accounts, portfolio, prices, account_map, verbose, color, out))
|
||||
try accumulatePresent(allocator, &wells_fargo_present, common.AccountComparison, rec.results);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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, "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 compareSchwabSummary = reconcile.compareSchwabSummary;
|
||||
pub const reconcileCsv = reconcile.reconcileCsv;
|
||||
pub const csvScope = reconcile.schwabCsvScope;
|
||||
pub const reconcileSummary = reconcile.reconcileSummary;
|
||||
pub const hasSchwabDiscrepancies = reconcile.hasSchwabDiscrepancies;
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
|
|||
var discrepancy_count: usize = 0;
|
||||
|
||||
for (results) |r| {
|
||||
const label = if (r.account_name.len > 0) r.account_name else r.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_total_buf: [24]u8 = undefined;
|
||||
|
|
@ -200,14 +201,14 @@ pub fn displaySchwabSummaryRatioSuggestions(
|
|||
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
|
||||
const results = [_]SchwabAccountComparison{
|
||||
// clean mapped -> no status
|
||||
.{ .account_name = "Sample Roth", .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
|
||||
.{ .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
|
||||
// +$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
|
||||
.{ .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;
|
||||
|
|
@ -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, "Value +") != 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, "--") != 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" {
|
||||
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 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
|
||||
// off (a missing lot). No CD, no options -> a real, counted warning.
|
||||
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 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.
|
||||
const results = [_]SchwabAccountComparison{
|
||||
// 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.
|
||||
.{ .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 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.
|
||||
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;
|
||||
|
|
@ -330,7 +331,7 @@ test "displaySchwabSummaryRatioSuggestions: no account_map produces no output" {
|
|||
defer prices.deinit();
|
||||
|
||||
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 w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
@ -355,7 +356,7 @@ test "displaySchwabSummaryRatioSuggestions: non-direct-indexing account is skipp
|
|||
try prices.put("SPY", 500.0);
|
||||
|
||||
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 w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue