separate audit compute from audit display
This commit is contained in:
parent
4f1ca3d34d
commit
940c745dd3
8 changed files with 1991 additions and 1914 deletions
34
src/analytics/reconcile.zig
Normal file
34
src/analytics/reconcile.zig
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
//! Portfolio-vs-brokerage reconciliation: pure compute lifted out of
|
||||
//! the `audit` command so it is unit-testable and reusable (finrev
|
||||
//! consumes it too). The ANSI renderers stay in `commands/audit/`.
|
||||
//!
|
||||
//! - `common` broker-agnostic comparison engine (compareAccounts,
|
||||
//! cdLotAllowance, findAbsentAccounts, ...)
|
||||
//! - `schwab` Schwab positions CSV + summary reconcilers
|
||||
//! - `fidelity` Fidelity positions CSV reconciler
|
||||
|
||||
pub const common = @import("reconcile/common.zig");
|
||||
pub const schwab = @import("reconcile/schwab.zig");
|
||||
pub const fidelity = @import("reconcile/fidelity.zig");
|
||||
|
||||
// ── Flat convenience re-exports ──────────────────────────────
|
||||
pub const value_tolerance = common.value_tolerance;
|
||||
pub const cash_tolerance = common.cash_tolerance;
|
||||
pub const AccountValueExpectation = common.AccountValueExpectation;
|
||||
pub const SymbolComparison = common.SymbolComparison;
|
||||
pub const AccountComparison = common.AccountComparison;
|
||||
pub const AbsentAccount = common.AbsentAccount;
|
||||
pub const cdLotAllowance = common.cdLotAllowance;
|
||||
pub const accountValueExpectation = common.accountValueExpectation;
|
||||
pub const compareAccounts = common.compareAccounts;
|
||||
pub const hasAccountDiscrepancies = common.hasAccountDiscrepancies;
|
||||
pub const presentNumbers = common.presentNumbers;
|
||||
pub const findAbsentAccounts = common.findAbsentAccounts;
|
||||
|
||||
pub const SchwabAccountComparison = schwab.SchwabAccountComparison;
|
||||
pub const compareSchwabSummary = schwab.compareSchwabSummary;
|
||||
pub const reconcileCsv = schwab.reconcileCsv;
|
||||
pub const reconcileSummary = schwab.reconcileSummary;
|
||||
pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies;
|
||||
|
||||
pub const reconcileFidelity = fidelity.reconcile;
|
||||
1368
src/analytics/reconcile/common.zig
Normal file
1368
src/analytics/reconcile/common.zig
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,12 @@
|
|||
//! Fidelity reconciler for the `audit` command.
|
||||
//! Fidelity reconciler (pure compute).
|
||||
//!
|
||||
//! Fidelity exports a single "all accounts" positions CSV. Parsing
|
||||
//! lives in `brokerage/fidelity.zig`; this module wires that parser
|
||||
//! into the shared per-account comparison engine in `common.zig`.
|
||||
//! Because the Fidelity export is a plain per-account positions list,
|
||||
//! the only Fidelity-specific knowledge here is "use the Fidelity CSV
|
||||
//! parser" and "the institution key is `fidelity`" - everything else
|
||||
//! (comparison, display) is shared.
|
||||
//! parser" and "the institution key is `fidelity`" - the comparison is
|
||||
//! shared, and the ANSI display lives in `commands/audit/`.
|
||||
|
||||
const std = @import("std");
|
||||
const zfin = @import("../../root.zig");
|
||||
555
src/analytics/reconcile/schwab.zig
Normal file
555
src/analytics/reconcile/schwab.zig
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
//! Schwab reconcilers (pure compute).
|
||||
//!
|
||||
//! Schwab has two export shapes, so this module carries more than
|
||||
//! the Fidelity one:
|
||||
//!
|
||||
//! 1. **Per-account positions CSV** (`--schwab`) - same per-account
|
||||
//! positions shape Fidelity uses, so it feeds the shared
|
||||
//! `common.compareAccounts` engine via `reconcileCsv`.
|
||||
//! 2. **Account summary paste** (`--schwab-summary`) - a
|
||||
//! per-account totals-only view with no per-symbol detail. This
|
||||
//! is the one genuinely broker-specific reconciler, with its own
|
||||
//! comparison type (`SchwabAccountComparison`) and comparator
|
||||
//! (`compareSchwabSummary`).
|
||||
//!
|
||||
//! Parsing lives in `brokerage/schwab.zig`; the ANSI renderers live in
|
||||
//! `commands/audit/schwab.zig`.
|
||||
|
||||
const std = @import("std");
|
||||
const zfin = @import("../../root.zig");
|
||||
const analysis = @import("../../analytics/analysis.zig");
|
||||
const Date = @import("../../Date.zig");
|
||||
const common = @import("common.zig");
|
||||
const schwab_parser = @import("../../brokerage/schwab.zig");
|
||||
|
||||
const AccountSummary = schwab_parser.AccountSummary;
|
||||
|
||||
/// Account-level comparison result for Schwab summary audit.
|
||||
pub const SchwabAccountComparison = struct {
|
||||
account_name: []const u8,
|
||||
schwab_name: []const u8,
|
||||
account_number: []const u8,
|
||||
portfolio_cash: f64,
|
||||
schwab_cash: ?f64,
|
||||
cash_delta: ?f64,
|
||||
portfolio_total: f64,
|
||||
schwab_total: ?f64,
|
||||
total_delta: ?f64,
|
||||
/// Account holds open options: zfin values them at cost, the broker
|
||||
/// at market, so a totals-level value delta is expected (mute it).
|
||||
has_options: bool = false,
|
||||
/// Bounded band within which a value delta is an expected CD mark.
|
||||
cd_allowance: f64 = 0,
|
||||
has_discrepancy: bool,
|
||||
};
|
||||
|
||||
// ── Per-account positions CSV (--schwab) ─────────────────────
|
||||
|
||||
/// Parse a Schwab per-account positions CSV and reconcile it against
|
||||
/// the portfolio via the shared engine. Returns owned
|
||||
/// `AccountComparison` results (free each `.comparisons` slice, then
|
||||
/// the results slice). String fields borrow from `csv_data`, which
|
||||
/// must outlive them. Propagates parser and allocation errors.
|
||||
pub fn reconcileCsv(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
csv_data: []const u8,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]common.AccountComparison {
|
||||
const parsed = try schwab_parser.parseCsv(allocator, csv_data);
|
||||
// Result strings borrow from `csv_data`, not the positions slice,
|
||||
// so freeing the slice array here is safe.
|
||||
defer allocator.free(parsed.positions);
|
||||
return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of);
|
||||
}
|
||||
|
||||
// ── Account summary paste (--schwab-summary) ─────────────────
|
||||
|
||||
/// Parse a Schwab account summary and reconcile its per-account
|
||||
/// totals against portfolio.srf. Returns owned results (free the
|
||||
/// slice). String fields borrow from `summary_data`, which must
|
||||
/// outlive them. Propagates parser (`NoAccountsFound`) and
|
||||
/// allocation errors.
|
||||
pub fn reconcileSummary(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
summary_data: []const u8,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]SchwabAccountComparison {
|
||||
const schwab_accounts = try schwab_parser.parseSummary(allocator, summary_data);
|
||||
defer allocator.free(schwab_accounts);
|
||||
return compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of);
|
||||
}
|
||||
|
||||
/// Compare Schwab summary against portfolio.srf account totals.
|
||||
pub fn compareSchwabSummary(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
schwab_accounts: []const AccountSummary,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]SchwabAccountComparison {
|
||||
var results = std.ArrayList(SchwabAccountComparison).empty;
|
||||
errdefer results.deinit(allocator);
|
||||
|
||||
for (schwab_accounts) |sa| {
|
||||
const portfolio_acct = account_map.findByInstitutionAccount("schwab", sa.account_number);
|
||||
|
||||
var pf_cash: f64 = 0;
|
||||
var pf_total: f64 = 0;
|
||||
var expectation: common.AccountValueExpectation = .{};
|
||||
|
||||
if (portfolio_acct) |pa| {
|
||||
pf_cash = portfolio.cashForAccount(pa);
|
||||
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
|
||||
expectation = common.accountValueExpectation(portfolio, as_of, pa);
|
||||
}
|
||||
|
||||
const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
|
||||
const total_delta = if (sa.total_value) |st| st - pf_total else null;
|
||||
|
||||
const cash_ok = if (cash_delta) |d| @abs(d) < common.cash_tolerance else true;
|
||||
const total_ok = if (total_delta) |d| @abs(d) < common.value_tolerance else true;
|
||||
|
||||
try results.append(allocator, .{
|
||||
.account_name = portfolio_acct orelse "",
|
||||
.schwab_name = sa.account_name,
|
||||
.account_number = sa.account_number,
|
||||
.portfolio_cash = pf_cash,
|
||||
.schwab_cash = sa.cash,
|
||||
.cash_delta = cash_delta,
|
||||
.portfolio_total = pf_total,
|
||||
.schwab_total = sa.total_value,
|
||||
.total_delta = total_delta,
|
||||
.has_options = expectation.has_options,
|
||||
.cd_allowance = expectation.cd_allowance,
|
||||
// A value delta is a discrepancy regardless of cause: an
|
||||
// audit must never hide it. Muting (below) is a display
|
||||
// hint, not a suppression - so visibility stays keyed on the
|
||||
// raw tolerance, same as before.
|
||||
.has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null,
|
||||
});
|
||||
}
|
||||
|
||||
return results.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Check if any Schwab summary results have discrepancies.
|
||||
pub fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool {
|
||||
for (results) |r| {
|
||||
if (r.has_discrepancy) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
const portfolio_mod = @import("../../models/portfolio.zig");
|
||||
|
||||
test "hasSchwabDiscrepancies" {
|
||||
const clean = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.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,
|
||||
}};
|
||||
try std.testing.expect(!hasSchwabDiscrepancies(&clean));
|
||||
|
||||
const dirty = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.schwab_name = "Roth IRA",
|
||||
.account_number = "1234",
|
||||
.portfolio_cash = 100,
|
||||
.schwab_cash = 200,
|
||||
.cash_delta = 100,
|
||||
.portfolio_total = 5000,
|
||||
.schwab_total = 5100,
|
||||
.total_delta = 100,
|
||||
.has_discrepancy = true,
|
||||
}};
|
||||
try std.testing.expect(hasSchwabDiscrepancies(&dirty));
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: matching account -> no discrepancy" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
// Portfolio: $5000 cash + 10 AAPL @ open_price 150 = $1500 cost basis.
|
||||
// With AAPL price=200, total = 5000 + 10*200 = 7000.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "CASH",
|
||||
.shares = 5000,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 1.0,
|
||||
.security_type = .cash,
|
||||
.account = "Sample Brokerage",
|
||||
},
|
||||
.{
|
||||
.symbol = "AAPL",
|
||||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 150,
|
||||
.account = "Sample Brokerage",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Sample Brokerage",
|
||||
.account_number = "1234",
|
||||
.cash = 5000.0,
|
||||
.total_value = 7000.0,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "Sample Brokerage",
|
||||
.tax_type = .taxable,
|
||||
.institution = "schwab",
|
||||
.account_number = "1234",
|
||||
},
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AAPL", 200.0);
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 5000), results[0].portfolio_cash, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 7000), results[0].portfolio_total, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].cash_delta.?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
|
||||
try std.testing.expect(!results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: cash mismatch -> has_discrepancy true" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
// Portfolio cash = 5000, Schwab reports 5500 -> $500 delta.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "CASH",
|
||||
.shares = 5000,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 1.0,
|
||||
.security_type = .cash,
|
||||
.account = "Brokerage",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Brokerage",
|
||||
.account_number = "1234",
|
||||
.cash = 5500.0,
|
||||
.total_value = 5500.0,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "Brokerage",
|
||||
.tax_type = .taxable,
|
||||
.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 compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 500), results[0].cash_delta.?, 0.01);
|
||||
try std.testing.expect(results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to the penny)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 6, 19);
|
||||
|
||||
// Portfolio cash $38.75; Schwab reports $38.97 - a $0.22 accrual.
|
||||
// Below the $1 securities tolerance, but a real cash drift that
|
||||
// must surface.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "CASH", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{ .account_name = "Sample Brokerage", .account_number = "1234", .cash = 38.97, .total_value = 38.97 },
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .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 compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.22), results[0].cash_delta.?, 0.001);
|
||||
try std.testing.expect(results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: account_number with no match -> empty account_name" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
const lots = [_]portfolio_mod.Lot{};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Unknown Acct",
|
||||
.account_number = "9999",
|
||||
.cash = 1000.0,
|
||||
.total_value = 1000.0,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: null cash/total fields produce null deltas (within tolerance)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "CASH",
|
||||
.shares = 5000,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 1.0,
|
||||
.security_type = .cash,
|
||||
.account = "X",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
// Schwab summary missing cash + total fields (.cash = null, .total_value = null).
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "X",
|
||||
.account_number = "1234",
|
||||
.cash = null,
|
||||
.total_value = null,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "X",
|
||||
.tax_type = .taxable,
|
||||
.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 compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expect(results[0].cash_delta == null);
|
||||
try std.testing.expect(results[0].total_delta == null);
|
||||
// Null deltas are treated as "ok" (no discrepancy possible to assert).
|
||||
try std.testing.expect(!results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: today affects valuation of held assets" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
// Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before
|
||||
// open), it's not held -> portfolio_total excludes it. With
|
||||
// today=2025-01-01 (after open), portfolio_total includes 10 * price.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "AAPL",
|
||||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2024, 6, 1),
|
||||
.open_price = 150,
|
||||
.account = "Acct",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Acct",
|
||||
.account_number = "1234",
|
||||
.cash = 0,
|
||||
.total_value = 2000,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "Acct",
|
||||
.tax_type = .taxable,
|
||||
.institution = "schwab",
|
||||
.account_number = "1234",
|
||||
},
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AAPL", 200.0);
|
||||
|
||||
// Before open: portfolio holds nothing for this account.
|
||||
{
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2024, 1, 1));
|
||||
defer allocator.free(results);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_total, 0.01);
|
||||
}
|
||||
|
||||
// After open: portfolio holds 10 * 200 = 2000.
|
||||
{
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1));
|
||||
defer allocator.free(results);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01);
|
||||
// Matches schwab -> no discrepancy.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
|
||||
try std.testing.expect(!results[0].has_discrepancy);
|
||||
}
|
||||
}
|
||||
|
||||
// ── reconcile wrappers (parse + compare wiring) ──────────────
|
||||
|
||||
test "reconcileCsv: parses a Schwab positions CSV and reconciles it" {
|
||||
const allocator = std.testing.allocator;
|
||||
const csv =
|
||||
"\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"\n" ++
|
||||
"\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";
|
||||
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "AMZN", .shares = 1488, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 74, .account = "Sample Trust" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AMZN", 239.208);
|
||||
|
||||
const results = try reconcileCsv(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 4, 10));
|
||||
defer {
|
||||
for (results) |r| allocator.free(r.comparisons);
|
||||
allocator.free(results);
|
||||
}
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectEqualStrings("Sample Trust", results[0].account_name);
|
||||
var found_amzn = false;
|
||||
for (results[0].comparisons) |c| {
|
||||
if (std.mem.eql(u8, c.symbol, "AMZN")) found_amzn = true;
|
||||
}
|
||||
try std.testing.expect(found_amzn);
|
||||
}
|
||||
|
||||
test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" {
|
||||
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 Inherited IRA
|
||||
\\Account number ending in 5678 ...5678
|
||||
\\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73%
|
||||
;
|
||||
const portfolio = portfolio_mod.Portfolio{ .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, 4, 10));
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), results.len);
|
||||
// 1234 maps; 5678 is absent from the map -> unmapped (empty name).
|
||||
try std.testing.expectEqualStrings("Sample Roth IRA", results[0].account_name);
|
||||
try std.testing.expectEqualStrings("", results[1].account_name);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: populates option flag and CD allowance from the portfolio" {
|
||||
const allocator = std.testing.allocator;
|
||||
const as_of = Date.fromYmd(2026, 6, 28);
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
// CD >1yr out -> caps at one year's coupon: 4% of 50000 = 2000.
|
||||
.{ .symbol = "CDA", .security_type = .cd, .shares = 50000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2028, 1, 1), .account = "Sample IRA" },
|
||||
// Open option -> has_options.
|
||||
.{ .symbol = "NVDA C", .security_type = .option, .underlying = "NVDA", .strike = 200, .option_type = .call, .maturity_date = Date.fromYmd(2026, 12, 18), .shares = -5, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 5.0, .multiplier = 100, .account = "Sample IRA" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
var summary = [_]AccountSummary{
|
||||
.{ .account_name = "IRA", .account_number = "1234", .cash = 0, .total_value = 60000 },
|
||||
};
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &summary, acct_map, prices, as_of);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expect(results[0].has_options);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].cd_allowance, 0.01);
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ const cli = @import("common.zig");
|
|||
const framework = @import("framework.zig");
|
||||
|
||||
const common = @import("audit/common.zig");
|
||||
const fidelity = @import("audit/fidelity.zig");
|
||||
const fidelity = @import("../analytics/reconcile/fidelity.zig");
|
||||
const schwab = @import("audit/schwab.zig");
|
||||
const hygiene = @import("audit/hygiene.zig");
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -26,7 +26,7 @@ const git = @import("../../git.zig");
|
|||
const test_git = @import("../../testutil/git.zig");
|
||||
|
||||
const common = @import("common.zig");
|
||||
const fidelity = @import("fidelity.zig");
|
||||
const fidelity = @import("../../analytics/reconcile/fidelity.zig");
|
||||
const schwab = @import("schwab.zig");
|
||||
const fmt = cli.fmt;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,144 +1,22 @@
|
|||
//! Schwab reconcilers for the `audit` command.
|
||||
//!
|
||||
//! Schwab has two export shapes, so this module carries more than
|
||||
//! the Fidelity one:
|
||||
//!
|
||||
//! 1. **Per-account positions CSV** (`--schwab`) - same per-account
|
||||
//! positions shape Fidelity uses, so it feeds the shared
|
||||
//! `common.compareAccounts` engine via `reconcileCsv`.
|
||||
//! 2. **Account summary paste** (`--schwab-summary`) - a
|
||||
//! per-account totals-only view with no per-symbol detail. This
|
||||
//! is the one genuinely broker-specific reconciler, with its own
|
||||
//! comparison type (`SchwabAccountComparison`), comparator
|
||||
//! (`compareSchwabSummary`), and display.
|
||||
//!
|
||||
//! Parsing for both lives in `brokerage/schwab.zig`.
|
||||
//! Display/rendering for the Schwab audit output. Reconciliation
|
||||
//! compute lives in `analytics/reconcile/schwab.zig`; re-exported here
|
||||
//! for back-compat so `audit.zig` / `hygiene.zig` keep using `schwab.*`.
|
||||
|
||||
const std = @import("std");
|
||||
const zfin = @import("../../root.zig");
|
||||
const cli = @import("../common.zig");
|
||||
const Money = @import("../../Money.zig");
|
||||
const analysis = @import("../../analytics/analysis.zig");
|
||||
const portfolio_mod = @import("../../models/portfolio.zig");
|
||||
const Date = @import("../../Date.zig");
|
||||
const common = @import("common.zig");
|
||||
const schwab_parser = @import("../../brokerage/schwab.zig");
|
||||
const reconcile = @import("../../analytics/reconcile.zig");
|
||||
|
||||
const AccountSummary = schwab_parser.AccountSummary;
|
||||
|
||||
/// Account-level comparison result for Schwab summary audit.
|
||||
pub const SchwabAccountComparison = struct {
|
||||
account_name: []const u8,
|
||||
schwab_name: []const u8,
|
||||
account_number: []const u8,
|
||||
portfolio_cash: f64,
|
||||
schwab_cash: ?f64,
|
||||
cash_delta: ?f64,
|
||||
portfolio_total: f64,
|
||||
schwab_total: ?f64,
|
||||
total_delta: ?f64,
|
||||
/// Account holds open options: zfin values them at cost, the broker
|
||||
/// at market, so a totals-level value delta is expected (mute it).
|
||||
has_options: bool = false,
|
||||
/// Bounded band within which a value delta is an expected CD mark.
|
||||
cd_allowance: f64 = 0,
|
||||
has_discrepancy: bool,
|
||||
};
|
||||
|
||||
// ── Per-account positions CSV (--schwab) ─────────────────────
|
||||
|
||||
/// Parse a Schwab per-account positions CSV and reconcile it against
|
||||
/// the portfolio via the shared engine. Returns owned
|
||||
/// `AccountComparison` results (free each `.comparisons` slice, then
|
||||
/// the results slice). String fields borrow from `csv_data`, which
|
||||
/// must outlive them. Propagates parser and allocation errors.
|
||||
pub fn reconcileCsv(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
csv_data: []const u8,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]common.AccountComparison {
|
||||
const parsed = try schwab_parser.parseCsv(allocator, csv_data);
|
||||
// Result strings borrow from `csv_data`, not the positions slice,
|
||||
// so freeing the slice array here is safe.
|
||||
defer allocator.free(parsed.positions);
|
||||
return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of);
|
||||
}
|
||||
|
||||
// ── Account summary paste (--schwab-summary) ─────────────────
|
||||
|
||||
/// Parse a Schwab account summary and reconcile its per-account
|
||||
/// totals against portfolio.srf. Returns owned results (free the
|
||||
/// slice). String fields borrow from `summary_data`, which must
|
||||
/// outlive them. Propagates parser (`NoAccountsFound`) and
|
||||
/// allocation errors.
|
||||
pub fn reconcileSummary(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
summary_data: []const u8,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]SchwabAccountComparison {
|
||||
const schwab_accounts = try schwab_parser.parseSummary(allocator, summary_data);
|
||||
defer allocator.free(schwab_accounts);
|
||||
return compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of);
|
||||
}
|
||||
|
||||
/// Compare Schwab summary against portfolio.srf account totals.
|
||||
pub fn compareSchwabSummary(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
schwab_accounts: []const AccountSummary,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]SchwabAccountComparison {
|
||||
var results = std.ArrayList(SchwabAccountComparison).empty;
|
||||
errdefer results.deinit(allocator);
|
||||
|
||||
for (schwab_accounts) |sa| {
|
||||
const portfolio_acct = account_map.findByInstitutionAccount("schwab", sa.account_number);
|
||||
|
||||
var pf_cash: f64 = 0;
|
||||
var pf_total: f64 = 0;
|
||||
var expectation: common.AccountValueExpectation = .{};
|
||||
|
||||
if (portfolio_acct) |pa| {
|
||||
pf_cash = portfolio.cashForAccount(pa);
|
||||
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
|
||||
expectation = common.accountValueExpectation(portfolio, as_of, pa);
|
||||
}
|
||||
|
||||
const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
|
||||
const total_delta = if (sa.total_value) |st| st - pf_total else null;
|
||||
|
||||
const cash_ok = if (cash_delta) |d| @abs(d) < common.cash_tolerance else true;
|
||||
const total_ok = if (total_delta) |d| @abs(d) < common.value_tolerance else true;
|
||||
|
||||
try results.append(allocator, .{
|
||||
.account_name = portfolio_acct orelse "",
|
||||
.schwab_name = sa.account_name,
|
||||
.account_number = sa.account_number,
|
||||
.portfolio_cash = pf_cash,
|
||||
.schwab_cash = sa.cash,
|
||||
.cash_delta = cash_delta,
|
||||
.portfolio_total = pf_total,
|
||||
.schwab_total = sa.total_value,
|
||||
.total_delta = total_delta,
|
||||
.has_options = expectation.has_options,
|
||||
.cd_allowance = expectation.cd_allowance,
|
||||
// A value delta is a discrepancy regardless of cause: an
|
||||
// audit must never hide it. Muting (below) is a display
|
||||
// hint, not a suppression - so visibility stays keyed on the
|
||||
// raw tolerance, same as before.
|
||||
.has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null,
|
||||
});
|
||||
}
|
||||
|
||||
return results.toOwnedSlice(allocator);
|
||||
}
|
||||
pub const SchwabAccountComparison = reconcile.SchwabAccountComparison;
|
||||
pub const compareSchwabSummary = reconcile.compareSchwabSummary;
|
||||
pub const reconcileCsv = reconcile.reconcileCsv;
|
||||
pub const reconcileSummary = reconcile.reconcileSummary;
|
||||
pub const hasSchwabDiscrepancies = reconcile.hasSchwabDiscrepancies;
|
||||
|
||||
pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, out: *std.Io.Writer) !void {
|
||||
try cli.printBold(out, color, "\nSchwab Account Audit", .{});
|
||||
|
|
@ -346,422 +224,6 @@ pub fn displaySchwabSummaryRatioSuggestions(
|
|||
|
||||
if (has_header) try out.print("\n", .{});
|
||||
}
|
||||
|
||||
/// Check if any Schwab summary results have discrepancies.
|
||||
pub fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool {
|
||||
for (results) |r| {
|
||||
if (r.has_discrepancy) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
const portfolio_mod = @import("../../models/portfolio.zig");
|
||||
|
||||
test "hasSchwabDiscrepancies" {
|
||||
const clean = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.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,
|
||||
}};
|
||||
try std.testing.expect(!hasSchwabDiscrepancies(&clean));
|
||||
|
||||
const dirty = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.schwab_name = "Roth IRA",
|
||||
.account_number = "1234",
|
||||
.portfolio_cash = 100,
|
||||
.schwab_cash = 200,
|
||||
.cash_delta = 100,
|
||||
.portfolio_total = 5000,
|
||||
.schwab_total = 5100,
|
||||
.total_delta = 100,
|
||||
.has_discrepancy = true,
|
||||
}};
|
||||
try std.testing.expect(hasSchwabDiscrepancies(&dirty));
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: matching account -> no discrepancy" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
// Portfolio: $5000 cash + 10 AAPL @ open_price 150 = $1500 cost basis.
|
||||
// With AAPL price=200, total = 5000 + 10*200 = 7000.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "CASH",
|
||||
.shares = 5000,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 1.0,
|
||||
.security_type = .cash,
|
||||
.account = "Sample Brokerage",
|
||||
},
|
||||
.{
|
||||
.symbol = "AAPL",
|
||||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 150,
|
||||
.account = "Sample Brokerage",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Sample Brokerage",
|
||||
.account_number = "1234",
|
||||
.cash = 5000.0,
|
||||
.total_value = 7000.0,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "Sample Brokerage",
|
||||
.tax_type = .taxable,
|
||||
.institution = "schwab",
|
||||
.account_number = "1234",
|
||||
},
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AAPL", 200.0);
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 5000), results[0].portfolio_cash, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 7000), results[0].portfolio_total, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].cash_delta.?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
|
||||
try std.testing.expect(!results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: cash mismatch -> has_discrepancy true" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
// Portfolio cash = 5000, Schwab reports 5500 -> $500 delta.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "CASH",
|
||||
.shares = 5000,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 1.0,
|
||||
.security_type = .cash,
|
||||
.account = "Brokerage",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Brokerage",
|
||||
.account_number = "1234",
|
||||
.cash = 5500.0,
|
||||
.total_value = 5500.0,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "Brokerage",
|
||||
.tax_type = .taxable,
|
||||
.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 compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 500), results[0].cash_delta.?, 0.01);
|
||||
try std.testing.expect(results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to the penny)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 6, 19);
|
||||
|
||||
// Portfolio cash $38.75; Schwab reports $38.97 - a $0.22 accrual.
|
||||
// Below the $1 securities tolerance, but a real cash drift that
|
||||
// must surface.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "CASH", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{ .account_name = "Sample Brokerage", .account_number = "1234", .cash = 38.97, .total_value = 38.97 },
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .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 compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.22), results[0].cash_delta.?, 0.001);
|
||||
try std.testing.expect(results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: account_number with no match -> empty account_name" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
const lots = [_]portfolio_mod.Lot{};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Unknown Acct",
|
||||
.account_number = "9999",
|
||||
.cash = 1000.0,
|
||||
.total_value = 1000.0,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: null cash/total fields produce null deltas (within tolerance)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "CASH",
|
||||
.shares = 5000,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 1.0,
|
||||
.security_type = .cash,
|
||||
.account = "X",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
// Schwab summary missing cash + total fields (.cash = null, .total_value = null).
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "X",
|
||||
.account_number = "1234",
|
||||
.cash = null,
|
||||
.total_value = null,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "X",
|
||||
.tax_type = .taxable,
|
||||
.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 compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expect(results[0].cash_delta == null);
|
||||
try std.testing.expect(results[0].total_delta == null);
|
||||
// Null deltas are treated as "ok" (no discrepancy possible to assert).
|
||||
try std.testing.expect(!results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: today affects valuation of held assets" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
// Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before
|
||||
// open), it's not held -> portfolio_total excludes it. With
|
||||
// today=2025-01-01 (after open), portfolio_total includes 10 * price.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "AAPL",
|
||||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2024, 6, 1),
|
||||
.open_price = 150,
|
||||
.account = "Acct",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Acct",
|
||||
.account_number = "1234",
|
||||
.cash = 0,
|
||||
.total_value = 2000,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "Acct",
|
||||
.tax_type = .taxable,
|
||||
.institution = "schwab",
|
||||
.account_number = "1234",
|
||||
},
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AAPL", 200.0);
|
||||
|
||||
// Before open: portfolio holds nothing for this account.
|
||||
{
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2024, 1, 1));
|
||||
defer allocator.free(results);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_total, 0.01);
|
||||
}
|
||||
|
||||
// After open: portfolio holds 10 * 200 = 2000.
|
||||
{
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1));
|
||||
defer allocator.free(results);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01);
|
||||
// Matches schwab -> no discrepancy.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
|
||||
try std.testing.expect(!results[0].has_discrepancy);
|
||||
}
|
||||
}
|
||||
|
||||
// ── reconcile wrappers (parse + compare wiring) ──────────────
|
||||
|
||||
test "reconcileCsv: parses a Schwab positions CSV and reconciles it" {
|
||||
const allocator = std.testing.allocator;
|
||||
const csv =
|
||||
"\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"\n" ++
|
||||
"\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";
|
||||
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "AMZN", .shares = 1488, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 74, .account = "Sample Trust" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AMZN", 239.208);
|
||||
|
||||
const results = try reconcileCsv(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 4, 10));
|
||||
defer {
|
||||
for (results) |r| allocator.free(r.comparisons);
|
||||
allocator.free(results);
|
||||
}
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectEqualStrings("Sample Trust", results[0].account_name);
|
||||
var found_amzn = false;
|
||||
for (results[0].comparisons) |c| {
|
||||
if (std.mem.eql(u8, c.symbol, "AMZN")) found_amzn = true;
|
||||
}
|
||||
try std.testing.expect(found_amzn);
|
||||
}
|
||||
|
||||
test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" {
|
||||
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 Inherited IRA
|
||||
\\Account number ending in 5678 ...5678
|
||||
\\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73%
|
||||
;
|
||||
const portfolio = portfolio_mod.Portfolio{ .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, 4, 10));
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), results.len);
|
||||
// 1234 maps; 5678 is absent from the map -> unmapped (empty name).
|
||||
try std.testing.expectEqualStrings("Sample Roth IRA", results[0].account_name);
|
||||
try std.testing.expectEqualStrings("", results[1].account_name);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: populates option flag and CD allowance from the portfolio" {
|
||||
const allocator = std.testing.allocator;
|
||||
const as_of = Date.fromYmd(2026, 6, 28);
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
// CD >1yr out -> caps at one year's coupon: 4% of 50000 = 2000.
|
||||
.{ .symbol = "CDA", .security_type = .cd, .shares = 50000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2028, 1, 1), .account = "Sample IRA" },
|
||||
// Open option -> has_options.
|
||||
.{ .symbol = "NVDA C", .security_type = .option, .underlying = "NVDA", .strike = 200, .option_type = .call, .maturity_date = Date.fromYmd(2026, 12, 18), .shares = -5, .open_date = Date.fromYmd(2026, 4, 1), .open_price = 5.0, .multiplier = 100, .account = "Sample IRA" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
var summary = [_]AccountSummary{
|
||||
.{ .account_name = "IRA", .account_number = "1234", .cash = 0, .total_value = 60000 },
|
||||
};
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &summary, acct_map, prices, as_of);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expect(results[0].has_options);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].cd_allowance, 0.01);
|
||||
}
|
||||
|
||||
// ── displaySchwabResults rendering ───────────────────────────
|
||||
|
||||
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue