681 lines
37 KiB
Zig
681 lines
37 KiB
Zig
//! Display/rendering for the broker-agnostic `audit` output.
|
|
//!
|
|
//! Reconciliation *compute* now lives in `analytics/reconcile/` (pure,
|
|
//! testable, reusable by finrev). This file keeps only the ANSI-table
|
|
//! renderers, plus back-compat re-exports so `audit.zig` and
|
|
//! `hygiene.zig` keep addressing the API as `common.*`.
|
|
|
|
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 reconcile = @import("../../analytics/reconcile.zig");
|
|
|
|
// ── Back-compat re-exports of the reconciliation compute API ──
|
|
pub const value_tolerance = reconcile.common.value_tolerance;
|
|
pub const cash_tolerance = reconcile.common.cash_tolerance;
|
|
pub const AccountValueExpectation = reconcile.AccountValueExpectation;
|
|
pub const cdLotAllowance = reconcile.cdLotAllowance;
|
|
pub const accountValueExpectation = reconcile.accountValueExpectation;
|
|
pub const SymbolComparison = reconcile.SymbolComparison;
|
|
pub const AccountComparison = reconcile.AccountComparison;
|
|
pub const compareAccounts = reconcile.compareAccounts;
|
|
pub const hasAccountDiscrepancies = reconcile.hasAccountDiscrepancies;
|
|
pub const ExportScope = reconcile.ExportScope;
|
|
pub const ExportView = reconcile.ExportView;
|
|
pub const splitUnmapped = reconcile.splitUnmapped;
|
|
pub const AbsentAccount = reconcile.AbsentAccount;
|
|
pub const presentNumbers = reconcile.presentNumbers;
|
|
pub const findAbsentAccounts = reconcile.findAbsentAccounts;
|
|
|
|
// ── Unmapped accounts ────────────────────────────────────────
|
|
|
|
/// Report an export's unmapped accounts (see `splitUnmapped`) in muted
|
|
/// text: a line with the count, then one line per account so the number
|
|
/// to map is right there. For an export skipped outright, the
|
|
/// per-account list is printed only when `list_skipped`: someone else's
|
|
/// whole household is noise on every routine audit, but worth seeing
|
|
/// when you point audit at the file yourself. Prints nothing when every
|
|
/// account is mapped. `T` is `AccountComparison` or
|
|
/// `SchwabAccountComparison`; both carry `brokerage_name` and
|
|
/// `account_number`.
|
|
pub fn displayUnmapped(
|
|
comptime T: type,
|
|
label: []const u8,
|
|
view: ExportView(T),
|
|
list_skipped: bool,
|
|
color: bool,
|
|
out: *std.Io.Writer,
|
|
) !void {
|
|
const n = view.unmapped.len;
|
|
if (n == 0) return;
|
|
if (view.skipped()) {
|
|
if (n == 1) {
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " {s}: its only account is not in accounts.srf; skipped (another portfolio's export?)\n", .{label});
|
|
} else {
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " {s}: none of its {d} accounts are in accounts.srf; skipped (another portfolio's export?)\n", .{ label, n });
|
|
}
|
|
if (!list_skipped) return;
|
|
} else {
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " {s}: {d} {s} not in accounts.srf, not reconciled\n", .{ label, n, accountsNoun(n) });
|
|
}
|
|
for (view.unmapped) |r| {
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " {s} #{s}\n", .{ r.brokerage_name, r.account_number });
|
|
}
|
|
}
|
|
|
|
/// "account" or "accounts", for a count of them.
|
|
pub fn accountsNoun(n: usize) []const u8 {
|
|
return if (n == 1) "account" else "accounts";
|
|
}
|
|
|
|
// ── Ratio suggestions ────────────────────────────────────────
|
|
|
|
/// After displaying audit results, check for price_ratio positions where
|
|
/// the brokerage NAV implies a different ratio than what's configured.
|
|
/// Outputs actionable suggestions for portfolio.srf updates.
|
|
///
|
|
/// Normally only lots with `price_ratio != 1.0` get suggestions -
|
|
/// the typical case is an institutional share class where the
|
|
/// configured ratio needs to drift toward current retail-vs-
|
|
/// institutional NAV. Lots with `price_ratio == 1.0` usually have
|
|
/// nothing to adjust.
|
|
///
|
|
/// Exception: accounts flagged `direct_indexing::true` in
|
|
/// `accounts.srf`. These are proxy baskets whose tracking-error
|
|
/// drift is expressed by periodically nudging the ratio even
|
|
/// though the starting ratio is 1.0. For those accounts we still
|
|
/// emit a suggestion when brokerage and portfolio values disagree
|
|
/// - the suggested ratio is just `brokerage_NAV / retail_price`
|
|
/// applied against the existing lot share count, same formula as
|
|
/// the institutional-class case.
|
|
pub fn displayRatioSuggestions(
|
|
allocator: std.mem.Allocator,
|
|
results: []const AccountComparison,
|
|
portfolio: zfin.Portfolio,
|
|
prices: std.StringHashMap(f64),
|
|
account_map: ?analysis.AccountMap,
|
|
color: bool,
|
|
out: *std.Io.Writer,
|
|
) !void {
|
|
const suggestions = try reconcile.ratioSuggestions(allocator, results, portfolio, prices, account_map);
|
|
defer allocator.free(suggestions);
|
|
if (suggestions.len == 0) return;
|
|
|
|
try out.print("\n", .{});
|
|
try cli.printBold(out, color, " Ratio updates", .{});
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf)\n", .{});
|
|
|
|
for (suggestions) |s| {
|
|
var cur_buf: [24]u8 = undefined;
|
|
var sug_buf: [24]u8 = undefined;
|
|
var drift_buf: [16]u8 = undefined;
|
|
const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{s.current_ratio}) catch "?";
|
|
const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{s.suggested_ratio}) catch "?";
|
|
const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.2}%", .{s.drift_pct}) catch "?";
|
|
|
|
try out.print(" {s:<16} ", .{s.lot_symbol});
|
|
try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{s.price_symbol});
|
|
try out.print(" ratio {s} -> ", .{cur_str});
|
|
try cli.printBold(out, color, "{s}", .{sug_str});
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str});
|
|
}
|
|
|
|
try out.print("\n", .{});
|
|
}
|
|
|
|
// ── Display ─────────────────────────────────────────────────
|
|
|
|
pub fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void {
|
|
try cli.printBold(out, color, "\nPortfolio Audit", .{});
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{});
|
|
try out.print("========================================\n\n", .{});
|
|
|
|
var total_portfolio: f64 = 0;
|
|
var total_brokerage: f64 = 0;
|
|
var total_option_delta: f64 = 0;
|
|
var discrepancy_count: usize = 0;
|
|
|
|
for (results) |acct| {
|
|
// Account header
|
|
if (acct.account_name.len > 0) {
|
|
try cli.printBold(out, color, " {s}", .{acct.account_name});
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " ({s}, #{s})\n", .{ acct.brokerage_name, acct.account_number });
|
|
} else {
|
|
try cli.printBold(out, color, " {s} #{s}", .{ acct.brokerage_name, acct.account_number });
|
|
try cli.printFg(out, color, cli.CLR_WARNING, " (unmapped - add account_number to accounts.srf)\n", .{});
|
|
}
|
|
|
|
// Column headers
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>12} {s:>12} {s:>10} {s:>10} {s}\n", .{
|
|
"Symbol", "PF Shares", "BR Shares", "PF Price", "BR Price", "",
|
|
});
|
|
|
|
for (acct.comparisons) |cmp| {
|
|
var pf_shares_buf: [16]u8 = undefined;
|
|
var br_shares_buf: [16]u8 = undefined;
|
|
var pf_price_buf: [16]u8 = undefined;
|
|
var br_price_buf: [16]u8 = undefined;
|
|
|
|
// 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);
|
|
// 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;
|
|
|
|
// Format share strings
|
|
const pf_shares_str: []const u8 = if (cmp.is_cash)
|
|
(std.fmt.bufPrint(&pf_shares_buf, "{f}", .{Money.from(cmp.portfolio_value)}) catch "$?")
|
|
else
|
|
std.fmt.bufPrint(&pf_shares_buf, "{d:.3}", .{cmp.portfolio_shares}) catch "?";
|
|
|
|
const br_shares_str: []const u8 = if (cmp.is_cash)
|
|
(if (cmp.brokerage_value) |v| (std.fmt.bufPrint(&br_shares_buf, "{f}", .{Money.from(v)}) catch "$?") else "--")
|
|
else if (cmp.brokerage_shares) |s|
|
|
(std.fmt.bufPrint(&br_shares_buf, "{d:.3}", .{s}) catch "?")
|
|
else
|
|
"--";
|
|
|
|
const pf_price_str: []const u8 = if (cmp.is_cash)
|
|
""
|
|
else if (cmp.portfolio_price) |p|
|
|
(std.fmt.bufPrint(&pf_price_buf, "{d:.2}", .{p}) catch "?")
|
|
else
|
|
"--";
|
|
|
|
const br_price_str: []const u8 = if (cmp.is_cash)
|
|
""
|
|
else if (cmp.brokerage_price) |p|
|
|
(std.fmt.bufPrint(&br_price_buf, "{d:.2}", .{p}) catch "?")
|
|
else
|
|
"--";
|
|
|
|
// Determine PF Shares color (relative to brokerage)
|
|
const shares_color: enum { normal, positive, negative, warning } = blk: {
|
|
if (cmp.only_in_portfolio) break :blk .warning;
|
|
if (cmp.is_cash and is_cash_mismatch) {
|
|
break :blk if (cmp.value_delta.? > 0) .negative else .positive;
|
|
}
|
|
if (shares_ok) break :blk .normal;
|
|
if (cmp.shares_delta) |d| {
|
|
break :blk if (d > 0) .negative else .positive;
|
|
}
|
|
break :blk .normal;
|
|
};
|
|
|
|
// Determine status text
|
|
var status_buf: [64]u8 = undefined;
|
|
const status: []const u8 = blk: {
|
|
if (cmp.only_in_brokerage) break :blk "Brokerage only";
|
|
if (cmp.only_in_portfolio) break :blk "Portfolio only";
|
|
|
|
if (is_cash_mismatch) {
|
|
if (cmp.value_delta) |d| {
|
|
const sign: []const u8 = if (d >= 0) "+" else "-";
|
|
break :blk std.fmt.bufPrint(&status_buf, "Cash {s}{f}", .{ sign, Money.from(@abs(d)) }) catch "Cash mismatch";
|
|
}
|
|
}
|
|
|
|
if (!shares_ok) {
|
|
if (cmp.shares_delta) |d| {
|
|
const sign: []const u8 = if (d > 0) "+" else "";
|
|
break :blk std.fmt.bufPrint(&status_buf, "Shares {s}{d:.3}", .{ sign, d }) catch "Shares mismatch";
|
|
}
|
|
}
|
|
|
|
// Options: shares match is sufficient - value delta is expected
|
|
// (cost basis vs mark-to-market) and not actionable
|
|
if (cmp.is_option) break :blk "Option";
|
|
|
|
// Shares match - show value delta (stale price) if any, muted
|
|
if (cmp.value_delta) |d| {
|
|
if (@abs(d) >= value_tolerance) {
|
|
const sign: []const u8 = if (d >= 0) "+" else "-";
|
|
break :blk std.fmt.bufPrint(&status_buf, "Value {s}{f}", .{ sign, Money.from(@abs(d)) }) catch "";
|
|
}
|
|
}
|
|
|
|
break :blk "";
|
|
};
|
|
|
|
// Status color: real mismatches in warning, stale values muted, ok is blank
|
|
const status_color: enum { warning, muted, none } = blk: {
|
|
if (is_real_mismatch) break :blk .warning;
|
|
if (status.len > 0) break :blk .muted;
|
|
break :blk .none;
|
|
};
|
|
|
|
// Print symbol
|
|
try out.print(" {s:<24} ", .{cmp.symbol});
|
|
|
|
// Print PF Shares with color
|
|
switch (shares_color) {
|
|
.positive => try cli.printFg(out, color, cli.CLR_POSITIVE, "{s:>12}", .{pf_shares_str}),
|
|
.negative => try cli.printFg(out, color, cli.CLR_NEGATIVE, "{s:>12}", .{pf_shares_str}),
|
|
.warning => try cli.printFg(out, color, cli.CLR_WARNING, "{s:>12}", .{pf_shares_str}),
|
|
.normal => try out.print("{s:>12}", .{pf_shares_str}),
|
|
}
|
|
|
|
// Print BR Shares, prices
|
|
try out.print(" {s:>12} {s:>10} {s:>10} ", .{ br_shares_str, pf_price_str, br_price_str });
|
|
|
|
// Print status
|
|
switch (status_color) {
|
|
.warning => try cli.printFg(out, color, cli.CLR_WARNING, "{s}\n", .{status}),
|
|
.muted => try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{status}),
|
|
.none => try out.print("{s}\n", .{status}),
|
|
}
|
|
}
|
|
|
|
// Account totals
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>12} {s:>12} {f} {f} ", .{
|
|
"", "", "", Money.from(acct.portfolio_total).padRight(10), Money.from(acct.brokerage_total).padRight(10),
|
|
});
|
|
|
|
const adj_delta = acct.total_delta - acct.option_value_delta;
|
|
if (@abs(adj_delta) < 1.0) {
|
|
// no delta text needed
|
|
} else {
|
|
const sign: []const u8 = if (adj_delta >= 0) "+" else "-";
|
|
const rgb = if (adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
|
try cli.printFg(out, color, rgb, "Delta {s}{f}", .{ sign, Money.from(@abs(adj_delta)) });
|
|
}
|
|
|
|
if (@abs(acct.option_value_delta) >= 1.0) {
|
|
const opt_sign: []const u8 = if (acct.option_value_delta >= 0) "+" else "-";
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(acct.option_value_delta)) });
|
|
}
|
|
try out.print("\n\n", .{});
|
|
|
|
total_portfolio += acct.portfolio_total;
|
|
total_brokerage += acct.brokerage_total;
|
|
total_option_delta += acct.option_value_delta;
|
|
}
|
|
|
|
// Grand totals
|
|
const grand_delta = total_brokerage - total_portfolio;
|
|
const grand_adj_delta = grand_delta - total_option_delta;
|
|
|
|
try cli.printBold(out, color, " Total: portfolio {f} brokerage {f}", .{
|
|
Money.from(total_portfolio),
|
|
Money.from(total_brokerage),
|
|
});
|
|
|
|
if (@abs(grand_adj_delta) < 1.0) {
|
|
// no delta text needed
|
|
} else {
|
|
const sign: []const u8 = if (grand_adj_delta >= 0) "+" else "-";
|
|
const rgb = if (grand_adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
|
try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_adj_delta)) });
|
|
}
|
|
|
|
if (@abs(total_option_delta) >= 1.0) {
|
|
const opt_sign: []const u8 = if (total_option_delta >= 0) "+" else "-";
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(total_option_delta)) });
|
|
}
|
|
try out.print("\n", .{});
|
|
|
|
if (discrepancy_count > 0) {
|
|
try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} to investigate\n", .{ discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches") });
|
|
}
|
|
try out.print("\n", .{});
|
|
}
|
|
/// Render the "portfolio accounts not found in <scope>" advisory.
|
|
/// Silent when `absent` is empty, so it composes cleanly after both
|
|
/// the verbose audit table and the compact "no discrepancies" line.
|
|
///
|
|
/// `scope_phrase` names what the absence is relative to: the explicit
|
|
/// single-file command passes "this export"; the flagless audit, which
|
|
/// unions the present accounts across every discovered file before
|
|
/// computing absence, passes "any export".
|
|
pub fn displayAbsentAccounts(absent: []const AbsentAccount, color: bool, scope_phrase: []const u8, out: *std.Io.Writer) !void {
|
|
if (absent.len == 0) return;
|
|
|
|
try out.print("\n", .{});
|
|
try cli.printFg(out, color, cli.CLR_WARNING, " Portfolio accounts not found in {s}", .{scope_phrase});
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " (stale, or not exported?)\n", .{});
|
|
for (absent) |a| {
|
|
try out.print(" ", .{});
|
|
try cli.printFg(out, color, cli.CLR_WARNING, "{s}", .{a.account_name});
|
|
try cli.printFg(out, color, cli.CLR_MUTED, " (#{s})", .{a.account_number});
|
|
try out.print(" {f}\n", .{Money.from(a.portfolio_total)});
|
|
}
|
|
try out.print("\n", .{});
|
|
}
|
|
// ── displayResults rendering ─────────────────────────────────
|
|
|
|
test "displayResults: renders every row classification and the totals block" {
|
|
const allocator = std.testing.allocator;
|
|
_ = allocator;
|
|
|
|
// One mapped account exercising each status branch, plus one
|
|
// unmapped account exercising the unmapped header + only_in_* rows.
|
|
const ira_cmps = [_]SymbolComparison{
|
|
// matched, clean -> blank status, normal color
|
|
.{ .symbol = "AAPL", .portfolio_shares = 10, .brokerage_shares = 10, .portfolio_price = 200, .brokerage_price = 200, .portfolio_value = 2000, .brokerage_value = 2000, .shares_delta = 0, .value_delta = 0, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
|
// share mismatch -> "Shares +2.000"
|
|
.{ .symbol = "MSFT", .portfolio_shares = 5, .brokerage_shares = 7, .portfolio_price = 300, .brokerage_price = 300, .portfolio_value = 1500, .brokerage_value = 2100, .shares_delta = 2, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
|
// share mismatch, broker shows FEWER shares -> negative delta, "positive" color arm
|
|
.{ .symbol = "NVDA", .portfolio_shares = 10, .brokerage_shares = 8, .portfolio_price = 120, .brokerage_price = 120, .portfolio_value = 1200, .brokerage_value = 960, .shares_delta = -2, .value_delta = -240, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
|
// cash mismatch -> "Cash +$0.25"
|
|
.{ .symbol = "FDRXX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 38.75, .brokerage_value = 39.00, .shares_delta = null, .value_delta = 0.25, .is_cash = true, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
|
// option -> "Option"
|
|
.{ .symbol = "AMZN C", .portfolio_shares = -2, .brokerage_shares = -2, .portfolio_price = 875, .brokerage_price = 1625, .portfolio_value = 1750, .brokerage_value = 3250, .shares_delta = 0, .value_delta = 1500, .is_cash = false, .is_option = true, .only_in_brokerage = false, .only_in_portfolio = false },
|
|
// shares match, stale value -> "Value +$50.00" (muted)
|
|
.{ .symbol = "QTUM", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 116.0, .brokerage_price = 116.5, .portfolio_value = 11600, .brokerage_value = 11650, .shares_delta = 0, .value_delta = 50, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
|
};
|
|
const unmapped_cmps = [_]SymbolComparison{
|
|
.{ .symbol = "TSLA", .portfolio_shares = 0, .brokerage_shares = 3, .portfolio_price = null, .brokerage_price = 200, .portfolio_value = 0, .brokerage_value = 600, .shares_delta = 3, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = true, .only_in_portfolio = false },
|
|
.{ .symbol = "GOOG", .portfolio_shares = 4, .brokerage_shares = null, .portfolio_price = 200, .brokerage_price = null, .portfolio_value = 800, .brokerage_value = null, .shares_delta = null, .value_delta = null, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = true },
|
|
};
|
|
const results = [_]AccountComparison{
|
|
.{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &ira_cmps, .portfolio_total = 16928.75, .brokerage_total = 17479.0, .total_delta = 550.25, .option_value_delta = 1500, .has_discrepancies = true },
|
|
.{ .account_name = "", .brokerage_name = "FIDELITY", .account_number = "9999", .comparisons = &unmapped_cmps, .portfolio_total = 800, .brokerage_total = 600, .total_delta = -200, .option_value_delta = 0, .has_discrepancies = true },
|
|
};
|
|
|
|
var buf: [8192]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try displayResults(&results, false, &w);
|
|
const out = w.buffered();
|
|
|
|
// Header + account identity
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Sample IRA") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "SCHWAB") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "1234") != null);
|
|
// Unmapped-account header branch
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "unmapped") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "FIDELITY") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "9999") != null);
|
|
// Per-row status strings
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Shares +2.000") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Shares -2.000") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Cash +") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Option") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Brokerage only") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio only") != null);
|
|
// Totals block
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "options") != null);
|
|
// 5 real mismatches (2 share, cash, brokerage-only, portfolio-only) -> plural
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null);
|
|
}
|
|
|
|
test "displayResults: color=true emits ANSI and singular mismatch label" {
|
|
const cmps = [_]SymbolComparison{
|
|
.{ .symbol = "GOOG", .portfolio_shares = 4, .brokerage_shares = null, .portfolio_price = 200, .brokerage_price = null, .portfolio_value = 800, .brokerage_value = null, .shares_delta = null, .value_delta = null, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = true },
|
|
};
|
|
const results = [_]AccountComparison{
|
|
.{ .account_name = "Sample Roth", .brokerage_name = "SCHWAB", .account_number = "5678", .comparisons = &cmps, .portfolio_total = 800, .brokerage_total = 0, .total_delta = -800, .option_value_delta = 0, .has_discrepancies = true },
|
|
};
|
|
|
|
var buf: [4096]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try displayResults(&results, true, &w);
|
|
const out = w.buffered();
|
|
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null); // ANSI present
|
|
// exactly one real mismatch -> singular 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" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
var lots = [_]portfolio_mod.Lot{
|
|
.{ .symbol = "VTHRX", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 30, .account = "Sample IRA", .price_ratio = 5.0 },
|
|
};
|
|
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
|
|
|
var prices = std.StringHashMap(f64).init(allocator);
|
|
defer prices.deinit();
|
|
try prices.put("VTHRX", 5.0); // retail close
|
|
|
|
const cmps = [_]SymbolComparison{
|
|
// brokerage_price (inst NAV) 30 / retail 5 = suggested ratio 6, vs configured 5
|
|
.{ .symbol = "VTHRX", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 25.0, .brokerage_price = 30.0, .portfolio_value = 2500, .brokerage_value = 3000, .shares_delta = 0, .value_delta = 500, .is_cash = false, .is_option = false, .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 = 2500, .brokerage_total = 3000, .total_delta = 500, .option_value_delta = 0, .has_discrepancies = true },
|
|
};
|
|
|
|
var buf: [2048]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try displayRatioSuggestions(allocator, &results, portfolio, prices, null, false, &w);
|
|
const out = w.buffered();
|
|
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "VTHRX") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "ratio 5 -> ") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "6") != null); // suggested ratio
|
|
}
|
|
|
|
test "displayRatioSuggestions: direct-indexing account suggests even at ratio 1.0" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
// price_ratio == 1.0 would normally be skipped, but the account is
|
|
// flagged direct_indexing -> the ratio==1.0 skip is bypassed.
|
|
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 = "5678", .direct_indexing = true },
|
|
};
|
|
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
|
|
|
var prices = std.StringHashMap(f64).init(allocator);
|
|
defer prices.deinit();
|
|
try prices.put("SPY", 500.0);
|
|
|
|
const cmps = [_]SymbolComparison{
|
|
.{ .symbol = "SPY", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 500, .brokerage_price = 510.0, .portfolio_value = 50000, .brokerage_value = 51000, .shares_delta = 0, .value_delta = 1000, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
|
};
|
|
const results = [_]AccountComparison{
|
|
.{ .account_name = "Sample Brokerage", .brokerage_name = "SCHWAB", .account_number = "5678", .comparisons = &cmps, .portfolio_total = 50000, .brokerage_total = 51000, .total_delta = 1000, .option_value_delta = 0, .has_discrepancies = true },
|
|
};
|
|
|
|
var buf: [2048]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try displayRatioSuggestions(allocator, &results, portfolio, prices, acct_map, false, &w);
|
|
const out = w.buffered();
|
|
|
|
// suggested = 510/500 = 1.02, configured 1.0 -> drift suggestion emitted
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "SPY") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "ratio 1 -> ") != null);
|
|
}
|
|
|
|
test "displayRatioSuggestions: cash/option/only rows produce no output" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
|
|
var prices = std.StringHashMap(f64).init(allocator);
|
|
defer prices.deinit();
|
|
|
|
const cmps = [_]SymbolComparison{
|
|
.{ .symbol = "FDRXX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 100, .brokerage_value = 100, .shares_delta = null, .value_delta = 0, .is_cash = true, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
|
.{ .symbol = "AMZN C", .portfolio_shares = -2, .brokerage_shares = -2, .portfolio_price = 875, .brokerage_price = 1625, .portfolio_value = 1750, .brokerage_value = 3250, .shares_delta = 0, .value_delta = 1500, .is_cash = false, .is_option = true, .only_in_brokerage = false, .only_in_portfolio = false },
|
|
.{ .symbol = "TSLA", .portfolio_shares = 0, .brokerage_shares = 3, .portfolio_price = null, .brokerage_price = 200, .portfolio_value = 0, .brokerage_value = 600, .shares_delta = 3, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = true, .only_in_portfolio = false },
|
|
};
|
|
const results = [_]AccountComparison{
|
|
.{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 1850, .brokerage_total = 3950, .total_delta = 2100, .option_value_delta = 1500, .has_discrepancies = true },
|
|
};
|
|
|
|
var buf: [1024]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try displayRatioSuggestions(allocator, &results, portfolio, prices, null, false, &w);
|
|
// No qualifying (matched, non-cash, non-option) rows -> header never prints.
|
|
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
|
}
|
|
test "displayAbsentAccounts: silent when empty, renders names + totals otherwise" {
|
|
var buf: [1024]u8 = undefined;
|
|
|
|
// Empty -> no output.
|
|
{
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
try displayAbsentAccounts(&.{}, false, "this export", &w);
|
|
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
|
}
|
|
|
|
// Non-empty -> names, numbers, and a money total appear.
|
|
{
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
const absent = [_]AbsentAccount{
|
|
.{ .account_name = "Sample IRA", .account_number = "1234", .portfolio_total = 2100.0 },
|
|
};
|
|
try displayAbsentAccounts(&absent, false, "this export", &w);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "not found in this export") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Sample IRA") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "#1234") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "$2,100") != null);
|
|
}
|
|
|
|
// The scope phrase is caller-supplied (flagless audit passes "any export").
|
|
{
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
const absent = [_]AbsentAccount{
|
|
.{ .account_name = "Sample IRA", .account_number = "1234", .portfolio_total = 2100.0 },
|
|
};
|
|
try displayAbsentAccounts(&absent, false, "any export", &w);
|
|
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "not found in any export") != null);
|
|
}
|
|
}
|
|
|
|
// ── displayUnmapped ──────────────────────────────────────────
|
|
|
|
fn testComparison(account_name: []const u8, brokerage_name: []const u8, number: []const u8) AccountComparison {
|
|
return .{
|
|
.account_name = account_name,
|
|
.brokerage_name = brokerage_name,
|
|
.account_number = number,
|
|
.comparisons = &.{},
|
|
.portfolio_total = 0,
|
|
.brokerage_total = 0,
|
|
.total_delta = 0,
|
|
.option_value_delta = 0,
|
|
.has_discrepancies = account_name.len == 0,
|
|
};
|
|
}
|
|
|
|
fn renderUnmapped(buf: []u8, results: []AccountComparison, scope: ExportScope, list_skipped: bool) ![]const u8 {
|
|
var w: std.Io.Writer = .fixed(buf);
|
|
try displayUnmapped(AccountComparison, "schwab", splitUnmapped(AccountComparison, results, scope), list_skipped, false, &w);
|
|
return w.buffered();
|
|
}
|
|
|
|
test "displayUnmapped: names each unmapped account of an all-accounts export" {
|
|
var results = [_]AccountComparison{
|
|
testComparison("Sample IRA", "Sample_IRA", "1234"),
|
|
testComparison("", "Sample_Account", "9012"),
|
|
};
|
|
var buf: [512]u8 = undefined;
|
|
const out = try renderUnmapped(&buf, &results, .all_accounts, false);
|
|
try std.testing.expectEqualStrings(
|
|
" schwab: 1 account not in accounts.srf, not reconciled\n" ++
|
|
" Sample_Account #9012\n",
|
|
out,
|
|
);
|
|
|
|
// Plural, and the list follows the count.
|
|
var two = [_]AccountComparison{
|
|
testComparison("Sample IRA", "Sample_IRA", "1234"),
|
|
testComparison("", "Sample_Account", "9012"),
|
|
testComparison("", "Sample_Trust", "3456"),
|
|
};
|
|
const out2 = try renderUnmapped(&buf, &two, .all_accounts, false);
|
|
try std.testing.expect(std.mem.startsWith(u8, out2, " schwab: 2 accounts not in accounts.srf, not reconciled\n"));
|
|
try std.testing.expect(std.mem.indexOf(u8, out2, " Sample_Trust #3456\n") != null);
|
|
}
|
|
|
|
test "displayUnmapped: a skipped export lists its accounts only when asked" {
|
|
var buf: [512]u8 = undefined;
|
|
|
|
var one = [_]AccountComparison{testComparison("", "Sample_Account", "9012")};
|
|
try std.testing.expectEqualStrings(
|
|
" schwab: its only account is not in accounts.srf; skipped (another portfolio's export?)\n",
|
|
try renderUnmapped(&buf, &one, .all_accounts, false),
|
|
);
|
|
|
|
var two = [_]AccountComparison{ testComparison("", "Sample_Account", "9012"), testComparison("", "Sample_Trust", "3456") };
|
|
try std.testing.expectEqualStrings(
|
|
" schwab: none of its 2 accounts are in accounts.srf; skipped (another portfolio's export?)\n",
|
|
try renderUnmapped(&buf, &two, .all_accounts, false),
|
|
);
|
|
const listed = try renderUnmapped(&buf, &two, .all_accounts, true);
|
|
try std.testing.expect(std.mem.indexOf(u8, listed, " Sample_Account #9012\n") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, listed, " Sample_Trust #3456\n") != null);
|
|
}
|
|
|
|
test "displayUnmapped: silent when every account maps, and for a one-account export" {
|
|
var buf: [512]u8 = undefined;
|
|
var mapped = [_]AccountComparison{testComparison("Sample IRA", "Sample_IRA", "1234")};
|
|
try std.testing.expectEqualStrings("", try renderUnmapped(&buf, &mapped, .all_accounts, true));
|
|
// A one-account export's unmapped account gets the full report from
|
|
// displayResults instead.
|
|
var picked = [_]AccountComparison{testComparison("", "Sample_Account", "9012")};
|
|
try std.testing.expectEqualStrings("", try renderUnmapped(&buf, &picked, .one_account, true));
|
|
}
|
|
|
|
test "displayUnmapped: color mutes the note" {
|
|
var results = [_]AccountComparison{
|
|
testComparison("Sample IRA", "Sample_IRA", "1234"),
|
|
testComparison("", "Sample_Account", "9012"),
|
|
};
|
|
var buf: [512]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
try displayUnmapped(AccountComparison, "schwab", splitUnmapped(AccountComparison, &results, .all_accounts), false, true, &w);
|
|
var seqbuf: [32]u8 = undefined;
|
|
const mute_seq = try std.fmt.bufPrint(&seqbuf, "\x1b[38;2;{d};{d};{d}m", .{ cli.CLR_MUTED[0], cli.CLR_MUTED[1], cli.CLR_MUTED[2] });
|
|
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), mute_seq) != null);
|
|
}
|