first class support for tax loss harvesting amounts
This commit is contained in:
parent
dd4d68280f
commit
e5726feffd
11 changed files with 1011 additions and 28 deletions
|
|
@ -99,6 +99,24 @@ Declare it in
|
||||||
it isn't counted as new money. See
|
it isn't counted as new money. See
|
||||||
[Track contributions](../guides/track-contributions.md#dont-double-count-transfers).
|
[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
|
## See also
|
||||||
|
|
||||||
- [Core concepts](concepts.md) -- the mental model.
|
- [Core concepts](concepts.md) -- the mental model.
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ account::Old Rollover,tax_type::traditional,update_cadence::none
|
||||||
|
|
||||||
## 4. Advanced flags
|
## 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:
|
account -- see the reference for details:
|
||||||
|
|
||||||
- **`shielded:bool:false`** -- mark a pre-tax account that is *not*
|
- **`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
|
account as real external contributions in
|
||||||
[`zfin contributions`](track-contributions.md), instead of internal
|
[`zfin contributions`](track-contributions.md), instead of internal
|
||||||
noise.
|
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
|
- **`audit_large_lot_threshold:num:50000`** -- raise (or lower) the
|
||||||
dollar cutoff at which a flagless [`zfin audit`](../reference/cli/audit.md)
|
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;
|
nudges you to confirm a **new lot**'s source. The default is $10,000;
|
||||||
|
|
@ -89,6 +92,98 @@ account -- see the reference for details:
|
||||||
account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
|
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. Entries more
|
||||||
|
than 12 months old stop displaying on purpose. The sign is ignored, so
|
||||||
|
`-45300` works too.
|
||||||
|
|
||||||
|
**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`)
|
## Example (from `examples/pre-retirement-both`)
|
||||||
|
|
||||||
```srf
|
```srf
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,11 @@ Four sections, each line tagged `OK` / `INFO` / `WARN` / `FAIL`:
|
||||||
from, and does it parse?
|
from, and does it parse?
|
||||||
- **Cross-checks** -- do `accounts.srf` / `metadata.srf` /
|
- **Cross-checks** -- do `accounts.srf` / `metadata.srf` /
|
||||||
`transaction_log.srf` reference entries that actually exist (e.g.
|
`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
|
- **Environment** -- cache size, staleness of the hand-maintained data
|
||||||
tables, and `ZFIN_SERVER` reachability and version.
|
tables, and `ZFIN_SERVER` reachability and version.
|
||||||
- **Capabilities** -- which API keys are set and what each enables (or
|
- **Capabilities** -- which API keys are set and what each enables (or
|
||||||
|
|
@ -56,6 +60,7 @@ Files
|
||||||
|
|
||||||
Cross-checks
|
Cross-checks
|
||||||
[OK ] accounts.srf coverage: all referenced entries present
|
[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
|
[OK ] metadata.srf coverage: all referenced entries present
|
||||||
[INFO] transaction_log.srf references: skipped (transaction_log.srf not loaded)
|
[INFO] transaction_log.srf references: skipped (transaction_log.srf not loaded)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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). |
|
| `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). |
|
| `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. |
|
| `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
|
## Tax types
|
||||||
|
|
||||||
|
|
@ -75,8 +77,70 @@ 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
|
The value must be **positive** -- zero or a negative number is rejected
|
||||||
at load time and the account falls back to the default.
|
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.
|
||||||
|
- **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 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.
|
||||||
|
|
||||||
|
### 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
|
[`zfin audit`](../cli/audit.md) (run flagless) flags accounts you
|
||||||
haven't refreshed within their cadence window: `weekly` = 7 days,
|
haven't refreshed within their cadence window: `weekly` = 7 days,
|
||||||
`monthly` = 30, `quarterly` = 90, `none` = never nag. The default is
|
`monthly` = 30, `quarterly` = 90, `none` = never nag. The default is
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const ClassificationMap = @import("../models/classification.zig").Classification
|
||||||
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;
|
const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry;
|
||||||
const Portfolio = @import("../models/portfolio.zig").Portfolio;
|
const Portfolio = @import("../models/portfolio.zig").Portfolio;
|
||||||
const Date = @import("../Date.zig");
|
const Date = @import("../Date.zig");
|
||||||
|
const fmt = @import("../format.zig");
|
||||||
|
|
||||||
const log = std.log.scoped(.accounts);
|
const log = std.log.scoped(.accounts);
|
||||||
|
|
||||||
|
|
@ -18,6 +19,48 @@ pub const BreakdownItem = struct {
|
||||||
label: []const u8,
|
label: []const u8,
|
||||||
value: f64, // dollar amount
|
value: f64, // dollar amount
|
||||||
weight: f64, // fraction of total (0.0 - 1.0)
|
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.
|
/// Tax type classification for accounts.
|
||||||
|
|
@ -113,6 +156,37 @@ pub const AccountTaxEntry = struct {
|
||||||
/// time (warned + treated as unset) since zero would flag every
|
/// time (warned + treated as unset) since zero would flag every
|
||||||
/// new lot and negative is meaningless.
|
/// new lot and negative is meaningless.
|
||||||
audit_large_lot_threshold: ?f64 = null,
|
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.
|
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
|
||||||
|
|
@ -230,11 +304,47 @@ pub const AccountMap = struct {
|
||||||
}
|
}
|
||||||
return null;
|
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.
|
/// Parse an accounts.srf file into an AccountMap.
|
||||||
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>][,<flags>]
|
/// 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 {
|
pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap {
|
||||||
var entries = std.ArrayList(AccountTaxEntry).empty;
|
var entries = std.ArrayList(AccountTaxEntry).empty;
|
||||||
errdefer {
|
errdefer {
|
||||||
|
|
@ -266,6 +376,21 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
|
||||||
break :blk null;
|
break :blk null;
|
||||||
} else 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, .{
|
try entries.append(allocator, .{
|
||||||
.account = try allocator.dupe(u8, entry.account),
|
.account = try allocator.dupe(u8, entry.account),
|
||||||
.tax_type = entry.tax_type,
|
.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,
|
.direct_indexing = entry.direct_indexing,
|
||||||
.shielded = entry.shielded,
|
.shielded = entry.shielded,
|
||||||
.audit_large_lot_threshold = lot_threshold,
|
.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
|
// Convert maps to sorted slices
|
||||||
const total = if (total_portfolio_value > 0) total_portfolio_value else 1.0;
|
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 .{
|
return .{
|
||||||
.asset_category = try mapToSortedBreakdown(allocator, asset_cat_map, total),
|
.asset_category = try mapToSortedBreakdown(allocator, asset_cat_map, total),
|
||||||
.sector = try mapToSortedBreakdown(allocator, sector_map, total),
|
.sector = try mapToSortedBreakdown(allocator, sector_map, total),
|
||||||
.geo = try mapToSortedBreakdown(allocator, geo_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),
|
.tax_type = try mapToSortedBreakdown(allocator, tax_map, total),
|
||||||
.unclassified = try unclassified_list.toOwnedSlice(allocator),
|
.unclassified = try unclassified_list.toOwnedSlice(allocator),
|
||||||
.total_value = total_portfolio_value,
|
.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 ─────────────────────────────────────────
|
// ── umbrellaExposure ─────────────────────────────────────────
|
||||||
|
|
||||||
/// Helper: build an in-memory AccountMap from a literal SRF
|
/// Helper: build an in-memory AccountMap from a literal SRF
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
if (color) try fmt.ansiSetFg(out, cli.CLR_ACCENT[0], cli.CLR_ACCENT[1], cli.CLR_ACCENT[2]);
|
||||||
try out.writeAll(bar);
|
try out.writeAll(bar);
|
||||||
if (color) try fmt.ansiReset(out);
|
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);
|
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" {
|
test "display shows all sections" {
|
||||||
var buf: [8192]u8 = undefined;
|
var buf: [8192]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
|
|
|
||||||
|
|
@ -1191,13 +1191,20 @@ const ChangeKind = enum {
|
||||||
cd_matured, // lot disappeared: CD with maturity_date <= today
|
cd_matured, // lot disappeared: CD with maturity_date <= today
|
||||||
cd_removed_early, // lot disappeared: CD with maturity_date > today
|
cd_removed_early, // lot disappeared: CD with maturity_date > today
|
||||||
lot_removed, // lot disappeared: stock/cash/option
|
lot_removed, // lot disappeared: stock/cash/option
|
||||||
/// Strict lot key broke (open_date / open_price / account rewritten)
|
/// Strict lot key broke (open_date / open_price / symbol rewritten)
|
||||||
/// but the same (security_type, symbol, account) reappears on the
|
/// but the same (security_type, priceSymbol, account) reappears on
|
||||||
/// other side with approximately the same share total. Classified
|
/// the other side with approximately the same share total.
|
||||||
/// as a lot edit - not counted as contribution, not counted as
|
/// Classified as a lot edit - not counted as contribution, not
|
||||||
/// disposal. Fixes the phantom-contribution bug from reconciliation
|
/// counted as disposal. Fixes the phantom-contribution bug from
|
||||||
/// tweaks, CD auto-renewals rewriting `open_date`, and account
|
/// reconciliation tweaks, CD auto-renewals rewriting `open_date`,
|
||||||
/// renames that shift every lot in the account under a new name.
|
/// 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,
|
lot_edited,
|
||||||
price_only, // same key, price:: field changed, no share change
|
price_only, // same key, price:: field changed, no share change
|
||||||
flagged, // any other shape of edit
|
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
|
// one account, opening another with the same positions). Account
|
||||||
// renames must therefore be handled manually: either avoid them
|
// renames must therefore be handled manually: either avoid them
|
||||||
// during a review window, or accept the phantom attribution for
|
// 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);
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
const allocator = arena_state.allocator();
|
const allocator = arena_state.allocator();
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// Build the per-key capability checks from a resolved `Config`. Pure
|
||||||
/// over `Config` (no I/O), so every branch is unit-testable by
|
/// over `Config` (no I/O), so every branch is unit-testable by
|
||||||
/// constructing a `Config` literal. Present keys -> OK with the
|
/// 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 lot_accts = try uniqueAccounts(arena, all_lots.items);
|
||||||
const known = try accountNames(arena, am);
|
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 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 {
|
} 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 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.
|
// 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"));
|
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)" {
|
test "capabilityChecks: present keys are ok, absent keys are info (never warn)" {
|
||||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
defer arena.deinit();
|
defer arena.deinit();
|
||||||
|
|
|
||||||
143
src/format.zig
143
src/format.zig
|
|
@ -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 "?";
|
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").
|
/// Format large numbers with T/B/M suffixes (e.g. "1.5B", "45.6M").
|
||||||
pub fn fmtLargeNum(val: f64) [15]u8 {
|
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(' ');
|
var result: [15]u8 = @splat(' ');
|
||||||
// bufPrint can only fail with NoSpaceLeft, which is impossible
|
// bufPrint can only fail with NoSpaceLeft, which is impossible
|
||||||
// here: a 15-byte buffer comfortably holds any "{d:.1}<X>" value
|
// 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});
|
_ = 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) {
|
} 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});
|
_ = 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 {
|
} else {
|
||||||
_ = std.fmt.bufPrint(&result, "{d:.0}", .{val}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
|
_ = std.fmt.bufPrint(&result, "{d:.0}", .{val}) catch |err| std.debug.panic("fmtLargeNum buffer too small: {t}", .{err});
|
||||||
}
|
}
|
||||||
return result;
|
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 ────────────────────────────────────
|
// ── Display-width helpers ────────────────────────────────────
|
||||||
|
|
||||||
/// Count the number of terminal display columns occupied by a
|
/// 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"));
|
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" {
|
test "fmtCashHeader" {
|
||||||
var buf: [128]u8 = undefined;
|
var buf: [128]u8 = undefined;
|
||||||
const header = fmtCashHeader(&buf);
|
const header = fmtCashHeader(&buf);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
const padded_label = try arena.alloc(u8, trimmed.len + pad_cols);
|
||||||
@memcpy(padded_label[0..trimmed.len], trimmed);
|
@memcpy(padded_label[0..trimmed.len], trimmed);
|
||||||
if (pad_cols > 0) @memset(padded_label[trimmed.len..], ' ');
|
if (pad_cols > 0) @memset(padded_label[trimmed.len..], ' ');
|
||||||
return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {f}", .{
|
// Optional trailing annotation (the "By Account" breakdown's
|
||||||
padded_label, bar, pct, Money.from(item.value),
|
// 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, "US Stock") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null);
|
try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null);
|
||||||
try testing.expect(std.mem.indexOf(u8, line, "$130,000") != 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" {
|
test "renderAnalysisLines with data" {
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,11 @@ pub const State = struct {
|
||||||
/// Auto-assigned shortcut-key per account, parallel to
|
/// Auto-assigned shortcut-key per account, parallel to
|
||||||
/// `account_list`. Used by the account picker modal.
|
/// `account_list`. Used by the account picker modal.
|
||||||
account_shortcut_keys: std.ArrayList(u8) = .empty,
|
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 ──
|
// ── Account picker / search modal ──
|
||||||
//
|
//
|
||||||
// The portfolio tab owns picker state in full: the cursor,
|
// 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_list.deinit(app.allocator);
|
||||||
state.account_numbers.deinit(app.allocator);
|
state.account_numbers.deinit(app.allocator);
|
||||||
state.account_shortcut_keys.deinit(app.allocator);
|
state.account_shortcut_keys.deinit(app.allocator);
|
||||||
|
state.account_harvested.deinit(app.allocator);
|
||||||
state.account_search_matches.deinit(app.allocator);
|
state.account_search_matches.deinit(app.allocator);
|
||||||
state.* = .{};
|
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.
|
/// Build the ordered list of distinct account names from portfolio lots.
|
||||||
/// Order: accounts.srf file order first, then any remaining accounts alphabetically.
|
/// 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 {
|
pub fn buildAccountList(state: *State, app: *App) void {
|
||||||
state.account_list.clearRetainingCapacity();
|
state.account_list.clearRetainingCapacity();
|
||||||
state.account_numbers.clearRetainingCapacity();
|
state.account_numbers.clearRetainingCapacity();
|
||||||
state.account_shortcut_keys.clearRetainingCapacity();
|
state.account_shortcut_keys.clearRetainingCapacity();
|
||||||
|
state.account_harvested.clearRetainingCapacity();
|
||||||
|
|
||||||
const pf = app.portfolio.file orelse return;
|
const pf = app.portfolio.file orelse return;
|
||||||
|
|
||||||
|
|
@ -1377,6 +1406,11 @@ pub fn buildAccountList(state: *State, app: *App) void {
|
||||||
if (seen.contains(entry.account)) {
|
if (seen.contains(entry.account)) {
|
||||||
state.account_list.append(app.allocator, entry.account) catch continue;
|
state.account_list.append(app.allocator, entry.account) catch continue;
|
||||||
state.account_numbers.append(app.allocator, entry.account_number) 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| {
|
for (extras.items) |acct| {
|
||||||
state.account_list.append(app.allocator, acct) catch continue;
|
state.account_list.append(app.allocator, acct) catch continue;
|
||||||
state.account_numbers.append(app.allocator, null) 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)
|
// 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_gl = filtered_value - filtered_cost;
|
||||||
const filtered_return = if (filtered_cost > 0) (filtered_gl / filtered_cost) else @as(f64, 0);
|
const filtered_return = if (filtered_cost > 0) (filtered_gl / filtered_cost) else @as(f64, 0);
|
||||||
|
|
||||||
// Account name line
|
// Account name line, with the harvested annotation when the
|
||||||
const acct_text = try std.fmt.allocPrint(arena, " Account: {s}", .{af});
|
// 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() });
|
try lines.append(arena, .{ .text = acct_text, .style = th.headerStyle() });
|
||||||
|
|
||||||
const gl_abs = if (filtered_gl >= 0) filtered_gl else -filtered_gl;
|
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.
|
/// Used for mouse click hit-testing.
|
||||||
pub const account_picker_header_lines: usize = 3;
|
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).
|
/// 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 {
|
pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||||
const th = app.theme;
|
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 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 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 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|
|
const text = try formatAccountPickerRow(arena, marker, shortcut, label, acct_num, harvested);
|
||||||
(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 });
|
|
||||||
|
|
||||||
var style = if (is_selected) th.selectStyle() else th.contentStyle();
|
var style = if (is_selected) th.selectStyle() else th.contentStyle();
|
||||||
if (is_searching and state.account_search_len > 0) {
|
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);
|
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" {
|
test "ensureCursorVisible: cursor above viewport scrolls up" {
|
||||||
var state: State = .{ .cursor = 5, .header_lines = 2 };
|
var state: State = .{ .cursor = 5, .header_lines = 2 };
|
||||||
var scroll: usize = 20;
|
var scroll: usize = 20;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue