add stale harvesting date checks to audit command
This commit is contained in:
parent
e5726feffd
commit
0e1a4862db
4 changed files with 380 additions and 16 deletions
|
|
@ -124,9 +124,21 @@ and the TUI Portfolio tab header when you filter to that account:
|
||||||
|
|
||||||
The date is required -- an undated hand-copied number is
|
The date is required -- an undated hand-copied number is
|
||||||
indistinguishable from a stale one, so zfin won't display it and
|
indistinguishable from a stale one, so zfin won't display it and
|
||||||
[`zfin doctor`](../reference/cli/doctor.md) will tell you. Entries more
|
[`zfin doctor`](../reference/cli/doctor.md) will tell you. The sign is
|
||||||
than 12 months old stop displaying on purpose. The sign is ignored, so
|
ignored, so `-45300` works too.
|
||||||
`-45300` works too.
|
|
||||||
|
You don't have to remember to refresh it. A flagless
|
||||||
|
[`zfin audit`](../reference/cli/audit.md) lists anything more than 90
|
||||||
|
days old, so it rides along with your normal maintenance sweep:
|
||||||
|
|
||||||
|
```
|
||||||
|
Stale harvested figures (>90 days - refresh 'harvested' in accounts.srf)
|
||||||
|
Sample Tax Loss last updated 146 days ago
|
||||||
|
```
|
||||||
|
|
||||||
|
Past 12 months the annotation retires itself -- data that old isn't
|
||||||
|
decision-useful. The audit nag keeps going and says
|
||||||
|
`no longer displayed`, so the figure never disappears silently.
|
||||||
|
|
||||||
**Don't put the number in the account name.** It's tempting -- the name
|
**Don't put the number in the account name.** It's tempting -- the name
|
||||||
already prints in a lot of places -- but the name is a join key, not a
|
already prints in a lot of places -- but the name is a join key, not a
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,11 @@ Usage: zfin audit [opts]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Flagless** runs the hygiene check -- stale manual prices,
|
**Flagless** runs the hygiene check -- stale manual prices,
|
||||||
accounts overdue for update, unhandled stock splits, and
|
accounts overdue for update, stale tax-loss-harvested figures,
|
||||||
auto-discovered brokerage-file candidates. **With brokerage flags**, it
|
unhandled stock splits, and auto-discovered brokerage-file candidates.
|
||||||
reconciles your portfolio against the export (treating the brokerage as
|
**With brokerage flags**, it reconciles your portfolio against the
|
||||||
source of truth) and reports discrepancies.
|
export (treating the brokerage as source of truth) and reports
|
||||||
|
discrepancies.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
|
|
@ -41,6 +42,14 @@ purchase date but haven't opted into automatic split adjustment, in an
|
||||||
date on its `metadata.srf` row -- without it, that holding's shares (and
|
date on its `metadata.srf` row -- without it, that holding's shares (and
|
||||||
every value derived from them) are misstated across the split.
|
every value derived from them) are misstated across the split.
|
||||||
|
|
||||||
|
Accounts carrying a hand-declared
|
||||||
|
[`harvested`](../config/accounts-srf.md#harvested-and-harvested_date)
|
||||||
|
figure get a **Stale harvested figures** section once the figure is more
|
||||||
|
than 90 days old, or if it has no `harvested_date` at all. The section
|
||||||
|
is omitted entirely for portfolios where no account declares one. Past
|
||||||
|
12 months the annotation itself retires, and the nag says
|
||||||
|
`no longer displayed` so it can't vanish without explanation.
|
||||||
|
|
||||||
## Example (hygiene check)
|
## Example (hygiene check)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -120,10 +120,21 @@ It appears in four places:
|
||||||
figure with no date is indistinguishable from a stale one, so zfin
|
figure with no date is indistinguishable from a stale one, so zfin
|
||||||
refuses to show it. [`zfin doctor`](../cli/doctor.md) warns when
|
refuses to show it. [`zfin doctor`](../cli/doctor.md) warns when
|
||||||
`harvested` is set without a date, and when a date is in the future.
|
`harvested` is set without a date, and when a date is in the future.
|
||||||
|
- **`zfin audit` nags after 90 days.** A flagless
|
||||||
|
[`zfin audit`](../cli/audit.md) lists any account whose figure is more
|
||||||
|
than 90 days old under **Stale harvested figures**, so it's part of
|
||||||
|
your normal maintenance sweep. The threshold isn't configurable, and
|
||||||
|
doesn't need to be: only accounts that declare `harvested` are ever
|
||||||
|
considered, so not wanting the nag and not wanting the field are the
|
||||||
|
same choice.
|
||||||
- **Entries older than 12 months stop displaying.** Harvest data that
|
- **Entries older than 12 months stop displaying.** Harvest data that
|
||||||
old isn't decision-useful. This is designed behavior, not an error --
|
old isn't decision-useful. This is designed behavior, not an error --
|
||||||
`doctor` stays quiet about it. Refresh the figure and the date to
|
`doctor` stays quiet about it. Refresh the figure and the date to
|
||||||
bring it back.
|
bring it back.
|
||||||
|
|
||||||
|
The audit nag keeps firing past 12 months, and says
|
||||||
|
`no longer displayed` once the annotation has retired -- otherwise the
|
||||||
|
annotation would simply vanish with nothing to explain why.
|
||||||
- **The sign doesn't matter.** `harvested:num:45300` and
|
- **The sign doesn't matter.** `harvested:num:45300` and
|
||||||
`harvested:num:-45300` are equivalent; the annotation's parentheses
|
`harvested:num:-45300` are equivalent; the annotation's parentheses
|
||||||
already carry the accounting "this is a loss" convention.
|
already carry the accounting "this is a loss" convention.
|
||||||
|
|
@ -131,6 +142,9 @@ It appears in four places:
|
||||||
breakdown value, or the contributions attribution. It is an
|
breakdown value, or the contributions attribution. It is an
|
||||||
annotation, not an input.
|
annotation, not an input.
|
||||||
|
|
||||||
|
The two thresholds together: fresh for 90 days, nagged from 90 to 365,
|
||||||
|
nagged *and* hidden past 365.
|
||||||
|
|
||||||
### Why not put it in the account name?
|
### Why not put it in the account name?
|
||||||
|
|
||||||
Because the account name is a **join key**, not a label. It is matched
|
Because the account name is a **join key**, not a label. It is matched
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
//! When `zfin audit` runs with no brokerage flag, this is what
|
//! When `zfin audit` runs with no brokerage flag, this is what
|
||||||
//! executes: stale-manual-price detection, working-tree-vs-HEAD
|
//! executes: stale-manual-price detection, working-tree-vs-HEAD
|
||||||
//! price/date mismatch detection, account-cadence overdue checks,
|
//! price/date mismatch detection, account-cadence overdue checks,
|
||||||
//! automatic brokerage-file discovery + reconciliation, and the
|
//! stale hand-declared `harvested` figures, automatic brokerage-file
|
||||||
//! large-new-lot transfer nudge.
|
//! discovery + reconciliation, and the large-new-lot transfer nudge.
|
||||||
//!
|
//!
|
||||||
//! The reconciliation in Section 4 delegates to the per-broker
|
//! The reconciliation in Section 5 delegates to the per-broker
|
||||||
//! modules (`fidelity.zig`, `schwab.zig`) and the shared display in
|
//! modules (`fidelity.zig`, `schwab.zig`) and the shared display in
|
||||||
//! `common.zig`; this file owns only the hygiene logic, so a future
|
//! `common.zig`; this file owns only the hygiene logic, so a future
|
||||||
//! `zfin doctor` can reuse it without pulling in the reconciler
|
//! `zfin doctor` can reuse it without pulling in the reconciler
|
||||||
|
|
@ -28,6 +28,7 @@ const test_git = @import("../../testutil/git.zig");
|
||||||
const common = @import("common.zig");
|
const common = @import("common.zig");
|
||||||
const fidelity = @import("fidelity.zig");
|
const fidelity = @import("fidelity.zig");
|
||||||
const schwab = @import("schwab.zig");
|
const schwab = @import("schwab.zig");
|
||||||
|
const fmt = cli.fmt;
|
||||||
|
|
||||||
// ── Hygiene check (flagless audit) ──────────────────────────
|
// ── Hygiene check (flagless audit) ──────────────────────────
|
||||||
|
|
||||||
|
|
@ -43,6 +44,20 @@ const stale_warning_multiplier: u32 = 2; // yellow -> red at 2× threshold
|
||||||
/// pathological case where an account never changed in tracked history
|
/// pathological case where an account never changed in tracked history
|
||||||
/// (so it never resolves) from forcing a scan of the entire repo.
|
/// (so it never resolves) from forcing a scan of the entire repo.
|
||||||
const max_history_commits_scanned: usize = 500;
|
const max_history_commits_scanned: usize = 500;
|
||||||
|
/// Age at which `zfin audit` starts nagging that an account's
|
||||||
|
/// hand-declared `harvested` figure (see `AccountTaxEntry.harvested`)
|
||||||
|
/// needs a refresh.
|
||||||
|
///
|
||||||
|
/// Not configurable, and deliberately so: unlike `update_cadence` -
|
||||||
|
/// which nags every account by default and therefore *needs* a
|
||||||
|
/// per-account silence knob - this section only ever considers accounts
|
||||||
|
/// that explicitly declare `harvested`. Not wanting the nag and not
|
||||||
|
/// wanting the field are the same choice, so the opt-out already exists.
|
||||||
|
///
|
||||||
|
/// 90 days is the same interval as `UpdateCadence.quarterly`, but is
|
||||||
|
/// deliberately an independent constant: retuning the account
|
||||||
|
/// reconciliation cadence shouldn't silently move this.
|
||||||
|
const harvested_stale_days: u32 = 90;
|
||||||
|
|
||||||
/// Type of a discovered brokerage file.
|
/// Type of a discovered brokerage file.
|
||||||
const BrokerFileKind = enum {
|
const BrokerFileKind = enum {
|
||||||
|
|
@ -421,6 +436,98 @@ fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool {
|
||||||
return std.mem.order(u8, a.symbol, b.symbol) == .lt;
|
return std.mem.order(u8, a.symbol, b.symbol) == .lt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One account whose hand-declared `harvested` figure has gone stale
|
||||||
|
/// (or was never dated). `account` borrows from the `AccountMap` it was
|
||||||
|
/// collected from and is valid for that map's lifetime.
|
||||||
|
const StaleHarvest = struct {
|
||||||
|
account: []const u8,
|
||||||
|
/// Days since `harvested_date`; `null` when the entry declares
|
||||||
|
/// `harvested` with no `harvested_date` - the worst case, since an
|
||||||
|
/// undated figure can't be aged and never renders at all.
|
||||||
|
age_days: ?i32,
|
||||||
|
/// True when the figure no longer renders anywhere.
|
||||||
|
///
|
||||||
|
/// Derived by asking `format.fmtHarvestAnnotation` whether it would
|
||||||
|
/// emit anything, NOT by comparing `age_days` against 365. Those two
|
||||||
|
/// disagree at leap-year boundaries, because the formatter gates on
|
||||||
|
/// the calendar-exact `as_of.subtractYears(1)`. Going through the
|
||||||
|
/// formatter makes the "no longer displayed" message definitionally
|
||||||
|
/// true - the nag cannot contradict what the user sees.
|
||||||
|
hidden: bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Collect accounts whose `harvested` figure is older than
|
||||||
|
/// `harvested_stale_days`, or that declare `harvested` with no
|
||||||
|
/// `harvested_date`, for the "Stale harvested figures" hygiene section.
|
||||||
|
///
|
||||||
|
/// Only accounts that explicitly declare `harvested` are considered -
|
||||||
|
/// that declaration IS the opt-in to being nagged, which is why the
|
||||||
|
/// threshold needs no per-account override.
|
||||||
|
///
|
||||||
|
/// Future-dated entries fall out as fresh (their age is negative).
|
||||||
|
/// That's deliberate: `zfin doctor` already reports a future
|
||||||
|
/// `harvested_date` as the config typo it is, and surfacing the same
|
||||||
|
/// mistake a second time in staleness clothing would be worse than
|
||||||
|
/// reporting it once.
|
||||||
|
///
|
||||||
|
/// Caller owns the returned list; `account` fields borrow from
|
||||||
|
/// `account_map`.
|
||||||
|
fn collectStaleHarvested(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
account_map: analysis.AccountMap,
|
||||||
|
as_of: Date,
|
||||||
|
) !std.ArrayList(StaleHarvest) {
|
||||||
|
var out = std.ArrayList(StaleHarvest).empty;
|
||||||
|
errdefer out.deinit(allocator);
|
||||||
|
|
||||||
|
const threshold: i32 = @intCast(harvested_stale_days);
|
||||||
|
for (account_map.entries) |e| {
|
||||||
|
if (e.harvested == null) continue;
|
||||||
|
|
||||||
|
// SAFETY: immediately overwritten by fmtHarvestAnnotation below.
|
||||||
|
var ann_buf: [fmt.harvest_annotation_max_len]u8 = undefined;
|
||||||
|
const hidden = fmt.fmtHarvestAnnotation(&ann_buf, e.harvested, e.harvested_date, as_of).len == 0;
|
||||||
|
|
||||||
|
if (e.harvested_date) |on| {
|
||||||
|
const age = as_of.days - on.days;
|
||||||
|
if (age <= threshold) continue; // fresh enough (or future-dated)
|
||||||
|
try out.append(allocator, .{ .account = e.account, .age_days = age, .hidden = hidden });
|
||||||
|
} else {
|
||||||
|
try out.append(allocator, .{ .account = e.account, .age_days = null, .hidden = hidden });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort stale harvested figures worst-first: undated entries (which
|
||||||
|
/// never render), then oldest, then account name as a stable tiebreak.
|
||||||
|
///
|
||||||
|
/// Deliberately unlike `staleLessThan`, which sorts alphabetically:
|
||||||
|
/// that section emits many rows per account and needs them grouped,
|
||||||
|
/// while this one emits exactly one row per account, so severity order
|
||||||
|
/// is strictly more useful.
|
||||||
|
fn harvestLessThan(_: void, a: StaleHarvest, b: StaleHarvest) bool {
|
||||||
|
if (a.age_days == null and b.age_days != null) return true;
|
||||||
|
if (a.age_days != null and b.age_days == null) return false;
|
||||||
|
if (a.age_days) |ad| {
|
||||||
|
const bd = b.age_days.?;
|
||||||
|
if (ad != bd) return ad > bd;
|
||||||
|
}
|
||||||
|
return std.mem.order(u8, a.account, b.account) == .lt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many accounts declare a `harvested` figure at all. Lets the
|
||||||
|
/// display distinguish "nothing to report because you don't use this
|
||||||
|
/// feature" (print nothing) from "nothing to report because everything
|
||||||
|
/// is current" (print a reassuring `(none)`).
|
||||||
|
fn countHarvestedAccounts(account_map: analysis.AccountMap) usize {
|
||||||
|
var n: usize = 0;
|
||||||
|
for (account_map.entries) |e| {
|
||||||
|
if (e.harvested != null) n += 1;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
/// A lot whose manual `price` moved between HEAD and the working tree
|
/// A lot whose manual `price` moved between HEAD and the working tree
|
||||||
/// while its `price_date` stayed identical - the "bumped the price,
|
/// while its `price_date` stayed identical - the "bumped the price,
|
||||||
/// forgot the date" mistake. String fields borrow from the working-
|
/// forgot the date" mistake. String fields borrow from the working-
|
||||||
|
|
@ -840,7 +947,48 @@ pub fn runHygieneCheck(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Section 3: Discover brokerage files ──
|
// ── Section 3: Stale harvested figures ──
|
||||||
|
//
|
||||||
|
// Accounts whose hand-declared `harvested` figure (accounts.srf) is
|
||||||
|
// older than `harvested_stale_days`, or that declare it with no
|
||||||
|
// `harvested_date`. Unlike Section 2 this ages an explicit date
|
||||||
|
// field rather than walking git history, so the two are separate
|
||||||
|
// sections - one "days ago" column with two meanings would mislead.
|
||||||
|
//
|
||||||
|
// The nag has no upper bound on purpose. Past 12 months the
|
||||||
|
// annotation stops rendering (see `format.fmtHarvestAnnotation`),
|
||||||
|
// and this section is then the only thing that explains where it
|
||||||
|
// went - so it escalates its wording instead of going quiet.
|
||||||
|
{
|
||||||
|
const declared = countHarvestedAccounts(account_map);
|
||||||
|
// Silent for portfolios that don't use the feature at all; once
|
||||||
|
// an account declares a figure, say so either way.
|
||||||
|
if (declared > 0) {
|
||||||
|
var stale_harvest = try collectStaleHarvested(allocator, account_map, as_of);
|
||||||
|
defer stale_harvest.deinit(allocator);
|
||||||
|
std.mem.sort(StaleHarvest, stale_harvest.items, {}, harvestLessThan);
|
||||||
|
|
||||||
|
try out.print("\n", .{});
|
||||||
|
try cli.printFg(out, color, cli.CLR_MUTED, " Stale harvested figures (>{d} days - refresh 'harvested' in accounts.srf)\n", .{harvested_stale_days});
|
||||||
|
|
||||||
|
if (stale_harvest.items.len == 0) {
|
||||||
|
try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{});
|
||||||
|
} else {
|
||||||
|
for (stale_harvest.items) |e| {
|
||||||
|
try out.print(" {s:<32} ", .{e.account});
|
||||||
|
if (e.age_days) |ad| {
|
||||||
|
const clr = stalenessColor(ad, harvested_stale_days);
|
||||||
|
const suffix: []const u8 = if (e.hidden) " - no longer displayed" else "";
|
||||||
|
try cli.printFg(out, color, clr, "last updated {d} days ago{s}\n", .{ @as(u32, @intCast(ad)), suffix });
|
||||||
|
} else {
|
||||||
|
try cli.printFg(out, color, cli.CLR_NEGATIVE, "no harvested_date set\n", .{});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 4: Discover brokerage files ──
|
||||||
|
|
||||||
// Resolve audit directories
|
// Resolve audit directories
|
||||||
const portfolio_dir = std.fs.path.dirnamePosix(portfolio_path) orelse ".";
|
const portfolio_dir = std.fs.path.dirnamePosix(portfolio_path) orelse ".";
|
||||||
|
|
@ -883,7 +1031,7 @@ pub fn runHygieneCheck(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Section 4: Auto-reconcile discovered files ──
|
// ── Section 5: Auto-reconcile discovered files ──
|
||||||
|
|
||||||
if (all_files.items.len > 0) {
|
if (all_files.items.len > 0) {
|
||||||
// Build prices map (shared by all reconciliations)
|
// Build prices map (shared by all reconciliations)
|
||||||
|
|
@ -1022,7 +1170,7 @@ pub fn runHygieneCheck(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Section 5: Large new lots - confirm source ──
|
// ── Section 6: Large new lots - confirm source ──
|
||||||
//
|
//
|
||||||
// Cross-check any new_* Change with value >= threshold against
|
// Cross-check any new_* Change with value >= threshold against
|
||||||
// `transaction_log.srf` (via the shared contributions pipeline).
|
// `transaction_log.srf` (via the shared contributions pipeline).
|
||||||
|
|
@ -1048,7 +1196,7 @@ pub fn runHygieneCheck(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Section 6: Unhandled stock splits ──
|
// ── Section 7: Unhandled stock splits ──
|
||||||
//
|
//
|
||||||
// Held symbols with a split AFTER a lot's purchase date that haven't
|
// Held symbols with a split AFTER a lot's purchase date that haven't
|
||||||
// opted into automatic split adjustment. Each needs a
|
// opted into automatic split adjustment. Each needs a
|
||||||
|
|
@ -1373,6 +1521,187 @@ test "collectStaleManualPrices: lot without a manual price is skipped" {
|
||||||
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
|
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── collectStaleHarvested ────────────────────────────────────
|
||||||
|
|
||||||
|
/// Build an in-memory AccountMap from a literal SRF string, mirroring
|
||||||
|
/// the helper in `analytics/analysis.zig`. Exercises the real parse
|
||||||
|
/// path so these tests can't drift from how `accounts.srf` is read.
|
||||||
|
fn testAccountMap(comptime data: []const u8) !analysis.AccountMap {
|
||||||
|
return analysis.parseAccountsFile(std.testing.allocator, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectStaleHarvested: no account declares harvested -> empty" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
var am = try testAccountMap(
|
||||||
|
\\#!srfv1
|
||||||
|
\\account::Sample Brokerage,tax_type::taxable
|
||||||
|
);
|
||||||
|
defer am.deinit();
|
||||||
|
|
||||||
|
var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), countHarvestedAccounts(am));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectStaleHarvested: figure within the window is fresh" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
var am = try testAccountMap(
|
||||||
|
\\#!srfv1
|
||||||
|
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-06-24
|
||||||
|
);
|
||||||
|
defer am.deinit();
|
||||||
|
|
||||||
|
// 31 days old.
|
||||||
|
var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
|
||||||
|
// ...but it IS declared, so the section still prints "(none)".
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), countHarvestedAccounts(am));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectStaleHarvested: exactly at the threshold is still fresh" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
var am = try testAccountMap(
|
||||||
|
\\#!srfv1
|
||||||
|
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-01-01
|
||||||
|
);
|
||||||
|
defer am.deinit();
|
||||||
|
|
||||||
|
const on = Date.fromYmd(2026, 1, 1);
|
||||||
|
const at_threshold = on.addDays(@intCast(harvested_stale_days));
|
||||||
|
var stale = try collectStaleHarvested(allocator, am, at_threshold);
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
|
||||||
|
|
||||||
|
// One day later it trips.
|
||||||
|
var stale2 = try collectStaleHarvested(allocator, am, at_threshold.addDays(1));
|
||||||
|
defer stale2.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), stale2.items.len);
|
||||||
|
try std.testing.expectEqual(@as(?i32, @intCast(harvested_stale_days + 1)), stale2.items[0].age_days);
|
||||||
|
try std.testing.expect(!stale2.items[0].hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectStaleHarvested: undated figure is flagged with a null age" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
var am = try testAccountMap(
|
||||||
|
\\#!srfv1
|
||||||
|
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300
|
||||||
|
);
|
||||||
|
defer am.deinit();
|
||||||
|
|
||||||
|
var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), stale.items.len);
|
||||||
|
try std.testing.expectEqualStrings("Sample Tax Loss", stale.items[0].account);
|
||||||
|
try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days);
|
||||||
|
// An undated figure never renders, so it's hidden too.
|
||||||
|
try std.testing.expect(stale.items[0].hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectStaleHarvested: future-dated figure is left to doctor" {
|
||||||
|
// A future `harvested_date` is a config typo, and `zfin doctor`
|
||||||
|
// already reports it as one. Surfacing the same mistake here in
|
||||||
|
// staleness clothing would double-nag for a single error.
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
var am = try testAccountMap(
|
||||||
|
\\#!srfv1
|
||||||
|
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2027-06-24
|
||||||
|
);
|
||||||
|
defer am.deinit();
|
||||||
|
|
||||||
|
var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectStaleHarvested: hidden agrees with fmtHarvestAnnotation across the 12-month edge" {
|
||||||
|
// The load-bearing invariant: the "no longer displayed" message must
|
||||||
|
// be true. `hidden` is derived from the formatter rather than from a
|
||||||
|
// day count precisely so the two can't disagree at a leap-year
|
||||||
|
// boundary. Walk a window that straddles the cutoff and assert
|
||||||
|
// agreement on every day.
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
var am = try testAccountMap(
|
||||||
|
\\#!srfv1
|
||||||
|
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2024-02-29
|
||||||
|
);
|
||||||
|
defer am.deinit();
|
||||||
|
|
||||||
|
const on = Date.fromYmd(2024, 2, 29);
|
||||||
|
var offset: i32 = 300;
|
||||||
|
while (offset <= 430) : (offset += 1) {
|
||||||
|
const as_of = on.addDays(offset);
|
||||||
|
var stale = try collectStaleHarvested(allocator, am, as_of);
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), stale.items.len); // always past 90d here
|
||||||
|
|
||||||
|
// SAFETY: immediately overwritten by fmtHarvestAnnotation.
|
||||||
|
var buf: [fmt.harvest_annotation_max_len]u8 = undefined;
|
||||||
|
const rendered = fmt.fmtHarvestAnnotation(&buf, 45_300, on, as_of);
|
||||||
|
try std.testing.expectEqual(rendered.len == 0, stale.items[0].hidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectStaleHarvested: hidden is set once the annotation retires" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
var am = try testAccountMap(
|
||||||
|
\\#!srfv1
|
||||||
|
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2024-06-24
|
||||||
|
);
|
||||||
|
defer am.deinit();
|
||||||
|
|
||||||
|
// ~400 days later: stale AND no longer rendered.
|
||||||
|
var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2025, 7, 29));
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), stale.items.len);
|
||||||
|
try std.testing.expectEqual(@as(?i32, 400), stale.items[0].age_days);
|
||||||
|
try std.testing.expect(stale.items[0].hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "harvestLessThan: undated first, then oldest, then account name" {
|
||||||
|
const undated_b: StaleHarvest = .{ .account = "B", .age_days = null, .hidden = true };
|
||||||
|
const undated_a: StaleHarvest = .{ .account = "A", .age_days = null, .hidden = true };
|
||||||
|
const old: StaleHarvest = .{ .account = "C", .age_days = 400, .hidden = true };
|
||||||
|
const newer: StaleHarvest = .{ .account = "D", .age_days = 100, .hidden = false };
|
||||||
|
|
||||||
|
// Undated outranks any dated entry, however old.
|
||||||
|
try std.testing.expect(harvestLessThan({}, undated_b, old));
|
||||||
|
try std.testing.expect(!harvestLessThan({}, old, undated_b));
|
||||||
|
// Among dated, older first.
|
||||||
|
try std.testing.expect(harvestLessThan({}, old, newer));
|
||||||
|
try std.testing.expect(!harvestLessThan({}, newer, old));
|
||||||
|
// Ties break on account name, so output is stable.
|
||||||
|
try std.testing.expect(harvestLessThan({}, undated_a, undated_b));
|
||||||
|
try std.testing.expect(!harvestLessThan({}, undated_b, undated_a));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "collectStaleHarvested + sort: mixed accounts come out worst-first" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
var am = try testAccountMap(
|
||||||
|
\\#!srfv1
|
||||||
|
\\account::Sample Fresh,tax_type::taxable,harvested:num:1000,harvested_date::2026-07-01
|
||||||
|
\\account::Sample Mild,tax_type::taxable,harvested:num:2000,harvested_date::2026-01-01
|
||||||
|
\\account::Sample Ancient,tax_type::taxable,harvested:num:3000,harvested_date::2024-01-01
|
||||||
|
\\account::Sample Undated,tax_type::taxable,harvested:num:4000
|
||||||
|
\\account::Sample None,tax_type::taxable
|
||||||
|
);
|
||||||
|
defer am.deinit();
|
||||||
|
|
||||||
|
var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
|
||||||
|
defer stale.deinit(allocator);
|
||||||
|
std.mem.sort(StaleHarvest, stale.items, {}, harvestLessThan);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 4), countHarvestedAccounts(am));
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), stale.items.len); // Fresh and None excluded
|
||||||
|
try std.testing.expectEqualStrings("Sample Undated", stale.items[0].account);
|
||||||
|
try std.testing.expectEqualStrings("Sample Ancient", stale.items[1].account);
|
||||||
|
try std.testing.expectEqualStrings("Sample Mild", stale.items[2].account);
|
||||||
|
// Only the ancient one has retired from display.
|
||||||
|
try std.testing.expect(stale.items[1].hidden);
|
||||||
|
try std.testing.expect(!stale.items[2].hidden);
|
||||||
|
}
|
||||||
|
|
||||||
// ── findPriceDateMismatches ──────────────────────────────────
|
// ── findPriceDateMismatches ──────────────────────────────────
|
||||||
|
|
||||||
test "findPriceDateMismatches: price moved but date unchanged is flagged" {
|
test "findPriceDateMismatches: price moved but date unchanged is flagged" {
|
||||||
|
|
@ -1719,7 +2048,7 @@ test "findPriceDateMismatches: duplicate HEAD identity collapses to one entry" {
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 160), mismatches.items[0].new_price, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 160), mismatches.items[0].new_price, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "runHygieneCheck: Section 6 flags an un-opted-in symbol's split, not an opted-in one" {
|
test "runHygieneCheck: Section 7 flags an un-opted-in symbol's split, not an opted-in one" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const io = std.testing.io;
|
const io = std.testing.io;
|
||||||
var tmp = std.testing.tmpDir(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
|
|
@ -1773,7 +2102,7 @@ test "runHygieneCheck: Section 6 flags an un-opted-in symbol's split, not an opt
|
||||||
const output = aw.written();
|
const output = aw.written();
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "Portfolio hygiene") != null);
|
try std.testing.expect(std.mem.indexOf(u8, output, "Portfolio hygiene") != null);
|
||||||
|
|
||||||
// Assert against Section 6 specifically (it's the last section, so
|
// Assert against Section 7 specifically (it's the last section, so
|
||||||
// slice from its header to end) - robust even if NVDA/AMZN surface
|
// slice from its header to end) - robust even if NVDA/AMZN surface
|
||||||
// in an earlier section. The un-opted-in NVDA is listed; the
|
// in an earlier section. The un-opted-in NVDA is listed; the
|
||||||
// opted-in AMZN is not.
|
// opted-in AMZN is not.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue