710 lines
30 KiB
Zig
710 lines
30 KiB
Zig
//! 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;
|
|
}
|
|
|
|
/// Direct-indexing ratio suggestions from Schwab-summary totals.
|
|
///
|
|
/// The summary path gives per-account totals only, so for a
|
|
/// direct-indexing account with exactly one stock lot we allocate the
|
|
/// whole account delta onto that lot:
|
|
///
|
|
/// current_stock_value = shares * retail_price * current_ratio
|
|
/// target_stock_value = current_stock_value + total_delta
|
|
/// suggested_ratio = target_stock_value / (shares * retail_price)
|
|
///
|
|
/// Skips non-direct-indexing accounts, unmapped accounts, sub-cent
|
|
/// deltas, accounts without exactly one stock lot, and zero
|
|
/// share/price/value. Caller owns the returned slice; strings borrow
|
|
/// from the inputs.
|
|
pub fn summaryRatioSuggestions(
|
|
allocator: std.mem.Allocator,
|
|
results: []const SchwabAccountComparison,
|
|
portfolio: zfin.Portfolio,
|
|
prices: std.StringHashMap(f64),
|
|
account_map: ?analysis.AccountMap,
|
|
) ![]common.RatioSuggestion {
|
|
var out: std.ArrayList(common.RatioSuggestion) = .empty;
|
|
errdefer out.deinit(allocator);
|
|
|
|
const am = account_map orelse return out.toOwnedSlice(allocator);
|
|
|
|
for (results) |r| {
|
|
if (r.account_name.len == 0) continue;
|
|
if (!am.isDirectIndexing(r.account_name)) continue;
|
|
const total_delta = r.total_delta orelse continue;
|
|
if (@abs(total_delta) < 0.01) continue;
|
|
|
|
// Find the single stock lot for this account.
|
|
var stock_lot: ?zfin.Lot = null;
|
|
var stock_lot_count: usize = 0;
|
|
for (portfolio.lots) |lot| {
|
|
if (lot.security_type != .stock) continue;
|
|
const lot_acct = lot.account orelse continue;
|
|
if (!std.mem.eql(u8, lot_acct, r.account_name)) continue;
|
|
stock_lot = lot;
|
|
stock_lot_count += 1;
|
|
}
|
|
if (stock_lot_count != 1) continue;
|
|
const lot = stock_lot.?;
|
|
|
|
const price_sym = lot.priceSymbol();
|
|
const retail_price = prices.get(price_sym) orelse continue;
|
|
if (retail_price == 0) continue;
|
|
if (lot.shares == 0) continue;
|
|
|
|
const current_stock_value = lot.shares * retail_price * lot.price_ratio;
|
|
if (current_stock_value == 0) continue;
|
|
const target_stock_value = current_stock_value + total_delta;
|
|
const suggested_ratio = target_stock_value / (lot.shares * retail_price);
|
|
|
|
try out.append(allocator, .{
|
|
.account_name = r.account_name,
|
|
.lot_symbol = lot.symbol,
|
|
.price_symbol = price_sym,
|
|
.current_ratio = lot.price_ratio,
|
|
.suggested_ratio = suggested_ratio,
|
|
.drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0,
|
|
});
|
|
}
|
|
|
|
return out.toOwnedSlice(allocator);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// ── summaryRatioSuggestions ──────────────────────────────────
|
|
|
|
test "summaryRatioSuggestions: single-lot direct-indexing yields exact ratio + drift" {
|
|
const allocator = std.testing.allocator;
|
|
var lots = [_]portfolio_mod.Lot{
|
|
.{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
|
|
};
|
|
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
|
var entries = [_]analysis.AccountTaxEntry{
|
|
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true },
|
|
};
|
|
const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
|
var prices = std.StringHashMap(f64).init(allocator);
|
|
defer prices.deinit();
|
|
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 },
|
|
};
|
|
const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am);
|
|
defer allocator.free(sugg);
|
|
try std.testing.expectEqual(@as(usize, 1), sugg.len);
|
|
try std.testing.expectEqualStrings("SPY", sugg[0].lot_symbol);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 1.0), sugg[0].current_ratio, 1e-9);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 1.02), sugg[0].suggested_ratio, 1e-9);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 2.0), sugg[0].drift_pct, 1e-9);
|
|
}
|
|
|
|
test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all skip" {
|
|
const allocator = std.testing.allocator;
|
|
var lots = [_]portfolio_mod.Lot{
|
|
.{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
|
|
};
|
|
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
|
var prices = std.StringHashMap(f64).init(allocator);
|
|
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 },
|
|
};
|
|
|
|
// null account_map -> empty.
|
|
{
|
|
const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, null);
|
|
defer allocator.free(sugg);
|
|
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
|
}
|
|
// account present but NOT direct-indexing -> skipped.
|
|
{
|
|
var entries = [_]analysis.AccountTaxEntry{
|
|
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = false },
|
|
};
|
|
const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
|
const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am);
|
|
defer allocator.free(sugg);
|
|
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
|
}
|
|
// direct-indexing but sub-cent delta -> skipped.
|
|
{
|
|
var entries = [_]analysis.AccountTaxEntry{
|
|
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true },
|
|
};
|
|
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 },
|
|
};
|
|
const sugg = try summaryRatioSuggestions(allocator, &tiny, portfolio, prices, am);
|
|
defer allocator.free(sugg);
|
|
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
|
}
|
|
// direct-indexing but two stock lots -> can't allocate the delta -> skipped.
|
|
{
|
|
var two = [_]portfolio_mod.Lot{
|
|
.{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
|
|
.{ .symbol = "QQQ", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
|
|
};
|
|
const pf2 = portfolio_mod.Portfolio{ .lots = &two, .allocator = allocator };
|
|
var entries = [_]analysis.AccountTaxEntry{
|
|
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true },
|
|
};
|
|
const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
|
const sugg = try summaryRatioSuggestions(allocator, &results, pf2, prices, am);
|
|
defer allocator.free(sugg);
|
|
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
|
}
|
|
}
|