make value discrepancies warnings if we can detect it is likely a problem

This commit is contained in:
Emil Lerch 2026-06-28 13:21:56 -07:00
parent 020fb2db77
commit 00482da00f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 355 additions and 8 deletions

View file

@ -39,6 +39,73 @@ const BrokeragePosition = brokerage_types.BrokeragePosition;
pub const value_tolerance: f64 = 1.0;
pub const cash_tolerance: f64 = 0.01;
/// A CD's reconciliation allowance is capped at one year's coupon:
/// years-to-maturity is clamped to 1.0 so a long-dated CD doesn't open
/// an unbounded muting window. (Near-maturity CDs - the common case -
/// get a tight band; e.g. a $87k 3.8% CD 17 days out -> ~$154.)
pub const cd_allowance_year_cap: f64 = 1.0;
/// Fallback band (percent of face) for a CD that carries no `rate`.
/// Rare, but we still want a non-zero allowance so a rate-less CD isn't
/// flagged on every routine dealer mark.
pub const cd_allowance_fallback_pct: f64 = 0.5;
/// Expected secondary-market mark on a single open CD, in dollars.
///
/// zfin values a CD at face (`shares`); the broker marks it to the
/// secondary market, which drifts from face by a small, bounded amount
/// driven by the remaining coupon and time to maturity. We can't
/// reproduce the broker's exact mark without a live yield, but we can
/// bound it: `face x (rate/100) x years_to_maturity` (years capped at
/// 1.0). A value delta within this band is an expected CD mark (mute);
/// beyond it, something else is off (warn). Returns 0 for non-CD lots.
pub fn cdLotAllowance(lot: portfolio_mod.Lot, as_of: Date) f64 {
if (lot.security_type != .cd) return 0;
const face = @abs(lot.shares);
const rate = lot.rate orelse return face * (cd_allowance_fallback_pct / 100.0);
const years: f64 = if (lot.maturity_date) |m| blk: {
const y = Date.yearsBetween(as_of, m);
break :blk if (y < 0) 0 else @min(y, cd_allowance_year_cap);
} else cd_allowance_year_cap;
return face * (rate / 100.0) * years;
}
/// Per-account, broker-agnostic classifier for "expected" value
/// differences between zfin and a brokerage. Reconcilers use it to
/// decide whether an account-level value delta should be muted
/// (expected) or warned (real discrepancy).
///
/// - `has_options`: the account holds >= 1 open option lot. zfin
/// values options at cost (premium) while brokers mark them to
/// market, so the gap is real but unbounded and unknowable without
/// a live option quote. At the totals-only summary level any value
/// delta is therefore "expected" - mute it. (The per-position path
/// carves options out symbol-by-symbol instead; this is the
/// totals-only fallback.)
/// - `cd_allowance`: summed `cdLotAllowance` over the account's open
/// CDs - the bounded band within which a value delta is explained
/// by CD dealer marks.
pub const AccountValueExpectation = struct {
has_options: bool = false,
cd_allowance: f64 = 0,
};
/// Walk an account's open lots and summarize its expected value
/// differences. See `AccountValueExpectation`.
pub fn accountValueExpectation(portfolio: zfin.Portfolio, as_of: Date, account_name: []const u8) AccountValueExpectation {
var result: AccountValueExpectation = .{};
for (portfolio.lots) |lot| {
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, account_name)) continue;
if (!lot.isOpen(as_of)) continue;
switch (lot.security_type) {
.option => result.has_options = true,
.cd => result.cd_allowance += cdLotAllowance(lot, as_of),
else => {},
}
}
return result;
}
/// Resolved position value for audit display: effective per-share price
/// and total market value, with correct `price_ratio` handling based on
/// the price's provenance.
@ -84,6 +151,12 @@ pub const SymbolComparison = struct {
value_delta: ?f64,
is_cash: bool,
is_option: bool,
/// True when the matched portfolio lot(s) are a CD. A CD's value
/// delta is muted up to `cd_allowance`; beyond that it warns.
is_cd: bool = false,
/// Summed `cdLotAllowance` for the matched CD lot(s). Zero unless
/// `is_cd`.
cd_allowance: f64 = 0,
only_in_brokerage: bool,
only_in_portfolio: bool,
};
@ -269,6 +342,8 @@ pub fn compareAccounts(
var pf_value: f64 = 0;
var pf_price: ?f64 = null;
var is_option = false;
var is_cd = false;
var cd_allowance: f64 = 0;
if (bp.is_cash) {
pf_shares = portfolio.cashForAccount(portfolio_acct_name.?);
@ -306,6 +381,8 @@ pub fn compareAccounts(
pf_shares += lot.shares;
pf_value += lot.shares;
pf_price = 1.0;
is_cd = true;
cd_allowance += cdLotAllowance(lot, as_of);
},
.option => {
pf_shares += lot.shares;
@ -356,6 +433,8 @@ pub fn compareAccounts(
.value_delta = value_delta,
.is_cash = bp.is_cash,
.is_option = is_option,
.is_cd = is_cd,
.cd_allowance = cd_allowance,
.only_in_brokerage = pf_shares == 0 and pf_value == 0,
.only_in_portfolio = false,
});
@ -446,6 +525,7 @@ pub fn compareAccounts(
.value_delta = null,
.is_cash = is_cd,
.is_option = !is_cd,
.is_cd = is_cd,
.only_in_brokerage = false,
.only_in_portfolio = true,
});
@ -604,7 +684,13 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std
// Classify this row
const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage;
const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= cash_tolerance else false);
const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch;
// A CD's value delta is an expected dealer mark up to its
// band; only a delta beyond what its terms can explain is a
// real discrepancy. Stale stock prices (shares match, no CD)
// stay muted, as before.
const cd_band = @max(value_tolerance, cmp.cd_allowance);
const cd_value_exceeded = cmp.is_cd and (if (cmp.value_delta) |d| @abs(d) > cd_band else false);
const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch or cd_value_exceeded;
if (is_real_mismatch) discrepancy_count += 1;
@ -1365,6 +1451,134 @@ test "compareAccounts: a broker CD row matches a portfolio CD lot" {
try std.testing.expect(!results[0].has_discrepancies);
}
test "cdLotAllowance: near-maturity CD yields a tight band" {
const as_of = Date.fromYmd(2026, 6, 28);
const cd = portfolio_mod.Lot{
.symbol = "CDNEAR",
.security_type = .cd,
.shares = 87000,
.open_date = Date.fromYmd(2026, 2, 25),
.open_price = 1.0,
.rate = 3.8,
.maturity_date = Date.fromYmd(2026, 7, 15),
.account = "Sample IRA",
};
// 17 days out: 87000 * 3.8% * (17/365.25) ~= $154. Tight enough to
// mute a dealer mark, far below a missing lot.
const band = cdLotAllowance(cd, as_of);
try std.testing.expect(band > 100 and band < 200);
}
test "cdLotAllowance: long CD is capped at one year's coupon" {
const as_of = Date.fromYmd(2026, 6, 28);
const cd = portfolio_mod.Lot{
.symbol = "CD5YR",
.security_type = .cd,
.shares = 100000,
.open_date = Date.fromYmd(2026, 1, 1),
.open_price = 1.0,
.rate = 4.0,
.maturity_date = Date.fromYmd(2031, 6, 28),
.account = "Sample IRA",
};
// ~5 years out, but years clamp to 1.0 -> 100000 * 4% * 1.0 = 4000.
try std.testing.expectApproxEqAbs(@as(f64, 4000), cdLotAllowance(cd, as_of), 0.01);
}
test "cdLotAllowance: rate-less CD falls back to a small percent of face" {
const as_of = Date.fromYmd(2026, 6, 28);
const cd = portfolio_mod.Lot{
.symbol = "CDNORATE",
.security_type = .cd,
.shares = 10000,
.open_date = Date.fromYmd(2026, 1, 1),
.open_price = 1.0,
.maturity_date = Date.fromYmd(2026, 12, 31),
.account = "Sample IRA",
};
// No rate -> 0.5% of $10,000 = $50.
try std.testing.expectApproxEqAbs(@as(f64, 50), cdLotAllowance(cd, as_of), 0.01);
}
test "cdLotAllowance: matured CD and non-CD lots yield zero" {
const as_of = Date.fromYmd(2026, 6, 28);
// Maturity already passed -> years clamp to 0 -> no band.
const matured = portfolio_mod.Lot{
.symbol = "CDOLD",
.security_type = .cd,
.shares = 50000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.rate = 5.0,
.maturity_date = Date.fromYmd(2026, 1, 1),
.account = "Sample IRA",
};
try std.testing.expectApproxEqAbs(@as(f64, 0), cdLotAllowance(matured, as_of), 0.01);
const stock = portfolio_mod.Lot{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 150,
.account = "Sample Brokerage",
};
try std.testing.expectEqual(@as(f64, 0), cdLotAllowance(stock, as_of));
}
test "accountValueExpectation: CDs sum into a band, an open option flips the flag" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 28);
var lots = [_]portfolio_mod.Lot{
// CD in Sample IRA, >1yr out so it 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 in Sample IRA -> 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" },
// Plain stock in a different account.
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample Brokerage" },
// Matured CD in Sample IRA must be excluded (not open as-of).
.{ .symbol = "CDMAT", .security_type = .cd, .shares = 99999, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .rate = 9.0, .maturity_date = Date.fromYmd(2026, 1, 1), .account = "Sample IRA" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
const ira = accountValueExpectation(portfolio, as_of, "Sample IRA");
try std.testing.expect(ira.has_options);
try std.testing.expectApproxEqAbs(@as(f64, 2000), ira.cd_allowance, 0.01);
const brk = accountValueExpectation(portfolio, as_of, "Sample Brokerage");
try std.testing.expect(!brk.has_options);
try std.testing.expectEqual(@as(f64, 0), brk.cd_allowance);
}
test "compareAccounts: a CD's value delta carries is_cd and the bounded allowance" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 28);
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "CDX", .security_type = .cd, .shares = 100000, .open_date = Date.fromYmd(2026, 1, 1), .open_price = 1.0, .rate = 4.0, .maturity_date = Date.fromYmd(2026, 9, 1), .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();
// Broker marks the CD $40 under face (~2mo out -> band is hundreds of $).
var brokerage = [_]BrokeragePosition{
.{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "CDX", .description = "BANK CD", .quantity = 100000, .current_value = 99960, .cost_basis = 100000, .is_cash = false },
};
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, as_of);
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len);
const cmp = results[0].comparisons[0];
try std.testing.expect(cmp.is_cd);
try std.testing.expect(cmp.cd_allowance > 200); // the $40 mark sits well within
try std.testing.expectApproxEqAbs(@as(f64, -40), cmp.value_delta.?, 0.01);
}
// displayResults rendering
test "displayResults: renders every row classification and the totals block" {
@ -1444,6 +1658,40 @@ test "displayResults: color=true emits ANSI and singular mismatch label" {
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null);
}
test "displayResults: CD value delta within band is muted+uncounted, beyond band warns+counts" {
// Within band: is_cd, |value_delta| <= cd_allowance -> shown but
// muted, and not counted in "to investigate".
{
const cmps = [_]SymbolComparison{
.{ .symbol = "CDX", .portfolio_shares = 100000, .brokerage_shares = 100000, .portfolio_price = 1.0, .brokerage_price = 0.9996, .portfolio_value = 100000, .brokerage_value = 99960, .shares_delta = 0, .value_delta = -40, .is_cash = false, .is_option = false, .is_cd = true, .cd_allowance = 680, .only_in_brokerage = false, .only_in_portfolio = false },
};
const results = [_]AccountComparison{
.{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 100000, .brokerage_total = 99960, .total_delta = -40, .option_value_delta = 0, .has_discrepancies = true },
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null); // shown, never hidden
try std.testing.expect(std.mem.indexOf(u8, out, "to investigate") == null); // not counted
}
// Beyond band: the same CD marked $5,000 under face -> warn + count.
{
const cmps = [_]SymbolComparison{
.{ .symbol = "CDX", .portfolio_shares = 100000, .brokerage_shares = 100000, .portfolio_price = 1.0, .brokerage_price = 0.95, .portfolio_value = 100000, .brokerage_value = 95000, .shares_delta = 0, .value_delta = -5000, .is_cash = false, .is_option = false, .is_cd = true, .cd_allowance = 680, .only_in_brokerage = false, .only_in_portfolio = false },
};
const results = [_]AccountComparison{
.{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 100000, .brokerage_total = 95000, .total_delta = -5000, .option_value_delta = 0, .has_discrepancies = true },
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null); // counted
}
}
// displayRatioSuggestions
test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from configured ratio" {

View file

@ -36,6 +36,11 @@ pub const SchwabAccountComparison = struct {
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,
};
@ -98,10 +103,12 @@ pub fn compareSchwabSummary(
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;
@ -120,6 +127,12 @@ pub fn compareSchwabSummary(
.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,
});
}
@ -157,9 +170,15 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
"--";
const cash_ok = if (r.cash_delta) |d| @abs(d) < common.cash_tolerance else true;
const total_ok = if (r.total_delta) |d| @abs(d) < common.value_tolerance else true;
// A value delta is "exact" (no note) under $1, "expected" (muted)
// when the account holds options or the delta fits the CD band,
// and a real "warn" otherwise.
const band = @max(common.value_tolerance, r.cd_allowance);
const value_exact = if (r.total_delta) |d| @abs(d) < common.value_tolerance else true;
const value_expected = r.has_options or (if (r.total_delta) |d| @abs(d) <= band else true);
const value_warn = !value_exact and !value_expected;
const is_unmapped = r.account_name.len == 0;
const is_real_mismatch = !cash_ok or is_unmapped;
const is_real_mismatch = !cash_ok or is_unmapped or value_warn;
if (is_real_mismatch) discrepancy_count += 1;
@ -183,9 +202,9 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
// BR Cash
try out.print(" {s:>14}", .{br_cash_str});
// PF Total - colored if not just stale prices
// PF Total - colored only on a real (beyond-allowance) value mismatch
try out.print(" ", .{});
if (!total_ok and !cash_ok) {
if (value_warn) {
const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_total).padRight(14)});
} else {
@ -202,7 +221,13 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
const d = r.cash_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_WARNING, " Cash {s}{f}", .{ sign, Money.from(@abs(d)) });
} else if (!total_ok) {
} else if (value_warn) {
const d = r.total_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_WARNING, " Value {s}{f}", .{ sign, Money.from(@abs(d)) });
} else if (!value_exact) {
// Expected difference (CD mark or options present): shown so
// nothing is hidden, but muted - you probably don't care.
const d = r.total_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_MUTED, " Value {s}{f}", .{ sign, Money.from(@abs(d)) });
@ -709,6 +734,34 @@ test "reconcileSummary: parses a Schwab summary paste and reconciles per-account
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" {
@ -717,8 +770,9 @@ test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals"
.{ .account_name = "Sample Roth", .schwab_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false },
// cash mismatch -> "Cash +$5.00", counts as a real mismatch
.{ .account_name = "Sample Trust", .schwab_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true },
// value-only mismatch (cash ok) -> muted "Value +$100.00", not 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, .has_discrepancy = false },
// 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 },
// 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 },
};
@ -758,6 +812,51 @@ test "displaySchwabResults: color=true emits ANSI and singular label" {
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null);
}
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 },
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabResults(&results, true, &w);
const out = w.buffered();
var seqbuf: [32]u8 = undefined;
const warn_seq = try std.fmt.bufPrint(&seqbuf, "\x1b[38;2;{d};{d};{d}m", .{ cli.CLR_WARNING[0], cli.CLR_WARNING[1], cli.CLR_WARNING[2] });
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null); // shown
try std.testing.expect(std.mem.indexOf(u8, out, warn_seq) != null); // warning-colored
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null); // counted
}
test "displaySchwabResults: CD-band and option value deltas are muted, shown, and uncounted" {
// muted != hidden: both rows show their "Value" delta, but neither
// 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 },
// 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 },
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabResults(&results, true, &w);
const out = w.buffered();
var warnbuf: [32]u8 = undefined;
const warn_seq = try std.fmt.bufPrint(&warnbuf, "\x1b[38;2;{d};{d};{d}m", .{ cli.CLR_WARNING[0], cli.CLR_WARNING[1], cli.CLR_WARNING[2] });
var mutebuf: [32]u8 = undefined;
const mute_seq = try std.fmt.bufPrint(&mutebuf, "\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, out, "Value -") != null); // CD mark shown
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null); // option gap shown
try std.testing.expect(std.mem.indexOf(u8, out, mute_seq) != null); // muted styling present
try std.testing.expect(std.mem.indexOf(u8, out, warn_seq) == null); // never escalated to warning
try std.testing.expect(std.mem.indexOf(u8, out, "mismatch") == null); // nothing to investigate
}
// displaySchwabSummaryRatioSuggestions
test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot direct-indexing account" {