Compare commits

...

2 commits

Author SHA1 Message Date
0e1a4862db
add stale harvesting date checks to audit command
All checks were successful
Generic zig build / build (push) Successful in 4m56s
Generic zig build / publish-macos (push) Successful in 17s
Generic zig build / deploy (push) Successful in 24s
2026-07-25 10:45:12 -07:00
e5726feffd
first class support for tax loss harvesting amounts 2026-07-25 10:16:28 -07:00
13 changed files with 1388 additions and 41 deletions

View file

@ -99,6 +99,24 @@ Declare it in
it isn't counted as new money. See
[Track contributions](../guides/track-contributions.md#dont-double-count-transfers).
**`compare` / `contributions` shows a big new lot I never bought, and
`audit` flags it under "Large new lots".**
Did you rename an account? The account name is zfin's only identifier
for an account, and it's part of each lot's identity key -- so renaming
one makes every lot in it look like it was closed and re-bought
elsewhere. It's cosmetic and happens only for the one review window
that straddles the rename commit; nothing is corrupted. See
[Renaming an account](../guides/set-up-accounts.md#renaming-an-account)
for the full list of consequences, and why account names should be
chosen to never change.
**I want to see my tax-loss-harvested total, but the account is a
synthetic proxy so zfin can't compute it.**
Record it by hand with
[`harvested` / `harvested_date`](../reference/config/accounts-srf.md#harvested-and-harvested_date)
in `accounts.srf`. Don't encode it in the account name -- see the entry
above for why.
## See also
- [Core concepts](concepts.md) -- the mental model.

View file

@ -67,7 +67,7 @@ account::Old Rollover,tax_type::traditional,update_cadence::none
## 4. Advanced flags
Three optional fields change how analysis and the audit treat an
Four optional fields change how analysis and the audit treat an
account -- see the reference for details:
- **`shielded:bool:false`** -- mark a pre-tax account that is *not*
@ -79,6 +79,9 @@ account -- see the reference for details:
account as real external contributions in
[`zfin contributions`](track-contributions.md), instead of internal
noise.
- **`direct_indexing:bool:true`** -- mark an account whose lots track a
benchmark with tracking-error drift rather than holding it directly,
so routine share reconciliation stops registering as money movement.
- **`audit_large_lot_threshold:num:50000`** -- raise (or lower) the
dollar cutoff at which a flagless [`zfin audit`](../reference/cli/audit.md)
nudges you to confirm a **new lot**'s source. The default is $10,000;
@ -89,6 +92,110 @@ account -- see the reference for details:
account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
```
## 5. Track tax-loss harvested on a synthetic account
If you run a tax-loss-harvesting sleeve (direct indexing, a managed
separate account), you probably don't want its hundreds of churning
positions in `portfolio.srf`. The usual shape is one aggregate lot with
a `ticker::` alias standing in for the whole sleeve:
```srf
symbol::DI-SPX,ticker::SPY,shares:num:1000,open_date::2024-01-15,open_price:num:400,account::Sample Tax Loss
```
That keeps the file readable, but it means zfin has no closed lots to
work from, so it can't compute realized gain/loss -- and the harvested
total is the whole reason the account exists. Record it by hand
instead:
```srf
account::Sample Tax Loss,tax_type::taxable,direct_indexing:bool:true,harvested:num:45300,harvested_date::2026-06-24
```
Next time you check the figure on the brokerage site, update both
fields. It shows up as a compact annotation beside the account name in
`zfin analysis`, the TUI Analysis tab, the TUI account picker (`a`),
and the TUI Portfolio tab header when you filter to that account:
```
By Account
Sample Tax Loss █████████████████████████████ 96.7% $738,930.00 (45k 6/24)
```
The date is required -- an undated hand-copied number is
indistinguishable from a stale one, so zfin won't display it and
[`zfin doctor`](../reference/cli/doctor.md) will tell you. The sign is
ignored, so `-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
already prints in a lot of places -- but the name is a join key, not a
label. See the next section for what a rename costs you.
## Renaming an account
Renaming an account is a bigger deal than it looks, because the account
name is zfin's **only** identifier for an account. There is no separate
account ID. The name is matched byte-exactly against `account::` on
every lot, and it is written into every historical snapshot at the time
that snapshot is taken.
To rename, change it in **both** `accounts.srf` and every `account::`
on your `portfolio.srf` lots, in a single commit. Then expect the
following:
**One-time noise in contributions and compare.** zfin identifies a lot
by `(security_type, symbol, account, open_date, open_price)`. Changing
the account breaks that key on every lot in the account, so
[`zfin contributions`](track-contributions.md) and `zfin compare` see
the old lots vanish and identical new ones appear -- indistinguishable
from closing one account and opening another. Any review window
spanning the rename commit will report phantom new lots and an inflated
contribution total, and a flagless [`zfin audit`](../reference/cli/audit.md)
will list them under **Large new lots - confirm source**. This happens
exactly once, for the one window that straddles the rename. Nothing is
corrupted; just ignore that run.
**Truncated audit-staleness history.** The cadence nag derives each
account's last-updated time by walking `portfolio.srf`'s git history.
Older commits only contain the old name, so the account's update
history restarts at the rename commit. The rename itself registers as
an update, so the account reads as freshly refreshed rather than
falsely overdue.
**A permanent split in per-account snapshot history.** Snapshots
already on disk record the old name and are immutable historical
records -- zfin does not rewrite them. Any per-account historical series
therefore has a discontinuity at the rename date. This one does not
heal.
**Possible renumbering in the TUI account picker.** Shortcut keys are
assigned by list position (`accounts.srf` order, then alphabetical for
anything not listed), so a name that sorts differently can shift the
keys. Cosmetic.
`zfin import` is unaffected: it resolves accounts by
`(institution, account_number)` and looks the name up in
`accounts.srf`, so a consistent rename in both files keeps working.
The practical advice: **pick account names that never need to change.**
Don't encode anything that varies -- balances, dates, harvested totals,
"current"/"old" markers. Values that change belong in fields
(see [`harvested`](../reference/config/accounts-srf.md#harvested-and-harvested_date)),
not in the key.
## Example (from `examples/pre-retirement-both`)
```srf

View file

@ -8,10 +8,11 @@ Usage: zfin audit [opts]
```
**Flagless** runs the hygiene check -- stale manual prices,
accounts overdue for update, unhandled stock splits, and
auto-discovered brokerage-file candidates. **With brokerage flags**, it
reconciles your portfolio against the export (treating the brokerage as
source of truth) and reports discrepancies.
accounts overdue for update, stale tax-loss-harvested figures,
unhandled stock splits, and auto-discovered brokerage-file candidates.
**With brokerage flags**, it reconciles your portfolio against the
export (treating the brokerage as source of truth) and reports
discrepancies.
## 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
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)
```bash

View file

@ -20,7 +20,11 @@ Four sections, each line tagged `OK` / `INFO` / `WARN` / `FAIL`:
from, and does it parse?
- **Cross-checks** -- do `accounts.srf` / `metadata.srf` /
`transaction_log.srf` reference entries that actually exist (e.g.
every account used by a lot has an `accounts.srf` entry)?
every account used by a lot has an `accounts.srf` entry)? Also
validates the hand-maintained
[`harvested` / `harvested_date`](../config/accounts-srf.md#harvested-and-harvested_date)
pair: an amount with no date, or a date in the future, won't display,
so `doctor` says so.
- **Environment** -- cache size, staleness of the hand-maintained data
tables, and `ZFIN_SERVER` reachability and version.
- **Capabilities** -- which API keys are set and what each enables (or
@ -56,6 +60,7 @@ Files
Cross-checks
[OK ] accounts.srf coverage: all referenced entries present
[INFO] accounts.srf harvested: no accounts declare a harvested figure
[OK ] metadata.srf coverage: all referenced entries present
[INFO] transaction_log.srf references: skipped (transaction_log.srf not loaded)

View file

@ -35,6 +35,8 @@ account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT0
| `direct_indexing` | bool | No | `false` | Marks an account whose lots track a benchmark with tracking-error drift (loosens contribution/audit tolerances). |
| `shielded` | bool | No | (derived) | Umbrella-exposure override (see below). |
| `audit_large_lot_threshold` | num | No | `10000` | Per-account dollar cutoff for the audit "Large new lots" nudge (see below). Must be positive. |
| `harvested` | num | No | -- | Hand-declared cumulative tax-loss-harvested figure, for accounts whose realized P&L zfin cannot derive (see below). |
| `harvested_date` | date | No | -- | `YYYY-MM-DD` you last refreshed `harvested`. Required for it to display. |
## Tax types
@ -75,8 +77,84 @@ Accounts you don't list, or list without the field, use that default.
The value must be **positive** -- zero or a negative number is rejected
at load time and the account falls back to the default.
## `update_cadence` and the audit nag
## `harvested` and `harvested_date`
Some accounts hold a number that matters to you but that zfin has no
way to compute. The motivating case is a **synthetic direct-indexing
account**: a tax-loss-harvesting sleeve holding hundreds of individual
positions that churn constantly. Rather than model all of them, you
track the sleeve as a single aggregate lot with a `ticker::` alias:
```srf
#!srfv1
symbol::DI-SPX,ticker::SPY,shares:num:1000,open_date::2024-01-15,open_price:num:400,account::Sample Tax Loss
```
That keeps the portfolio readable, but it means there are no closed
lots, so zfin cannot derive realized gain/loss -- and the harvested
total is the entire point of such an account. `harvested` is where you
park the figure you read off the brokerage site:
```srf
#!srfv1
account::Sample Tax Loss,tax_type::taxable,direct_indexing:bool:true,harvested:num:45300,harvested_date::2026-06-24
```
It renders as a compact annotation next to the account name:
```
By Account
Sample Tax Loss █████████████████████████████ 96.7% $738,930.00 (45k 6/24)
```
It appears in four places:
- the **By Account** breakdown of [`zfin analysis`](../cli/analysis.md)
- the same breakdown in the TUI's Analysis tab
- the TUI's account picker (`a`), beside the account number
- the TUI Portfolio tab header when an account filter is active
### Rules
- **`harvested_date` is required for it to display.** A hand-copied
figure with no date is indistinguishable from a stale one, so zfin
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.
- **`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
old isn't decision-useful. This is designed behavior, not an error --
`doctor` stays quiet about it. Refresh the figure and the date to
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
`harvested:num:-45300` are equivalent; the annotation's parentheses
already carry the accounting "this is a loss" convention.
- **It is display-only.** It never enters a total, a weight, a
breakdown value, or the contributions attribution. It is an
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?
Because the account name is a **join key**, not a label. It is matched
byte-exactly against `account::` on every lot, and it is written into
every historical snapshot. Encoding a changing number in it means
renaming the account every time the number changes, and every rename
costs you history -- see
[Renaming an account](../../guides/set-up-accounts.md#renaming-an-account).
## `update_cadence` and the audit nag
[`zfin audit`](../cli/audit.md) (run flagless) flags accounts you
haven't refreshed within their cadence window: `weekly` = 7 days,
`monthly` = 30, `quarterly` = 90, `none` = never nag. The default is

View file

@ -10,6 +10,7 @@ const ClassificationMap = @import("../models/classification.zig").Classification
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;
const Portfolio = @import("../models/portfolio.zig").Portfolio;
const Date = @import("../Date.zig");
const fmt = @import("../format.zig");
const log = std.log.scoped(.accounts);
@ -18,6 +19,48 @@ pub const BreakdownItem = struct {
label: []const u8,
value: f64, // dollar amount
weight: f64, // fraction of total (0.0 - 1.0)
/// Optional compact suffix rendered after the dollar value.
/// Only the "By Account" breakdown populates it (see
/// `annotateAccountBreakdown`); every other breakdown leaves it
/// null.
annotation: ?Annotation = null,
};
/// Pre-rendered compact annotation for a breakdown row.
///
/// A value type on purpose: `BreakdownItem.label` is *borrowed* (it
/// points into the aggregation map's keys, which point into lot /
/// accounts.srf strings) and `AnalysisResult.deinit` frees only the
/// item slices, never any strings. An allocated annotation would break
/// that invariant and force an asymmetric free loop over one of the
/// five breakdown slices. Inlining the bytes keeps "BreakdownItem owns
/// nothing" true.
pub const Annotation = struct {
/// Inline capacity in bytes. Sized for the longest annotation any
/// breakdown produces today - `format.harvest_annotation_max_len`
/// ("(999.9M 12/31)", 14 bytes) - rounded up with a little
/// headroom. `annotateAccountBreakdown` asserts the fit at comptime,
/// so widening a producer's format without widening this is a build
/// error rather than a silently-dropped annotation.
pub const capacity = 16;
buf: [capacity]u8,
len: u8,
/// Build from a rendered string. Returns null for an empty string
/// (the "nothing to render" signal from the formatters) so callers
/// can assign the result straight into `BreakdownItem.annotation`.
/// Also returns null rather than truncating when `s` doesn't fit.
pub fn from(s: []const u8) ?Annotation {
if (s.len == 0 or s.len > capacity) return null;
var a: Annotation = .{ .buf = @splat(0), .len = @intCast(s.len) };
@memcpy(a.buf[0..s.len], s);
return a;
}
pub fn slice(self: *const Annotation) []const u8 {
return self.buf[0..self.len];
}
};
/// Tax type classification for accounts.
@ -113,6 +156,37 @@ pub const AccountTaxEntry = struct {
/// time (warned + treated as unset) since zero would flag every
/// new lot and negative is meaningless.
audit_large_lot_threshold: ?f64 = null,
/// Cumulative tax-loss-harvested figure for this account, declared
/// by hand because zfin cannot derive it.
///
/// The motivating case is a synthetic direct-indexing account (see
/// `direct_indexing`): the portfolio models it as one aggregate lot
/// with a `ticker::` alias, deliberately NOT enumerating the
/// hundreds of underlying positions. Without per-position detail
/// there are no closed lots, so `Lot.realizedGainLoss` has nothing
/// to work from - yet the harvested total is the whole point of
/// such an account. This field is where you park the number you
/// read off the brokerage site.
///
/// Display-only. It never enters any total, weight, or breakdown
/// value; it is rendered as a compact annotation next to the
/// account name (`format.fmtHarvestAnnotation`). Sign-insensitive
/// on read - `harvested:num:45300` and `harvested:num:-45300` are
/// equivalent, since the annotation's parens already carry the
/// accounting "this is a loss" convention.
///
/// Requires `harvested_date` to render; see that field.
harvested: ?f64 = null,
/// The "as of" date for `harvested` - when you last copied the
/// figure from the brokerage. Required for the annotation to
/// render at all, because a hand-copied number with no date is
/// indistinguishable from a stale one.
///
/// Entries older than 12 months stop rendering: harvest data that
/// old is not decision-useful, and the cutoff is what lets the
/// annotation show a bare `M/D` with no year (within a trailing
/// year, each month/day pair occurs at most once).
harvested_date: ?Date = null,
};
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
@ -230,11 +304,47 @@ pub const AccountMap = struct {
}
return null;
}
/// Hand-declared tax-loss-harvested figure for `account`, plus the
/// date it was last refreshed. See `AccountTaxEntry.harvested`.
///
/// Returns null when the account isn't in the map or its entry
/// omits `harvested`. The amount is already sign-normalized by the
/// parser; `as_of` may still be null (the caller decides whether a
/// dateless figure is renderable - `format.fmtHarvestAnnotation`
/// says no).
pub fn harvestedFor(self: AccountMap, account: []const u8) ?struct { amount: f64, as_of: ?Date } {
for (self.entries) |e| {
if (std.mem.eql(u8, e.account, account)) {
const amt = e.harvested orelse return null;
return .{ .amount = amt, .as_of = e.harvested_date };
}
}
return null;
}
};
/// Populate `items[*].annotation` for a "By Account" breakdown with each
/// account's hand-declared tax-loss-harvested figure. Rows for accounts
/// with no `harvested` entry (or a stale / future-dated / dateless one)
/// are left null and render nothing.
///
/// `as_of` is the reference date for the 12-month staleness window, not
/// necessarily today: a back-dated analysis should not surface a harvest
/// figure recorded after the date being analyzed.
pub fn annotateAccountBreakdown(items: []BreakdownItem, account_map: AccountMap, as_of: Date) void {
comptime std.debug.assert(fmt.harvest_annotation_max_len <= Annotation.capacity);
for (items) |*item| {
const h = account_map.harvestedFor(item.label) orelse continue;
var buf: [fmt.harvest_annotation_max_len]u8 = undefined;
item.annotation = Annotation.from(fmt.fmtHarvestAnnotation(&buf, h.amount, h.as_of, as_of));
}
}
/// Parse an accounts.srf file into an AccountMap.
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>][,<flags>]
/// where the optional flags include `audit_large_lot_threshold:num:<DOLLARS>`.
/// where the optional flags include `audit_large_lot_threshold:num:<DOLLARS>`,
/// `harvested:num:<DOLLARS>` and `harvested_date::<YYYY-MM-DD>`.
pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap {
var entries = std.ArrayList(AccountTaxEntry).empty;
errdefer {
@ -266,6 +376,21 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
break :blk null;
} else null;
// `harvested` is a magnitude: the annotation's parens carry the
// "this is a loss" convention, so accept either sign from the
// user and normalize. A non-finite value can only arrive from a
// hand-typed `inf`/`nan`; there is nothing sensible to display
// for it, so drop the field rather than render garbage.
const harvested: ?f64 = if (entry.harvested) |h| blk: {
if (std.math.isFinite(h)) break :blk @abs(h);
// Silent under `zig build test`: the parser's own tests feed
// non-finite values on purpose, and the warn spam pollutes
// test output.
if (!builtin.is_test)
log.warn("accounts.srf: account '{s}': harvested must be a finite number (got {d}); ignoring", .{ entry.account, h });
break :blk null;
} else null;
try entries.append(allocator, .{
.account = try allocator.dupe(u8, entry.account),
.tax_type = entry.tax_type,
@ -276,6 +401,8 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
.direct_indexing = entry.direct_indexing,
.shielded = entry.shielded,
.audit_large_lot_threshold = lot_threshold,
.harvested = harvested,
.harvested_date = entry.harvested_date,
});
}
@ -797,11 +924,18 @@ pub fn analyzePortfolio(
// Convert maps to sorted slices
const total = if (total_portfolio_value > 0) total_portfolio_value else 1.0;
// Hoisted so the per-account harvested annotations can be stamped
// in before the result is returned. `analyzePortfolio` already has
// both inputs the annotation needs (`account_map` and `as_of`), so
// no renderer needs to learn about accounts.srf.
const account_items = try mapToSortedBreakdown(allocator, acct_map, total);
if (account_map) |am| annotateAccountBreakdown(account_items, am, as_of);
return .{
.asset_category = try mapToSortedBreakdown(allocator, asset_cat_map, total),
.sector = try mapToSortedBreakdown(allocator, sector_map, total),
.geo = try mapToSortedBreakdown(allocator, geo_map, total),
.account = try mapToSortedBreakdown(allocator, acct_map, total),
.account = account_items,
.tax_type = try mapToSortedBreakdown(allocator, tax_map, total),
.unclassified = try unclassified_list.toOwnedSlice(allocator),
.total_value = total_portfolio_value,
@ -1062,6 +1196,163 @@ test "parseAccountsFile: non-positive audit_large_lot_threshold is rejected -> n
}
}
// harvested / harvested_date
test "parseAccountsFile: harvested omitted -> harvestedFor returns null" {
const data =
\\#!srfv1
\\account::Sample Brokerage,tax_type::taxable
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expect(am.entries[0].harvested == null);
try std.testing.expect(am.entries[0].harvested_date == null);
try std.testing.expect(am.harvestedFor("Sample Brokerage") == null);
}
test "parseAccountsFile: harvested + harvested_date parse and look up by account" {
const data =
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,direct_indexing:bool:true,harvested:num:45300,harvested_date::2026-06-24
\\account::Sample Brokerage,tax_type::taxable
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
const h = am.harvestedFor("Sample Tax Loss").?;
try std.testing.expectApproxEqAbs(@as(f64, 45_300), h.amount, 0.001);
try std.testing.expect(h.as_of.?.eql(Date.fromYmd(2026, 6, 24)));
// Sibling account is unaffected.
try std.testing.expect(am.harvestedFor("Sample Brokerage") == null);
// Unknown account.
try std.testing.expect(am.harvestedFor("Nope") == null);
}
test "parseAccountsFile: negative harvested is normalized to a magnitude" {
// The annotation's parens carry the "this is a loss" convention, so
// a user writing the figure as a negative gets the same result.
inline for (.{ "45300", "-45300" }) |written| {
const data = "#!srfv1\naccount::Sample Tax Loss,tax_type::taxable,harvested:num:" ++ written ++ ",harvested_date::2026-06-24\n";
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectApproxEqAbs(@as(f64, 45_300), am.harvestedFor("Sample Tax Loss").?.amount, 0.001);
}
}
test "parseAccountsFile: zero harvested is kept (means nothing harvested yet)" {
const data =
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:0,harvested_date::2026-06-24
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectApproxEqAbs(@as(f64, 0), am.harvestedFor("Sample Tax Loss").?.amount, 0.001);
}
test "parseAccountsFile: harvested without harvested_date parses but is not renderable" {
// Kept in the map (doctor nags about the missing date); the
// annotation formatter is what refuses to render it.
const data =
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
const h = am.harvestedFor("Sample Tax Loss").?;
try std.testing.expectApproxEqAbs(@as(f64, 45_300), h.amount, 0.001);
try std.testing.expect(h.as_of == null);
var buf: [fmt.harvest_annotation_max_len]u8 = undefined;
try std.testing.expectEqualStrings("", fmt.fmtHarvestAnnotation(&buf, h.amount, h.as_of, Date.fromYmd(2026, 7, 25)));
}
test "parseAccountsFile: harvested_date without harvested is inert" {
const data =
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested_date::2026-06-24
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expect(am.harvestedFor("Sample Tax Loss") == null);
try std.testing.expect(am.entries[0].harvested_date != null);
}
test "parseAccountsFile: non-finite harvested is rejected -> null, account still parses" {
inline for (.{ "nan", "inf", "-inf" }) |bad| {
const data = "#!srfv1\naccount::Sample Tax Loss,tax_type::taxable,harvested:num:" ++ bad ++ ",harvested_date::2026-06-24\n";
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 1), am.entries.len);
try std.testing.expectEqual(TaxType.taxable, am.entries[0].tax_type);
try std.testing.expect(am.harvestedFor("Sample Tax Loss") == null);
}
}
test "Annotation.from: empty string yields null, round-trips otherwise" {
try std.testing.expect(Annotation.from("") == null);
// Longer than the inline buffer is dropped rather than truncated.
try std.testing.expect(Annotation.from("x" ** (Annotation.capacity + 1)) == null);
const a = Annotation.from("(45k 6/24)").?;
try std.testing.expectEqualStrings("(45k 6/24)", a.slice());
// Exactly-buffer-sized input fits.
const full = Annotation.from("x" ** Annotation.capacity).?;
try std.testing.expectEqualStrings("x" ** Annotation.capacity, full.slice());
// Every producer's widest output must fit - mirrored by the comptime
// assert in annotateAccountBreakdown.
try std.testing.expect(fmt.harvest_annotation_max_len <= Annotation.capacity);
}
test "annotateAccountBreakdown: stamps only accounts with fresh harvested data" {
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-06-24
\\account::Sample Stale,tax_type::taxable,harvested:num:12000,harvested_date::2024-01-15
\\account::Sample Dateless,tax_type::taxable,harvested:num:9000
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
var items = [_]BreakdownItem{
.{ .label = "Sample Tax Loss", .value = 412_300, .weight = 0.4 },
.{ .label = "Sample Stale", .value = 100_000, .weight = 0.2 },
.{ .label = "Sample Dateless", .value = 100_000, .weight = 0.2 },
.{ .label = "Sample Brokerage", .value = 100_000, .weight = 0.1 },
.{ .label = "Not In accounts.srf", .value = 50_000, .weight = 0.1 },
};
annotateAccountBreakdown(&items, am, Date.fromYmd(2026, 7, 25));
try std.testing.expectEqualStrings("(45k 6/24)", items[0].annotation.?.slice());
try std.testing.expect(items[1].annotation == null); // >12 months old
try std.testing.expect(items[2].annotation == null); // no date
try std.testing.expect(items[3].annotation == null); // no harvested field
try std.testing.expect(items[4].annotation == null); // not in accounts.srf
}
test "annotateAccountBreakdown: as_of is the reference date, not necessarily today" {
// A back-dated analysis must not surface a harvest figure recorded
// after the date being analyzed.
var am = try testParseAccountMap(
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-06-24
);
defer am.deinit();
var items = [_]BreakdownItem{.{ .label = "Sample Tax Loss", .value = 1000, .weight = 1.0 }};
annotateAccountBreakdown(&items, am, Date.fromYmd(2026, 1, 1));
try std.testing.expect(items[0].annotation == null);
}
// umbrellaExposure
/// Helper: build an in-memory AccountMap from a literal SRF

View file

@ -268,7 +268,13 @@ pub fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.B
if (color) try fmt.ansiSetFg(out, cli.CLR_ACCENT[0], cli.CLR_ACCENT[1], cli.CLR_ACCENT[2]);
try out.writeAll(bar);
if (color) try fmt.ansiReset(out);
try out.print(" {d:>5.1}% {f}\n", .{ pct, Money.from(item.value) });
try out.print(" {d:>5.1}% {f}", .{ pct, Money.from(item.value) });
// Optional trailing annotation (the "By Account" breakdown's
// per-account harvested figure). Appended after the value so it
// can't disturb the label or bar alignment. Unstyled - the
// parens are the affordance.
if (item.annotation) |ann| try out.print(" {s}", .{ann.slice()});
try out.print("\n", .{});
}
}
@ -342,6 +348,38 @@ test "printBreakdownSection with color emits ANSI" {
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
}
test "printBreakdownSection: annotation renders after the value" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const items = [_]zfin.analysis.BreakdownItem{
.{
.label = "Sample Tax Loss",
.weight = 0.40,
.value = 412_300.0,
.annotation = zfin.analysis.Annotation.from("(45k 6/24)"),
},
};
try printBreakdownSection(&w, &items, 24, 30, false);
const out = w.buffered();
const value_at = std.mem.indexOf(u8, out, "$412,300.00").?;
const ann_at = std.mem.indexOf(u8, out, "(45k 6/24)").?;
try std.testing.expect(ann_at > value_at);
// Exactly one row, annotation last before the newline.
try std.testing.expect(std.mem.endsWith(u8, std.mem.trimEnd(u8, out, "\n"), "(45k 6/24)"));
}
test "printBreakdownSection: null annotation adds nothing" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const items = [_]zfin.analysis.BreakdownItem{
.{ .label = "Sample Brokerage", .weight = 0.40, .value = 412_300.0 },
};
try printBreakdownSection(&w, &items, 24, 30, false);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "(") == null);
try std.testing.expect(std.mem.endsWith(u8, std.mem.trimEnd(u8, out, "\n"), "$412,300.00"));
}
test "display shows all sections" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -3,10 +3,10 @@
//! When `zfin audit` runs with no brokerage flag, this is what
//! executes: stale-manual-price detection, working-tree-vs-HEAD
//! price/date mismatch detection, account-cadence overdue checks,
//! automatic brokerage-file discovery + reconciliation, and the
//! large-new-lot transfer nudge.
//! stale hand-declared `harvested` figures, automatic brokerage-file
//! 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
//! `common.zig`; this file owns only the hygiene logic, so a future
//! `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 fidelity = @import("fidelity.zig");
const schwab = @import("schwab.zig");
const fmt = cli.fmt;
// 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
/// (so it never resolves) from forcing a scan of the entire repo.
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.
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;
}
/// 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
/// while its `price_date` stayed identical - the "bumped the price,
/// 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
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) {
// 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
// `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
// 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);
}
// 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
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);
}
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 io = std.testing.io;
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();
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
// in an earlier section. The un-opted-in NVDA is listed; the
// opted-in AMZN is not.

View file

@ -1191,13 +1191,20 @@ const ChangeKind = enum {
cd_matured, // lot disappeared: CD with maturity_date <= today
cd_removed_early, // lot disappeared: CD with maturity_date > today
lot_removed, // lot disappeared: stock/cash/option
/// Strict lot key broke (open_date / open_price / account rewritten)
/// but the same (security_type, symbol, account) reappears on the
/// other side with approximately the same share total. Classified
/// as a lot edit - not counted as contribution, not counted as
/// disposal. Fixes the phantom-contribution bug from reconciliation
/// tweaks, CD auto-renewals rewriting `open_date`, and account
/// renames that shift every lot in the account under a new name.
/// Strict lot key broke (open_date / open_price / symbol rewritten)
/// but the same (security_type, priceSymbol, account) reappears on
/// the other side with approximately the same share total.
/// Classified as a lot edit - not counted as contribution, not
/// counted as disposal. Fixes the phantom-contribution bug from
/// reconciliation tweaks, CD auto-renewals rewriting `open_date`,
/// and symbol-alias rewrites (`symbol::SPY` -> `symbol::DI-SPX,
/// ticker::SPY`).
///
/// Deliberately does NOT cover account renames: `secondaryKey`
/// includes the account, so renaming one breaks both keys and the
/// rename is indistinguishable from closing one account and opening
/// another with the same positions. See the "account rename is NOT
/// collapsed" test below and the TODO.md entry for the known fix.
lot_edited,
price_only, // same key, price:: field changed, no share change
flagged, // any other shape of edit
@ -4373,7 +4380,9 @@ test "computeReport: account rename is NOT collapsed (documented limitation)" {
// one account, opening another with the same positions). Account
// renames must therefore be handled manually: either avoid them
// during a review window, or accept the phantom attribution for
// that week. Tracked in TODO.md.
// that week. Tracked in TODO.md; the user-facing consequences are
// documented in docs/guides/set-up-accounts.md ("Renaming an
// account").
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const allocator = arena_state.allocator();

View file

@ -189,6 +189,74 @@ fn coverageCheck(
};
}
/// Validate the hand-maintained `harvested` / `harvested_date` pair on
/// each `accounts.srf` entry. Pure over the parsed map so every branch
/// is unit-testable.
///
/// Two failure modes, both hand-edit typos rather than corruption, so
/// both are WARN and neither stops anything:
///
/// - `harvested` with no `harvested_date`: renders nothing, because a
/// hand-copied figure with no date is indistinguishable from a
/// stale one. Silently invisible is worse than a nag.
/// - `harvested_date` in the future relative to `as_of`: also renders
/// nothing, and is almost always a year typo.
///
/// A `harvested_date` older than 12 months is NOT flagged - that's the
/// designed behavior (the annotation retires itself), not an error.
fn harvestedFieldChecks(arena: std.mem.Allocator, am: analysis.AccountMap, as_of: Date) !Check {
var undated: std.ArrayList([]const u8) = .empty;
var future: std.ArrayList([]const u8) = .empty;
var configured: usize = 0;
for (am.entries) |e| {
if (e.harvested == null) continue;
configured += 1;
const on = e.harvested_date orelse {
try undated.append(arena, e.account);
continue;
};
if (as_of.lessThan(on)) try future.append(arena, e.account);
}
if (configured == 0) {
return .{ .status = .info, .label = "accounts.srf harvested", .detail = "no accounts declare a harvested figure" };
}
if (undated.items.len == 0 and future.items.len == 0) {
return .{
.status = .ok,
.label = "accounts.srf harvested",
.detail = try std.fmt.allocPrint(arena, "{d} account(s) with a dated harvested figure", .{configured}),
};
}
if (undated.items.len > 0 and future.items.len > 0) {
return .{
.status = .warn,
.label = "accounts.srf harvested",
.detail = try std.fmt.allocPrint(arena, "missing harvested_date: {s}; harvested_date in the future: {s}", .{
try joinCapped(arena, undated.items, 6),
try joinCapped(arena, future.items, 6),
}),
};
}
if (undated.items.len > 0) {
return .{
.status = .warn,
.label = "accounts.srf harvested",
.detail = try std.fmt.allocPrint(arena, "harvested set but harvested_date missing (annotation will not render): {s}", .{
try joinCapped(arena, undated.items, 6),
}),
};
}
return .{
.status = .warn,
.label = "accounts.srf harvested",
.detail = try std.fmt.allocPrint(arena, "harvested_date is in the future (annotation will not render): {s}", .{
try joinCapped(arena, future.items, 6),
}),
};
}
/// Build the per-key capability checks from a resolved `Config`. Pure
/// over `Config` (no I/O), so every branch is unit-testable by
/// constructing a `Config` literal. Present keys -> OK with the
@ -404,8 +472,10 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
const lot_accts = try uniqueAccounts(arena, all_lots.items);
const known = try accountNames(arena, am);
try checks.append(arena, try coverageCheck(arena, "accounts.srf coverage", lot_accts, known, "accounts in portfolio missing from accounts.srf"));
try checks.append(arena, try harvestedFieldChecks(arena, am, ctx.today));
} else {
try checks.append(arena, .{ .status = .info, .label = "accounts.srf coverage", .detail = "skipped (accounts.srf not loaded)" });
try checks.append(arena, .{ .status = .info, .label = "accounts.srf harvested", .detail = "skipped (accounts.srf not loaded)" });
}
// Metadata (classification) coverage for classifiable holdings.
@ -770,6 +840,90 @@ test "coverageCheck: dedupes repeated missing names" {
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, c.detail, "Sample HSA"));
}
test "harvestedFieldChecks: no account declares harvested -> info" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var am = try analysis.parseAccountsFile(arena.allocator(),
\\#!srfv1
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
const c = try harvestedFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25));
try testing.expectEqual(Status.info, c.status);
}
test "harvestedFieldChecks: dated figures are ok and counted" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var am = try analysis.parseAccountsFile(arena.allocator(),
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-06-24
\\account::Sample Trust,tax_type::taxable,harvested:num:-8000,harvested_date::2025-11-01
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
const c = try harvestedFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25));
try testing.expectEqual(Status.ok, c.status);
try testing.expect(std.mem.indexOf(u8, c.detail, "2 account(s)") != null);
}
test "harvestedFieldChecks: a stale harvested_date is not a warning" {
// Retiring the annotation after 12 months is designed behavior, not
// an error - doctor must stay quiet about it.
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var am = try analysis.parseAccountsFile(arena.allocator(),
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2020-01-01
);
defer am.deinit();
const c = try harvestedFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25));
try testing.expectEqual(Status.ok, c.status);
}
test "harvestedFieldChecks: harvested without a date warns and names the account" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var am = try analysis.parseAccountsFile(arena.allocator(),
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300
);
defer am.deinit();
const c = try harvestedFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25));
try testing.expectEqual(Status.warn, c.status);
try testing.expect(std.mem.indexOf(u8, c.detail, "harvested_date missing") != null);
try testing.expect(std.mem.indexOf(u8, c.detail, "Sample Tax Loss") != null);
}
test "harvestedFieldChecks: future harvested_date warns and names the account" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var am = try analysis.parseAccountsFile(arena.allocator(),
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2027-06-24
);
defer am.deinit();
const c = try harvestedFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25));
try testing.expectEqual(Status.warn, c.status);
try testing.expect(std.mem.indexOf(u8, c.detail, "future") != null);
try testing.expect(std.mem.indexOf(u8, c.detail, "Sample Tax Loss") != null);
}
test "harvestedFieldChecks: both problems at once are reported together" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var am = try analysis.parseAccountsFile(arena.allocator(),
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300
\\account::Sample Trust,tax_type::taxable,harvested:num:8000,harvested_date::2027-06-24
);
defer am.deinit();
const c = try harvestedFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25));
try testing.expectEqual(Status.warn, c.status);
try testing.expect(std.mem.indexOf(u8, c.detail, "Sample Tax Loss") != null);
try testing.expect(std.mem.indexOf(u8, c.detail, "Sample Trust") != null);
}
test "capabilityChecks: present keys are ok, absent keys are info (never warn)" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();

View file

@ -218,8 +218,29 @@ pub fn fmtTimeAgo(buf: []u8, before_s: i64, after_s: i64) []const u8 {
return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divFloor(delta, std.time.s_per_day)))}) catch "?";
}
/// Knobs for `fmtLargeNumOpts`.
pub const LargeNumOpts = struct {
/// Emit a whole-thousands `k` suffix for values in
/// [1_000, 1_000_000). OFF by default, deliberately: the
/// chart-axis glyph set (`charts/text.zig` `glyphFor`) has
/// bitmaps for T/B/M but not `k`, so an axis label of "$45k"
/// would stamp as "$45 ". Sub-million axis labels already use
/// comma'd whole dollars, so they don't want this either.
///
/// The `k` tier renders whole thousands ("45k") rather than the
/// one decimal place T/B/M use ("1.5B"). At this magnitude a
/// decimal is noise - the callers that want `k` are compact
/// annotations where every column counts.
thousands: bool = false,
};
/// Format large numbers with T/B/M suffixes (e.g. "1.5B", "45.6M").
pub fn fmtLargeNum(val: f64) [15]u8 {
return fmtLargeNumOpts(val, .{});
}
/// `fmtLargeNum` with tier control. See `LargeNumOpts`.
pub fn fmtLargeNumOpts(val: f64, opts: LargeNumOpts) [15]u8 {
var result: [15]u8 = @splat(' ');
// bufPrint can only fail with NoSpaceLeft, which is impossible
// here: a 15-byte buffer comfortably holds any "{d:.1}<X>" value
@ -230,12 +251,44 @@ pub fn fmtLargeNum(val: f64) [15]u8 {
_ = std.fmt.bufPrint(&result, "{d:.1}B", .{val / 1_000_000_000}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
} else if (val >= 1_000_000) {
_ = std.fmt.bufPrint(&result, "{d:.1}M", .{val / 1_000_000}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
} else if (opts.thousands and val >= 1_000) {
_ = std.fmt.bufPrint(&result, "{d:.0}k", .{val / 1_000}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
} else {
_ = std.fmt.bufPrint(&result, "{d:.0}", .{val}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
}
return result;
}
/// Widest string `fmtHarvestAnnotation` can produce: "(999.9M 12/31)".
/// Callers sizing a stack buffer should use this.
pub const harvest_annotation_max_len = "(999.9M 12/31)".len;
/// Compact tax-loss-harvested annotation for an account row:
/// `"(45k 6/24)"`. Returns `""` (nothing to render) when `amount` or
/// `harvest_date` is unset, when the date is in the future relative to
/// `as_of`, or when it is more than 12 months old.
///
/// The 12-month gate is what makes a bare `M/D` unambiguous: within a
/// trailing-year window with future dates excluded, each (month, day)
/// pair occurs at most once, so no year is needed. Harvest data older
/// than a year is worthless for this purpose anyway, so there is
/// deliberately no month/year fallback for stale entries - they just
/// stop rendering.
///
/// `amount` is sign-insensitive (`accounts.srf` accepts either
/// `harvested:num:45300` or `harvested:num:-45300`); the parens carry
/// the "this is a loss" affordance, borrowing the accounting
/// convention. `buf` must be at least `harvest_annotation_max_len`.
pub fn fmtHarvestAnnotation(buf: []u8, amount: ?f64, harvest_date: ?Date, as_of: Date) []const u8 {
const amt = amount orelse return "";
const on = harvest_date orelse return "";
if (as_of.lessThan(on)) return ""; // future-dated
if (on.lessThan(as_of.subtractYears(1))) return ""; // stale
const mag = fmtLargeNumOpts(@abs(amt), .{ .thousands = true });
const trimmed = std.mem.trimEnd(u8, &mag, " ");
return std.fmt.bufPrint(buf, "({s} {d}/{d})", .{ trimmed, on.month(), on.day() }) catch "";
}
// Display-width helpers
/// Count the number of terminal display columns occupied by a
@ -963,6 +1016,96 @@ test "fmtLargeNum" {
try std.testing.expect(std.mem.startsWith(u8, &tril, "2.3T"));
}
test "fmtLargeNumOpts: thousands off leaves every tier byte-identical to fmtLargeNum" {
// Guards the chart-axis callers (charts/axis.zig, quote_tab,
// commands/etf) against a behavior change from the new option.
const vals = [_]f64{ 0, 999, 1_000, 12_345, 999_999, 45_600_000, 1_500_000_000, 2_300_000_000_000 };
for (vals) |v| {
const default = fmtLargeNum(v);
const explicit = fmtLargeNumOpts(v, .{ .thousands = false });
try std.testing.expectEqualStrings(&default, &explicit);
}
}
test "fmtLargeNumOpts: thousands on adds a whole-thousands k tier" {
const k = fmtLargeNumOpts(45_300.0, .{ .thousands = true });
try std.testing.expectEqualStrings("45k", std.mem.trimEnd(u8, &k, " "));
// Rounds to nearest whole thousand.
const up = fmtLargeNumOpts(45_800.0, .{ .thousands = true });
try std.testing.expectEqualStrings("46k", std.mem.trimEnd(u8, &up, " "));
// Boundaries: 999 stays bare, 1000 becomes 1k, 1M defers to the M tier.
const under = fmtLargeNumOpts(999.0, .{ .thousands = true });
try std.testing.expectEqualStrings("999", std.mem.trimEnd(u8, &under, " "));
const at = fmtLargeNumOpts(1_000.0, .{ .thousands = true });
try std.testing.expectEqualStrings("1k", std.mem.trimEnd(u8, &at, " "));
const mil = fmtLargeNumOpts(1_000_000.0, .{ .thousands = true });
try std.testing.expectEqualStrings("1.0M", std.mem.trimEnd(u8, &mil, " "));
}
test "fmtHarvestAnnotation: renders compact amount and month/day" {
var buf: [harvest_annotation_max_len]u8 = undefined;
const as_of = Date.fromYmd(2026, 7, 25);
try std.testing.expectEqualStrings(
"(45k 6/24)",
fmtHarvestAnnotation(&buf, 45_300, Date.fromYmd(2026, 6, 24), as_of),
);
// Millions tier keeps one decimal.
try std.testing.expectEqualStrings(
"(1.2M 12/31)",
fmtHarvestAnnotation(&buf, 1_240_000, Date.fromYmd(2025, 12, 31), as_of),
);
// Sub-thousand renders bare.
try std.testing.expectEqualStrings(
"(750 7/1)",
fmtHarvestAnnotation(&buf, 750, Date.fromYmd(2026, 7, 1), as_of),
);
}
test "fmtHarvestAnnotation: sign-insensitive on the amount" {
var buf: [harvest_annotation_max_len]u8 = undefined;
const as_of = Date.fromYmd(2026, 7, 25);
const on = Date.fromYmd(2026, 6, 24);
const positive = fmtHarvestAnnotation(&buf, 45_300, on, as_of);
var buf2: [harvest_annotation_max_len]u8 = undefined;
const negative = fmtHarvestAnnotation(&buf2, -45_300, on, as_of);
try std.testing.expectEqualStrings(positive, negative);
try std.testing.expectEqualStrings("(45k 6/24)", negative);
}
test "fmtHarvestAnnotation: suppressed when either input is unset" {
var buf: [harvest_annotation_max_len]u8 = undefined;
const as_of = Date.fromYmd(2026, 7, 25);
try std.testing.expectEqualStrings("", fmtHarvestAnnotation(&buf, null, Date.fromYmd(2026, 6, 24), as_of));
try std.testing.expectEqualStrings("", fmtHarvestAnnotation(&buf, 45_300, null, as_of));
try std.testing.expectEqualStrings("", fmtHarvestAnnotation(&buf, null, null, as_of));
}
test "fmtHarvestAnnotation: 12-month window boundaries" {
var buf: [harvest_annotation_max_len]u8 = undefined;
const as_of = Date.fromYmd(2026, 7, 25);
// Same day as `as_of` - in window.
try std.testing.expect(fmtHarvestAnnotation(&buf, 45_300, as_of, as_of).len > 0);
// 11 months old - in window.
try std.testing.expect(fmtHarvestAnnotation(&buf, 45_300, Date.fromYmd(2025, 8, 25), as_of).len > 0);
// Exactly 12 months old - in window (inclusive lower bound).
try std.testing.expect(fmtHarvestAnnotation(&buf, 45_300, Date.fromYmd(2025, 7, 25), as_of).len > 0);
// One day past 12 months - suppressed.
try std.testing.expectEqualStrings("", fmtHarvestAnnotation(&buf, 45_300, Date.fromYmd(2025, 7, 24), as_of));
// 13 months old - suppressed.
try std.testing.expectEqualStrings("", fmtHarvestAnnotation(&buf, 45_300, Date.fromYmd(2025, 6, 25), as_of));
// Future-dated - suppressed (a typo, not data).
try std.testing.expectEqualStrings("", fmtHarvestAnnotation(&buf, 45_300, Date.fromYmd(2026, 7, 26), as_of));
}
test "fmtHarvestAnnotation: widest output fits harvest_annotation_max_len" {
var buf: [harvest_annotation_max_len]u8 = undefined;
const as_of = Date.fromYmd(2026, 1, 1);
const s = fmtHarvestAnnotation(&buf, 999_900_000, Date.fromYmd(2025, 12, 31), as_of);
try std.testing.expectEqualStrings("(999.9M 12/31)", s);
try std.testing.expectEqual(harvest_annotation_max_len, s.len);
}
test "fmtCashHeader" {
var buf: [128]u8 = undefined;
const header = fmtCashHeader(&buf);

View file

@ -385,8 +385,17 @@ pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownI
const padded_label = try arena.alloc(u8, trimmed.len + pad_cols);
@memcpy(padded_label[0..trimmed.len], trimmed);
if (pad_cols > 0) @memset(padded_label[trimmed.len..], ' ');
return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {f}", .{
padded_label, bar, pct, Money.from(item.value),
// Optional trailing annotation (the "By Account" breakdown's
// per-account harvested figure). Appended after the value so it
// can't disturb the label padding or the caller's bar-fill span
// offsets. Unstyled - the parens are the affordance, and this line's
// single `alt_*` span is already spent on the bar.
const annotation: []const u8 = if (item.annotation) |*ann|
try std.fmt.allocPrint(arena, " {s}", .{ann.slice()})
else
"";
return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {f}{s}", .{
padded_label, bar, pct, Money.from(item.value), annotation,
});
}
@ -442,6 +451,30 @@ test "fmtBreakdownLine formats correctly" {
try testing.expect(std.mem.indexOf(u8, line, "US Stock") != null);
try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null);
try testing.expect(std.mem.indexOf(u8, line, "$130,000") != null);
// No annotation set -> nothing appended.
try testing.expect(std.mem.endsWith(u8, line, "$130,000.00"));
}
test "fmtBreakdownLine: annotation appends after the value without shifting the bar" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const plain = zfin.analysis.BreakdownItem{
.label = "Sample Tax Loss",
.weight = 0.4,
.value = 412_300,
};
var annotated = plain;
annotated.annotation = zfin.analysis.Annotation.from("(45k 6/24)");
const bare = try fmtBreakdownLine(arena, plain, 10, 12);
const with = try fmtBreakdownLine(arena, annotated, 10, 12);
// The annotated line is the bare line plus the suffix - so every
// column the caller's bar-fill span references is unmoved.
try testing.expectEqualStrings(bare, with[0..bare.len]);
try testing.expectEqualStrings(" (45k 6/24)", with[bare.len..]);
}
test "renderAnalysisLines with data" {

View file

@ -234,6 +234,11 @@ pub const State = struct {
/// Auto-assigned shortcut-key per account, parallel to
/// `account_list`. Used by the account picker modal.
account_shortcut_keys: std.ArrayList(u8) = .empty,
/// Pre-rendered harvested annotation per account, parallel to
/// `account_list`. Null when the account has no fresh
/// `harvested` entry in `accounts.srf`. Value type, so nothing
/// here needs freeing - see `analysis.Annotation`.
account_harvested: std.ArrayList(?zfin.analysis.Annotation) = .empty,
// Account picker / search modal
//
// The portfolio tab owns picker state in full: the cursor,
@ -346,6 +351,7 @@ pub const tab = struct {
state.account_list.deinit(app.allocator);
state.account_numbers.deinit(app.allocator);
state.account_shortcut_keys.deinit(app.allocator);
state.account_harvested.deinit(app.allocator);
state.account_search_matches.deinit(app.allocator);
state.* = .{};
}
@ -1343,13 +1349,36 @@ pub fn setAccountFilter(state: *State, app: *App, name: ?[]const u8) void {
}
}
/// Look up the pre-rendered harvested annotation for `account` in the
/// parallel account arrays built by `buildAccountList`.
///
/// Reads the cached annotation rather than re-consulting
/// `app.portfolio.accountMap()`, so the draw path never blocks on the
/// account-map worker. Tolerates the arrays being shorter than each
/// other (they're appended independently and an OOM mid-append can
/// leave them ragged).
fn lookupHarvestAnnotation(
account_list: []const []const u8,
account_harvested: []const ?zfin.analysis.Annotation,
account: []const u8,
) ?zfin.analysis.Annotation {
for (account_list, 0..) |name, i| {
if (!std.mem.eql(u8, name, account)) continue;
if (i >= account_harvested.len) return null;
return account_harvested[i];
}
return null;
}
/// Build the ordered list of distinct account names from portfolio lots.
/// Order: accounts.srf file order first, then any remaining accounts alphabetically.
/// Also assigns shortcut keys and loads account numbers from accounts.srf.
/// Also assigns shortcut keys and loads account numbers plus harvested
/// annotations from accounts.srf.
pub fn buildAccountList(state: *State, app: *App) void {
state.account_list.clearRetainingCapacity();
state.account_numbers.clearRetainingCapacity();
state.account_shortcut_keys.clearRetainingCapacity();
state.account_harvested.clearRetainingCapacity();
const pf = app.portfolio.file orelse return;
@ -1377,6 +1406,11 @@ pub fn buildAccountList(state: *State, app: *App) void {
if (seen.contains(entry.account)) {
state.account_list.append(app.allocator, entry.account) catch continue;
state.account_numbers.append(app.allocator, entry.account_number) catch continue;
var ann_buf: [fmt.harvest_annotation_max_len]u8 = undefined;
const ann = zfin.analysis.Annotation.from(
fmt.fmtHarvestAnnotation(&ann_buf, entry.harvested, entry.harvested_date, app.today),
);
state.account_harvested.append(app.allocator, ann) catch continue;
}
}
}
@ -1405,6 +1439,8 @@ pub fn buildAccountList(state: *State, app: *App) void {
for (extras.items) |acct| {
state.account_list.append(app.allocator, acct) catch continue;
state.account_numbers.append(app.allocator, null) catch continue;
// Not in accounts.srf, so by definition no harvested entry.
state.account_harvested.append(app.allocator, null) catch continue;
}
// Assign shortcut keys: 1-9, 0, then b-z (skipping conflict keys)
@ -1615,8 +1651,13 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
const filtered_gl = filtered_value - filtered_cost;
const filtered_return = if (filtered_cost > 0) (filtered_gl / filtered_cost) else @as(f64, 0);
// Account name line
const acct_text = try std.fmt.allocPrint(arena, " Account: {s}", .{af});
// Account name line, with the harvested annotation when the
// account carries one.
const harvest = lookupHarvestAnnotation(state.account_list.items, state.account_harvested.items, af);
const acct_text = if (harvest) |*h|
try std.fmt.allocPrint(arena, " Account: {s} {s}", .{ af, h.slice() })
else
try std.fmt.allocPrint(arena, " Account: {s}", .{af});
try lines.append(arena, .{ .text = acct_text, .style = th.headerStyle() });
const gl_abs = if (filtered_gl >= 0) filtered_gl else -filtered_gl;
@ -2279,6 +2320,41 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void {
/// Used for mouse click hit-testing.
pub const account_picker_header_lines: usize = 3;
/// Build one account-picker row's text: selection marker, a 3-column
/// shortcut-key cell, the account name, then optional trailing
/// metadata - the `accounts.srf` account number and the harvested
/// annotation.
///
/// The account number renders bare (`- 1234`) rather than
/// parenthesized: the harvested annotation that may follow it is
/// itself parenthesized, and two adjacent paren groups read as noise.
/// Search still matches on the number - `updateAccountSearchMatches`
/// consults `state.account_numbers` directly, not this display text.
fn formatAccountPickerRow(
arena: std.mem.Allocator,
marker: []const u8,
shortcut: u8,
label: []const u8,
acct_num: ?[]const u8,
harvested: ?zfin.analysis.Annotation,
) ![]const u8 {
// 3 columns either way, so names stay aligned whether or not the
// account got a shortcut key.
const key: []const u8 = if (shortcut != 0)
try std.fmt.allocPrint(arena, "{c}: ", .{shortcut})
else
" ";
const num_suffix: []const u8 = if (acct_num) |n|
try std.fmt.allocPrint(arena, " - {s}", .{n})
else
"";
const harvest_suffix: []const u8 = if (harvested) |*h|
try std.fmt.allocPrint(arena, " {s}", .{h.slice()})
else
"";
return std.fmt.allocPrint(arena, "{s}{s}{s}{s}{s}", .{ marker, key, label, num_suffix, harvest_suffix });
}
/// Draw the account picker overlay (replaces portfolio content).
pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const th = app.theme;
@ -2319,16 +2395,9 @@ pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf
const label = state.account_list.items[acct_idx];
const shortcut: u8 = if (acct_idx < state.account_shortcut_keys.items.len) state.account_shortcut_keys.items[acct_idx] else 0;
const acct_num: ?[]const u8 = if (acct_idx < state.account_numbers.items.len) state.account_numbers.items[acct_idx] else null;
const harvested: ?zfin.analysis.Annotation = if (acct_idx < state.account_harvested.items.len) state.account_harvested.items[acct_idx] else null;
const text = if (acct_num) |num|
(if (shortcut != 0)
try std.fmt.allocPrint(arena, "{s}{c}: {s} ({s})", .{ marker, shortcut, label, num })
else
try std.fmt.allocPrint(arena, "{s} {s} ({s})", .{ marker, label, num }))
else if (shortcut != 0)
try std.fmt.allocPrint(arena, "{s}{c}: {s}", .{ marker, shortcut, label })
else
try std.fmt.allocPrint(arena, "{s} {s}", .{ marker, label });
const text = try formatAccountPickerRow(arena, marker, shortcut, label, acct_num, harvested);
var style = if (is_selected) th.selectStyle() else th.contentStyle();
if (is_searching and state.account_search_len > 0) {
@ -2793,6 +2862,70 @@ test "statusSuffix: formats the active account filter, null when unset" {
try testing.expect(tab.formatAccountSuffix(arena, null) == null);
}
test "formatAccountPickerRow: shortcut, bare account number, harvested annotation" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const ann = zfin.analysis.Annotation.from("(45k 6/24)");
// Everything present. The account number is deliberately NOT
// parenthesized so it doesn't collide with the annotation.
try testing.expectEqualStrings(
" > 1: Sample Tax Loss - 1234 (45k 6/24)",
try formatAccountPickerRow(arena, " > ", '1', "Sample Tax Loss", "1234", ann),
);
// No annotation.
try testing.expectEqualStrings(
" 1: Sample Brokerage - 5678",
try formatAccountPickerRow(arena, " ", '1', "Sample Brokerage", "5678", null),
);
// No account number, annotation present.
try testing.expectEqualStrings(
" 1: Sample Tax Loss (45k 6/24)",
try formatAccountPickerRow(arena, " ", '1', "Sample Tax Loss", null, ann),
);
// Neither.
try testing.expectEqualStrings(
" 1: Sample Trust",
try formatAccountPickerRow(arena, " ", '1', "Sample Trust", null, null),
);
}
test "formatAccountPickerRow: no shortcut still occupies 3 columns so names align" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const with_key = try formatAccountPickerRow(arena, " ", '1', "Sample Trust", null, null);
const without = try formatAccountPickerRow(arena, " ", 0, "Sample Trust", null, null);
try testing.expectEqualStrings(" Sample Trust", without);
try testing.expectEqual(with_key.len, without.len);
try testing.expectEqual(
std.mem.indexOf(u8, with_key, "Sample Trust").?,
std.mem.indexOf(u8, without, "Sample Trust").?,
);
}
test "lookupHarvestAnnotation: finds by name, null when absent or ragged" {
const names = [_][]const u8{ "Sample IRA", "Sample Tax Loss", "Sample Trust" };
const anns = [_]?zfin.analysis.Annotation{
null,
zfin.analysis.Annotation.from("(45k 6/24)"),
null,
};
const hit = lookupHarvestAnnotation(&names, &anns, "Sample Tax Loss").?;
try testing.expectEqualStrings("(45k 6/24)", hit.slice());
// Present in the list but with no annotation.
try testing.expect(lookupHarvestAnnotation(&names, &anns, "Sample IRA") == null);
// Not in the list at all.
try testing.expect(lookupHarvestAnnotation(&names, &anns, "Nope") == null);
// Ragged parallel arrays (OOM mid-append) must not read out of bounds.
try testing.expect(lookupHarvestAnnotation(&names, anns[0..1], "Sample Tax Loss") == null);
// Empty.
try testing.expect(lookupHarvestAnnotation(&.{}, &.{}, "Sample IRA") == null);
}
test "ensureCursorVisible: cursor above viewport scrolls up" {
var state: State = .{ .cursor = 5, .header_lines = 2 };
var scroll: usize = 20;