tighten manual price hygiene / compare to penny on cash lots in audit
This commit is contained in:
parent
1c4f85f8da
commit
2268f1dfd0
2 changed files with 557 additions and 136 deletions
124
TODO.md
124
TODO.md
|
|
@ -240,6 +240,36 @@ hygiene without inheriting the reconciliation surface.
|
||||||
|
|
||||||
Pure internal refactor; no user-visible change.
|
Pure internal refactor; no user-visible change.
|
||||||
|
|
||||||
|
## Audit: reconcile accounts present in the portfolio but absent from the export — priority MEDIUM
|
||||||
|
|
||||||
|
`compareAccounts` (and `compareSchwabSummary`) iterate over the
|
||||||
|
accounts found in the *brokerage export*, then look up the matching
|
||||||
|
portfolio account. The two directions are asymmetric:
|
||||||
|
|
||||||
|
- **Export row → no portfolio account:** handled. The
|
||||||
|
`portfolio_acct_name == null` branch surfaces it as
|
||||||
|
"unmapped — add account_number to accounts.srf" and flags a
|
||||||
|
discrepancy.
|
||||||
|
- **Portfolio account → not in the export:** *silent gap.* An
|
||||||
|
account that exists in `accounts.srf` / has lots in
|
||||||
|
`portfolio.srf` but has no corresponding account in the CSV is
|
||||||
|
never iterated, so the reconciler says nothing. If you forget to
|
||||||
|
include an account in the download, or a brokerage drops it from
|
||||||
|
the export, audit can't tell you "you hold account X that wasn't
|
||||||
|
in this file — stale, or just not exported?"
|
||||||
|
|
||||||
|
Fix sketch: after the per-export-account loop, walk the
|
||||||
|
`account_map` entries for the institution being reconciled and, for
|
||||||
|
any whose portfolio account holds open lots as-of but never appeared
|
||||||
|
in the export, emit a "portfolio account not found in export" notice.
|
||||||
|
Gate it to the institution under audit (don't flag Schwab accounts
|
||||||
|
when reconciling a Fidelity export). Decide whether a zero-balance /
|
||||||
|
fully-closed account should be suppressed.
|
||||||
|
|
||||||
|
Found while debugging a BrokerageLink cash reconciliation — that
|
||||||
|
account *was* in the export, so this gap wasn't the culprit, but the
|
||||||
|
asymmetry is real and worth closing.
|
||||||
|
|
||||||
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers — priority LOW
|
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers — priority LOW
|
||||||
|
|
||||||
`src/format.zig` is still a ~1700-line grab-bag, but the money- and
|
`src/format.zig` is still a ~1700-line grab-bag, but the money- and
|
||||||
|
|
@ -300,100 +330,6 @@ change inputs to either of these loaders, change them in BOTH
|
||||||
places." Adding a third copy of the imported-only loader code
|
places." Adding a third copy of the imported-only loader code
|
||||||
makes that worse.
|
makes that worse.
|
||||||
|
|
||||||
## Audit: manual-check accounts mechanism — priority HIGH
|
|
||||||
|
|
||||||
Some accounts/positions can't be reconciled from broker CSVs and need a
|
|
||||||
human-in-the-loop reminder at the audit step. Two recurring shapes:
|
|
||||||
|
|
||||||
- **No-CSV-export accounts** (e.g. some insurance / annuity products)
|
|
||||||
where values only live in periodic statements. Git can't detect a
|
|
||||||
"change" because nothing changes locally; the user has to log in
|
|
||||||
to see the new value.
|
|
||||||
- **Payroll-deduction-then-purchase accounts** (e.g. ESPP) where
|
|
||||||
payroll-deducted cash doesn't appear in the broker positions CSV
|
|
||||||
until the purchase date hits (typically every 6 months). Between
|
|
||||||
purchases the cash is a real contribution that `zfin audit` can't
|
|
||||||
see.
|
|
||||||
|
|
||||||
The existing `update_cadence::weekly|monthly|quarterly|none` field already
|
|
||||||
sort-of covers this, but has two gaps:
|
|
||||||
|
|
||||||
1. It fires off the last *git-detected change*, not the last *human
|
|
||||||
review*. For statement-only accounts, the value sometimes hasn't
|
|
||||||
changed in months — so git never fires, cadence never trips.
|
|
||||||
2. Payroll-deduction accounts need weekly-ish attention while
|
|
||||||
accumulating cash between purchases, but the accrued balance is
|
|
||||||
invisible to the CSV audit.
|
|
||||||
|
|
||||||
Drift symptom seen in practice: several accounts on
|
|
||||||
`update_cadence::weekly` in `accounts.srf` weren't flagged as overdue
|
|
||||||
despite no changes in two weeks, because the cadence reads
|
|
||||||
git-detected change time rather than human-review time. The cadence
|
|
||||||
values themselves may also be wrong for these accounts — revisit
|
|
||||||
whether weekly is the right cadence vs. monthly/quarterly given how
|
|
||||||
rarely they actually change.
|
|
||||||
|
|
||||||
### Options
|
|
||||||
|
|
||||||
A. **New `update_cadence::manual` variant** — always fires every audit
|
|
||||||
run until silenced. Blunt but zero design work.
|
|
||||||
|
|
||||||
B. **`last_refreshed::YYYY-MM-DD` field on `accounts.srf`** — explicit
|
|
||||||
human-review timestamp, decoupled from git-detected changes. Audit
|
|
||||||
compares `today - last_refreshed` against the cadence. User bumps
|
|
||||||
the field when they check the statement. Probably the most
|
|
||||||
correct fit for statement-only accounts.
|
|
||||||
|
|
||||||
C. **Sticky TODO list** — a `todos.srf` or `todo::` field on accounts
|
|
||||||
that audit always surfaces until cleared. General-purpose; also
|
|
||||||
covers "remember to rebalance on 5/15".
|
|
||||||
|
|
||||||
### ESPP-style accrual follow-through
|
|
||||||
|
|
||||||
Payroll-deduction accounts are also a contribution-attribution blind
|
|
||||||
spot. If a paystub deducts $X/week but the cash lot doesn't reach
|
|
||||||
`portfolio.srf` until the purchase date, the attribution math is
|
|
||||||
under-counting contributions and over-counting the purchase-week
|
|
||||||
gain. Possible fixes are discussed in the "Contributions diff" TODO
|
|
||||||
below — option C there (per-account `cash_is_contribution`) would
|
|
||||||
make manually-entered ESPP-style cash additions count correctly.
|
|
||||||
|
|
||||||
## Audit: stale manual prices section is incorrect — priority HIGH
|
|
||||||
|
|
||||||
The `Stale manual prices` section in `zfin audit` (in
|
|
||||||
`src/commands/audit.zig` around line 1333) isn't computing the
|
|
||||||
right thing. The current logic walks `portfolio.lots`, filters to
|
|
||||||
lots with both `price` and `price_date` set, and flags any whose
|
|
||||||
`as_of.days - price_date.days > stale_days`. In practice this
|
|
||||||
either over-flags (counting lots that aren't really
|
|
||||||
manually-priced), under-flags (missing lots that ARE manually
|
|
||||||
priced but lack `price_date`), or both — needs investigation
|
|
||||||
against a real portfolio to determine which.
|
|
||||||
|
|
||||||
Things to check:
|
|
||||||
|
|
||||||
- Are we using the right field to identify "manually priced"? The
|
|
||||||
`Lot.price` field is set for any non-API price (manual override,
|
|
||||||
illiquid valuation, CD face, etc.); some of those shouldn't be
|
|
||||||
in a "stale prices" check (e.g. CDs with a fixed face value
|
|
||||||
aren't stale by age).
|
|
||||||
- Should the staleness comparison use `Allocation.is_manual_price`
|
|
||||||
(computed at the position level after the price-resolution
|
|
||||||
cascade) instead of the per-lot field? That captures "the price
|
|
||||||
this position is currently displaying came from a manual
|
|
||||||
source," which is what the user actually cares about.
|
|
||||||
- `price_date` falsely-null lots: if a lot has `price` set but no
|
|
||||||
`price_date`, we silently skip it instead of flagging it. That's
|
|
||||||
almost certainly wrong — a manually-priced lot with no recorded
|
|
||||||
date is the *most* stale case, not the least.
|
|
||||||
- Per-symbol vs per-lot: if the same symbol appears in multiple
|
|
||||||
lots with the same manual price, we currently emit one line per
|
|
||||||
lot. Probably wants to be one line per symbol with a count, or
|
|
||||||
at least dedup by `(symbol, price, price_date)`.
|
|
||||||
|
|
||||||
Fix should land with regression tests against a fixture portfolio
|
|
||||||
that exhibits each of the above shapes.
|
|
||||||
|
|
||||||
## Investigate: detailed 401(k) contributions data source
|
## Investigate: detailed 401(k) contributions data source
|
||||||
|
|
||||||
Found a more detailed contributions screen on at least one
|
Found a more detailed contributions screen on at least one
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,20 @@ const parseSchwabCsv = schwab.parseCsv;
|
||||||
const parseSchwabSummary = schwab.parseSummary;
|
const parseSchwabSummary = schwab.parseSummary;
|
||||||
const fidelityOptionMatchesLot = fidelity.optionMatchesLot;
|
const fidelityOptionMatchesLot = fidelity.optionMatchesLot;
|
||||||
|
|
||||||
|
/// Reconciliation match tolerances.
|
||||||
|
///
|
||||||
|
/// Securities get $1 of slack to absorb NAV-rounding on large
|
||||||
|
/// positions: a sub-cent per-share NAV difference between the broker
|
||||||
|
/// and zfin's fetched price on a six-figure mutual-fund position
|
||||||
|
/// easily exceeds a dollar, and that's not an actionable discrepancy.
|
||||||
|
///
|
||||||
|
/// Cash is different — it has no NAV and no share count, it's an exact
|
||||||
|
/// dollar figure on both sides. It must match to the penny; the $1
|
||||||
|
/// securities slack would otherwise silently hide real money-market
|
||||||
|
/// dividend accrual between updates (the whole point of the audit).
|
||||||
|
const value_tolerance: f64 = 1.0;
|
||||||
|
const cash_tolerance: f64 = 0.01;
|
||||||
|
|
||||||
/// Account-level comparison result for Schwab summary audit.
|
/// Account-level comparison result for Schwab summary audit.
|
||||||
pub const SchwabAccountComparison = struct {
|
pub const SchwabAccountComparison = struct {
|
||||||
account_name: []const u8,
|
account_name: []const u8,
|
||||||
|
|
@ -92,8 +106,8 @@ pub fn compareSchwabSummary(
|
||||||
const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
|
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 total_delta = if (sa.total_value) |st| st - pf_total else null;
|
||||||
|
|
||||||
const cash_ok = if (cash_delta) |d| @abs(d) < 1.0 else true;
|
const cash_ok = if (cash_delta) |d| @abs(d) < cash_tolerance else true;
|
||||||
const total_ok = if (total_delta) |d| @abs(d) < 1.0 else true;
|
const total_ok = if (total_delta) |d| @abs(d) < value_tolerance else true;
|
||||||
|
|
||||||
try results.append(allocator, .{
|
try results.append(allocator, .{
|
||||||
.account_name = portfolio_acct orelse "",
|
.account_name = portfolio_acct orelse "",
|
||||||
|
|
@ -141,8 +155,8 @@ fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, o
|
||||||
else
|
else
|
||||||
"--";
|
"--";
|
||||||
|
|
||||||
const cash_ok = if (r.cash_delta) |d| @abs(d) < 1.0 else true;
|
const cash_ok = if (r.cash_delta) |d| @abs(d) < cash_tolerance else true;
|
||||||
const total_ok = if (r.total_delta) |d| @abs(d) < 1.0 else true;
|
const total_ok = if (r.total_delta) |d| @abs(d) < value_tolerance else true;
|
||||||
const is_unmapped = r.account_name.len == 0;
|
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;
|
||||||
|
|
||||||
|
|
@ -484,7 +498,9 @@ pub fn compareAccounts(
|
||||||
const value_delta = if (bp.current_value) |bv| bv - pf_value else null;
|
const value_delta = if (bp.current_value) |bv| bv - pf_value else null;
|
||||||
|
|
||||||
const shares_match = if (shares_delta) |d| @abs(d) < 0.01 else true;
|
const shares_match = if (shares_delta) |d| @abs(d) < 0.01 else true;
|
||||||
const value_match = if (value_delta) |d| @abs(d) < 1.0 else true;
|
// Cash matches to the penny; securities get $1 of NAV-rounding slack.
|
||||||
|
const tol: f64 = if (bp.is_cash) cash_tolerance else value_tolerance;
|
||||||
|
const value_match = if (value_delta) |d| @abs(d) < tol else true;
|
||||||
|
|
||||||
// Option value deltas are expected (cost basis vs mark-to-market)
|
// Option value deltas are expected (cost basis vs mark-to-market)
|
||||||
// — track them separately rather than flagging as discrepancies
|
// — track them separately rather than flagging as discrepancies
|
||||||
|
|
@ -839,7 +855,7 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.
|
||||||
|
|
||||||
// Classify this row
|
// Classify this row
|
||||||
const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage;
|
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) >= 1.0 else false);
|
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;
|
const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch;
|
||||||
|
|
||||||
if (is_real_mismatch) discrepancy_count += 1;
|
if (is_real_mismatch) discrepancy_count += 1;
|
||||||
|
|
@ -910,7 +926,7 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.
|
||||||
|
|
||||||
// Shares match — show value delta (stale price) if any, muted
|
// Shares match — show value delta (stale price) if any, muted
|
||||||
if (cmp.value_delta) |d| {
|
if (cmp.value_delta) |d| {
|
||||||
if (@abs(d) >= 1.0) {
|
if (@abs(d) >= value_tolerance) {
|
||||||
const sign: []const u8 = if (d >= 0) "+" else "-";
|
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 std.fmt.bufPrint(&status_buf, "Value {s}{f}", .{ sign, Money.from(@abs(d)) }) catch "";
|
||||||
}
|
}
|
||||||
|
|
@ -1231,6 +1247,185 @@ fn stalenessColor(age_days: i32, threshold: u32) [3]u8 {
|
||||||
return cli.CLR_NEGATIVE;
|
return cli.CLR_NEGATIVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One stale (or undated) manual price found during the hygiene scan.
|
||||||
|
/// String fields borrow from the scanned portfolio's lots and are
|
||||||
|
/// valid for the lifetime of that portfolio.
|
||||||
|
const StaleManualPrice = struct {
|
||||||
|
account: []const u8,
|
||||||
|
symbol: []const u8,
|
||||||
|
note: ?[]const u8,
|
||||||
|
price: f64,
|
||||||
|
/// `null` when the lot carries a manual `price` but no
|
||||||
|
/// `price_date` — the most-stale case (it can't even be aged),
|
||||||
|
/// not the least.
|
||||||
|
price_date: ?Date,
|
||||||
|
/// Days since `price_date`; `null` when undated.
|
||||||
|
age_days: ?i32,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Collect manual-priced lots that are stale (older than `stale_days`)
|
||||||
|
/// or undated, for the "Stale manual prices" hygiene section.
|
||||||
|
///
|
||||||
|
/// Staleness is purely a property of the `price` / `price_date` the
|
||||||
|
/// user typed on the lot — it has nothing to do with the account's
|
||||||
|
/// `update_cadence` (that's the reconciliation cadence, a separate
|
||||||
|
/// concept handled by the "Accounts overdue" section). The single
|
||||||
|
/// threshold is `stale_days` (the `--stale-days` flag, default 3).
|
||||||
|
///
|
||||||
|
/// Restricted to open `security_type == .stock` lots: CDs/cash/options
|
||||||
|
/// carry `price` as a fixed face value that never goes "stale by age,"
|
||||||
|
/// and closed lots aren't worth nagging about. Undated manual prices
|
||||||
|
/// are always included regardless of `stale_days` — a manual price
|
||||||
|
/// with no `price_date` can't be aged, which is the worst case, not a
|
||||||
|
/// pass. Caller owns the returned list; string fields borrow from
|
||||||
|
/// `portfolio`.
|
||||||
|
fn collectStaleManualPrices(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
portfolio: zfin.Portfolio,
|
||||||
|
as_of: Date,
|
||||||
|
stale_days: u32,
|
||||||
|
) !std.ArrayList(StaleManualPrice) {
|
||||||
|
var out = std.ArrayList(StaleManualPrice).empty;
|
||||||
|
errdefer out.deinit(allocator);
|
||||||
|
|
||||||
|
const threshold: i32 = @intCast(stale_days);
|
||||||
|
for (portfolio.lots) |lot| {
|
||||||
|
if (lot.security_type != .stock) continue;
|
||||||
|
const price = lot.price orelse continue;
|
||||||
|
if (!lot.isOpen(as_of)) continue;
|
||||||
|
const account = lot.account orelse "(no account)";
|
||||||
|
|
||||||
|
if (lot.price_date) |pd| {
|
||||||
|
const age = as_of.days - pd.days;
|
||||||
|
if (age <= threshold) continue; // fresh enough
|
||||||
|
try out.append(allocator, .{
|
||||||
|
.account = account,
|
||||||
|
.symbol = lot.symbol,
|
||||||
|
.note = lot.note,
|
||||||
|
.price = price,
|
||||||
|
.price_date = pd,
|
||||||
|
.age_days = age,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
try out.append(allocator, .{
|
||||||
|
.account = account,
|
||||||
|
.symbol = lot.symbol,
|
||||||
|
.note = lot.note,
|
||||||
|
.price = price,
|
||||||
|
.price_date = null,
|
||||||
|
.age_days = null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort stale manual prices by account, then symbol — so the display
|
||||||
|
/// can group lines under per-account headers.
|
||||||
|
fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool {
|
||||||
|
const acc = std.mem.order(u8, a.account, b.account);
|
||||||
|
if (acc != .eq) return acc == .lt;
|
||||||
|
return std.mem.order(u8, a.symbol, b.symbol) == .lt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lot whose manual `price` moved between HEAD and the working tree
|
||||||
|
/// while its `price_date` stayed identical — the "bumped the price,
|
||||||
|
/// forgot the date" mistake. String fields borrow from the working-
|
||||||
|
/// tree portfolio.
|
||||||
|
const PriceDateMismatch = struct {
|
||||||
|
account: []const u8,
|
||||||
|
symbol: []const u8,
|
||||||
|
old_price: f64,
|
||||||
|
new_price: f64,
|
||||||
|
price_date: Date,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Build a lot identity that survives a manual-price edit: a lot keeps
|
||||||
|
/// its symbol, account, open_date, and open_price when you only change
|
||||||
|
/// `price`/`price_date`. Used to pair HEAD lots with working-tree lots.
|
||||||
|
/// Caller owns the returned slice.
|
||||||
|
fn lotIdentityKey(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 {
|
||||||
|
return std.fmt.allocPrint(allocator, "{s}\x00{s}\x00{d}\x00{d:.6}", .{
|
||||||
|
lot.symbol,
|
||||||
|
lot.account orelse "",
|
||||||
|
lot.open_date.days,
|
||||||
|
lot.open_price,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find lots whose manual `price` changed between `committed` (HEAD)
|
||||||
|
/// and `working` (on-disk) while `price_date` stayed identical.
|
||||||
|
///
|
||||||
|
/// This is the working-tree-vs-HEAD detector for the recurring "I
|
||||||
|
/// updated the price but forgot the date" mistake, run when `audit`
|
||||||
|
/// fires before a commit. Only open `security_type == .stock` lots
|
||||||
|
/// with a non-null `price_date` on both sides participate: the undated
|
||||||
|
/// case is reported by `collectStaleManualPrices`, and legitimate
|
||||||
|
/// back-dating (moving the date to a past close) is *not* flagged
|
||||||
|
/// because the date field changed. Lots are paired by
|
||||||
|
/// `lotIdentityKey`; newly-added or removed lots are ignored. Caller
|
||||||
|
/// owns the returned list; string fields borrow from `working`.
|
||||||
|
fn findPriceDateMismatches(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
committed: zfin.Portfolio,
|
||||||
|
working: zfin.Portfolio,
|
||||||
|
as_of: Date,
|
||||||
|
) !std.ArrayList(PriceDateMismatch) {
|
||||||
|
var out = std.ArrayList(PriceDateMismatch).empty;
|
||||||
|
errdefer out.deinit(allocator);
|
||||||
|
|
||||||
|
// Index HEAD lots by stable identity → their (price, price_date).
|
||||||
|
const HeadLot = struct { price: ?f64, price_date: ?Date };
|
||||||
|
var head = std.StringHashMap(HeadLot).init(allocator);
|
||||||
|
defer {
|
||||||
|
var it = head.keyIterator();
|
||||||
|
while (it.next()) |k| allocator.free(k.*);
|
||||||
|
head.deinit();
|
||||||
|
}
|
||||||
|
for (committed.lots) |lot| {
|
||||||
|
if (lot.security_type != .stock) continue;
|
||||||
|
const key = try lotIdentityKey(allocator, lot);
|
||||||
|
const gop = try head.getOrPut(key);
|
||||||
|
if (gop.found_existing) {
|
||||||
|
// Duplicate identity (rare) — ambiguous, don't guess.
|
||||||
|
allocator.free(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
gop.value_ptr.* = .{ .price = lot.price, .price_date = lot.price_date };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (working.lots) |lot| {
|
||||||
|
if (lot.security_type != .stock) continue;
|
||||||
|
if (!lot.isOpen(as_of)) continue;
|
||||||
|
const new_price = lot.price orelse continue;
|
||||||
|
const new_date = lot.price_date orelse continue; // undated → stale-price section's job
|
||||||
|
const key = try lotIdentityKey(allocator, lot);
|
||||||
|
defer allocator.free(key);
|
||||||
|
const prior = head.get(key) orelse continue; // newly-added lot
|
||||||
|
const old_price = prior.price orelse continue; // price added, not moved
|
||||||
|
const old_date = prior.price_date orelse continue; // was undated before
|
||||||
|
// The mistake: price moved, date did not.
|
||||||
|
if (old_date.days == new_date.days and @abs(old_price - new_price) >= 0.005) {
|
||||||
|
try out.append(allocator, .{
|
||||||
|
.account = lot.account orelse "(no account)",
|
||||||
|
.symbol = lot.symbol,
|
||||||
|
.old_price = old_price,
|
||||||
|
.new_price = new_price,
|
||||||
|
.price_date = new_date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort price/date mismatches by account, then symbol — for grouped
|
||||||
|
/// per-account display.
|
||||||
|
fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool {
|
||||||
|
const acc = std.mem.order(u8, a.account, b.account);
|
||||||
|
if (acc != .eq) return acc == .lt;
|
||||||
|
return std.mem.order(u8, a.symbol, b.symbol) == .lt;
|
||||||
|
}
|
||||||
|
|
||||||
/// Render one unmatched large-lot warning. Formats the line the
|
/// Render one unmatched large-lot warning. Formats the line the
|
||||||
/// user needs to paste into `transaction_log.srf` if the lot was
|
/// user needs to paste into `transaction_log.srf` if the lot was
|
||||||
/// an internal movement rather than a real external contribution.
|
/// an internal movement rather than a real external contribution.
|
||||||
|
|
@ -1331,45 +1526,47 @@ fn runHygieneCheck(
|
||||||
try cli.printBold(out, color, " Portfolio hygiene\n", .{});
|
try cli.printBold(out, color, " Portfolio hygiene\n", .{});
|
||||||
|
|
||||||
// ── Section 1: Stale manual prices ──
|
// ── Section 1: Stale manual prices ──
|
||||||
|
//
|
||||||
var stale_count: usize = 0;
|
// Manual prices on stock/fund lots whose `price_date` is older than
|
||||||
|
// `--stale-days` (default 3), plus manual prices with no
|
||||||
// Collect and display stale manual prices
|
// `price_date` at all (the most-stale case). Staleness is a
|
||||||
|
// property of the price the user typed on the lot — not of the
|
||||||
|
// account; grouping by account here is display-only organization.
|
||||||
|
// CDs/cash/options are excluded — their `price` is a fixed face
|
||||||
|
// value, not an age-stale quote — as are closed lots.
|
||||||
{
|
{
|
||||||
var header_shown = false;
|
var stale = try collectStaleManualPrices(allocator, portfolio, as_of, stale_days);
|
||||||
for (portfolio.lots) |lot| {
|
defer stale.deinit(allocator);
|
||||||
if (lot.price == null) continue;
|
std.mem.sort(StaleManualPrice, stale.items, {}, staleLessThan);
|
||||||
const pd = lot.price_date orelse continue;
|
|
||||||
const age_days = as_of.days - pd.days;
|
|
||||||
const threshold: i32 = @intCast(stale_days);
|
|
||||||
if (age_days <= threshold) continue;
|
|
||||||
|
|
||||||
if (!header_shown) {
|
try out.print("\n", .{});
|
||||||
try out.print("\n", .{});
|
try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days — --stale-days to configure)\n", .{stale_days});
|
||||||
try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days — --stale-days to configure)\n", .{stale_days});
|
|
||||||
header_shown = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
stale_count += 1;
|
if (stale.items.len == 0) {
|
||||||
var date_buf: [10]u8 = undefined;
|
|
||||||
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{pd}) catch "????-??-??";
|
|
||||||
const note_display = lot.note orelse "";
|
|
||||||
var price_buf: [24]u8 = undefined;
|
|
||||||
const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(lot.price.?)}) catch "$?";
|
|
||||||
|
|
||||||
try out.print(" {s:<16} {s:<16} {s:>10} {s} ", .{
|
|
||||||
lot.symbol,
|
|
||||||
note_display,
|
|
||||||
price_str,
|
|
||||||
date_str,
|
|
||||||
});
|
|
||||||
const clr = stalenessColor(age_days, stale_days);
|
|
||||||
try cli.printFg(out, color, clr, "({d} days)\n", .{@as(u32, @intCast(age_days))});
|
|
||||||
}
|
|
||||||
if (!header_shown) {
|
|
||||||
try out.print("\n", .{});
|
|
||||||
try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days)\n", .{stale_days});
|
|
||||||
try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{});
|
try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{});
|
||||||
|
} else {
|
||||||
|
var current_account: ?[]const u8 = null;
|
||||||
|
for (stale.items) |e| {
|
||||||
|
if (current_account == null or !std.mem.eql(u8, current_account.?, e.account)) {
|
||||||
|
current_account = e.account;
|
||||||
|
try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{e.account});
|
||||||
|
}
|
||||||
|
|
||||||
|
var price_buf: [24]u8 = undefined;
|
||||||
|
const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(e.price)}) catch "$?";
|
||||||
|
const note_display = e.note orelse "";
|
||||||
|
|
||||||
|
if (e.price_date) |pd| {
|
||||||
|
var date_buf: [10]u8 = undefined;
|
||||||
|
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{pd}) catch "????-??-??";
|
||||||
|
try out.print(" {s:<14} {s:<16} {s:>12} {s} ", .{ e.symbol, note_display, price_str, date_str });
|
||||||
|
const clr = stalenessColor(e.age_days.?, stale_days);
|
||||||
|
try cli.printFg(out, color, clr, "({d} days)\n", .{@as(u32, @intCast(e.age_days.?))});
|
||||||
|
} else {
|
||||||
|
try out.print(" {s:<14} {s:<16} {s:>12} ", .{ e.symbol, note_display, price_str });
|
||||||
|
try cli.printFg(out, color, cli.CLR_NEGATIVE, "(no price_date set)\n", .{});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1397,6 +1594,41 @@ fn runHygieneCheck(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Section 1b: manual price changed without bumping price_date ──
|
||||||
|
//
|
||||||
|
// Catches the recurring "I updated the price but forgot the
|
||||||
|
// date" mistake at the moment it matters — when `audit` runs
|
||||||
|
// against the working tree before a commit. Diffs the on-disk
|
||||||
|
// portfolio against HEAD; a lot whose `price` moved while its
|
||||||
|
// `price_date` stayed put is flagged. Silent when there's no
|
||||||
|
// committed version to compare against (not a repo / new file)
|
||||||
|
// and when no such mismatch exists.
|
||||||
|
if (committed_portfolio) |cp| {
|
||||||
|
var mismatches = try findPriceDateMismatches(allocator, cp, portfolio, as_of);
|
||||||
|
defer mismatches.deinit(allocator);
|
||||||
|
std.mem.sort(PriceDateMismatch, mismatches.items, {}, mismatchLessThan);
|
||||||
|
|
||||||
|
if (mismatches.items.len > 0) {
|
||||||
|
try out.print("\n", .{});
|
||||||
|
try cli.printFg(out, color, cli.CLR_MUTED, " Manual price changed without updating price_date (working tree vs HEAD)\n", .{});
|
||||||
|
var current_account: ?[]const u8 = null;
|
||||||
|
for (mismatches.items) |m| {
|
||||||
|
if (current_account == null or !std.mem.eql(u8, current_account.?, m.account)) {
|
||||||
|
current_account = m.account;
|
||||||
|
try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{m.account});
|
||||||
|
}
|
||||||
|
var old_buf: [24]u8 = undefined;
|
||||||
|
var new_buf: [24]u8 = undefined;
|
||||||
|
var date_buf: [10]u8 = undefined;
|
||||||
|
const old_str = std.fmt.bufPrint(&old_buf, "{f}", .{Money.from(m.old_price)}) catch "$?";
|
||||||
|
const new_str = std.fmt.bufPrint(&new_buf, "{f}", .{Money.from(m.new_price)}) catch "$?";
|
||||||
|
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{m.price_date}) catch "????-??-??";
|
||||||
|
try out.print(" {s:<14} {s} → {s} ", .{ m.symbol, old_str, new_str });
|
||||||
|
try cli.printFg(out, color, cli.CLR_WARNING, "price_date still {s} — bump it\n", .{date_str});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Find accounts modified in working copy (uncommitted changes)
|
// Find accounts modified in working copy (uncommitted changes)
|
||||||
var working_copy_modified = std.StringHashMap(void).init(allocator);
|
var working_copy_modified = std.StringHashMap(void).init(allocator);
|
||||||
defer working_copy_modified.deinit();
|
defer working_copy_modified.deinit();
|
||||||
|
|
@ -2235,6 +2467,49 @@ test "option delta tracking in compareAccounts" {
|
||||||
try std.testing.expect(found_option);
|
try std.testing.expect(found_option);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "compareAccounts: sub-dollar cash drift is flagged (cash matches to the penny)" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
|
// Portfolio cash $38.75; Fidelity reports $38.97 — a $0.22
|
||||||
|
// money-market dividend accrual. It's below the $1 securities
|
||||||
|
// tolerance, but cash carries no NAV rounding, so it must match to
|
||||||
|
// the penny rather than be silently swallowed.
|
||||||
|
var lots = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "FDRXX", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample 401k BL" },
|
||||||
|
};
|
||||||
|
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
|
||||||
|
var brokerage = [_]BrokeragePosition{
|
||||||
|
.{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDRXX", .description = "HELD IN MONEY MARKET", .quantity = null, .current_value = 38.97, .cost_basis = null, .is_cash = true },
|
||||||
|
};
|
||||||
|
|
||||||
|
var entries = [_]analysis.AccountTaxEntry{
|
||||||
|
.{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .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 compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 6, 19));
|
||||||
|
defer {
|
||||||
|
for (results) |r| allocator.free(r.comparisons);
|
||||||
|
allocator.free(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||||
|
try std.testing.expect(results[0].has_discrepancies);
|
||||||
|
|
||||||
|
var found_cash = false;
|
||||||
|
for (results[0].comparisons) |cmp| {
|
||||||
|
if (cmp.is_cash) {
|
||||||
|
found_cash = true;
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 0.22), cmp.value_delta.?, 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try std.testing.expect(found_cash);
|
||||||
|
}
|
||||||
|
|
||||||
test "detectBrokerFileKind: fidelity csv" {
|
test "detectBrokerFileKind: fidelity csv" {
|
||||||
const fidelity_header = "Account Number,Account Name,Symbol,Description";
|
const fidelity_header = "Account Number,Account Name,Symbol,Description";
|
||||||
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?);
|
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?);
|
||||||
|
|
@ -2379,6 +2654,184 @@ test "findModifiedAccounts: no changes" {
|
||||||
try std.testing.expectEqual(@as(u32, 0), modified.count());
|
try std.testing.expectEqual(@as(u32, 0), modified.count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── collectStaleManualPrices ─────────────────────────────────
|
||||||
|
|
||||||
|
test "collectStaleManualPrices: dated stale stock lot is flagged with account + age" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const as_of = Date.fromYmd(2026, 6, 1);
|
||||||
|
var lots = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
|
||||||
|
};
|
||||||
|
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
|
||||||
|
var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), stale.items.len);
|
||||||
|
try std.testing.expectEqualStrings("Sample 529", stale.items[0].account);
|
||||||
|
try std.testing.expectEqualStrings("F529A", stale.items[0].symbol);
|
||||||
|
try std.testing.expectEqual(@as(?i32, 31), stale.items[0].age_days);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectStaleManualPrices: price within threshold is skipped" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const as_of = Date.fromYmd(2026, 6, 1);
|
||||||
|
var lots = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 30) },
|
||||||
|
};
|
||||||
|
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectStaleManualPrices: undated manual price is always flagged" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const as_of = Date.fromYmd(2026, 6, 1);
|
||||||
|
var lots = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = null },
|
||||||
|
};
|
||||||
|
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), stale.items.len);
|
||||||
|
try std.testing.expectEqual(@as(?Date, null), stale.items[0].price_date);
|
||||||
|
try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectStaleManualPrices: CDs and cash are excluded" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const as_of = Date.fromYmd(2026, 6, 1);
|
||||||
|
var lots = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2020, 1, 1) },
|
||||||
|
.{ .symbol = "", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cash, .price = 5000.0, .price_date = Date.fromYmd(2020, 1, 1) },
|
||||||
|
};
|
||||||
|
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectStaleManualPrices: closed lot is excluded" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const as_of = Date.fromYmd(2026, 6, 1);
|
||||||
|
var lots = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2020, 1, 1), .close_date = Date.fromYmd(2026, 1, 1), .close_price = 26.0 },
|
||||||
|
};
|
||||||
|
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectStaleManualPrices: lot without a manual price is skipped" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const as_of = Date.fromYmd(2026, 6, 1);
|
||||||
|
var lots = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" },
|
||||||
|
};
|
||||||
|
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── findPriceDateMismatches ──────────────────────────────────
|
||||||
|
|
||||||
|
test "findPriceDateMismatches: price moved but date unchanged is flagged" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const as_of = Date.fromYmd(2026, 6, 1);
|
||||||
|
var head = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
|
||||||
|
};
|
||||||
|
var work = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) },
|
||||||
|
};
|
||||||
|
const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
|
||||||
|
const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
|
||||||
|
|
||||||
|
var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
|
||||||
|
defer m.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), m.items.len);
|
||||||
|
try std.testing.expectEqualStrings("F529A", m.items[0].symbol);
|
||||||
|
try std.testing.expectEqual(@as(f64, 25.0), m.items[0].old_price);
|
||||||
|
try std.testing.expectEqual(@as(f64, 27.5), m.items[0].new_price);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "findPriceDateMismatches: price moved AND date moved (back-date) is not flagged" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const as_of = Date.fromYmd(2026, 6, 1);
|
||||||
|
var head = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
|
||||||
|
};
|
||||||
|
var work = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 29) },
|
||||||
|
};
|
||||||
|
const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
|
||||||
|
const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
|
||||||
|
var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
|
||||||
|
defer m.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), m.items.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "findPriceDateMismatches: unchanged price is not flagged" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const as_of = Date.fromYmd(2026, 6, 1);
|
||||||
|
var lots = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
|
||||||
|
};
|
||||||
|
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
var m = try findPriceDateMismatches(allocator, pf, pf, as_of);
|
||||||
|
defer m.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), m.items.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "findPriceDateMismatches: newly-added lot is not flagged" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const as_of = Date.fromYmd(2026, 6, 1);
|
||||||
|
var head = [_]portfolio_mod.Lot{};
|
||||||
|
var work = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) },
|
||||||
|
};
|
||||||
|
const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
|
||||||
|
const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
|
||||||
|
var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
|
||||||
|
defer m.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), m.items.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "findPriceDateMismatches: undated working lot is left to the stale-price section" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const as_of = Date.fromYmd(2026, 6, 1);
|
||||||
|
var head = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
|
||||||
|
};
|
||||||
|
var work = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = null },
|
||||||
|
};
|
||||||
|
const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
|
||||||
|
const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
|
||||||
|
var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
|
||||||
|
defer m.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), m.items.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "findPriceDateMismatches: CD price change is ignored" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const as_of = Date.fromYmd(2026, 6, 1);
|
||||||
|
var head = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2026, 5, 1) },
|
||||||
|
};
|
||||||
|
var work = [_]portfolio_mod.Lot{
|
||||||
|
.{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10100.0, .price_date = Date.fromYmd(2026, 5, 1) },
|
||||||
|
};
|
||||||
|
const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
|
||||||
|
const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
|
||||||
|
var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
|
||||||
|
defer m.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), m.items.len);
|
||||||
|
}
|
||||||
|
|
||||||
test "hasAccountDiscrepancies" {
|
test "hasAccountDiscrepancies" {
|
||||||
const clean = [_]AccountComparison{.{
|
const clean = [_]AccountComparison{.{
|
||||||
.account_name = "Acct",
|
.account_name = "Acct",
|
||||||
|
|
@ -2736,6 +3189,38 @@ test "compareSchwabSummary: cash mismatch → has_discrepancy true" {
|
||||||
try std.testing.expect(results[0].has_discrepancy);
|
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 = [_]SchwabAccountSummary{
|
||||||
|
.{ .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" {
|
test "compareSchwabSummary: account_number with no match → empty account_name" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const today = Date.fromYmd(2026, 5, 8);
|
const today = Date.fromYmd(2026, 5, 8);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue